Compare commits
39 Commits
9cf88d9f5a
...
release-1.
| Author | SHA1 | Date | |
|---|---|---|---|
| f4786e970a | |||
| 361cf46905 | |||
| d56b91479f | |||
| 7c1e4cf3e2 | |||
| 3a5eed6b96 | |||
| e8080be405 | |||
| 82d3daf20f | |||
| 13584d2b0f | |||
| 9bf038278a | |||
| 10cffd77cb | |||
| 25fcd9974a | |||
| 536faaefd5 | |||
| 9edb3255e8 | |||
| b96c0b50ab | |||
| 8dce8b238c | |||
| abd14b713e | |||
| a7cafd1400 | |||
| 6e65c0392c | |||
| e257da41b0 | |||
| 89e49bc56d | |||
| a0869f6f65 | |||
| 4c2849611f | |||
| 279a071a15 | |||
| 1450c0b4c9 | |||
| 95a264dbae | |||
|
|
2201446ee6 | ||
|
|
8a6355cde1 | ||
|
|
28d743deb5 | ||
|
|
dfa5fb81a3 | ||
|
|
a1ab2df975 | ||
| 4c3e6cd384 | |||
| 55fb17634d | |||
|
|
fc9bcbf8da | ||
|
|
d29d2ea052 | ||
|
|
4f0b6d22f2 | ||
|
|
b1272bd008 | ||
|
|
9609a7fcf7 | ||
|
|
61dcbda581 | ||
|
|
21648ab171 |
27
Jenkinsfile
vendored
@@ -83,12 +83,17 @@ pipeline {
|
|||||||
env.FRONTEND_API_URL = ''
|
env.FRONTEND_API_URL = ''
|
||||||
env.FRONTEND_GA_ID = ""
|
env.FRONTEND_GA_ID = ""
|
||||||
}
|
}
|
||||||
|
env.TURNSTILE_SITE_KEY = sh(
|
||||||
|
script: "grep '^TURNSTILE_SITE_KEY=' /opt/furious-ducks/secrets/tiptop-mail.env | cut -d= -f2-",
|
||||||
|
returnStdout: true
|
||||||
|
).trim()
|
||||||
|
|
||||||
echo "Branche : ${env.BRANCH_NAME}"
|
echo "Branche : ${env.BRANCH_NAME}"
|
||||||
echo "Tag Docker : ${env.IMAGE_TAG}"
|
echo "Tag Docker : ${env.IMAGE_TAG}"
|
||||||
echo "Environnement : ${env.DEPLOY_ENV}"
|
echo "Environnement : ${env.DEPLOY_ENV}"
|
||||||
echo "Déploiement activé : ${env.DEPLOY_ENABLED}"
|
echo "Déploiement activé : ${env.DEPLOY_ENABLED}"
|
||||||
echo "API frontend : ${env.FRONTEND_API_URL}"
|
echo "API frontend : ${env.FRONTEND_API_URL}"
|
||||||
|
echo "Turnstile : ${env.TURNSTILE_SITE_KEY ? '[SET]' : '[EMPTY]'}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,8 +224,8 @@ pipeline {
|
|||||||
docker run --rm \
|
docker run --rm \
|
||||||
--volumes-from furious-jenkins \
|
--volumes-from furious-jenkins \
|
||||||
-v tiptop-npm-cache-frontend:/npm-cache \
|
-v tiptop-npm-cache-frontend:/npm-cache \
|
||||||
--user "$(id -u):$(id -g)" \
|
|
||||||
-e HOME=/tmp \
|
-e HOME=/tmp \
|
||||||
|
-e CHROMIUM_PATH=/usr/bin/chromium \
|
||||||
-e NODE_OPTIONS=--dns-result-order=ipv4first \
|
-e NODE_OPTIONS=--dns-result-order=ipv4first \
|
||||||
-e NPM_CONFIG_CACHE=/npm-cache \
|
-e NPM_CONFIG_CACHE=/npm-cache \
|
||||||
-e NPM_CONFIG_FETCH_RETRIES=5 \
|
-e NPM_CONFIG_FETCH_RETRIES=5 \
|
||||||
@@ -230,6 +235,8 @@ pipeline {
|
|||||||
-w "${WORKSPACE}/frontend" \
|
-w "${WORKSPACE}/frontend" \
|
||||||
node:22-alpine \
|
node:22-alpine \
|
||||||
sh -c "
|
sh -c "
|
||||||
|
apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont &&
|
||||||
|
export CHROMIUM_PATH=/usr/bin/chromium &&
|
||||||
npm ci --prefer-offline --no-audit --no-fund &&
|
npm ci --prefer-offline --no-audit --no-fund &&
|
||||||
npm run lint &&
|
npm run lint &&
|
||||||
npm run test:coverage &&
|
npm run test:coverage &&
|
||||||
@@ -314,7 +321,7 @@ stage('SonarQube Quality Gate') {
|
|||||||
|
|
||||||
steps {
|
steps {
|
||||||
timeout(time: 5, unit: 'MINUTES') {
|
timeout(time: 5, unit: 'MINUTES') {
|
||||||
waitForQualityGate abortPipeline: false
|
waitForQualityGate abortPipeline: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,15 +334,23 @@ stage('SonarQube Quality Gate') {
|
|||||||
|
|
||||||
steps {
|
steps {
|
||||||
sh '''
|
sh '''
|
||||||
|
set -a
|
||||||
|
. /opt/furious-ducks/secrets/tiptop-mail.env
|
||||||
|
set +a
|
||||||
|
|
||||||
|
test -n "${TURNSTILE_SITE_KEY}"
|
||||||
|
echo "Turnstile site key chargée (longueur: ${#TURNSTILE_SITE_KEY})"
|
||||||
|
|
||||||
docker build \
|
docker build \
|
||||||
-t "${BACKEND_IMAGE}:${IMAGE_TAG}" \
|
-t "${BACKEND_IMAGE}:${IMAGE_TAG}" \
|
||||||
backend
|
backend
|
||||||
|
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg VITE_API_URL="${FRONTEND_API_URL}" \
|
--build-arg VITE_API_URL="${FRONTEND_API_URL}" \
|
||||||
--build-arg VITE_GA_ID="${FRONTEND_GA_ID}" \
|
--build-arg VITE_GA_ID="${FRONTEND_GA_ID}" \
|
||||||
-t "${FRONTEND_IMAGE}:${IMAGE_TAG}" \
|
--build-arg VITE_TURNSTILE_SITE_KEY="${TURNSTILE_SITE_KEY:-}" \
|
||||||
frontend
|
-t "${FRONTEND_IMAGE}:${IMAGE_TAG}" \
|
||||||
|
frontend
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,3 +19,8 @@ GOOGLE_CLIENT_ID=""
|
|||||||
GOOGLE_CLIENT_SECRET=""
|
GOOGLE_CLIENT_SECRET=""
|
||||||
FACEBOOK_APP_ID=""
|
FACEBOOK_APP_ID=""
|
||||||
FACEBOOK_APP_SECRET=""
|
FACEBOOK_APP_SECRET=""
|
||||||
|
|
||||||
|
# Cloudflare Turnstile (anti-robot sur inscription et connexion). Laisser vide
|
||||||
|
# désactive la vérification. La clé publique va dans frontend/.env
|
||||||
|
# (VITE_TURNSTILE_SITE_KEY).
|
||||||
|
TURNSTILE_SECRET_KEY=""
|
||||||
|
|||||||
12
backend/.gitignore
vendored
@@ -4,3 +4,15 @@ node_modules/
|
|||||||
coverage/
|
coverage/
|
||||||
dist/
|
dist/
|
||||||
test-results/
|
test-results/
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -3,6 +3,8 @@ module.exports = {
|
|||||||
|
|
||||||
testMatch: ["**/tests/**/*.test.js"],
|
testMatch: ["**/tests/**/*.test.js"],
|
||||||
|
|
||||||
|
setupFiles: ["<rootDir>/tests/setupEnv.js"],
|
||||||
|
|
||||||
clearMocks: true,
|
clearMocks: true,
|
||||||
|
|
||||||
testTimeout: 10000,
|
testTimeout: 10000,
|
||||||
|
|||||||
12
backend/package-lock.json
generated
@@ -18,6 +18,7 @@
|
|||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
|
"passport-facebook": "^3.0.0",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
"prisma": "^6.19.3",
|
"prisma": "^6.19.3",
|
||||||
"swagger-jsdoc": "^6.3.0",
|
"swagger-jsdoc": "^6.3.0",
|
||||||
@@ -5845,6 +5846,17 @@
|
|||||||
"url": "https://github.com/sponsors/jaredhanson"
|
"url": "https://github.com/sponsors/jaredhanson"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/passport-facebook": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/passport-facebook/-/passport-facebook-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-K/qNzuFsFISYAyC1Nma4qgY/12V3RSLFdFVsPKXiKZt434wOvthFW1p7zKa1iQihQMRhaWorVE1o3Vi1o+ZgeQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"passport-oauth2": "1.x.x"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/passport-google-oauth20": {
|
"node_modules/passport-google-oauth20": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
|
"passport-facebook": "^3.0.0",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
"prisma": "^6.19.3",
|
"prisma": "^6.19.3",
|
||||||
"swagger-jsdoc": "^6.3.0",
|
"swagger-jsdoc": "^6.3.0",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "mustChangePassword" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Les emails sont désormais stockés et recherchés en minuscules, sans espaces
|
||||||
|
-- autour (voir src/validations/email.validation.js). On aligne les comptes
|
||||||
|
-- existants, sauf ceux dont la version normalisée appartient déjà à un autre
|
||||||
|
-- compte : les modifier violerait la contrainte d'unicité et ferait échouer le
|
||||||
|
-- déploiement. Ces doublons éventuels sont à traiter à la main.
|
||||||
|
UPDATE "User" AS u
|
||||||
|
SET "email" = lower(trim(u."email"))
|
||||||
|
WHERE u."email" <> lower(trim(u."email"))
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM "User" AS other
|
||||||
|
WHERE other."id" <> u."id"
|
||||||
|
AND lower(trim(other."email")) = lower(trim(u."email"))
|
||||||
|
);
|
||||||
@@ -51,6 +51,11 @@ model User {
|
|||||||
resetPasswordToken String?
|
resetPasswordToken String?
|
||||||
resetPasswordExpires DateTime?
|
resetPasswordExpires DateTime?
|
||||||
|
|
||||||
|
// Compte employé créé par un admin avec un mot de passe provisoire envoyé
|
||||||
|
// par email : tant qu'il vaut true, seul le choix d'un nouveau mot de passe
|
||||||
|
// est autorisé (voir auth.middleware.js).
|
||||||
|
mustChangePassword Boolean @default(false)
|
||||||
|
|
||||||
provider AuthProvider @default(LOCAL)
|
provider AuthProvider @default(LOCAL)
|
||||||
providerId String?
|
providerId String?
|
||||||
avatarUrl String?
|
avatarUrl String?
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const prisma = require("../src/config/prisma");
|
const prisma = require("../src/config/prisma");
|
||||||
|
const { DEFAULT_START_DATE } = require("../src/utils/gameWindow");
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
await prisma.lot.deleteMany();
|
await prisma.lot.deleteMany();
|
||||||
@@ -42,8 +43,8 @@ async function main() {
|
|||||||
|
|
||||||
const existingSettings = await prisma.gameSettings.findFirst();
|
const existingSettings = await prisma.gameSettings.findFirst();
|
||||||
if (!existingSettings) {
|
if (!existingSettings) {
|
||||||
await prisma.gameSettings.create({ data: {} });
|
await prisma.gameSettings.create({ data: { startDate: DEFAULT_START_DATE } });
|
||||||
console.log("Date de début du jeu-concours initialisée à aujourd'hui.");
|
console.log("Date de début du jeu-concours initialisée au 2 octobre 2026.");
|
||||||
} else {
|
} else {
|
||||||
console.log("Date de début du jeu-concours déjà définie, inchangée.");
|
console.log("Date de début du jeu-concours déjà définie, inchangée.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
const passport = require("passport");
|
const passport = require("passport");
|
||||||
const GoogleStrategy = require("passport-google-oauth20").Strategy;
|
const GoogleStrategy = require("passport-google-oauth20").Strategy;
|
||||||
|
const FacebookStrategy = require("passport-facebook").Strategy;
|
||||||
const crypto = require("crypto");
|
const crypto = require("crypto");
|
||||||
const prisma = require("./prisma");
|
const prisma = require("./prisma");
|
||||||
const transporter = require("./mailer");
|
const transporter = require("./mailer");
|
||||||
|
const getFrontendUrl = require("../utils/frontendUrl");
|
||||||
|
const { normalizeEmail } = require("../validations/email.validation");
|
||||||
|
|
||||||
|
const PROVIDER_LABELS = { GOOGLE: "Google", FACEBOOK: "Facebook" };
|
||||||
|
|
||||||
// Un compte créé via un provider externe n'a pas de mot de passe. On lui en
|
// 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
|
// 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
|
// réinitialisation : sans mot de passe, l'utilisateur dépend entièrement du
|
||||||
// Google pour se connecter, et le formulaire de changement de son profil lui
|
// provider pour se connecter, et le formulaire de changement de son profil lui
|
||||||
// reste inaccessible.
|
// reste inaccessible.
|
||||||
const PASSWORD_SETUP_EXPIRY_MS = 1000 * 60 * 60 * 24; // 24 h
|
const PASSWORD_SETUP_EXPIRY_MS = 1000 * 60 * 60 * 24; // 24 h
|
||||||
|
|
||||||
async function sendPasswordSetupEmail(user) {
|
async function sendPasswordSetupEmail(user, providerLabel) {
|
||||||
const token = crypto.randomBytes(32).toString("hex");
|
const token = crypto.randomBytes(32).toString("hex");
|
||||||
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
@@ -22,8 +27,7 @@ async function sendPasswordSetupEmail(user) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
|
const setupUrl = `${getFrontendUrl()}/definir-mot-de-passe/${token}`;
|
||||||
const setupUrl = `${frontendUrl}/definir-mot-de-passe/${token}`;
|
|
||||||
|
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: process.env.MAIL_FROM,
|
from: process.env.MAIL_FROM,
|
||||||
@@ -32,9 +36,9 @@ async function sendPasswordSetupEmail(user) {
|
|||||||
html: `
|
html: `
|
||||||
<div style="font-family:sans-serif;max-width:480px;margin:auto">
|
<div style="font-family:sans-serif;max-width:480px;margin:auto">
|
||||||
<h2 style="color:#006B6B">Bienvenue ${user.firstname} !</h2>
|
<h2 style="color:#006B6B">Bienvenue ${user.firstname} !</h2>
|
||||||
<p>Votre compte a été créé avec Google. Vous pouvez continuer à vous
|
<p>Votre compte a été créé avec ${providerLabel}. Vous pouvez continuer à vous
|
||||||
connecter de cette façon, ou définir un mot de passe pour accéder
|
connecter de cette façon, ou définir un mot de passe pour accéder
|
||||||
également à votre compte sans passer par Google.</p>
|
également à votre compte sans passer par ${providerLabel}.</p>
|
||||||
<p style="text-align:center;padding:20px 0">
|
<p style="text-align:center;padding:20px 0">
|
||||||
<a href="${setupUrl}" style="background:#006B6B;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;display:inline-block">
|
<a href="${setupUrl}" style="background:#006B6B;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;display:inline-block">
|
||||||
Définir mon mot de passe
|
Définir mon mot de passe
|
||||||
@@ -42,95 +46,126 @@ async function sendPasswordSetupEmail(user) {
|
|||||||
</p>
|
</p>
|
||||||
<p style="color:#888;font-size:13px">Ce lien expire dans 24 heures. Si vous
|
<p style="color:#888;font-size:13px">Ce lien expire dans 24 heures. Si vous
|
||||||
ne souhaitez pas de mot de passe, ignorez simplement cet email : votre
|
ne souhaitez pas de mot de passe, ignorez simplement cet email : votre
|
||||||
connexion Google continuera de fonctionner.</p>
|
connexion ${providerLabel} continuera de fonctionner.</p>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Callback de vérification commun à tous les providers : passport normalise
|
||||||
|
// le profil (id, name, emails, photos), seule la valeur de `provider` change.
|
||||||
|
function verifyOAuthProfile(provider) {
|
||||||
|
const providerLabel = PROVIDER_LABELS[provider];
|
||||||
|
|
||||||
|
return async (accessToken, refreshToken, profile, done) => {
|
||||||
|
try {
|
||||||
|
const email = normalizeEmail(profile.emails?.[0]?.value);
|
||||||
|
|
||||||
|
// Cas fréquent chez Facebook : compte ouvert avec un numéro de téléphone.
|
||||||
|
if (!email) {
|
||||||
|
return done(null, false, {
|
||||||
|
message: `Aucune adresse email n'est associée à ce compte ${providerLabel}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Compte déjà lié à ce compte externe : connexion directe.
|
||||||
|
const byProvider = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
provider_providerId: { provider, providerId: profile.id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (byProvider) return done(null, byProvider);
|
||||||
|
|
||||||
|
// 2. Un compte existe avec le même email : on le rattache au provider.
|
||||||
|
// L'email étant déjà validé par le provider, on en profite pour lever
|
||||||
|
// la vérification si elle était en attente.
|
||||||
|
const byEmail = await prisma.user.findUnique({ where: { email } });
|
||||||
|
|
||||||
|
if (byEmail) {
|
||||||
|
const linked = await prisma.user.update({
|
||||||
|
where: { id: byEmail.id },
|
||||||
|
data: {
|
||||||
|
provider,
|
||||||
|
providerId: profile.id,
|
||||||
|
isEmailVerified: true,
|
||||||
|
emailVerifyToken: null,
|
||||||
|
emailVerifyExpires: null,
|
||||||
|
avatarUrl: byEmail.avatarUrl || profile.photos?.[0]?.value || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return done(null, linked);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Première connexion : création du compte.
|
||||||
|
const created = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
firstname: profile.name?.givenName || profile.displayName || "Utilisateur",
|
||||||
|
lastname: profile.name?.familyName || "",
|
||||||
|
email,
|
||||||
|
password: null,
|
||||||
|
provider,
|
||||||
|
providerId: profile.id,
|
||||||
|
avatarUrl: profile.photos?.[0]?.value || null,
|
||||||
|
isEmailVerified: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Un envoi qui échoue ne doit pas bloquer la connexion : le compte
|
||||||
|
// est valide, et « mot de passe oublié » permet de rattraper.
|
||||||
|
try {
|
||||||
|
await sendPasswordSetupEmail(created, providerLabel);
|
||||||
|
} catch (mailError) {
|
||||||
|
console.error("Erreur envoi email de definition du mot de passe:", mailError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return done(null, created);
|
||||||
|
} catch (error) {
|
||||||
|
return done(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Authentification stateless : le callback OAuth émet directement notre JWT,
|
// Authentification stateless : le callback OAuth émet directement notre JWT,
|
||||||
// donc pas de session Express ni de serialize/deserialize à brancher.
|
// donc pas de session Express ni de serialize/deserialize à brancher.
|
||||||
|
|
||||||
|
const apiUrl = process.env.APP_URL || "http://localhost:5000";
|
||||||
|
|
||||||
const googleEnabled =
|
const googleEnabled =
|
||||||
Boolean(process.env.GOOGLE_CLIENT_ID) && Boolean(process.env.GOOGLE_CLIENT_SECRET);
|
Boolean(process.env.GOOGLE_CLIENT_ID) && Boolean(process.env.GOOGLE_CLIENT_SECRET);
|
||||||
|
|
||||||
|
const facebookEnabled =
|
||||||
|
Boolean(process.env.FACEBOOK_APP_ID) && Boolean(process.env.FACEBOOK_APP_SECRET);
|
||||||
|
|
||||||
if (googleEnabled) {
|
if (googleEnabled) {
|
||||||
passport.use(
|
passport.use(
|
||||||
new GoogleStrategy(
|
new GoogleStrategy(
|
||||||
{
|
{
|
||||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||||
callbackURL: `${process.env.APP_URL || "http://localhost:5000"}/api/auth/google/callback`,
|
callbackURL: `${apiUrl}/api/auth/google/callback`,
|
||||||
scope: ["profile", "email"],
|
scope: ["profile", "email"],
|
||||||
},
|
},
|
||||||
async (accessToken, refreshToken, profile, done) => {
|
verifyOAuthProfile("GOOGLE")
|
||||||
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 };
|
if (facebookEnabled) {
|
||||||
|
passport.use(
|
||||||
|
new FacebookStrategy(
|
||||||
|
{
|
||||||
|
clientID: process.env.FACEBOOK_APP_ID,
|
||||||
|
clientSecret: process.env.FACEBOOK_APP_SECRET,
|
||||||
|
callbackURL: `${apiUrl}/api/auth/facebook/callback`,
|
||||||
|
// Sans profileFields, la Graph API ne renvoie que l'id et le nom.
|
||||||
|
profileFields: ["id", "displayName", "name", "emails", "photos"],
|
||||||
|
// appsecret_proof : empêche l'usage d'un access token volé ailleurs.
|
||||||
|
enableProof: true,
|
||||||
|
},
|
||||||
|
verifyOAuthProfile("FACEBOOK")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { passport, googleEnabled, facebookEnabled, verifyOAuthProfile };
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ const bcrypt = require("bcrypt");
|
|||||||
const crypto = require("crypto");
|
const crypto = require("crypto");
|
||||||
const prisma = require("../config/prisma");
|
const prisma = require("../config/prisma");
|
||||||
const transporter = require("../config/mailer");
|
const transporter = require("../config/mailer");
|
||||||
const { getGameWindow } = require("../utils/gameWindow");
|
const { getGameWindow, effectiveClaimStatus } = require("../utils/gameWindow");
|
||||||
|
const getFrontendUrl = require("../utils/frontendUrl");
|
||||||
|
|
||||||
const AGE_BRACKETS = ["-18", "18-24", "25-34", "35-44", "45-54", "55-64", "65+"];
|
const AGE_BRACKETS = ["-18", "18-24", "25-34", "35-44", "45-54", "55-64", "65+"];
|
||||||
|
|
||||||
@@ -166,9 +167,30 @@ const getEmailingContacts = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sans caractères ambigus (0/O, 1/l/I) : l'employé peut devoir le recopier.
|
||||||
|
const TEMP_PASSWORD_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
|
||||||
|
// Aucun caractère à échapper dans l'email HTML (&, <, >, guillemets).
|
||||||
|
const TEMP_PASSWORD_SPECIALS = "!@#$%*?";
|
||||||
|
const TEMP_PASSWORD_LENGTH = 12;
|
||||||
|
|
||||||
|
// Respecte la règle commune des mots de passe (au moins un caractère spécial),
|
||||||
|
// placé à une position aléatoire.
|
||||||
|
function generateTempPassword() {
|
||||||
|
const chars = [];
|
||||||
|
for (let i = 0; i < TEMP_PASSWORD_LENGTH - 1; i++) {
|
||||||
|
chars.push(TEMP_PASSWORD_ALPHABET[crypto.randomInt(TEMP_PASSWORD_ALPHABET.length)]);
|
||||||
|
}
|
||||||
|
const special = TEMP_PASSWORD_SPECIALS[crypto.randomInt(TEMP_PASSWORD_SPECIALS.length)];
|
||||||
|
chars.splice(crypto.randomInt(chars.length + 1), 0, special);
|
||||||
|
return chars.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'admin ne choisit pas le mot de passe de l'employé : on en génère un
|
||||||
|
// provisoire, envoyé uniquement par email, que l'employé devra remplacer à
|
||||||
|
// sa première connexion (mustChangePassword).
|
||||||
const createEmployee = async (req, res) => {
|
const createEmployee = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { firstname, lastname, email, password, birthdate, gender } = req.body;
|
const { firstname, lastname, email, birthdate, gender } = req.body;
|
||||||
|
|
||||||
const existingUser = await prisma.user.findUnique({
|
const existingUser = await prisma.user.findUnique({
|
||||||
where: { email },
|
where: { email },
|
||||||
@@ -180,7 +202,8 @@ const createEmployee = async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 10);
|
const tempPassword = generateTempPassword();
|
||||||
|
const hashedPassword = await bcrypt.hash(tempPassword, 10);
|
||||||
|
|
||||||
const employee = await prisma.user.create({
|
const employee = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -192,6 +215,8 @@ const createEmployee = async (req, res) => {
|
|||||||
gender,
|
gender,
|
||||||
role: "EMPLOYEE",
|
role: "EMPLOYEE",
|
||||||
marketingOptIn: false,
|
marketingOptIn: false,
|
||||||
|
isEmailVerified: true,
|
||||||
|
mustChangePassword: true,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -203,8 +228,45 @@ const createEmployee = async (req, res) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const loginUrl = `${getFrontendUrl()}/connexion`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: process.env.MAIL_FROM,
|
||||||
|
to: email,
|
||||||
|
subject: "Votre compte employé Thé Tip Top",
|
||||||
|
html: `
|
||||||
|
<div style="font-family:sans-serif;max-width:480px;margin:auto">
|
||||||
|
<h2 style="color:#006B6B">Bienvenue ${firstname} !</h2>
|
||||||
|
<p>Un compte employé Thé Tip Top vient d'être créé pour vous.</p>
|
||||||
|
<p>Voici vos identifiants de première connexion :</p>
|
||||||
|
<p style="background:#f3f4f6;padding:12px 16px;border-radius:8px">
|
||||||
|
Email : <strong>${email}</strong><br />
|
||||||
|
Mot de passe provisoire : <strong style="font-family:monospace;font-size:16px">${tempPassword}</strong>
|
||||||
|
</p>
|
||||||
|
<p style="text-align:center;padding:20px 0">
|
||||||
|
<a href="${loginUrl}" style="background:#006B6B;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;display:inline-block">
|
||||||
|
Me connecter
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="color:#888;font-size:13px">Dès votre première connexion, vous devrez
|
||||||
|
choisir un nouveau mot de passe. Le mot de passe provisoire ne fonctionnera
|
||||||
|
plus ensuite.</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (mailError) {
|
||||||
|
// Sans l'email, l'employé n'a aucun moyen de connaître son mot de passe :
|
||||||
|
// on annule la création pour que l'admin puisse simplement réessayer.
|
||||||
|
console.error("Erreur envoi email employé:", mailError);
|
||||||
|
await prisma.user.delete({ where: { id: employee.id } });
|
||||||
|
return res.status(502).json({
|
||||||
|
message: "L'email d'invitation n'a pas pu être envoyé : le compte n'a pas été créé. Réessayez.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
message: "Employé créé avec succès.",
|
message: "Employé créé. Un email avec un mot de passe provisoire lui a été envoyé.",
|
||||||
employee,
|
employee,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -427,11 +489,137 @@ const getGrandPrizeWinner = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PERSON_SELECT = { id: true, firstname: true, lastname: true, email: true };
|
||||||
|
|
||||||
|
// Comptes employés, avec le nombre de lots que chacun a remis. `q` filtre sur
|
||||||
|
// le prénom, le nom ou l'email.
|
||||||
|
const getEmployees = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const q = typeof req.query.q === "string" ? req.query.q.trim() : "";
|
||||||
|
const contains = { contains: q, mode: "insensitive" };
|
||||||
|
|
||||||
|
const employees = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
role: "EMPLOYEE",
|
||||||
|
...(q && { OR: [{ firstname: contains }, { lastname: contains }, { email: contains }] }),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
...PERSON_SELECT,
|
||||||
|
createdAt: true,
|
||||||
|
mustChangePassword: true,
|
||||||
|
_count: { select: { claimsValidated: { where: { status: "DELIVERED" } } } },
|
||||||
|
},
|
||||||
|
orderBy: [{ lastname: "asc" }, { firstname: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
employees: employees.map(({ _count, ...employee }) => ({
|
||||||
|
...employee,
|
||||||
|
deliveredCount: _count.claimsValidated,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur liste employés:", error);
|
||||||
|
return res.status(500).json({ message: "Erreur serveur lors de la récupération des employés." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lots remis par un employé, du plus récent au plus ancien.
|
||||||
|
const getEmployeeDeliveries = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(id)) {
|
||||||
|
return res.status(400).json({ message: "Identifiant d'employé invalide." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const employee = await prisma.user.findFirst({
|
||||||
|
where: { id, role: "EMPLOYEE" },
|
||||||
|
select: PERSON_SELECT,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!employee) {
|
||||||
|
return res.status(404).json({ message: "Employé introuvable." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = await prisma.claim.findMany({
|
||||||
|
where: { validatedByEmployeeId: id, status: "DELIVERED" },
|
||||||
|
orderBy: { claimedAt: "desc" },
|
||||||
|
include: {
|
||||||
|
participation: {
|
||||||
|
include: {
|
||||||
|
ticket: { select: { code: true } },
|
||||||
|
lot: { select: { name: true, value: true } },
|
||||||
|
user: { select: PERSON_SELECT },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
employee,
|
||||||
|
deliveries: claims.map((claim) => ({
|
||||||
|
claimId: claim.id,
|
||||||
|
ticketCode: claim.participation.ticket.code,
|
||||||
|
lot: claim.participation.lot,
|
||||||
|
client: claim.participation.user,
|
||||||
|
deliveredAt: claim.claimedAt,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur remises employé:", error);
|
||||||
|
return res.status(500).json({ message: "Erreur serveur lors de la récupération des remises." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Traçabilité d'un ticket : qui l'a joué, quel lot, et quel employé l'a remis
|
||||||
|
// et quand. `status` vaut NOT_PLAYED tant qu'aucun client ne l'a saisi.
|
||||||
|
const getDeliveryByTicketCode = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const code = req.params.code.trim().toUpperCase();
|
||||||
|
const gameWindow = await getGameWindow();
|
||||||
|
|
||||||
|
const ticket = await prisma.ticket.findUnique({
|
||||||
|
where: { code },
|
||||||
|
include: {
|
||||||
|
lot: { select: { name: true, value: true } },
|
||||||
|
participation: {
|
||||||
|
include: {
|
||||||
|
user: { select: PERSON_SELECT },
|
||||||
|
claim: { include: { validatedByEmployee: { select: PERSON_SELECT } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return res.status(404).json({ message: "Aucun ticket ne correspond à ce numéro." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const claim = ticket.participation?.claim;
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
ticketCode: ticket.code,
|
||||||
|
lot: ticket.lot,
|
||||||
|
status: claim ? effectiveClaimStatus(claim.status, gameWindow) : "NOT_PLAYED",
|
||||||
|
playedAt: ticket.usedAt,
|
||||||
|
client: ticket.participation?.user ?? null,
|
||||||
|
deliveredBy: claim?.validatedByEmployee ?? null,
|
||||||
|
deliveredAt: claim?.status === "DELIVERED" ? claim.claimedAt : null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur recherche remise:", error);
|
||||||
|
return res.status(500).json({ message: "Erreur serveur lors de la recherche du ticket." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getStats,
|
getStats,
|
||||||
getUsers,
|
getUsers,
|
||||||
getEmailingContacts,
|
getEmailingContacts,
|
||||||
createEmployee,
|
createEmployee,
|
||||||
|
getEmployees,
|
||||||
|
getEmployeeDeliveries,
|
||||||
|
getDeliveryByTicketCode,
|
||||||
drawWinner,
|
drawWinner,
|
||||||
getGrandPrizeWinner,
|
getGrandPrizeWinner,
|
||||||
setGameSettings,
|
setGameSettings,
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ const jwt = require("jsonwebtoken");
|
|||||||
const crypto = require("crypto");
|
const crypto = require("crypto");
|
||||||
const prisma = require("../config/prisma");
|
const prisma = require("../config/prisma");
|
||||||
const transporter = require("../config/mailer");
|
const transporter = require("../config/mailer");
|
||||||
|
const getFrontendUrl = require("../utils/frontendUrl");
|
||||||
|
const { passwordRuleError } = require("../validations/password.validation");
|
||||||
|
const { normalizeEmail } = require("../validations/email.validation");
|
||||||
|
|
||||||
const register = async (req, res) => {
|
const register = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -210,6 +213,7 @@ const login = async (req, res) => {
|
|||||||
lastname: user.lastname,
|
lastname: user.lastname,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
mustChangePassword: user.mustChangePassword,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -220,9 +224,47 @@ const login = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RESET_LINK_EXPIRY_MS = 1000 * 60 * 30; // 30 min
|
||||||
|
|
||||||
|
// Enregistre un jeton de réinitialisation (qui remplace le précédent) et envoie
|
||||||
|
// le lien par email. Partagé par « mot de passe oublié » et par le profil.
|
||||||
|
async function sendPasswordResetEmail(user) {
|
||||||
|
const resetPasswordToken = crypto.randomBytes(32).toString("hex");
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
resetPasswordToken,
|
||||||
|
resetPasswordExpires: new Date(Date.now() + RESET_LINK_EXPIRY_MS),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Le lien doit ouvrir le formulaire du front, pas la route POST de l'API :
|
||||||
|
// un clic depuis un email est une requête GET, qui tombait en 404.
|
||||||
|
const resetUrl = `${getFrontendUrl()}/definir-mot-de-passe/${resetPasswordToken}`;
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: process.env.MAIL_FROM,
|
||||||
|
to: user.email,
|
||||||
|
subject: "Réinitialisation de votre mot de passe Thé Tip Top",
|
||||||
|
html: `
|
||||||
|
<h2>Réinitialisation du mot de passe</h2>
|
||||||
|
<p>Bonjour ${user.firstname},</p>
|
||||||
|
<p>Vous avez demandé à définir un nouveau mot de passe.</p>
|
||||||
|
<p>Cliquez sur le lien ci-dessous pour le choisir :</p>
|
||||||
|
<a href="${resetUrl}">Choisir mon nouveau mot de passe</a>
|
||||||
|
<p>Ce lien expire dans 30 minutes.</p>
|
||||||
|
<p>Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORGOT_PASSWORD_MESSAGE =
|
||||||
|
"Si un compte existe avec cet email, un lien de réinitialisation vient de lui être envoyé.";
|
||||||
|
|
||||||
const forgotPassword = async (req, res) => {
|
const forgotPassword = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { email } = req.body;
|
const email = normalizeEmail(req.body.email);
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -236,45 +278,12 @@ const forgotPassword = async (req, res) => {
|
|||||||
|
|
||||||
// Sécurité : ne pas dire si l'email existe ou non
|
// Sécurité : ne pas dire si l'email existe ou non
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(200).json({
|
return res.status(200).json({ message: FORGOT_PASSWORD_MESSAGE });
|
||||||
message: "Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetPasswordToken = crypto.randomBytes(32).toString("hex");
|
await sendPasswordResetEmail(user);
|
||||||
const resetPasswordExpires = new Date(Date.now() + 1000 * 60 * 30); // 30 min
|
|
||||||
|
|
||||||
await prisma.user.update({
|
return res.status(200).json({ message: FORGOT_PASSWORD_MESSAGE });
|
||||||
where: { id: user.id },
|
|
||||||
data: {
|
|
||||||
resetPasswordToken,
|
|
||||||
resetPasswordExpires,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
to: user.email,
|
|
||||||
subject: "Réinitialisation de votre mot de passe Thé Tip Top",
|
|
||||||
html: `
|
|
||||||
<h2>Réinitialisation du mot de passe</h2>
|
|
||||||
<p>Bonjour ${user.firstname},</p>
|
|
||||||
<p>Vous avez demandé la réinitialisation de votre mot de passe.</p>
|
|
||||||
<p>Cliquez sur le lien ci-dessous pour définir un nouveau mot de passe :</p>
|
|
||||||
<a href="${resetUrl}">Réinitialiser mon mot de passe</a>
|
|
||||||
<p>Ce lien expire dans 30 minutes.</p>
|
|
||||||
<p>Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.</p>
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(200).json({
|
|
||||||
message: "Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erreur forgotPassword:", error);
|
console.error("Erreur forgotPassword:", error);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
@@ -283,15 +292,27 @@ const forgotPassword = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Profil connecté (compte Google sans mot de passe) : même lien, envoyé à
|
||||||
|
// l'adresse du compte. Authentifié, donc sans captcha.
|
||||||
|
const sendPasswordSetupLink = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
|
||||||
|
await sendPasswordResetEmail(user);
|
||||||
|
return res.json({ message: `Lien envoyé à ${user.email}.` });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur sendPasswordSetupLink:", error);
|
||||||
|
return res.status(500).json({ message: "Erreur serveur lors de l'envoi du lien." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const resetPassword = async (req, res) => {
|
const resetPassword = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { token } = req.params;
|
const { token } = req.params;
|
||||||
const { password, confirmPassword } = req.body;
|
const { password, confirmPassword } = req.body;
|
||||||
|
|
||||||
if (!password || password.length < 8) {
|
const passwordError = passwordRuleError(password);
|
||||||
return res.status(400).json({
|
if (passwordError) {
|
||||||
message: "Le nouveau mot de passe doit contenir au moins 8 caractères.",
|
return res.status(400).json({ message: passwordError });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// La confirmation est vérifiée côté serveur aussi : le contrôle du
|
// La confirmation est vérifiée côté serveur aussi : le contrôle du
|
||||||
@@ -325,6 +346,14 @@ const resetPassword = async (req, res) => {
|
|||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
resetPasswordToken: null,
|
resetPasswordToken: null,
|
||||||
resetPasswordExpires: null,
|
resetPasswordExpires: null,
|
||||||
|
// Le lien reçu par email prouve l'accès à la boîte : le mot de passe
|
||||||
|
// choisi ici remplace aussi un éventuel mot de passe provisoire, et
|
||||||
|
// l'adresse est considérée comme validée (sinon la connexion qui suit
|
||||||
|
// serait refusée pour email non vérifié).
|
||||||
|
mustChangePassword: false,
|
||||||
|
isEmailVerified: true,
|
||||||
|
emailVerifyToken: null,
|
||||||
|
emailVerifyExpires: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -341,7 +370,8 @@ const resetPassword = async (req, res) => {
|
|||||||
|
|
||||||
const verifyCode = async (req, res) => {
|
const verifyCode = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { email, code } = req.body;
|
const { code } = req.body;
|
||||||
|
const email = normalizeEmail(req.body.email);
|
||||||
|
|
||||||
if (!email || !code) {
|
if (!email || !code) {
|
||||||
return res.status(400).json({ message: "Email et code requis." });
|
return res.status(400).json({ message: "Email et code requis." });
|
||||||
@@ -397,7 +427,7 @@ const verifyCode = async (req, res) => {
|
|||||||
// le même JWT que la connexion classique et on renvoie le navigateur vers le
|
// 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.
|
// front, qui récupère le token dans l'URL.
|
||||||
const oauthCallback = (req, res) => {
|
const oauthCallback = (req, res) => {
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
|
const frontendUrl = getFrontendUrl();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = req.user;
|
const user = req.user;
|
||||||
@@ -423,10 +453,15 @@ const oauthCallback = (req, res) => {
|
|||||||
lastname: user.lastname,
|
lastname: user.lastname,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
mustChangePassword: user.mustChangePassword,
|
||||||
})
|
})
|
||||||
).toString("base64");
|
).toString("base64");
|
||||||
|
|
||||||
return res.redirect(`${frontendUrl}/auth/callback?token=${token}&user=${payload}`);
|
// encodeURIComponent : le base64 peut contenir des « + » que le front
|
||||||
|
// relirait comme des espaces dans la query string.
|
||||||
|
return res.redirect(
|
||||||
|
`${frontendUrl}/auth/callback?token=${token}&user=${encodeURIComponent(payload)}`
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erreur oauthCallback:", error);
|
console.error("Erreur oauthCallback:", error);
|
||||||
return res.redirect(
|
return res.redirect(
|
||||||
@@ -486,6 +521,47 @@ const deleteAccount = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Première connexion d'un employé : remplace le mot de passe provisoire reçu
|
||||||
|
// par email. L'utilisateur vient de s'authentifier avec ce mot de passe, on ne
|
||||||
|
// le redemande donc pas.
|
||||||
|
const setInitialPassword = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { password, confirmPassword } = req.body;
|
||||||
|
|
||||||
|
if (!req.user.mustChangePassword) {
|
||||||
|
return res.status(400).json({ message: "Votre mot de passe a déjà été défini." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordError = passwordRuleError(password);
|
||||||
|
if (passwordError) {
|
||||||
|
return res.status(400).json({ message: passwordError });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
return res.status(400).json({ message: "Les deux mots de passe ne correspondent pas." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
|
||||||
|
|
||||||
|
if (user.password && (await bcrypt.compare(password, user.password))) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: "Choisissez un mot de passe différent du mot de passe provisoire.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashed = await bcrypt.hash(password, 10);
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { password: hashed, mustChangePassword: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ message: "Mot de passe enregistré." });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur setInitialPassword:", error);
|
||||||
|
return res.status(500).json({ message: "Erreur serveur lors de l'enregistrement du mot de passe." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const changePassword = async (req, res) => {
|
const changePassword = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { currentPassword, newPassword } = req.body;
|
const { currentPassword, newPassword } = req.body;
|
||||||
@@ -495,8 +571,9 @@ const changePassword = async (req, res) => {
|
|||||||
return res.status(400).json({ message: "Les deux mots de passe sont requis." });
|
return res.status(400).json({ message: "Les deux mots de passe sont requis." });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword.length < 8) {
|
const passwordError = passwordRuleError(newPassword);
|
||||||
return res.status(400).json({ message: "Le nouveau mot de passe doit faire au moins 8 caractères." });
|
if (passwordError) {
|
||||||
|
return res.status(400).json({ message: passwordError });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
@@ -531,7 +608,9 @@ module.exports = {
|
|||||||
verifyCode,
|
verifyCode,
|
||||||
resetPassword,
|
resetPassword,
|
||||||
forgotPassword,
|
forgotPassword,
|
||||||
|
sendPasswordSetupLink,
|
||||||
changePassword,
|
changePassword,
|
||||||
|
setInitialPassword,
|
||||||
deleteAccount,
|
deleteAccount,
|
||||||
oauthCallback,
|
oauthCallback,
|
||||||
};
|
};
|
||||||
@@ -185,7 +185,20 @@ const myGains = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Dates publiques du jeu-concours, affichées sur la page d'accueil : elles
|
||||||
|
// suivent la date de début fixée par l'admin.
|
||||||
|
const getPublicGameWindow = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { configured, phase, startDate, participationDeadline, claimDeadline } = await getGameWindow();
|
||||||
|
return res.json({ configured, phase, startDate, participationDeadline, claimDeadline });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lecture dates du jeu:", error);
|
||||||
|
return res.status(500).json({ message: "Erreur serveur lors de la lecture des dates du jeu-concours." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
participate,
|
participate,
|
||||||
myGains,
|
myGains,
|
||||||
|
getPublicGameWindow,
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
const jwt = require("jsonwebtoken");
|
const jwt = require("jsonwebtoken");
|
||||||
const prisma = require("../config/prisma");
|
const prisma = require("../config/prisma");
|
||||||
|
|
||||||
|
// Seules routes accessibles tant qu'un mot de passe provisoire n'a pas été
|
||||||
|
// remplacé : sans ce filtre côté serveur, il suffirait d'appeler l'API
|
||||||
|
// directement pour contourner la page du front.
|
||||||
|
const ALLOWED_WHILE_PASSWORD_CHANGE_REQUIRED = ["/api/auth/me", "/api/auth/initial-password"];
|
||||||
|
|
||||||
const authMiddleware = async (req, res, next) => {
|
const authMiddleware = async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
@@ -25,6 +30,7 @@ const authMiddleware = async (req, res, next) => {
|
|||||||
role: true,
|
role: true,
|
||||||
provider: true,
|
provider: true,
|
||||||
password: true,
|
password: true,
|
||||||
|
mustChangePassword: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,6 +40,14 @@ const authMiddleware = async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const route = `${req.baseUrl}${req.path}`.replace(/\/+$/, "");
|
||||||
|
if (user.mustChangePassword && !ALLOWED_WHILE_PASSWORD_CHANGE_REQUIRED.includes(route)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
message: "Vous devez choisir un nouveau mot de passe avant de continuer.",
|
||||||
|
code: "PASSWORD_CHANGE_REQUIRED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// req.user est renvoyé tel quel par /api/auth/me : on remplace le hash par
|
// 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
|
// un simple booléen, qui suffit au profil pour proposer « définir » ou
|
||||||
// « modifier » son mot de passe.
|
// « modifier » son mot de passe.
|
||||||
|
|||||||
34
backend/src/middlewares/captcha.middleware.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
const VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
||||||
|
|
||||||
|
// Vérifie le jeton Cloudflare Turnstile envoyé par le front dans
|
||||||
|
// `captchaToken`. Sans TURNSTILE_SECRET_KEY, la vérification est ignorée
|
||||||
|
// (même logique que googleEnabled dans config/passport.js).
|
||||||
|
// À placer AVANT validate() : zod retire les clés inconnues de req.body.
|
||||||
|
const verifyCaptcha = async (req, res, next) => {
|
||||||
|
const secret = process.env.TURNSTILE_SECRET_KEY;
|
||||||
|
if (!secret) return next();
|
||||||
|
|
||||||
|
const token = req.body?.captchaToken;
|
||||||
|
if (!token) {
|
||||||
|
return res.status(400).json({ message: "Veuillez confirmer que vous n'êtes pas un robot." });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(VERIFY_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ secret, response: token, remoteip: req.ip }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
return res.status(400).json({ message: "Vérification anti-robot échouée, veuillez réessayer." });
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur vérification Turnstile :", error);
|
||||||
|
return res.status(503).json({ message: "Service de vérification indisponible, réessayez plus tard." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = verifyCaptcha;
|
||||||
@@ -4,6 +4,9 @@ const {
|
|||||||
getUsers,
|
getUsers,
|
||||||
getEmailingContacts,
|
getEmailingContacts,
|
||||||
createEmployee,
|
createEmployee,
|
||||||
|
getEmployees,
|
||||||
|
getEmployeeDeliveries,
|
||||||
|
getDeliveryByTicketCode,
|
||||||
drawWinner,
|
drawWinner,
|
||||||
getGrandPrizeWinner,
|
getGrandPrizeWinner,
|
||||||
setGameSettings,
|
setGameSettings,
|
||||||
@@ -81,6 +84,73 @@ router.get("/emailing", getEmailingContacts);
|
|||||||
*/
|
*/
|
||||||
router.post("/employees", validate(createEmployeeSchema), createEmployee);
|
router.post("/employees", validate(createEmployeeSchema), createEmployee);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/admin/employees:
|
||||||
|
* get:
|
||||||
|
* summary: Liste des comptes employés avec leur nombre de lots remis
|
||||||
|
* tags:
|
||||||
|
* - Admin
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: q
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Filtre sur le prénom, le nom ou l'email
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Employés
|
||||||
|
*/
|
||||||
|
router.get("/employees", getEmployees);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/admin/employees/{id}/deliveries:
|
||||||
|
* get:
|
||||||
|
* summary: Lots remis par un employé
|
||||||
|
* tags:
|
||||||
|
* - Admin
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Employé et liste de ses remises (ticket, lot, client, date)
|
||||||
|
* 404:
|
||||||
|
* description: Employé introuvable
|
||||||
|
*/
|
||||||
|
router.get("/employees/:id/deliveries", getEmployeeDeliveries);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/admin/deliveries/{code}:
|
||||||
|
* get:
|
||||||
|
* summary: Traçabilité d'un ticket (lot, client, employé ayant remis le lot, date)
|
||||||
|
* tags:
|
||||||
|
* - Admin
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: code
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Statut du ticket (NOT_PLAYED, PENDING, DELIVERED, CANCELLED) et remise éventuelle
|
||||||
|
* 404:
|
||||||
|
* description: Aucun ticket ne correspond à ce numéro
|
||||||
|
*/
|
||||||
|
router.get("/deliveries/:code", getDeliveryByTicketCode);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
* /api/admin/draw:
|
* /api/admin/draw:
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount, oauthCallback } = require("../controllers/auth.controller");
|
const { register, login, verifyEmail, verifyCode, forgotPassword, sendPasswordSetupLink, resetPassword, me, changePassword, setInitialPassword, deleteAccount, oauthCallback } = require("../controllers/auth.controller");
|
||||||
const authMiddleware = require("../middlewares/auth.middleware");
|
const authMiddleware = require("../middlewares/auth.middleware");
|
||||||
const validate = require("../middlewares/validate.middleware");
|
const validate = require("../middlewares/validate.middleware");
|
||||||
|
const verifyCaptcha = require("../middlewares/captcha.middleware");
|
||||||
const { registerSchema, loginSchema } = require("../validations/auth.validation");
|
const { registerSchema, loginSchema } = require("../validations/auth.validation");
|
||||||
const { passport, googleEnabled } = require("../config/passport");
|
const { passport, googleEnabled, facebookEnabled } = require("../config/passport");
|
||||||
|
const getFrontendUrl = require("../utils/frontendUrl");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -33,7 +35,7 @@ const router = express.Router();
|
|||||||
* 201:
|
* 201:
|
||||||
* description: Utilisateur créé
|
* description: Utilisateur créé
|
||||||
*/
|
*/
|
||||||
router.post("/register", validate(registerSchema), register);
|
router.post("/register", verifyCaptcha, validate(registerSchema), register);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
@@ -49,7 +51,7 @@ router.post("/register", validate(registerSchema), register);
|
|||||||
* description: Connexion réussie
|
* description: Connexion réussie
|
||||||
*/
|
*/
|
||||||
|
|
||||||
router.post("/login", validate(loginSchema), login);
|
router.post("/login", verifyCaptcha, validate(loginSchema), login);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
@@ -111,16 +113,36 @@ router.get("/verify-email/:token",verifyEmail);
|
|||||||
* type: string
|
* type: string
|
||||||
* format: email
|
* format: email
|
||||||
* example: jean.dupont@gmail.com
|
* example: jean.dupont@gmail.com
|
||||||
|
* captchaToken:
|
||||||
|
* type: string
|
||||||
|
* description: Jeton Cloudflare Turnstile (exigé si TURNSTILE_SECRET_KEY est configurée)
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.
|
* description: Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.
|
||||||
* 400:
|
* 400:
|
||||||
* description: Email requis.
|
* description: Email requis, ou vérification anti-robot manquante.
|
||||||
* 500:
|
* 500:
|
||||||
* description: Erreur serveur.
|
* description: Erreur serveur.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
router.post("/forgot-password", forgotPassword);
|
// Page publique : le captcha empêche d'envoyer des emails en masse vers
|
||||||
|
// n'importe quelle adresse.
|
||||||
|
router.post("/forgot-password", verifyCaptcha, forgotPassword);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/auth/password-setup-link:
|
||||||
|
* post:
|
||||||
|
* summary: Envoie à l'utilisateur connecté un lien pour définir son mot de passe
|
||||||
|
* tags:
|
||||||
|
* - Auth
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Lien envoyé à l'adresse du compte.
|
||||||
|
*/
|
||||||
|
router.post("/password-setup-link", authMiddleware, sendPasswordSetupLink);
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
* /api/auth/reset-password/{token}:
|
* /api/auth/reset-password/{token}:
|
||||||
@@ -160,6 +182,37 @@ router.post("/reset-password/:token", resetPassword);
|
|||||||
|
|
||||||
router.post("/verify-code", verifyCode);
|
router.post("/verify-code", verifyCode);
|
||||||
router.patch("/change-password", authMiddleware, changePassword);
|
router.patch("/change-password", authMiddleware, changePassword);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/auth/initial-password:
|
||||||
|
* post:
|
||||||
|
* summary: Remplace le mot de passe provisoire d'un employé à sa première connexion
|
||||||
|
* tags:
|
||||||
|
* - Auth
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - password
|
||||||
|
* - confirmPassword
|
||||||
|
* properties:
|
||||||
|
* password:
|
||||||
|
* type: string
|
||||||
|
* confirmPassword:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Mot de passe enregistré, l'accès complet est rétabli.
|
||||||
|
* 400:
|
||||||
|
* description: Mot de passe invalide, non confirmé, identique au provisoire ou déjà défini.
|
||||||
|
*/
|
||||||
|
router.post("/initial-password", authMiddleware, setInitialPassword);
|
||||||
router.delete("/account", authMiddleware, deleteAccount);
|
router.delete("/account", authMiddleware, deleteAccount);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -187,30 +240,73 @@ router.delete("/account", authMiddleware, deleteAccount);
|
|||||||
* 302:
|
* 302:
|
||||||
* description: Redirection vers FRONTEND_URL/auth/callback avec le token.
|
* description: Redirection vers FRONTEND_URL/auth/callback avec le token.
|
||||||
*/
|
*/
|
||||||
if (googleEnabled) {
|
|
||||||
router.get(
|
/**
|
||||||
"/google",
|
* @swagger
|
||||||
passport.authenticate("google", { scope: ["profile", "email"], session: false })
|
* /api/auth/facebook:
|
||||||
);
|
* get:
|
||||||
|
* summary: Démarre la connexion via Facebook (redirection OAuth 2.0)
|
||||||
|
* tags:
|
||||||
|
* - Auth
|
||||||
|
* responses:
|
||||||
|
* 302:
|
||||||
|
* description: Redirection vers l'écran de consentement Facebook.
|
||||||
|
* 503:
|
||||||
|
* description: Connexion Facebook non configurée sur ce serveur.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/auth/facebook/callback:
|
||||||
|
* get:
|
||||||
|
* summary: Retour de Facebook, émet le JWT et redirige vers le front
|
||||||
|
* tags:
|
||||||
|
* - Auth
|
||||||
|
* responses:
|
||||||
|
* 302:
|
||||||
|
* description: Redirection vers FRONTEND_URL/auth/callback avec le token.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function mountOAuthRoutes({ path, strategy, label, enabled, scope }) {
|
||||||
|
if (!enabled) {
|
||||||
|
// Sans identifiants configurés, on répond explicitement plutôt que de
|
||||||
|
// laisser passport planter au démarrage.
|
||||||
|
const notConfigured = (req, res) =>
|
||||||
|
res.status(503).json({ message: `La connexion ${label} n'est pas configurée sur ce serveur.` });
|
||||||
|
|
||||||
|
router.get(path, notConfigured);
|
||||||
|
router.get(`${path}/callback`, notConfigured);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get(path, passport.authenticate(strategy, { scope, session: false }));
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
"/google/callback",
|
`${path}/callback`,
|
||||||
passport.authenticate("google", {
|
passport.authenticate(strategy, {
|
||||||
session: false,
|
session: false,
|
||||||
failureRedirect: `${process.env.FRONTEND_URL || "http://localhost:5173"}/auth/callback?error=${encodeURIComponent(
|
failureRedirect: `${getFrontendUrl()}/auth/callback?error=${encodeURIComponent(
|
||||||
"Connexion Google refusée."
|
`Connexion ${label} refusée.`
|
||||||
)}`,
|
)}`,
|
||||||
}),
|
}),
|
||||||
oauthCallback
|
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mountOAuthRoutes({
|
||||||
|
path: "/google",
|
||||||
|
strategy: "google",
|
||||||
|
label: "Google",
|
||||||
|
enabled: googleEnabled,
|
||||||
|
scope: ["profile", "email"],
|
||||||
|
});
|
||||||
|
|
||||||
|
mountOAuthRoutes({
|
||||||
|
path: "/facebook",
|
||||||
|
strategy: "facebook",
|
||||||
|
label: "Facebook",
|
||||||
|
enabled: facebookEnabled,
|
||||||
|
scope: ["email"],
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -36,7 +36,7 @@ const router = express.Router();
|
|||||||
router.get(
|
router.get(
|
||||||
"/ticket/:code",
|
"/ticket/:code",
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
roleMiddleware("EMPLOYEE", "ADMIN"),
|
roleMiddleware("EMPLOYEE"),
|
||||||
getGainByTicketCode
|
getGainByTicketCode
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ router.get(
|
|||||||
router.patch(
|
router.patch(
|
||||||
"/claim/:id/deliver",
|
"/claim/:id/deliver",
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
roleMiddleware("EMPLOYEE", "ADMIN"),
|
roleMiddleware("EMPLOYEE"),
|
||||||
deliverClaim
|
deliverClaim
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const { participate, myGains } = require("../controllers/game.controller");
|
const { participate, myGains, getPublicGameWindow } = require("../controllers/game.controller");
|
||||||
const authMiddleware = require("../middlewares/auth.middleware");
|
const authMiddleware = require("../middlewares/auth.middleware");
|
||||||
const roleMiddleware = require("../middlewares/role.middleware");
|
const roleMiddleware = require("../middlewares/role.middleware");
|
||||||
const validate = require("../middlewares/validate.middleware");
|
const validate = require("../middlewares/validate.middleware");
|
||||||
@@ -41,4 +41,17 @@ router.post("/participate", authMiddleware, roleMiddleware("CLIENT"), validate(p
|
|||||||
|
|
||||||
router.get("/my-gains", authMiddleware, roleMiddleware("CLIENT"), myGains);
|
router.get("/my-gains", authMiddleware, roleMiddleware("CLIENT"), myGains);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/game/window:
|
||||||
|
* get:
|
||||||
|
* summary: Dates publiques du jeu-concours (début, fin des participations, fin des retraits)
|
||||||
|
* tags:
|
||||||
|
* - Game
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Phase et échéances courantes ; dates nulles si le jeu n'est pas configuré
|
||||||
|
*/
|
||||||
|
router.get("/window", getPublicGameWindow);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
|
require('dotenv').config()
|
||||||
|
|
||||||
const bcrypt = require('bcrypt')
|
const bcrypt = require('bcrypt')
|
||||||
const prisma = require('../config/prisma')
|
const prisma = require('../config/prisma')
|
||||||
|
|
||||||
async function createAdmin() {
|
async function createAdmin() {
|
||||||
const email = 'admin@thetiptop.fr'
|
const email = process.env.ADMIN_EMAIL
|
||||||
const password = 'Admin@TipTop2026!'
|
const password = process.env.ADMIN_PASSWORD
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
throw new Error('ADMIN_EMAIL et ADMIN_PASSWORD doivent être définis')
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await prisma.user.findUnique({ where: { email } })
|
const existing = await prisma.user.findUnique({ where: { email } })
|
||||||
|
|
||||||
|
|||||||
9
backend/src/utils/frontendUrl.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// FRONTEND_URL peut lister plusieurs origines séparées par des virgules (prod :
|
||||||
|
// domaine nu + www), ce qui sert à CORS dans app.js. Pour construire un lien
|
||||||
|
// (email, redirection OAuth), on ne garde que la première, l'URL canonique.
|
||||||
|
function getFrontendUrl() {
|
||||||
|
const first = (process.env.FRONTEND_URL || "").split(",")[0].trim();
|
||||||
|
return (first || "http://localhost:5173").replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = getFrontendUrl;
|
||||||
@@ -11,6 +11,11 @@ const PHASES = {
|
|||||||
CLOSED: "CLOSED",
|
CLOSED: "CLOSED",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Date de lancement prévue par le règlement : du 2 au 31 octobre 2026 (heure
|
||||||
|
// de Paris). Utilisée pour initialiser la base ; l'admin peut la corriger
|
||||||
|
// depuis son tableau de bord.
|
||||||
|
const DEFAULT_START_DATE = new Date("2026-10-02T00:00:00+02:00");
|
||||||
|
|
||||||
const GAME_DURATION_DAYS = 30;
|
const GAME_DURATION_DAYS = 30;
|
||||||
const CLAIM_EXTENSION_DAYS = 30;
|
const CLAIM_EXTENSION_DAYS = 30;
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
@@ -95,6 +100,7 @@ module.exports = {
|
|||||||
effectiveClaimStatus,
|
effectiveClaimStatus,
|
||||||
participationClosedMessage,
|
participationClosedMessage,
|
||||||
PHASES,
|
PHASES,
|
||||||
|
DEFAULT_START_DATE,
|
||||||
GAME_DURATION_DAYS,
|
GAME_DURATION_DAYS,
|
||||||
CLAIM_EXTENSION_DAYS,
|
CLAIM_EXTENSION_DAYS,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
const { z } = require("zod");
|
const { z } = require("zod");
|
||||||
|
const { emailSchema } = require("./email.validation");
|
||||||
|
|
||||||
const createEmployeeSchema = z.object({
|
const createEmployeeSchema = z.object({
|
||||||
firstname: z.string().min(2),
|
firstname: z.string().min(2),
|
||||||
lastname: z.string().min(2),
|
lastname: z.string().min(2),
|
||||||
email: z.string().email(),
|
// Pas de mot de passe : il est généré côté serveur (voir createEmployee).
|
||||||
password: z.string().min(8),
|
email: emailSchema,
|
||||||
birthdate: z.string().optional(),
|
birthdate: z.string().optional(),
|
||||||
gender: z.string().optional(),
|
gender: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
const { z } = require("zod");
|
const { z } = require("zod");
|
||||||
|
const { passwordSchema } = require("./password.validation");
|
||||||
|
const { emailSchema } = require("./email.validation");
|
||||||
|
|
||||||
const registerSchema = z.object({
|
const registerSchema = z
|
||||||
firstname: z.string().min(2, "Le prénom doit contenir au moins 2 caractères"),
|
.object({
|
||||||
lastname: z.string().min(2, "Le nom doit contenir au moins 2 caractères"),
|
firstname: z.string().min(2, "Le prénom doit contenir au moins 2 caractères"),
|
||||||
email: z.string().email("Email invalide"),
|
lastname: z.string().min(2, "Le nom doit contenir au moins 2 caractères"),
|
||||||
password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caractères"),
|
email: emailSchema,
|
||||||
birthdate: z.string().optional(),
|
password: passwordSchema,
|
||||||
gender: z.string().optional(),
|
// Vérifiée aussi côté serveur : le contrôle du navigateur ne protège pas
|
||||||
marketingOptIn: z.boolean().optional(),
|
// un appel direct à l'API.
|
||||||
});
|
confirmPassword: z.string({ error: "Veuillez confirmer le mot de passe" }),
|
||||||
|
birthdate: z.string().optional(),
|
||||||
|
gender: z.string().optional(),
|
||||||
|
marketingOptIn: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: "Les deux mots de passe ne correspondent pas",
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
});
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
email: z.string().email("Email invalide"),
|
email: emailSchema,
|
||||||
password: z.string().min(1, "Mot de passe requis"),
|
password: z.string().min(1, "Mot de passe requis"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
11
backend/src/validations/email.validation.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
const { z } = require("zod");
|
||||||
|
|
||||||
|
// Les emails sont stockés et recherchés en minuscules, sans espaces autour :
|
||||||
|
// « Jean@Exemple.fr » et « jean@exemple.fr » désignent le même compte.
|
||||||
|
function normalizeEmail(email) {
|
||||||
|
return typeof email === "string" ? email.trim().toLowerCase() : email;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailSchema = z.string().trim().toLowerCase().email("Email invalide");
|
||||||
|
|
||||||
|
module.exports = { emailSchema, normalizeEmail };
|
||||||
21
backend/src/validations/password.validation.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const { z } = require("zod");
|
||||||
|
|
||||||
|
// Règle de mot de passe, reprise à l'identique par le front
|
||||||
|
// (frontend/src/utils/passwordRules.js) : plus de 7 caractères et au moins un
|
||||||
|
// caractère spécial, c'est-à-dire ni lettre (accentuée comprise), ni chiffre,
|
||||||
|
// ni espace.
|
||||||
|
const SPECIAL_CHARACTER = /[^\p{L}\p{N}\s]/u;
|
||||||
|
|
||||||
|
const passwordSchema = z
|
||||||
|
.string()
|
||||||
|
.min(8, "Le mot de passe doit contenir au moins 8 caractères")
|
||||||
|
.regex(SPECIAL_CHARACTER, "Le mot de passe doit contenir au moins un caractère spécial");
|
||||||
|
|
||||||
|
// Pour les contrôleurs qui valident à la main : message de la première règle
|
||||||
|
// non respectée, ou null si le mot de passe est valide.
|
||||||
|
function passwordRuleError(password) {
|
||||||
|
const result = passwordSchema.safeParse(password ?? "");
|
||||||
|
return result.success ? null : `${result.error.issues[0].message}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { passwordSchema, passwordRuleError, SPECIAL_CHARACTER };
|
||||||
144
backend/tests/adminDeliveries.test.js
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
const request = require("supertest");
|
||||||
|
const bcrypt = require("bcrypt");
|
||||||
|
const jwt = require("jsonwebtoken");
|
||||||
|
const app = require("../src/app");
|
||||||
|
const prisma = require("../src/config/prisma");
|
||||||
|
|
||||||
|
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) }));
|
||||||
|
|
||||||
|
const PREFIX = "tracabilite-";
|
||||||
|
const CODES = ["TRACE00001", "TRACE00002", "TRACE00003", "TRACE00004"];
|
||||||
|
const ctx = {};
|
||||||
|
|
||||||
|
const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" });
|
||||||
|
const asAdmin = (req) => req.set("Authorization", `Bearer ${ctx.tAdmin}`);
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
const users = await prisma.user.findMany({ where: { email: { startsWith: PREFIX } } });
|
||||||
|
const ids = users.map((u) => u.id);
|
||||||
|
const participations = await prisma.participation.findMany({ where: { userId: { in: ids } } });
|
||||||
|
await prisma.claim.deleteMany({ where: { participationId: { in: participations.map((p) => p.id) } } });
|
||||||
|
await prisma.participation.deleteMany({ where: { userId: { in: ids } } });
|
||||||
|
await prisma.ticket.deleteMany({ where: { code: { in: CODES } } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ticket joué par le client, avec une réclamation éventuellement remise.
|
||||||
|
async function jouer(code, { remisPar, remisLe } = {}) {
|
||||||
|
const ticket = await prisma.ticket.create({
|
||||||
|
data: { code, lotId: ctx.lot.id, isUsed: true, usedAt: new Date("2026-09-01T10:00:00Z"), userId: ctx.client.id },
|
||||||
|
});
|
||||||
|
const participation = await prisma.participation.create({
|
||||||
|
data: { userId: ctx.client.id, ticketId: ticket.id, lotId: ctx.lot.id },
|
||||||
|
});
|
||||||
|
return prisma.claim.create({
|
||||||
|
data: {
|
||||||
|
participationId: participation.id,
|
||||||
|
status: remisPar ? "DELIVERED" : "PENDING",
|
||||||
|
validatedByEmployeeId: remisPar?.id ?? null,
|
||||||
|
claimedAt: remisLe ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
const h = await bcrypt.hash("Password123", 10);
|
||||||
|
const mk = (email, role, firstname, lastname, extra = {}) =>
|
||||||
|
prisma.user.create({ data: { firstname, lastname, email: PREFIX + email, password: h, role, isEmailVerified: true, ...extra } });
|
||||||
|
|
||||||
|
ctx.admin = await mk("admin@test.local", "ADMIN", "Alice", "Admin");
|
||||||
|
ctx.marc = await mk("marc@test.local", "EMPLOYEE", "Marc", "Durand");
|
||||||
|
ctx.sophie = await mk("sophie@test.local", "EMPLOYEE", "Sophie", "Bernard", { mustChangePassword: true });
|
||||||
|
ctx.client = await mk("client@test.local", "CLIENT", "Chloé", "Martin");
|
||||||
|
ctx.tAdmin = tk(ctx.admin);
|
||||||
|
ctx.lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } });
|
||||||
|
|
||||||
|
await jouer(CODES[0], { remisPar: ctx.marc, remisLe: new Date("2026-09-10T14:30:00Z") });
|
||||||
|
await jouer(CODES[1], { remisPar: ctx.marc, remisLe: new Date("2026-09-12T09:15:00Z") });
|
||||||
|
await jouer(CODES[2]);
|
||||||
|
await prisma.ticket.create({ data: { code: CODES[3], lotId: ctx.lot.id } });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Liste des employes", () => {
|
||||||
|
test("renvoie les employes avec leur nombre de lots remis", async () => {
|
||||||
|
const res = await asAdmin(request(app).get("/api/admin/employees")).expect(200);
|
||||||
|
|
||||||
|
const marc = res.body.employees.find((e) => e.id === ctx.marc.id);
|
||||||
|
const sophie = res.body.employees.find((e) => e.id === ctx.sophie.id);
|
||||||
|
expect(marc.deliveredCount).toBe(2);
|
||||||
|
expect(sophie.deliveredCount).toBe(0);
|
||||||
|
expect(sophie.mustChangePassword).toBe(true);
|
||||||
|
// Ni client, ni admin, ni hash de mot de passe.
|
||||||
|
expect(res.body.employees.every((e) => e.id !== ctx.client.id && e.id !== ctx.admin.id)).toBe(true);
|
||||||
|
expect(JSON.stringify(res.body)).not.toMatch(/"password"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filtre par nom ou email avec q", async () => {
|
||||||
|
const res = await asAdmin(request(app).get("/api/admin/employees?q=BERNARD")).expect(200);
|
||||||
|
expect(res.body.employees.map((e) => e.id)).toEqual([ctx.sophie.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("est reserve a l admin", async () => {
|
||||||
|
await request(app).get("/api/admin/employees").set("Authorization", `Bearer ${tk(ctx.marc)}`).expect(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Lots remis par un employe", () => {
|
||||||
|
test("liste les remises de l employe, de la plus recente a la plus ancienne", async () => {
|
||||||
|
const res = await asAdmin(request(app).get(`/api/admin/employees/${ctx.marc.id}/deliveries`)).expect(200);
|
||||||
|
|
||||||
|
expect(res.body.employee.email).toBe(PREFIX + "marc@test.local");
|
||||||
|
expect(res.body.deliveries.map((d) => d.ticketCode)).toEqual([CODES[1], CODES[0]]);
|
||||||
|
expect(res.body.deliveries[0].client.firstname).toBe("Chloé");
|
||||||
|
expect(res.body.deliveries[0].lot.name).toBe(ctx.lot.name);
|
||||||
|
expect(new Date(res.body.deliveries[0].deliveredAt).toISOString()).toBe("2026-09-12T09:15:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renvoie une liste vide pour un employe sans remise", async () => {
|
||||||
|
const res = await asAdmin(request(app).get(`/api/admin/employees/${ctx.sophie.id}/deliveries`)).expect(200);
|
||||||
|
expect(res.body.deliveries).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuse un identifiant qui n est pas un employe", async () => {
|
||||||
|
await asAdmin(request(app).get(`/api/admin/employees/${ctx.client.id}/deliveries`)).expect(404);
|
||||||
|
await asAdmin(request(app).get("/api/admin/employees/abc/deliveries")).expect(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Recherche par numero de ticket", () => {
|
||||||
|
test("indique l employe qui a remis le lot et la date, sans tenir compte de la casse", async () => {
|
||||||
|
const res = await asAdmin(request(app).get(`/api/admin/deliveries/${CODES[0].toLowerCase()}`)).expect(200);
|
||||||
|
|
||||||
|
expect(res.body.ticketCode).toBe(CODES[0]);
|
||||||
|
expect(res.body.status).toBe("DELIVERED");
|
||||||
|
expect(res.body.deliveredBy).toMatchObject({ id: ctx.marc.id, firstname: "Marc", lastname: "Durand" });
|
||||||
|
expect(new Date(res.body.deliveredAt).toISOString()).toBe("2026-09-10T14:30:00.000Z");
|
||||||
|
expect(res.body.client.email).toBe(PREFIX + "client@test.local");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un lot gagne mais pas encore remis n a pas d employe", async () => {
|
||||||
|
const res = await asAdmin(request(app).get(`/api/admin/deliveries/${CODES[2]}`)).expect(200);
|
||||||
|
|
||||||
|
// PENDING, ou CANCELLED si la période de retrait du jeu en base est passée.
|
||||||
|
expect(["PENDING", "CANCELLED"]).toContain(res.body.status);
|
||||||
|
expect(res.body.deliveredBy).toBeNull();
|
||||||
|
expect(res.body.deliveredAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un ticket jamais joue est signale comme tel", async () => {
|
||||||
|
const res = await asAdmin(request(app).get(`/api/admin/deliveries/${CODES[3]}`)).expect(200);
|
||||||
|
|
||||||
|
expect(res.body.status).toBe("NOT_PLAYED");
|
||||||
|
expect(res.body.client).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un numero inconnu renvoie 404", async () => {
|
||||||
|
await asAdmin(request(app).get("/api/admin/deliveries/INEXISTANT")).expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
75
backend/tests/captcha.middleware.test.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
const verifyCaptcha = require("../src/middlewares/captcha.middleware");
|
||||||
|
|
||||||
|
function mockRes() {
|
||||||
|
const res = {};
|
||||||
|
res.status = jest.fn().mockReturnValue(res);
|
||||||
|
res.json = jest.fn().mockReturnValue(res);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("verifyCaptcha", () => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.TURNSTILE_SECRET_KEY = "secret-test";
|
||||||
|
global.fetch = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.TURNSTILE_SECRET_KEY = "";
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("laisse passer sans vérifier quand aucune clé n'est configurée", async () => {
|
||||||
|
process.env.TURNSTILE_SECRET_KEY = "";
|
||||||
|
const next = jest.fn();
|
||||||
|
|
||||||
|
await verifyCaptcha({ body: {} }, mockRes(), next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuse une requête sans jeton", async () => {
|
||||||
|
const res = mockRes();
|
||||||
|
const next = jest.fn();
|
||||||
|
|
||||||
|
await verifyCaptcha({ body: {} }, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appelle next quand Cloudflare valide le jeton", async () => {
|
||||||
|
global.fetch.mockResolvedValue({ json: () => Promise.resolve({ success: true }) });
|
||||||
|
const next = jest.fn();
|
||||||
|
|
||||||
|
await verifyCaptcha({ body: { captchaToken: "jeton" }, ip: "1.2.3.4" }, mockRes(), next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
const [, options] = global.fetch.mock.calls[0];
|
||||||
|
expect(JSON.parse(options.body)).toEqual({ secret: "secret-test", response: "jeton", remoteip: "1.2.3.4" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuse quand Cloudflare rejette le jeton", async () => {
|
||||||
|
global.fetch.mockResolvedValue({ json: () => Promise.resolve({ success: false }) });
|
||||||
|
const res = mockRes();
|
||||||
|
const next = jest.fn();
|
||||||
|
|
||||||
|
await verifyCaptcha({ body: { captchaToken: "faux" } }, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("répond 503 quand Cloudflare est injoignable", async () => {
|
||||||
|
global.fetch.mockRejectedValue(new Error("réseau"));
|
||||||
|
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
const res = mockRes();
|
||||||
|
|
||||||
|
await verifyCaptcha({ body: { captchaToken: "jeton" } }, res, jest.fn());
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(503);
|
||||||
|
console.error.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -30,7 +30,30 @@ const jourJ = (d) =>
|
|||||||
data: { startDate: new Date(Date.now() - d * DAY) },
|
data: { startDate: new Date(Date.now() - d * DAY) },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Un tirage reel peut exister en base : la table GrandPrizeWinner est unique
|
||||||
|
// pour tout le jeu. On la met de cote le temps du test, puis on la restaure.
|
||||||
|
async function mettreDeCoteLeGagnant() {
|
||||||
|
const existant = await prisma.grandPrizeWinner.findFirst();
|
||||||
|
if (existant) await prisma.grandPrizeWinner.delete({ where: { id: existant.id } });
|
||||||
|
return existant;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restaurerLeGagnant(sauvegarde) {
|
||||||
|
if (!sauvegarde) return;
|
||||||
|
await prisma.grandPrizeWinner.deleteMany({});
|
||||||
|
await prisma.grandPrizeWinner.create({
|
||||||
|
data: {
|
||||||
|
id: sauvegarde.id,
|
||||||
|
userId: sauvegarde.userId,
|
||||||
|
prize: sauvegarde.prize,
|
||||||
|
drawnAt: sauvegarde.drawnAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
ctx.gagnantSauvegarde = await mettreDeCoteLeGagnant();
|
||||||
await cleanup();
|
await cleanup();
|
||||||
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||||
ctx.settingsId = row.id;
|
ctx.settingsId = row.id;
|
||||||
@@ -51,6 +74,7 @@ beforeAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
await restaurerLeGagnant(ctx.gagnantSauvegarde);
|
||||||
await prisma.gameSettings.update({ where: { id: ctx.settingsId }, data: { startDate: ctx.startDateOriginale } });
|
await prisma.gameSettings.update({ where: { id: ctx.settingsId }, data: { startDate: ctx.startDateOriginale } });
|
||||||
await cleanup();
|
await cleanup();
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
@@ -67,13 +91,16 @@ describe("Notification du grand gagnant", () => {
|
|||||||
expect(res.body.emailSent).toBe(true);
|
expect(res.body.emailSent).toBe(true);
|
||||||
expect(mailer.sendMail).toHaveBeenCalledTimes(1);
|
expect(mailer.sendMail).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Le tirage est aleatoire parmi tous les participants : on verifie que
|
||||||
|
// l email part bien au gagnant annonce, quel qu il soit.
|
||||||
|
const gagnant = res.body.winner.user;
|
||||||
const envoi = mailer.sendMail.mock.calls[0][0];
|
const envoi = mailer.sendMail.mock.calls[0][0];
|
||||||
expect(envoi.to).toBe(ctx.client.email);
|
expect(envoi.to).toBe(gagnant.email);
|
||||||
expect(envoi.subject).toMatch(/gros lot/i);
|
expect(envoi.subject).toMatch(/gros lot/i);
|
||||||
expect(envoi.html).toContain("Chloe");
|
expect(envoi.html).toContain(gagnant.firstname);
|
||||||
expect(envoi.html).toContain("360");
|
expect(envoi.html).toContain("360");
|
||||||
|
|
||||||
await prisma.grandPrizeWinner.deleteMany({ where: { userId: ctx.client.id } });
|
await prisma.grandPrizeWinner.deleteMany({});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("un SMTP en panne n invalide pas le tirage", async () => {
|
test("un SMTP en panne n invalide pas le tirage", async () => {
|
||||||
@@ -86,10 +113,11 @@ describe("Notification du grand gagnant", () => {
|
|||||||
expect(res.body.message).toMatch(/contacter directement/);
|
expect(res.body.message).toMatch(/contacter directement/);
|
||||||
|
|
||||||
// Le tirage reste enregistre : c est un acte irreversible.
|
// Le tirage reste enregistre : c est un acte irreversible.
|
||||||
const enBase = await prisma.grandPrizeWinner.findFirst({ where: { userId: ctx.client.id } });
|
const enBase = await prisma.grandPrizeWinner.findFirst();
|
||||||
expect(enBase).not.toBeNull();
|
expect(enBase).not.toBeNull();
|
||||||
|
expect(enBase.userId).toBe(res.body.winner.user.id);
|
||||||
|
|
||||||
await prisma.grandPrizeWinner.deleteMany({ where: { userId: ctx.client.id } });
|
await prisma.grandPrizeWinner.deleteMany({});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("aucun email n est envoye si le tirage est refuse", async () => {
|
test("aucun email n est envoye si le tirage est refuse", async () => {
|
||||||
|
|||||||
@@ -21,13 +21,31 @@ const testEmail = "e2e-flow-test@test.com";
|
|||||||
const ticketCode = "ZZE2ETEST1";
|
const ticketCode = "ZZE2ETEST1";
|
||||||
let token;
|
let token;
|
||||||
let createdTicketId;
|
let createdTicketId;
|
||||||
|
let settingsBackup;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await prisma.user.deleteMany({ where: { email: testEmail } });
|
await prisma.user.deleteMany({ where: { email: testEmail } });
|
||||||
await prisma.ticket.deleteMany({ where: { code: ticketCode } });
|
await prisma.ticket.deleteMany({ where: { code: ticketCode } });
|
||||||
|
|
||||||
|
// Le parcours a besoin d'un jeu ouvert, quelle que soit la date de début
|
||||||
|
// enregistrée en base (par défaut le 2 octobre 2026) : on la place à hier
|
||||||
|
// le temps du test, puis on la restaure.
|
||||||
|
settingsBackup = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||||
|
const hier = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
if (settingsBackup) {
|
||||||
|
await prisma.gameSettings.update({ where: { id: settingsBackup.id }, data: { startDate: hier } });
|
||||||
|
} else {
|
||||||
|
await prisma.gameSettings.create({ data: { startDate: hier } });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
if (settingsBackup) {
|
||||||
|
await prisma.gameSettings.update({ where: { id: settingsBackup.id }, data: { startDate: settingsBackup.startDate } });
|
||||||
|
} else {
|
||||||
|
await prisma.gameSettings.deleteMany({});
|
||||||
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { email: testEmail } });
|
const user = await prisma.user.findUnique({ where: { email: testEmail } });
|
||||||
if (user) {
|
if (user) {
|
||||||
await prisma.claim.deleteMany({ where: { participation: { userId: user.id } } });
|
await prisma.claim.deleteMany({ where: { participation: { userId: user.id } } });
|
||||||
@@ -48,7 +66,8 @@ describe("THE TIP TOP - FULL E2E FLOW", () => {
|
|||||||
firstname: "E2E",
|
firstname: "E2E",
|
||||||
lastname: "Test",
|
lastname: "Test",
|
||||||
email: testEmail,
|
email: testEmail,
|
||||||
password: "Password123",
|
password: "Password123!",
|
||||||
|
confirmPassword: "Password123!",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.statusCode).toBe(201);
|
expect(res.statusCode).toBe(201);
|
||||||
@@ -62,7 +81,7 @@ describe("THE TIP TOP - FULL E2E FLOW", () => {
|
|||||||
|
|
||||||
const res = await request(app).post("/api/auth/login").send({
|
const res = await request(app).post("/api/auth/login").send({
|
||||||
email: testEmail,
|
email: testEmail,
|
||||||
password: "Password123",
|
password: "Password123!",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
|
|||||||
166
backend/tests/employeeOnboarding.test.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
const request = require("supertest");
|
||||||
|
const bcrypt = require("bcrypt");
|
||||||
|
const jwt = require("jsonwebtoken");
|
||||||
|
const app = require("../src/app");
|
||||||
|
const prisma = require("../src/config/prisma");
|
||||||
|
const mailer = require("../src/config/mailer");
|
||||||
|
const { passwordRuleError } = require("../src/validations/password.validation");
|
||||||
|
|
||||||
|
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() }));
|
||||||
|
|
||||||
|
const PREFIX = "onboarding-";
|
||||||
|
const ADMIN_EMAIL = PREFIX + "admin@test.local";
|
||||||
|
const EMPLOYEE_EMAIL = PREFIX + "employe@test.local";
|
||||||
|
const ctx = {};
|
||||||
|
|
||||||
|
const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" });
|
||||||
|
|
||||||
|
// Récupère le mot de passe provisoire dans l'email envoyé à l'employé.
|
||||||
|
function tempPasswordFromMail() {
|
||||||
|
const html = mailer.sendMail.mock.calls[0][0].html;
|
||||||
|
return html.match(/Mot de passe provisoire : <strong[^>]*>([^<]+)<\/strong>/)[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
await prisma.user.deleteMany({ where: { email: { startsWith: PREFIX } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
const h = await bcrypt.hash("Password123", 10);
|
||||||
|
ctx.admin = await prisma.user.create({
|
||||||
|
data: { firstname: "Alice", lastname: "Admin", email: ADMIN_EMAIL, password: h, role: "ADMIN", isEmailVerified: true },
|
||||||
|
});
|
||||||
|
ctx.tAdmin = tk(ctx.admin);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Creation d un employe avec mot de passe provisoire", () => {
|
||||||
|
test("un echec d envoi de l email annule la creation du compte", async () => {
|
||||||
|
mailer.sendMail.mockRejectedValueOnce(new Error("SMTP indisponible"));
|
||||||
|
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/admin/employees")
|
||||||
|
.set("Authorization", `Bearer ${ctx.tAdmin}`)
|
||||||
|
.send({ firstname: "Marc", lastname: "Durand", email: EMPLOYEE_EMAIL });
|
||||||
|
|
||||||
|
console.error.mockRestore();
|
||||||
|
expect(res.status).toBe(502);
|
||||||
|
expect(await prisma.user.findUnique({ where: { email: EMPLOYEE_EMAIL } })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("l admin cree l employe sans mot de passe, un mot de passe provisoire est envoye", async () => {
|
||||||
|
mailer.sendMail.mockClear();
|
||||||
|
mailer.sendMail.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/admin/employees")
|
||||||
|
.set("Authorization", `Bearer ${ctx.tAdmin}`)
|
||||||
|
// Un mot de passe choisi par l'admin est ignoré.
|
||||||
|
.send({ firstname: "Marc", lastname: "Durand", email: EMPLOYEE_EMAIL, password: "ChoixAdmin123" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.employee.role).toBe("EMPLOYEE");
|
||||||
|
// Le mot de passe ne transite jamais par la réponse faite à l'admin.
|
||||||
|
expect(JSON.stringify(res.body)).not.toMatch(/password/i);
|
||||||
|
|
||||||
|
expect(mailer.sendMail).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mailer.sendMail.mock.calls[0][0].to).toBe(EMPLOYEE_EMAIL);
|
||||||
|
ctx.tempPassword = tempPasswordFromMail();
|
||||||
|
expect(ctx.tempPassword).toHaveLength(12);
|
||||||
|
expect(ctx.tempPassword).not.toBe("ChoixAdmin123");
|
||||||
|
// Le provisoire respecte lui aussi la règle commune.
|
||||||
|
expect(passwordRuleError(ctx.tempPassword)).toBeNull();
|
||||||
|
|
||||||
|
const enBase = await prisma.user.findUnique({ where: { email: EMPLOYEE_EMAIL } });
|
||||||
|
expect(enBase.mustChangePassword).toBe(true);
|
||||||
|
expect(await bcrypt.compare("ChoixAdmin123", enBase.password)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("la connexion avec le mot de passe provisoire signale le changement obligatoire", async () => {
|
||||||
|
const res = await request(app).post("/api/auth/login").send({ email: EMPLOYEE_EMAIL, password: ctx.tempPassword });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.user.mustChangePassword).toBe(true);
|
||||||
|
ctx.tEmployee = res.body.token;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tant que le mot de passe n est pas change, l API est bloquee sauf /me", async () => {
|
||||||
|
const bloque = await request(app).get("/api/employee/ticket/INCONNU").set("Authorization", `Bearer ${ctx.tEmployee}`);
|
||||||
|
expect(bloque.status).toBe(403);
|
||||||
|
expect(bloque.body.code).toBe("PASSWORD_CHANGE_REQUIRED");
|
||||||
|
|
||||||
|
const changement = await request(app)
|
||||||
|
.patch("/api/auth/change-password")
|
||||||
|
.set("Authorization", `Bearer ${ctx.tEmployee}`)
|
||||||
|
.send({ currentPassword: ctx.tempPassword, newPassword: "Contournement1" });
|
||||||
|
expect(changement.status).toBe(403);
|
||||||
|
|
||||||
|
const me = await request(app).get("/api/auth/me").set("Authorization", `Bearer ${ctx.tEmployee}`);
|
||||||
|
expect(me.status).toBe(200);
|
||||||
|
expect(me.body.user.mustChangePassword).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le nouveau mot de passe doit etre confirme et different du provisoire", async () => {
|
||||||
|
const auth = { Authorization: `Bearer ${ctx.tEmployee}` };
|
||||||
|
|
||||||
|
const different = await request(app)
|
||||||
|
.post("/api/auth/initial-password")
|
||||||
|
.set(auth)
|
||||||
|
.send({ password: "NouveauMdp123!", confirmPassword: "AutreMdp456!" });
|
||||||
|
expect(different.status).toBe(400);
|
||||||
|
expect(different.body.message).toMatch(/ne correspondent pas/);
|
||||||
|
|
||||||
|
const court = await request(app)
|
||||||
|
.post("/api/auth/initial-password")
|
||||||
|
.set(auth)
|
||||||
|
.send({ password: "court", confirmPassword: "court" });
|
||||||
|
expect(court.status).toBe(400);
|
||||||
|
|
||||||
|
const sansSpecial = await request(app)
|
||||||
|
.post("/api/auth/initial-password")
|
||||||
|
.set(auth)
|
||||||
|
.send({ password: "NouveauMdp123", confirmPassword: "NouveauMdp123" });
|
||||||
|
expect(sansSpecial.status).toBe(400);
|
||||||
|
expect(sansSpecial.body.message).toMatch(/caractère spécial/);
|
||||||
|
|
||||||
|
const identique = await request(app)
|
||||||
|
.post("/api/auth/initial-password")
|
||||||
|
.set(auth)
|
||||||
|
.send({ password: ctx.tempPassword, confirmPassword: ctx.tempPassword });
|
||||||
|
expect(identique.status).toBe(400);
|
||||||
|
expect(identique.body.message).toMatch(/provisoire/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("apres le changement, l acces est retabli et seul le nouveau mot de passe fonctionne", async () => {
|
||||||
|
const auth = { Authorization: `Bearer ${ctx.tEmployee}` };
|
||||||
|
|
||||||
|
const ok = await request(app)
|
||||||
|
.post("/api/auth/initial-password")
|
||||||
|
.set(auth)
|
||||||
|
.send({ password: "NouveauMdp123!", confirmPassword: "NouveauMdp123!" });
|
||||||
|
expect(ok.status).toBe(200);
|
||||||
|
|
||||||
|
// Le ticket n'existe pas : un 404 prouve que la requête a franchi le filtre.
|
||||||
|
const acces = await request(app).get("/api/employee/ticket/INCONNU").set(auth);
|
||||||
|
expect(acces.status).not.toBe(403);
|
||||||
|
|
||||||
|
const encore = await request(app)
|
||||||
|
.post("/api/auth/initial-password")
|
||||||
|
.set(auth)
|
||||||
|
.send({ password: "EncoreUnAutre1!", confirmPassword: "EncoreUnAutre1!" });
|
||||||
|
expect(encore.status).toBe(400);
|
||||||
|
|
||||||
|
const ancien = await request(app).post("/api/auth/login").send({ email: EMPLOYEE_EMAIL, password: ctx.tempPassword });
|
||||||
|
expect(ancien.status).toBe(401);
|
||||||
|
|
||||||
|
const nouveau = await request(app).post("/api/auth/login").send({ email: EMPLOYEE_EMAIL, password: "NouveauMdp123!" });
|
||||||
|
expect(nouveau.status).toBe(200);
|
||||||
|
expect(nouveau.body.user.mustChangePassword).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
113
backend/tests/facebookLogin.test.js
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
// Identifiants factices posés avant le chargement de passport : la stratégie
|
||||||
|
// Facebook est ainsi enregistrée même sans vraie app Meta configurée.
|
||||||
|
process.env.FACEBOOK_APP_ID = "test-app-id";
|
||||||
|
process.env.FACEBOOK_APP_SECRET = "test-app-secret";
|
||||||
|
|
||||||
|
const request = require("supertest");
|
||||||
|
const app = require("../src/app");
|
||||||
|
const prisma = require("../src/config/prisma");
|
||||||
|
const mailer = require("../src/config/mailer");
|
||||||
|
const { passport } = require("../src/config/passport");
|
||||||
|
|
||||||
|
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() }));
|
||||||
|
|
||||||
|
const EMAIL = "facebook-login-test@test.local";
|
||||||
|
const EMAIL_EXISTANT = "facebook-link-test@test.local";
|
||||||
|
|
||||||
|
function profil(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: "sim-facebook-id-987654",
|
||||||
|
displayName: "Léa Durand",
|
||||||
|
name: { givenName: "Léa", familyName: "Durand" },
|
||||||
|
emails: [{ value: EMAIL }],
|
||||||
|
photos: [{ value: "https://example.test/fb.png" }],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rejoue le callback de la strategie Facebook sans sortir sur le reseau.
|
||||||
|
function verifierProfilFacebook(profile) {
|
||||||
|
const strategy = passport._strategy("facebook");
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
strategy._verify("access-token", "refresh-token", profile, (err, user, info) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve({ user, info });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
await prisma.user.deleteMany({ where: { email: { in: [EMAIL, EMAIL_EXISTANT] } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(cleanup);
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Connexion Facebook", () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
test("la route de depart redirige vers Facebook", async () => {
|
||||||
|
const res = await request(app).get("/api/auth/facebook");
|
||||||
|
|
||||||
|
expect(res.status).toBe(302);
|
||||||
|
const url = new URL(res.headers.location);
|
||||||
|
expect(url.hostname).toBe("www.facebook.com");
|
||||||
|
expect(url.searchParams.get("client_id")).toBe("test-app-id");
|
||||||
|
expect(url.searchParams.get("scope")).toBe("email");
|
||||||
|
expect(url.searchParams.get("redirect_uri")).toMatch(/\/api\/auth\/facebook\/callback$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un premier passage cree un compte FACEBOOK sans mot de passe", async () => {
|
||||||
|
mailer.sendMail.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const { user } = await verifierProfilFacebook(profil());
|
||||||
|
|
||||||
|
expect(user.provider).toBe("FACEBOOK");
|
||||||
|
expect(user.providerId).toBe("sim-facebook-id-987654");
|
||||||
|
expect(user.password).toBeNull();
|
||||||
|
expect(user.isEmailVerified).toBe(true);
|
||||||
|
expect(user.firstname).toBe("Léa");
|
||||||
|
|
||||||
|
expect(mailer.sendMail).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mailer.sendMail.mock.calls[0][0].html).toContain("créé avec Facebook");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un second passage reconnait le compte sans renvoyer d email", async () => {
|
||||||
|
const { user } = await verifierProfilFacebook(profil());
|
||||||
|
|
||||||
|
expect(user.email).toBe(EMAIL);
|
||||||
|
expect(mailer.sendMail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un compte existant avec le meme email est rattache a Facebook", async () => {
|
||||||
|
await prisma.user.create({
|
||||||
|
data: { firstname: "Paul", lastname: "Martin", email: EMAIL_EXISTANT, password: "hash", isEmailVerified: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { user } = await verifierProfilFacebook(
|
||||||
|
profil({ id: "sim-facebook-id-link", emails: [{ value: EMAIL_EXISTANT }] })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(user.provider).toBe("FACEBOOK");
|
||||||
|
expect(user.isEmailVerified).toBe(true);
|
||||||
|
// Le mot de passe existant est conserve.
|
||||||
|
expect(user.password).toBe("hash");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un compte Facebook sans email est refuse", async () => {
|
||||||
|
const { user, info } = await verifierProfilFacebook(profil({ id: "sim-sans-email", emails: undefined }));
|
||||||
|
|
||||||
|
expect(user).toBe(false);
|
||||||
|
expect(info.message).toMatch(/Facebook/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("la connexion par mot de passe oriente vers le bouton Facebook", async () => {
|
||||||
|
const res = await request(app).post("/api/auth/login").send({ email: EMAIL, password: "PeuImporte123" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.message).toMatch(/connexion Facebook/);
|
||||||
|
});
|
||||||
|
});
|
||||||
167
backend/tests/forgotPassword.test.js
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
const request = require("supertest");
|
||||||
|
const bcrypt = require("bcrypt");
|
||||||
|
const app = require("../src/app");
|
||||||
|
const prisma = require("../src/config/prisma");
|
||||||
|
const mailer = require("../src/config/mailer");
|
||||||
|
|
||||||
|
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) }));
|
||||||
|
|
||||||
|
const EMAIL = "forgot-password-test@test.local";
|
||||||
|
const NON_VERIFIE = "forgot-password-unverified@test.local";
|
||||||
|
const INSCRIT = "forgot-password-register@test.local";
|
||||||
|
|
||||||
|
// Jeton extrait du lien reçu par email.
|
||||||
|
function tokenFromMail() {
|
||||||
|
const html = mailer.sendMail.mock.calls.at(-1)[0].html;
|
||||||
|
return html.match(/\/definir-mot-de-passe\/([a-f0-9]+)"/)[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
await prisma.user.deleteMany({ where: { email: { in: [EMAIL, NON_VERIFIE, INSCRIT] } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
const h = await bcrypt.hash("AncienMdp1!", 10);
|
||||||
|
await prisma.user.create({ data: { firstname: "Léa", lastname: "Durand", email: EMAIL, password: h, isEmailVerified: true } });
|
||||||
|
await prisma.user.create({ data: { firstname: "Tom", lastname: "Petit", email: NON_VERIFIE, password: h, isEmailVerified: false } });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Mot de passe oublie", () => {
|
||||||
|
beforeEach(() => mailer.sendMail.mockClear());
|
||||||
|
|
||||||
|
test("un email inconnu recoit la meme reponse, sans envoi", async () => {
|
||||||
|
const res = await request(app).post("/api/auth/forgot-password").send({ email: "inconnu@test.local" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.message).toMatch(/Si un compte existe/);
|
||||||
|
expect(mailer.sendMail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un email connu recoit un lien vers la page du front", async () => {
|
||||||
|
const res = await request(app).post("/api/auth/forgot-password").send({ email: EMAIL });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mailer.sendMail).toHaveBeenCalledTimes(1);
|
||||||
|
const mail = mailer.sendMail.mock.calls[0][0];
|
||||||
|
expect(mail.to).toBe(EMAIL);
|
||||||
|
const frontendUrl = (process.env.FRONTEND_URL || "http://localhost:5173").split(",")[0].trim();
|
||||||
|
expect(mail.html).toContain(`${frontendUrl}/definir-mot-de-passe/`);
|
||||||
|
expect(tokenFromMail()).toHaveLength(64);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une nouvelle demande invalide le lien precedent", async () => {
|
||||||
|
await request(app).post("/api/auth/forgot-password").send({ email: EMAIL });
|
||||||
|
const ancien = tokenFromMail();
|
||||||
|
await request(app).post("/api/auth/forgot-password").send({ email: EMAIL });
|
||||||
|
const nouveau = tokenFromMail();
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/auth/reset-password/${ancien}`)
|
||||||
|
.send({ password: "NouveauMdp1!", confirmPassword: "NouveauMdp1!" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(nouveau).not.toBe(ancien);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le lien permet de changer le mot de passe une seule fois", async () => {
|
||||||
|
await request(app).post("/api/auth/forgot-password").send({ email: EMAIL });
|
||||||
|
const token = tokenFromMail();
|
||||||
|
|
||||||
|
const ok = await request(app)
|
||||||
|
.post(`/api/auth/reset-password/${token}`)
|
||||||
|
.send({ password: "NouveauMdp1!", confirmPassword: "NouveauMdp1!" });
|
||||||
|
expect(ok.status).toBe(200);
|
||||||
|
|
||||||
|
expect((await request(app).post("/api/auth/login").send({ email: EMAIL, password: "AncienMdp1!" })).status).toBe(401);
|
||||||
|
expect((await request(app).post("/api/auth/login").send({ email: EMAIL, password: "NouveauMdp1!" })).status).toBe(200);
|
||||||
|
|
||||||
|
const reutilise = await request(app)
|
||||||
|
.post(`/api/auth/reset-password/${token}`)
|
||||||
|
.send({ password: "EncoreAutre1!", confirmPassword: "EncoreAutre1!" });
|
||||||
|
expect(reutilise.status).toBe(400);
|
||||||
|
expect(reutilise.body.message).toMatch(/invalide ou expiré/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un lien expire est refuse", async () => {
|
||||||
|
await request(app).post("/api/auth/forgot-password").send({ email: EMAIL });
|
||||||
|
const token = tokenFromMail();
|
||||||
|
await prisma.user.update({ where: { email: EMAIL }, data: { resetPasswordExpires: new Date(Date.now() - 1000) } });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/auth/reset-password/${token}`)
|
||||||
|
.send({ password: "EncoreAutre1!", confirmPassword: "EncoreAutre1!" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.message).toMatch(/invalide ou expiré/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("l email est reconnu quelle que soit la casse et malgre des espaces", async () => {
|
||||||
|
const res = await request(app).post("/api/auth/forgot-password").send({ email: ` ${EMAIL.toUpperCase()} ` });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mailer.sendMail).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mailer.sendMail.mock.calls[0][0].to).toBe(EMAIL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("la reinitialisation valide un compte jamais verifie, qui peut ensuite se connecter", async () => {
|
||||||
|
await request(app).post("/api/auth/forgot-password").send({ email: NON_VERIFIE });
|
||||||
|
await request(app)
|
||||||
|
.post(`/api/auth/reset-password/${tokenFromMail()}`)
|
||||||
|
.send({ password: "NouveauMdp1!", confirmPassword: "NouveauMdp1!" })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const login = await request(app).post("/api/auth/login").send({ email: NON_VERIFIE, password: "NouveauMdp1!" });
|
||||||
|
expect(login.status).toBe(200);
|
||||||
|
const enBase = await prisma.user.findUnique({ where: { email: NON_VERIFIE } });
|
||||||
|
expect(enBase.isEmailVerified).toBe(true);
|
||||||
|
expect(enBase.emailVerifyToken).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le profil connecte recoit le lien sur l adresse du compte, sans captcha", async () => {
|
||||||
|
await request(app).post("/api/auth/password-setup-link").expect(401);
|
||||||
|
|
||||||
|
const login = await request(app).post("/api/auth/login").send({ email: EMAIL, password: "NouveauMdp1!" }).expect(200);
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/auth/password-setup-link")
|
||||||
|
.set("Authorization", `Bearer ${login.body.token}`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mailer.sendMail.mock.calls[0][0].to).toBe(EMAIL);
|
||||||
|
expect(tokenFromMail()).toHaveLength(64);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Emails normalises a l inscription et a la connexion", () => {
|
||||||
|
test("un email saisi avec majuscules est stocke en minuscules et reconnu a la connexion", async () => {
|
||||||
|
const res = await request(app).post("/api/auth/register").send({
|
||||||
|
firstname: "Nora",
|
||||||
|
lastname: "Blanc",
|
||||||
|
email: ` ${INSCRIT.replace("forgot", "FORGOT")} `,
|
||||||
|
password: "MotDePasse1!",
|
||||||
|
confirmPassword: "MotDePasse1!",
|
||||||
|
birthdate: "1990-05-14",
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
|
||||||
|
const enBase = await prisma.user.findUnique({ where: { email: INSCRIT } });
|
||||||
|
expect(enBase).not.toBeNull();
|
||||||
|
|
||||||
|
// Même adresse avec une autre casse : doublon refusé.
|
||||||
|
const doublon = await request(app).post("/api/auth/register").send({
|
||||||
|
firstname: "Nora",
|
||||||
|
lastname: "Blanc",
|
||||||
|
email: INSCRIT.toUpperCase(),
|
||||||
|
password: "MotDePasse1!",
|
||||||
|
confirmPassword: "MotDePasse1!",
|
||||||
|
});
|
||||||
|
expect(doublon.status).toBe(409);
|
||||||
|
|
||||||
|
await prisma.user.update({ where: { email: INSCRIT }, data: { isEmailVerified: true } });
|
||||||
|
const login = await request(app).post("/api/auth/login").send({ email: INSCRIT.toUpperCase(), password: "MotDePasse1!" });
|
||||||
|
expect(login.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,7 +26,30 @@ async function cleanup() {
|
|||||||
const setStart = (d) =>
|
const setStart = (d) =>
|
||||||
prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: new Date(Date.now() - d * DAY) } });
|
prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: new Date(Date.now() - d * DAY) } });
|
||||||
|
|
||||||
|
|
||||||
|
// Un tirage reel peut exister en base : la table GrandPrizeWinner est unique
|
||||||
|
// pour tout le jeu. On la met de cote le temps du test, puis on la restaure.
|
||||||
|
async function mettreDeCoteLeGagnant() {
|
||||||
|
const existant = await prisma.grandPrizeWinner.findFirst();
|
||||||
|
if (existant) await prisma.grandPrizeWinner.delete({ where: { id: existant.id } });
|
||||||
|
return existant;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restaurerLeGagnant(sauvegarde) {
|
||||||
|
if (!sauvegarde) return;
|
||||||
|
await prisma.grandPrizeWinner.deleteMany({});
|
||||||
|
await prisma.grandPrizeWinner.create({
|
||||||
|
data: {
|
||||||
|
id: sauvegarde.id,
|
||||||
|
userId: sauvegarde.userId,
|
||||||
|
prize: sauvegarde.prize,
|
||||||
|
drawnAt: sauvegarde.drawnAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
ctx.gagnantSauvegarde = await mettreDeCoteLeGagnant();
|
||||||
await cleanup();
|
await cleanup();
|
||||||
ctx.settings = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
ctx.settings = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||||
ctx.orig = ctx.settings.startDate;
|
ctx.orig = ctx.settings.startDate;
|
||||||
@@ -46,6 +69,7 @@ beforeAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
await restaurerLeGagnant(ctx.gagnantSauvegarde);
|
||||||
await prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: ctx.orig } });
|
await prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: ctx.orig } });
|
||||||
await cleanup();
|
await cleanup();
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
|
|||||||
@@ -33,7 +33,25 @@ async function jourJ(offsetJours) {
|
|||||||
|
|
||||||
const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "2h" });
|
const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "2h" });
|
||||||
|
|
||||||
|
|
||||||
|
// La table GrandPrizeWinner est unique pour tout le jeu : on met de cote un
|
||||||
|
// eventuel tirage reel le temps du test, puis on le restaure.
|
||||||
|
async function mettreDeCoteLeGagnant() {
|
||||||
|
const existant = await prisma.grandPrizeWinner.findFirst();
|
||||||
|
if (existant) await prisma.grandPrizeWinner.delete({ where: { id: existant.id } });
|
||||||
|
return existant;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restaurerLeGagnant(sauvegarde) {
|
||||||
|
if (!sauvegarde) return;
|
||||||
|
await prisma.grandPrizeWinner.deleteMany({});
|
||||||
|
await prisma.grandPrizeWinner.create({
|
||||||
|
data: { id: sauvegarde.id, userId: sauvegarde.userId, prize: sauvegarde.prize, drawnAt: sauvegarde.drawnAt },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
ctx.gagnantSauvegarde = await mettreDeCoteLeGagnant();
|
||||||
await cleanup();
|
await cleanup();
|
||||||
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||||
ctx.startDateOriginale = row ? row.startDate : null;
|
ctx.startDateOriginale = row ? row.startDate : null;
|
||||||
@@ -49,6 +67,7 @@ beforeAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
await restaurerLeGagnant(ctx.gagnantSauvegarde);
|
||||||
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||||
if (ctx.startDateOriginale) {
|
if (ctx.startDateOriginale) {
|
||||||
if (row) await prisma.gameSettings.update({ where: { id: row.id }, data: { startDate: ctx.startDateOriginale } });
|
if (row) await prisma.gameSettings.update({ where: { id: row.id }, data: { startDate: ctx.startDateOriginale } });
|
||||||
@@ -68,6 +87,11 @@ describe("VERIFICATION DES 3 CORRECTIFS DE PHASE", () => {
|
|||||||
log("phase :", vue.body.gameWindow.phase, "| participations ouvertes :", vue.body.gameWindow.isParticipationOpen);
|
log("phase :", vue.body.gameWindow.phase, "| participations ouvertes :", vue.body.gameWindow.isParticipationOpen);
|
||||||
expect(vue.body.gameWindow.phase).toBe("NOT_CONFIGURED");
|
expect(vue.body.gameWindow.phase).toBe("NOT_CONFIGURED");
|
||||||
|
|
||||||
|
// Route publique de la page d'accueil : sans configuration, pas de date,
|
||||||
|
// le front affiche alors les dates par défaut.
|
||||||
|
const publique = await request(app).get("/api/game/window").expect(200);
|
||||||
|
expect(publique.body).toMatchObject({ configured: false, startDate: null });
|
||||||
|
|
||||||
const essai = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] });
|
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);
|
log("un client tente de jouer ->", essai.status, essai.body.message);
|
||||||
expect(essai.status).toBe(410);
|
expect(essai.status).toBe(410);
|
||||||
@@ -89,6 +113,12 @@ describe("VERIFICATION DES 3 CORRECTIFS DE PHASE", () => {
|
|||||||
log("PUT game-settings ->", r.status, "| phase :", r.body.gameWindow.phase);
|
log("PUT game-settings ->", r.status, "| phase :", r.body.gameWindow.phase);
|
||||||
expect(r.body.gameWindow.phase).toBe("SCHEDULED");
|
expect(r.body.gameWindow.phase).toBe("SCHEDULED");
|
||||||
|
|
||||||
|
// La page d'accueil reçoit aussitôt la nouvelle date, sans authentification.
|
||||||
|
const publique = await request(app).get("/api/game/window").expect(200);
|
||||||
|
expect(publique.body.configured).toBe(true);
|
||||||
|
expect(publique.body.startDate).toBe(futur);
|
||||||
|
expect(new Date(publique.body.participationDeadline).getTime()).toBe(new Date(futur).getTime() + 30 * DAY);
|
||||||
|
|
||||||
const anticipe = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] });
|
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("client joue avant ouverture ->", anticipe.status);
|
||||||
log("message :", JSON.stringify(anticipe.body.message));
|
log("message :", JSON.stringify(anticipe.body.message));
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ decrire("Inscription Google et definition du mot de passe", () => {
|
|||||||
test("une confirmation qui ne correspond pas est refusee", async () => {
|
test("une confirmation qui ne correspond pas est refusee", async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post("/api/auth/reset-password/" + ctxToken)
|
.post("/api/auth/reset-password/" + ctxToken)
|
||||||
.send({ password: "MonMotDePasse1", confirmPassword: "AutreMotDePasse2" });
|
.send({ password: "MonMotDePasse1!", confirmPassword: "AutreMotDePasse2!" });
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
expect(res.body.message).toMatch(/ne correspondent pas/);
|
expect(res.body.message).toMatch(/ne correspondent pas/);
|
||||||
@@ -105,14 +105,23 @@ decrire("Inscription Google et definition du mot de passe", () => {
|
|||||||
expect(res.body.message).toMatch(/8 caractères/);
|
expect(res.body.message).toMatch(/8 caractères/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("un mot de passe sans caractere special est refuse", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/auth/reset-password/" + ctxToken)
|
||||||
|
.send({ password: "MonMotDePasse1", confirmPassword: "MonMotDePasse1" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.message).toMatch(/caractère spécial/);
|
||||||
|
});
|
||||||
|
|
||||||
test("le mot de passe defini permet ensuite de se connecter et de le changer", async () => {
|
test("le mot de passe defini permet ensuite de se connecter et de le changer", async () => {
|
||||||
const ok = await request(app)
|
const ok = await request(app)
|
||||||
.post("/api/auth/reset-password/" + ctxToken)
|
.post("/api/auth/reset-password/" + ctxToken)
|
||||||
.send({ password: "MonMotDePasse1", confirmPassword: "MonMotDePasse1" })
|
.send({ password: "MonMotDePasse1!", confirmPassword: "MonMotDePasse1!" })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
expect(ok.body.message).toMatch(/réinitialisé/);
|
expect(ok.body.message).toMatch(/réinitialisé/);
|
||||||
|
|
||||||
const login = await request(app).post("/api/auth/login").send({ email: EMAIL, password: "MonMotDePasse1" }).expect(200);
|
const login = await request(app).post("/api/auth/login").send({ email: EMAIL, password: "MonMotDePasse1!" }).expect(200);
|
||||||
expect(login.body.token).toBeDefined();
|
expect(login.body.token).toBeDefined();
|
||||||
|
|
||||||
// Le jeton a bien ete consomme.
|
// Le jeton a bien ete consomme.
|
||||||
@@ -125,8 +134,16 @@ decrire("Inscription Google et definition du mot de passe", () => {
|
|||||||
const change = await request(app)
|
const change = await request(app)
|
||||||
.patch("/api/auth/change-password")
|
.patch("/api/auth/change-password")
|
||||||
.set("Authorization", "Bearer " + login.body.token)
|
.set("Authorization", "Bearer " + login.body.token)
|
||||||
.send({ currentPassword: "MonMotDePasse1", newPassword: "EncoreUnAutre2" });
|
.send({ currentPassword: "MonMotDePasse1!", newPassword: "EncoreUnAutre2!" });
|
||||||
expect(change.status).toBe(200);
|
expect(change.status).toBe(200);
|
||||||
|
|
||||||
|
// Même règle qu'à l'inscription pour le changement depuis le profil.
|
||||||
|
const sansSpecial = await request(app)
|
||||||
|
.patch("/api/auth/change-password")
|
||||||
|
.set("Authorization", "Bearer " + login.body.token)
|
||||||
|
.send({ currentPassword: "EncoreUnAutre2!", newPassword: "SansCaractere3" });
|
||||||
|
expect(sansSpecial.status).toBe(400);
|
||||||
|
expect(sansSpecial.body.message).toMatch(/caractère spécial/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("une seconde connexion Google ne renvoie pas de nouveau lien", async () => {
|
test("une seconde connexion Google ne renvoie pas de nouveau lien", async () => {
|
||||||
|
|||||||
64
backend/tests/oauthCallback.test.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// Ce fichier ne charge pas app.js, donc pas le .env : le JWT a besoin d'un secret.
|
||||||
|
process.env.JWT_SECRET = process.env.JWT_SECRET || "test-secret";
|
||||||
|
|
||||||
|
const getFrontendUrl = require("../src/utils/frontendUrl");
|
||||||
|
const { oauthCallback } = require("../src/controllers/auth.controller");
|
||||||
|
|
||||||
|
describe("getFrontendUrl", () => {
|
||||||
|
const original = process.env.FRONTEND_URL;
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.FRONTEND_URL = original;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ne garde que la premiere origine d une liste (CORS prod)", () => {
|
||||||
|
process.env.FRONTEND_URL = "https://site.fr, https://www.site.fr";
|
||||||
|
expect(getFrontendUrl()).toBe("https://site.fr");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retire le slash final", () => {
|
||||||
|
process.env.FRONTEND_URL = "https://site.fr/";
|
||||||
|
expect(getFrontendUrl()).toBe("https://site.fr");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retombe sur localhost sans configuration", () => {
|
||||||
|
delete process.env.FRONTEND_URL;
|
||||||
|
expect(getFrontendUrl()).toBe("http://localhost:5173");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("oauthCallback", () => {
|
||||||
|
const original = process.env.FRONTEND_URL;
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.FRONTEND_URL = original;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("redirige vers une URL valide avec un profil lisible par le front", () => {
|
||||||
|
process.env.FRONTEND_URL = "https://site.fr,https://www.site.fr";
|
||||||
|
const res = { redirect: jest.fn() };
|
||||||
|
// Accents (UTF-8) et « ~ » : ce profil produit un base64 contenant « + »,
|
||||||
|
// qui deviendrait un espace sans encodage.
|
||||||
|
const user = { id: 7, firstname: "Chloé", lastname: "Lefèvre", email: "ab~test@test.local", role: "CLIENT" };
|
||||||
|
expect(Buffer.from(JSON.stringify(user)).toString("base64")).toContain("+");
|
||||||
|
|
||||||
|
oauthCallback({ user }, res);
|
||||||
|
|
||||||
|
const url = new URL(res.redirect.mock.calls[0][0]);
|
||||||
|
expect(`${url.origin}${url.pathname}`).toBe("https://site.fr/auth/callback");
|
||||||
|
expect(url.searchParams.get("token")).toBeTruthy();
|
||||||
|
|
||||||
|
// Même décodage que frontend/src/pages/AuthCallback.jsx.
|
||||||
|
const bytes = Uint8Array.from(atob(url.searchParams.get("user")), (c) => c.charCodeAt(0));
|
||||||
|
expect(JSON.parse(new TextDecoder().decode(bytes))).toEqual(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renvoie une erreur au front quand passport n a pas d utilisateur", () => {
|
||||||
|
process.env.FRONTEND_URL = "https://site.fr,https://www.site.fr";
|
||||||
|
const res = { redirect: jest.fn() };
|
||||||
|
|
||||||
|
oauthCallback({}, res);
|
||||||
|
|
||||||
|
const url = new URL(res.redirect.mock.calls[0][0]);
|
||||||
|
expect(url.origin).toBe("https://site.fr");
|
||||||
|
expect(url.searchParams.get("error")).toMatch(/refusée/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,8 @@ const payload = {
|
|||||||
firstname: "Camille",
|
firstname: "Camille",
|
||||||
lastname: "Martin",
|
lastname: "Martin",
|
||||||
email: testEmail,
|
email: testEmail,
|
||||||
password: "Password123",
|
password: "Password123!",
|
||||||
|
confirmPassword: "Password123!",
|
||||||
birthdate: "1990-05-14",
|
birthdate: "1990-05-14",
|
||||||
gender: "Femme",
|
gender: "Femme",
|
||||||
};
|
};
|
||||||
|
|||||||
62
backend/tests/registerPassword.test.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
const request = require("supertest");
|
||||||
|
const app = require("../src/app");
|
||||||
|
const prisma = require("../src/config/prisma");
|
||||||
|
|
||||||
|
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) }));
|
||||||
|
|
||||||
|
const EMAIL = "register-password-test@test.local";
|
||||||
|
|
||||||
|
const payload = (password, confirmPassword = password) => ({
|
||||||
|
firstname: "Camille",
|
||||||
|
lastname: "Martin",
|
||||||
|
email: EMAIL,
|
||||||
|
password,
|
||||||
|
confirmPassword,
|
||||||
|
birthdate: "1990-05-14",
|
||||||
|
});
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
await prisma.user.deleteMany({ where: { email: EMAIL } });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(cleanup);
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Regles du mot de passe a l inscription", () => {
|
||||||
|
test.each([
|
||||||
|
["trop court", "Abc12!", /8 caractères/],
|
||||||
|
["sans caractere special", "MotDePasse123", /caractère spécial/],
|
||||||
|
["accents et espaces ne comptent pas comme speciaux", "Mot de passé 123", /caractère spécial/],
|
||||||
|
])("refuse un mot de passe %s", async (_, password, message) => {
|
||||||
|
const res = await request(app).post("/api/auth/register").send(payload(password));
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.errors.password[0]).toMatch(message);
|
||||||
|
expect(await prisma.user.findUnique({ where: { email: EMAIL } })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuse une confirmation differente", async () => {
|
||||||
|
const res = await request(app).post("/api/auth/register").send(payload("MotDePasse1!", "MotDePasse2!"));
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.errors.confirmPassword[0]).toMatch(/ne correspondent pas/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuse une inscription sans confirmation", async () => {
|
||||||
|
const { confirmPassword, ...sansConfirmation } = payload("MotDePasse1!");
|
||||||
|
const res = await request(app).post("/api/auth/register").send(sansConfirmation);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.errors.confirmPassword).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepte 8 caracteres avec un caractere special", async () => {
|
||||||
|
const res = await request(app).post("/api/auth/register").send(payload("abcdefg#"));
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(await prisma.user.findUnique({ where: { email: EMAIL } })).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
4
backend/tests/setupEnv.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Exécuté avant chaque fichier de test, donc avant le dotenv de src/app.js :
|
||||||
|
// dotenv n'écrase pas une variable déjà définie, le captcha reste désactivé
|
||||||
|
// pour les tests d'API (captcha.middleware.test.js le teste à part).
|
||||||
|
process.env.TURNSTILE_SECRET_KEY = "";
|
||||||
@@ -40,6 +40,15 @@ services:
|
|||||||
API_PUBLIC_URL: https://api-dev.dsp5-archi-o24a-4-5-g4.fr
|
API_PUBLIC_URL: https://api-dev.dsp5-archi-o24a-4-5-g4.fr
|
||||||
FRONTEND_URL: https://dev.dsp5-archi-o24a-4-5-g4.fr
|
FRONTEND_URL: https://dev.dsp5-archi-o24a-4-5-g4.fr
|
||||||
|
|
||||||
|
# Injectés par le pipeline, jamais commités. Vides, les routes de
|
||||||
|
# connexion externe répondent 503 sans bloquer le démarrage.
|
||||||
|
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID}"
|
||||||
|
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET}"
|
||||||
|
FACEBOOK_APP_ID: "${FACEBOOK_APP_ID}"
|
||||||
|
FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET}"
|
||||||
|
# Vide, la vérification anti-robot Turnstile est désactivée.
|
||||||
|
TURNSTILE_SECRET_KEY: "${TURNSTILE_SECRET_KEY}"
|
||||||
|
|
||||||
command: >
|
command: >
|
||||||
sh -c "
|
sh -c "
|
||||||
npx prisma migrate deploy &&
|
npx prisma migrate deploy &&
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ services:
|
|||||||
API_PUBLIC_URL: https://api-preprod.dsp5-archi-o24a-4-5-g4.fr
|
API_PUBLIC_URL: https://api-preprod.dsp5-archi-o24a-4-5-g4.fr
|
||||||
FRONTEND_URL: https://preprod.dsp5-archi-o24a-4-5-g4.fr
|
FRONTEND_URL: https://preprod.dsp5-archi-o24a-4-5-g4.fr
|
||||||
|
|
||||||
|
# Injectés par le pipeline, jamais commités. Vides, les routes de
|
||||||
|
# connexion externe répondent 503 sans bloquer le démarrage.
|
||||||
|
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID}"
|
||||||
|
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET}"
|
||||||
|
FACEBOOK_APP_ID: "${FACEBOOK_APP_ID}"
|
||||||
|
FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET}"
|
||||||
|
# Vide, la vérification anti-robot Turnstile est désactivée.
|
||||||
|
TURNSTILE_SECRET_KEY: "${TURNSTILE_SECRET_KEY}"
|
||||||
|
|
||||||
command: >
|
command: >
|
||||||
sh -c "
|
sh -c "
|
||||||
npx prisma migrate deploy &&
|
npx prisma migrate deploy &&
|
||||||
|
|||||||
@@ -38,7 +38,16 @@ services:
|
|||||||
JWT_SECRET: prod_secret_change_me
|
JWT_SECRET: prod_secret_change_me
|
||||||
APP_URL: https://api.dsp5-archi-o24a-4-5-g4.fr
|
APP_URL: https://api.dsp5-archi-o24a-4-5-g4.fr
|
||||||
API_PUBLIC_URL: https://api.dsp5-archi-o24a-4-5-g4.fr
|
API_PUBLIC_URL: https://api.dsp5-archi-o24a-4-5-g4.fr
|
||||||
FRONTEND_URL: https://dsp5-archi-o24a-4-5-g4.fr
|
FRONTEND_URL: https://dsp5-archi-o24a-4-5-g4.fr,https://www.dsp5-archi-o24a-4-5-g4.fr
|
||||||
|
|
||||||
|
# Injectés par le pipeline, jamais commités. Vides, les routes de
|
||||||
|
# connexion externe répondent 503 sans bloquer le démarrage.
|
||||||
|
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID}"
|
||||||
|
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET}"
|
||||||
|
FACEBOOK_APP_ID: "${FACEBOOK_APP_ID}"
|
||||||
|
FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET}"
|
||||||
|
# Vide, la vérification anti-robot Turnstile est désactivée.
|
||||||
|
TURNSTILE_SECRET_KEY: "${TURNSTILE_SECRET_KEY}"
|
||||||
|
|
||||||
command: >
|
command: >
|
||||||
sh -c "
|
sh -c "
|
||||||
|
|||||||
1
frontend/.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
.env
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
FROM node:22-alpine AS build
|
FROM node:22-slim AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
RUN npx playwright install --with-deps chromium
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
@@ -13,6 +14,9 @@ ARG VITE_GA_ID
|
|||||||
ENV VITE_API_URL=${VITE_API_URL}
|
ENV VITE_API_URL=${VITE_API_URL}
|
||||||
ENV VITE_GA_ID=${VITE_GA_ID}
|
ENV VITE_GA_ID=${VITE_GA_ID}
|
||||||
|
|
||||||
|
ARG VITE_TURNSTILE_SITE_KEY
|
||||||
|
ENV VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY}
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export default defineConfig([
|
|||||||
globalIgnores(['dist']),
|
globalIgnores(['dist']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{js,jsx}'],
|
files: ['**/*.{js,jsx}'],
|
||||||
|
ignores: ['scripts/**'],
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
reactHooks.configs.flat.recommended,
|
reactHooks.configs.flat.recommended,
|
||||||
@@ -18,4 +19,13 @@ export default defineConfig([
|
|||||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Scripts Node exécutés à la compilation (ex. prerender.js), jamais
|
||||||
|
// dans le navigateur : globals Node, pas de règles React.
|
||||||
|
files: ['scripts/**/*.js'],
|
||||||
|
extends: [js.configs.recommended],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.node,
|
||||||
|
},
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -5,7 +5,43 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="Participez au jeu-concours Thé Tip Top : saisissez le code de votre ticket et découvrez immédiatement votre lot." />
|
<meta name="description" content="Participez au jeu-concours Thé Tip Top : saisissez le code de votre ticket et découvrez immédiatement votre lot." />
|
||||||
|
<link rel="canonical" href="https://www.dsp5-archi-o24a-4-5-g4.fr/accueil" />
|
||||||
<title>Jeu concours - Thé Tip Top</title>
|
<title>Jeu concours - Thé Tip Top</title>
|
||||||
|
|
||||||
|
<!-- Open Graph : valeurs par défaut, mises à jour par page via le hook usePageMeta -->
|
||||||
|
<meta property="og:site_name" content="Thé Tip Top" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:locale" content="fr_FR" />
|
||||||
|
<meta property="og:title" content="Jeu concours - Thé Tip Top" />
|
||||||
|
<meta property="og:description" content="Participez au jeu-concours Thé Tip Top : saisissez le code de votre ticket et découvrez immédiatement votre lot." />
|
||||||
|
<meta property="og:url" content="https://www.dsp5-archi-o24a-4-5-g4.fr/accueil" />
|
||||||
|
<meta property="og:image" content="https://www.dsp5-archi-o24a-4-5-g4.fr/og-image.webp" />
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Jeu concours - Thé Tip Top" />
|
||||||
|
<meta name="twitter:description" content="Participez au jeu-concours Thé Tip Top : saisissez le code de votre ticket et découvrez immédiatement votre lot." />
|
||||||
|
<meta name="twitter:image" content="https://www.dsp5-archi-o24a-4-5-g4.fr/og-image.webp" />
|
||||||
|
|
||||||
|
<!-- Schema.org : identité du site uniquement, aucune adresse (non communiquée) -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@graph": [
|
||||||
|
{
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "Thé Tip Top",
|
||||||
|
"url": "https://www.dsp5-archi-o24a-4-5-g4.fr/",
|
||||||
|
"email": "thetiptop.gp4@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "WebSite",
|
||||||
|
"name": "Thé Tip Top",
|
||||||
|
"url": "https://www.dsp5-archi-o24a-4-5-g4.fr/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ server {
|
|||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
# $uri/index.html (et non $uri/) : sert directement le fichier des
|
||||||
|
# pages pré-rendues (ex. /accueil -> dist/accueil/index.html) sans
|
||||||
|
# passer par la redirection 301 vers l'URL à slash final que nginx
|
||||||
|
# déclenche pour une résolution de type répertoire ($uri/). Les
|
||||||
|
# canonical/sitemap pointent vers les URLs sans slash : elles
|
||||||
|
# doivent répondre 200 directement, pas via un redirect.
|
||||||
|
try_files $uri $uri/index.html /index.html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "ttt-front",
|
"name": "ttt-front",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@marsidev/react-turnstile": "^1.6.1",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-router-dom": "^7.17.0"
|
"react-router-dom": "^7.17.0"
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"vite": "^8.0.12",
|
"vite": "^8.0.12",
|
||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.10"
|
||||||
@@ -802,6 +804,15 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@marsidev/react-turnstile": {
|
||||||
|
"version": "1.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.6.1.tgz",
|
||||||
|
"integrity": "sha512-ojI9NLYv821IRKi7KraTkxTto6KC/m58hNNyKxvYxm/cQBu5MI5eMkaiJs2JSQBFiXE3LPQI3seJVjv/2lgALA==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^17.0.2 || ^18.0.0 || ^19.0",
|
||||||
|
"react-dom": "^17.0.2 || ^18.0.0 || ^19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||||
@@ -3419,6 +3430,33 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.63.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build && node scripts/prerender.js",
|
||||||
|
"prerender": "node scripts/prerender.js",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@marsidev/react-turnstile": "^1.6.1",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-router-dom": "^7.17.0"
|
"react-router-dom": "^7.17.0"
|
||||||
@@ -32,6 +34,7 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"vite": "^8.0.12",
|
"vite": "^8.0.12",
|
||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.10"
|
||||||
|
|||||||
BIN
frontend/public/og-image.webp
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
frontend/public/reglement-jeu-concours-100-gagnant.pdf
Normal file
@@ -3,10 +3,13 @@ User-agent: *
|
|||||||
# Pages publiques autorisées
|
# Pages publiques autorisées
|
||||||
Allow: /
|
Allow: /
|
||||||
Allow: /accueil
|
Allow: /accueil
|
||||||
|
Allow: /notre-concept
|
||||||
Allow: /regles-jeu
|
Allow: /regles-jeu
|
||||||
Allow: /contact
|
Allow: /contact
|
||||||
Allow: /mentions-legales
|
Allow: /mentions-legales
|
||||||
Allow: /confidentialite
|
Allow: /confidentialite
|
||||||
|
Allow: /accessibilite
|
||||||
|
Allow: /cgu
|
||||||
Allow: /sitemap
|
Allow: /sitemap
|
||||||
Allow: /inscription
|
Allow: /inscription
|
||||||
Allow: /connexion
|
Allow: /connexion
|
||||||
@@ -20,4 +23,4 @@ Disallow: /saisir-code-unique
|
|||||||
Disallow: /perdu
|
Disallow: /perdu
|
||||||
|
|
||||||
# Sitemap
|
# Sitemap
|
||||||
Sitemap: https://thetiptop.fr/sitemap
|
Sitemap: https://www.dsp5-archi-o24a-4-5-g4.fr/sitemap.xml
|
||||||
|
|||||||
14
frontend/public/sitemap.xml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/accueil</loc></url>
|
||||||
|
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/notre-concept</loc></url>
|
||||||
|
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/regles-jeu</loc></url>
|
||||||
|
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/contact</loc></url>
|
||||||
|
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/mentions-legales</loc></url>
|
||||||
|
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/cgu</loc></url>
|
||||||
|
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/confidentialite</loc></url>
|
||||||
|
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/accessibilite</loc></url>
|
||||||
|
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/sitemap</loc></url>
|
||||||
|
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/inscription</loc></url>
|
||||||
|
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/connexion</loc></url>
|
||||||
|
</urlset>
|
||||||
57
frontend/scripts/prerender.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { preview } from 'vite'
|
||||||
|
import { chromium } from 'playwright'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const root = join(__dirname, '..')
|
||||||
|
const distDir = join(root, 'dist')
|
||||||
|
|
||||||
|
// Pages publiques, indexables (voir public/robots.txt) : ce sont les seules
|
||||||
|
// qui ont besoin d'un HTML pré-rendu pour le SEO et les aperçus sociaux. Les
|
||||||
|
// pages privées (profil, mes gains, admin...) restent en rendu 100% client,
|
||||||
|
// servies par le index.html générique via le fallback SPA de nginx.
|
||||||
|
const ROUTES = [
|
||||||
|
'/accueil',
|
||||||
|
'/notre-concept',
|
||||||
|
'/regles-jeu',
|
||||||
|
'/contact',
|
||||||
|
'/mentions-legales',
|
||||||
|
'/confidentialite',
|
||||||
|
'/accessibilite',
|
||||||
|
'/cgu',
|
||||||
|
'/sitemap',
|
||||||
|
'/inscription',
|
||||||
|
'/connexion',
|
||||||
|
]
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const server = await preview({ root, preview: { port: 4173, strictPort: false } })
|
||||||
|
const base = server.resolvedUrls.local[0].replace(/\/$/, '')
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
executablePath: process.env.CHROMIUM_PATH || chromium.executablePath(),
|
||||||
|
headless: true,
|
||||||
|
})
|
||||||
|
const page = await browser.newPage()
|
||||||
|
|
||||||
|
for (const route of ROUTES) {
|
||||||
|
await page.goto(`${base}${route}`, { waitUntil: 'networkidle' })
|
||||||
|
await page.waitForSelector('h1')
|
||||||
|
|
||||||
|
const html = await page.content()
|
||||||
|
const outDir = join(distDir, route.replace(/^\//, ''))
|
||||||
|
mkdirSync(outDir, { recursive: true })
|
||||||
|
writeFileSync(join(outDir, 'index.html'), `<!doctype html>\n${html}`)
|
||||||
|
console.log(`✓ pré-rendu ${route} -> dist${route}/index.html`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
|
await server.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error('Erreur de pré-rendu :', error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -7,7 +7,10 @@ import Connexion from './pages/Connexion'
|
|||||||
import Inscription from './pages/Inscription'
|
import Inscription from './pages/Inscription'
|
||||||
import AuthCallback from './pages/AuthCallback'
|
import AuthCallback from './pages/AuthCallback'
|
||||||
import DefinirMotDePasse from './pages/DefinirMotDePasse'
|
import DefinirMotDePasse from './pages/DefinirMotDePasse'
|
||||||
|
import MotDePasseOublie from './pages/MotDePasseOublie'
|
||||||
|
import NouveauMotDePasse from './pages/NouveauMotDePasse'
|
||||||
import Accueil from './pages/Accueil'
|
import Accueil from './pages/Accueil'
|
||||||
|
import NotreConcept from './pages/NotreConcept'
|
||||||
import ReglesJeu from './pages/ReglesJeu'
|
import ReglesJeu from './pages/ReglesJeu'
|
||||||
import MesGains from './pages/MesGains'
|
import MesGains from './pages/MesGains'
|
||||||
import Employe from './pages/Employe'
|
import Employe from './pages/Employe'
|
||||||
@@ -32,10 +35,13 @@ export default function App() {
|
|||||||
<Route path="/connexion" element={<Connexion />} />
|
<Route path="/connexion" element={<Connexion />} />
|
||||||
<Route path="/inscription" element={<Inscription />} />
|
<Route path="/inscription" element={<Inscription />} />
|
||||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||||
|
<Route path="/mot-de-passe-oublie" element={<MotDePasseOublie />} />
|
||||||
<Route path="/definir-mot-de-passe/:token" element={<DefinirMotDePasse />} />
|
<Route path="/definir-mot-de-passe/:token" element={<DefinirMotDePasse />} />
|
||||||
|
<Route path="/nouveau-mot-de-passe" element={<NouveauMotDePasse />} />
|
||||||
|
|
||||||
{/* Pages publiques */}
|
{/* Pages publiques */}
|
||||||
<Route path="/accueil" element={<Layout><Accueil /></Layout>} />
|
<Route path="/accueil" element={<Layout><Accueil /></Layout>} />
|
||||||
|
<Route path="/notre-concept" element={<Layout><NotreConcept /></Layout>} />
|
||||||
<Route path="/regles-jeu" element={<Layout><ReglesJeu /></Layout>} />
|
<Route path="/regles-jeu" element={<Layout><ReglesJeu /></Layout>} />
|
||||||
<Route path="/mentions-legales" element={<Layout><MentionsLegales /></Layout>} />
|
<Route path="/mentions-legales" element={<Layout><MentionsLegales /></Layout>} />
|
||||||
<Route path="/confidentialite" element={<Layout><Confidentialite /></Layout>} />
|
<Route path="/confidentialite" element={<Layout><Confidentialite /></Layout>} />
|
||||||
|
|||||||
BIN
frontend/src/assets/img/banner-accueil.webp
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
frontend/src/assets/img/banner-the-tip-top.webp
Normal file
|
After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 7.2 MiB |
BIN
frontend/src/assets/img/img-contact.webp
Normal file
|
After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 18 KiB |
BIN
frontend/src/assets/img/logo.webp
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
125
frontend/src/components/Captcha.jsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { CAPTCHA_ENABLED, TURNSTILE_SITE_KEY } from '../config/captcha'
|
||||||
|
|
||||||
|
const TURNSTILE_SCRIPT_ID = 'cf-turnstile-script'
|
||||||
|
const TURNSTILE_SCRIPT_URL =
|
||||||
|
'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
|
||||||
|
|
||||||
|
export default function Captcha({ onToken }) {
|
||||||
|
const containerRef = useRef(null)
|
||||||
|
const widgetIdRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!CAPTCHA_ENABLED || !TURNSTILE_SITE_KEY || !containerRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
let localWidgetId = null
|
||||||
|
|
||||||
|
const renderWidget = () => {
|
||||||
|
if (
|
||||||
|
cancelled ||
|
||||||
|
!containerRef.current ||
|
||||||
|
!window.turnstile
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nettoyage d'un éventuel widget précédent.
|
||||||
|
if (localWidgetId !== null) {
|
||||||
|
try {
|
||||||
|
window.turnstile.remove(localWidgetId)
|
||||||
|
} catch {
|
||||||
|
// Widget déjà supprimé.
|
||||||
|
}
|
||||||
|
|
||||||
|
localWidgetId = null
|
||||||
|
widgetIdRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
containerRef.current.innerHTML = ''
|
||||||
|
|
||||||
|
localWidgetId = window.turnstile.render(
|
||||||
|
containerRef.current,
|
||||||
|
{
|
||||||
|
sitekey: TURNSTILE_SITE_KEY,
|
||||||
|
language: 'fr',
|
||||||
|
size: 'flexible',
|
||||||
|
appearance: 'always',
|
||||||
|
|
||||||
|
callback: (token) => {
|
||||||
|
onToken(token)
|
||||||
|
},
|
||||||
|
|
||||||
|
'expired-callback': () => {
|
||||||
|
onToken('')
|
||||||
|
},
|
||||||
|
|
||||||
|
'error-callback': () => {
|
||||||
|
onToken('')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
widgetIdRef.current = localWidgetId
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingScript = document.getElementById(
|
||||||
|
TURNSTILE_SCRIPT_ID
|
||||||
|
)
|
||||||
|
|
||||||
|
if (window.turnstile) {
|
||||||
|
renderWidget()
|
||||||
|
} else if (existingScript) {
|
||||||
|
existingScript.addEventListener('load', renderWidget, {
|
||||||
|
once: true,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const script = document.createElement('script')
|
||||||
|
|
||||||
|
script.id = TURNSTILE_SCRIPT_ID
|
||||||
|
script.src = TURNSTILE_SCRIPT_URL
|
||||||
|
script.async = true
|
||||||
|
script.defer = true
|
||||||
|
|
||||||
|
script.addEventListener('load', renderWidget, {
|
||||||
|
once: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
document.head.appendChild(script)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
|
||||||
|
const widgetId =
|
||||||
|
localWidgetId !== null
|
||||||
|
? localWidgetId
|
||||||
|
: widgetIdRef.current
|
||||||
|
|
||||||
|
if (widgetId !== null && window.turnstile) {
|
||||||
|
try {
|
||||||
|
window.turnstile.remove(widgetId)
|
||||||
|
} catch {
|
||||||
|
// Widget déjà supprimé.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
localWidgetId = null
|
||||||
|
widgetIdRef.current = null
|
||||||
|
}
|
||||||
|
}, [onToken])
|
||||||
|
|
||||||
|
if (!CAPTCHA_ENABLED || !TURNSTILE_SITE_KEY) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
id="turnstile-captcha"
|
||||||
|
className="w-full min-h-[65px] flex justify-center"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ export default function Footer() {
|
|||||||
<footer className="bg-[#006B6B] text-white">
|
<footer className="bg-[#006B6B] text-white">
|
||||||
|
|
||||||
{/* Partie principale */}
|
{/* Partie principale */}
|
||||||
<div className="max-w-7xl mx-auto px-6 py-10 grid grid-cols-2 md:grid-cols-4 gap-8">
|
<div className="max-w-7xl mx-auto px-6 py-10 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8">
|
||||||
|
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div>
|
<div>
|
||||||
@@ -57,6 +57,16 @@ export default function Footer() {
|
|||||||
</FooterLink>
|
</FooterLink>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<FooterLink
|
||||||
|
to="/notre-concept"
|
||||||
|
pathname={pathname}
|
||||||
|
className="hover:text-white transition"
|
||||||
|
>
|
||||||
|
Notre concept
|
||||||
|
</FooterLink>
|
||||||
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<FooterLink
|
<FooterLink
|
||||||
to="/regles-jeu"
|
to="/regles-jeu"
|
||||||
@@ -179,7 +189,6 @@ export default function Footer() {
|
|||||||
>
|
>
|
||||||
01 84 80 20 34
|
01 84 80 20 34
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="mailto:contact@thetiptop.fr"
|
href="mailto:contact@thetiptop.fr"
|
||||||
className="inline-block mt-3 font-medium hover:text-teal-300"
|
className="inline-block mt-3 font-medium hover:text-teal-300"
|
||||||
@@ -198,9 +207,23 @@ export default function Footer() {
|
|||||||
|
|
||||||
{/* Barre basse */}
|
{/* Barre basse */}
|
||||||
<div className="border-t border-teal-800">
|
<div className="border-t border-teal-800">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-4 text-center">
|
||||||
|
<p className="text-teal-100 text-xs leading-relaxed">
|
||||||
|
Jeu avec obligation d'achat. Participation réservée aux personnes majeures résidant en
|
||||||
|
France métropolitaine, sur présentation d'un ticket de caisse supérieur à 49 € émis
|
||||||
|
dans la boutique Thé Tip Top de Nice. Le nombre de participations n'augmente pas les
|
||||||
|
chances de remporter le gros lot.{' '}
|
||||||
|
<a
|
||||||
|
href="/reglement-jeu-concours-100-gagnant.pdf"
|
||||||
|
className="underline hover:text-white"
|
||||||
|
>
|
||||||
|
Télécharger le règlement (PDF)
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="max-w-7xl mx-auto px-6 py-5 flex flex-col md:flex-row justify-between items-center gap-3">
|
<div className="max-w-7xl mx-auto px-6 py-5 flex flex-col md:flex-row justify-between items-center gap-3">
|
||||||
|
|
||||||
<p className="text-teal-200 text-sm text-center md:text-left">
|
<p className="text-teal-100 text-sm text-center md:text-left">
|
||||||
© {new Date().getFullYear()} Thé Tip Top. Tous droits réservés.
|
© {new Date().getFullYear()} Thé Tip Top. Tous droits réservés.
|
||||||
<br className="md:hidden" />
|
<br className="md:hidden" />
|
||||||
<span className="md:before:content-['_—_']">
|
<span className="md:before:content-['_—_']">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import logo from '../assets/img/logo.png'
|
import logo from '../assets/img/logo.webp'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
@@ -19,7 +19,9 @@ export default function Header() {
|
|||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ to: '/accueil', label: 'Accueil' },
|
{ to: '/accueil', label: 'Accueil' },
|
||||||
|
{ to: '/notre-concept', label: 'Notre concept' },
|
||||||
{ to: '/regles-jeu', label: 'Règles du jeu' },
|
{ to: '/regles-jeu', label: 'Règles du jeu' },
|
||||||
|
{ to: '/contact', label: 'Contact' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const initials = user
|
const initials = user
|
||||||
|
|||||||
21
frontend/src/components/PasswordRules.jsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { PASSWORD_RULES } from '../utils/passwordRules'
|
||||||
|
|
||||||
|
// Règles du mot de passe, cochées au fil de la saisie. À relier au champ via
|
||||||
|
// aria-describedby={id}.
|
||||||
|
export default function PasswordRules({ id, password }) {
|
||||||
|
return (
|
||||||
|
<ul id={id} className="mt-2 space-y-1">
|
||||||
|
{PASSWORD_RULES.map((rule) => {
|
||||||
|
const ok = rule.test(password)
|
||||||
|
return (
|
||||||
|
<li key={rule.id} data-rule={rule.id} data-valid={ok}
|
||||||
|
className={`flex items-center gap-2 text-xs ${ok ? 'text-green-700' : 'text-gray-500'}`}>
|
||||||
|
<span aria-hidden="true">{ok ? '✓' : '○'}</span>
|
||||||
|
{rule.label}
|
||||||
|
<span className="sr-only">{ok ? ' : respecté' : ' : non respecté'}</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Navigate } from 'react-router-dom'
|
import { Navigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { homeFor, INITIAL_PASSWORD_PATH } from '../config/roles'
|
||||||
|
|
||||||
export default function ProtectedRoute({ children, roles }) {
|
export default function ProtectedRoute({ children, roles }) {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
@@ -8,9 +9,14 @@ export default function ProtectedRoute({ children, roles }) {
|
|||||||
return <Navigate to="/connexion" replace />
|
return <Navigate to="/connexion" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mot de passe provisoire : aucune page protégée tant qu'il n'est pas
|
||||||
|
// remplacé (le backend refuse de toute façon les appels à l'API).
|
||||||
|
if (user.mustChangePassword) {
|
||||||
|
return <Navigate to={INITIAL_PASSWORD_PATH} replace />
|
||||||
|
}
|
||||||
|
|
||||||
if (roles && !roles.includes(user.role)) {
|
if (roles && !roles.includes(user.role)) {
|
||||||
const redirects = { ADMIN: '/admin', EMPLOYEE: '/employe', CLIENT: '/mes-gains' }
|
return <Navigate to={homeFor(user)} replace />
|
||||||
return <Navigate to={redirects[user.role] || '/connexion'} replace />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return children
|
return children
|
||||||
|
|||||||
288
frontend/src/components/admin/EmployeeDeliveries.jsx
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { getEmployeesApi, getEmployeeDeliveriesApi, getDeliveryByTicketCodeApi } from '../../services/admin'
|
||||||
|
|
||||||
|
const STATUS_BADGES = {
|
||||||
|
DELIVERED: { label: 'Lot remis', className: 'bg-green-100 text-green-700' },
|
||||||
|
PENDING: { label: 'En attente de retrait', className: 'bg-amber-100 text-amber-700' },
|
||||||
|
CANCELLED: { label: 'Expiré', className: 'bg-gray-200 text-gray-700' },
|
||||||
|
NOT_PLAYED: { label: 'Pas encore joué', className: 'bg-gray-100 text-gray-600' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const INPUT_CLASS =
|
||||||
|
'w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]'
|
||||||
|
const BUTTON_CLASS =
|
||||||
|
'bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm'
|
||||||
|
|
||||||
|
function formatDateTime(dateStr) {
|
||||||
|
return new Date(dateStr).toLocaleString('fr-FR', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullName = (person) => `${person.firstname} ${person.lastname}`
|
||||||
|
|
||||||
|
// Traçabilité des remises de lots : liste des employés, lots remis par chacun,
|
||||||
|
// et recherche inverse par numéro de ticket.
|
||||||
|
// `refreshKey` change quand un employé est créé, pour recharger la liste.
|
||||||
|
export default function EmployeeDeliveries({ refreshKey, onAuthError }) {
|
||||||
|
const [employees, setEmployees] = useState(null)
|
||||||
|
const [employeesError, setEmployeesError] = useState('')
|
||||||
|
const [filter, setFilter] = useState('')
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState(null)
|
||||||
|
const [deliveries, setDeliveries] = useState(null)
|
||||||
|
const [deliveriesLoading, setDeliveriesLoading] = useState(false)
|
||||||
|
const [deliveriesError, setDeliveriesError] = useState('')
|
||||||
|
const detailRef = useRef(null)
|
||||||
|
|
||||||
|
const [ticketCode, setTicketCode] = useState('')
|
||||||
|
const [ticketResult, setTicketResult] = useState(null)
|
||||||
|
const [ticketLoading, setTicketLoading] = useState(false)
|
||||||
|
const [ticketError, setTicketError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getEmployeesApi()
|
||||||
|
.then((data) => {
|
||||||
|
setEmployees(data.employees)
|
||||||
|
setEmployeesError('')
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!onAuthError(err)) setEmployeesError(err.message)
|
||||||
|
})
|
||||||
|
}, [refreshKey, onAuthError])
|
||||||
|
|
||||||
|
const openEmployee = async (employee) => {
|
||||||
|
setSelected(employee)
|
||||||
|
setDeliveries(null)
|
||||||
|
setDeliveriesError('')
|
||||||
|
setDeliveriesLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await getEmployeeDeliveriesApi(employee.id)
|
||||||
|
setDeliveries(data.deliveries)
|
||||||
|
} catch (err) {
|
||||||
|
if (!onAuthError(err)) setDeliveriesError(err.message)
|
||||||
|
} finally {
|
||||||
|
setDeliveriesLoading(false)
|
||||||
|
detailRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTicketSearch = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setTicketError('')
|
||||||
|
setTicketResult(null)
|
||||||
|
setTicketLoading(true)
|
||||||
|
try {
|
||||||
|
setTicketResult(await getDeliveryByTicketCodeApi(ticketCode))
|
||||||
|
} catch (err) {
|
||||||
|
if (!onAuthError(err)) setTicketError(err.message)
|
||||||
|
} finally {
|
||||||
|
setTicketLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const needle = filter.trim().toLowerCase()
|
||||||
|
const visibleEmployees = (employees ?? []).filter((employee) =>
|
||||||
|
!needle || `${fullName(employee)} ${employee.email}`.toLowerCase().includes(needle)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-1">Employés et remises des lots</h2>
|
||||||
|
<p className="text-xs sm:text-sm text-gray-500">
|
||||||
|
Retrouvez quel employé a remis un lot et à quelle date, par numéro de ticket ou par employé.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Recherche par numéro de ticket ── */}
|
||||||
|
<div>
|
||||||
|
<form onSubmit={handleTicketSearch} className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<label htmlFor="delivery-ticket-code" className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
|
Numéro du ticket
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="delivery-ticket-code"
|
||||||
|
value={ticketCode}
|
||||||
|
onChange={(e) => setTicketCode(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="Ex. A1B2C3D4E5"
|
||||||
|
className={`${INPUT_CLASS} uppercase placeholder:normal-case`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={ticketLoading || !ticketCode.trim()} className={BUTTON_CLASS}>
|
||||||
|
{ticketLoading ? 'Recherche…' : 'Rechercher'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{ticketError && <p className="text-red-500 text-xs sm:text-sm mt-3">{ticketError}</p>}
|
||||||
|
|
||||||
|
{ticketResult && (
|
||||||
|
<div className="mt-4 p-4 rounded-lg border border-gray-200 bg-gray-50 text-sm space-y-1.5" data-testid="ticket-result">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<p className="font-mono font-semibold text-gray-800">{ticketResult.ticketCode}</p>
|
||||||
|
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full ${STATUS_BADGES[ticketResult.status]?.className}`}>
|
||||||
|
{STATUS_BADGES[ticketResult.status]?.label ?? ticketResult.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600">Lot : <span className="font-medium text-gray-800">{ticketResult.lot.name}</span></p>
|
||||||
|
{ticketResult.client && (
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Gagné par : <span className="font-medium text-gray-800">{fullName(ticketResult.client)}</span>{' '}
|
||||||
|
<span className="text-gray-500">({ticketResult.client.email})</span>
|
||||||
|
{ticketResult.playedAt && <> le {formatDateTime(ticketResult.playedAt)}</>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{ticketResult.deliveredBy ? (
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Remis par :{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openEmployee(ticketResult.deliveredBy)}
|
||||||
|
className="font-semibold text-[#006B6B] hover:underline"
|
||||||
|
>
|
||||||
|
{fullName(ticketResult.deliveredBy)}
|
||||||
|
</button>{' '}
|
||||||
|
le <span className="font-medium text-gray-800">{formatDateTime(ticketResult.deliveredAt)}</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
ticketResult.status !== 'NOT_PLAYED' && (
|
||||||
|
<p className="text-gray-500">Ce lot n'a pas encore été remis.</p>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Liste des employés ── */}
|
||||||
|
<div className="pt-6 border-t border-gray-100">
|
||||||
|
<div className="flex items-end justify-between flex-wrap gap-3 mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-800">
|
||||||
|
Comptes employés
|
||||||
|
{employees && <span className="text-gray-500 font-normal"> - {employees.length}</span>}
|
||||||
|
</h3>
|
||||||
|
<div className="w-full sm:w-64">
|
||||||
|
<label htmlFor="employee-filter" className="sr-only">Rechercher un employé</label>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
id="employee-filter"
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
placeholder="Rechercher un employé…"
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{employeesError && <p className="text-red-500 text-sm">{employeesError}</p>}
|
||||||
|
{!employees && !employeesError && <p className="text-gray-500 text-sm">Chargement…</p>}
|
||||||
|
|
||||||
|
{employees && (
|
||||||
|
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
||||||
|
<table className="w-full text-sm min-w-[520px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-500 border-b text-xs sm:text-sm">
|
||||||
|
<th className="pb-2 px-4 sm:px-0">Nom</th>
|
||||||
|
<th className="pb-2 px-4 sm:px-0">Email</th>
|
||||||
|
<th className="pb-2 px-4 sm:px-0">Créé le</th>
|
||||||
|
<th className="pb-2 px-4 sm:px-0 text-center">Lots remis</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visibleEmployees.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="py-6 text-center text-gray-500 text-sm">
|
||||||
|
{employees.length === 0 ? "Aucun compte employé pour l'instant" : 'Aucun employé ne correspond à la recherche'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{visibleEmployees.map((employee) => (
|
||||||
|
<tr key={employee.id} className={`border-b last:border-0 ${selected?.id === employee.id ? 'bg-[#006B6B]/5' : ''}`}>
|
||||||
|
<td className="py-3 px-4 sm:px-0 text-xs sm:text-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openEmployee(employee)}
|
||||||
|
className="font-semibold text-[#006B6B] hover:underline text-left"
|
||||||
|
>
|
||||||
|
{fullName(employee)}
|
||||||
|
</button>
|
||||||
|
{employee.mustChangePassword && (
|
||||||
|
<span className="block text-[11px] text-amber-700">Première connexion en attente</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 sm:px-0 text-gray-500 text-xs">{employee.email}</td>
|
||||||
|
<td className="py-3 px-4 sm:px-0 text-gray-500 text-xs">
|
||||||
|
{new Date(employee.createdAt).toLocaleDateString('fr-FR')}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 sm:px-0 text-center font-semibold text-gray-800">{employee.deliveredCount}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Lots remis par l'employé sélectionné ── */}
|
||||||
|
{selected && (
|
||||||
|
<div ref={detailRef} className="pt-6 border-t border-gray-100">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2 mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-800">
|
||||||
|
Lots remis par {fullName(selected)}
|
||||||
|
{deliveries && <span className="text-gray-500 font-normal"> — {deliveries.length}</span>}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelected(null)}
|
||||||
|
className="border border-gray-200 text-gray-600 hover:bg-gray-50 font-semibold px-3 py-1.5 rounded-lg transition-colors text-xs"
|
||||||
|
>
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deliveriesLoading && <p className="text-gray-500 text-sm">Chargement…</p>}
|
||||||
|
{deliveriesError && <p className="text-red-500 text-sm">{deliveriesError}</p>}
|
||||||
|
|
||||||
|
{deliveries && (
|
||||||
|
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
||||||
|
<table className="w-full text-sm min-w-[560px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-500 border-b text-xs sm:text-sm">
|
||||||
|
<th className="pb-2 px-4 sm:px-0">Ticket</th>
|
||||||
|
<th className="pb-2 px-4 sm:px-0">Lot</th>
|
||||||
|
<th className="pb-2 px-4 sm:px-0">Client</th>
|
||||||
|
<th className="pb-2 px-4 sm:px-0 text-right">Remis le</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{deliveries.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="py-6 text-center text-gray-500 text-sm">
|
||||||
|
Cet employé n'a encore remis aucun lot
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{deliveries.map((delivery) => (
|
||||||
|
<tr key={delivery.claimId} className="border-b last:border-0">
|
||||||
|
<td className="py-3 px-4 sm:px-0 font-mono text-xs text-gray-800">{delivery.ticketCode}</td>
|
||||||
|
<td className="py-3 px-4 sm:px-0 text-xs sm:text-sm text-gray-800">{delivery.lot.name}</td>
|
||||||
|
<td className="py-3 px-4 sm:px-0 text-xs text-gray-500">
|
||||||
|
{fullName(delivery.client)}
|
||||||
|
<span className="block">{delivery.client.email}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 sm:px-0 text-right text-xs text-gray-500 whitespace-nowrap">
|
||||||
|
{formatDateTime(delivery.deliveredAt)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
8
frontend/src/config/captcha.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY
|
||||||
|
|
||||||
|
// Sans clé (tests, dev sans .env), aucun widget n'est affiché et aucun jeton
|
||||||
|
// n'est exigé côté front ; le backend fait de même sans TURNSTILE_SECRET_KEY.
|
||||||
|
// navigator.webdriver : le pré-rendu Playwright (scripts/prerender.js) ne doit
|
||||||
|
// pas figer l'iframe Cloudflare dans le HTML statique.
|
||||||
|
export const CAPTCHA_ENABLED =
|
||||||
|
Boolean(TURNSTILE_SITE_KEY) && !(typeof navigator !== 'undefined' && navigator.webdriver)
|
||||||
10
frontend/src/config/roles.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// Page d'accueil de chaque rôle après connexion.
|
||||||
|
export const HOME_BY_ROLE = { ADMIN: '/admin', EMPLOYEE: '/employe', CLIENT: '/mes-gains' }
|
||||||
|
|
||||||
|
// Page imposée tant qu'un mot de passe provisoire n'a pas été remplacé.
|
||||||
|
export const INITIAL_PASSWORD_PATH = '/nouveau-mot-de-passe'
|
||||||
|
|
||||||
|
export function homeFor(user) {
|
||||||
|
if (user?.mustChangePassword) return INITIAL_PASSWORD_PATH
|
||||||
|
return HOME_BY_ROLE[user?.role] || '/connexion'
|
||||||
|
}
|
||||||
26
frontend/src/hooks/useGameDates.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { getGameWindowApi } from '../services/game'
|
||||||
|
import { toGameDates } from '../utils/gameDates'
|
||||||
|
|
||||||
|
// Dates du jeu-concours pour l'affichage public. Rend immédiatement les dates
|
||||||
|
// par défaut (2 → 31 octobre 2026), puis celles fixées par l'admin dès que
|
||||||
|
// l'API répond. Rechargées à chaque montage : une date modifiée dans le
|
||||||
|
// tableau de bord apparaît dès le retour sur la page.
|
||||||
|
export default function useGameDates() {
|
||||||
|
const [gameWindow, setGameWindow] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
getGameWindowApi()
|
||||||
|
.then((data) => {
|
||||||
|
if (active) setGameWindow(data)
|
||||||
|
})
|
||||||
|
// API injoignable : les dates par défaut restent affichées.
|
||||||
|
.catch(() => {})
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return useMemo(() => toGameDates(gameWindow), [gameWindow])
|
||||||
|
}
|
||||||
50
frontend/src/hooks/usePageMeta.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
|
const SITE_URL = 'https://www.dsp5-archi-o24a-4-5-g4.fr'
|
||||||
|
|
||||||
|
function setAttr(selector, attr, value) {
|
||||||
|
if (value == null) return null
|
||||||
|
|
||||||
|
const el = document.querySelector(selector)
|
||||||
|
if (!el) return null
|
||||||
|
|
||||||
|
const previous = el.getAttribute(attr)
|
||||||
|
el.setAttribute(attr, value)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (previous == null) {
|
||||||
|
el.removeAttribute(attr)
|
||||||
|
} else {
|
||||||
|
el.setAttribute(attr, previous)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function usePageMeta({ title, description, image } = {}) {
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (title) {
|
||||||
|
document.title = title
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${SITE_URL}${pathname}`
|
||||||
|
|
||||||
|
const restores = [
|
||||||
|
setAttr('meta[name="description"]', 'content', description),
|
||||||
|
setAttr('link[rel="canonical"]', 'href', url),
|
||||||
|
setAttr('meta[property="og:title"]', 'content', title),
|
||||||
|
setAttr('meta[property="og:description"]', 'content', description),
|
||||||
|
setAttr('meta[property="og:url"]', 'content', url),
|
||||||
|
setAttr('meta[property="og:image"]', 'content', image),
|
||||||
|
setAttr('meta[name="twitter:title"]', 'content', title),
|
||||||
|
setAttr('meta[name="twitter:description"]', 'content', description),
|
||||||
|
setAttr('meta[name="twitter:image"]', 'content', image),
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
restores.forEach((restore) => restore())
|
||||||
|
}
|
||||||
|
}, [title, description, image, pathname])
|
||||||
|
}
|
||||||
@@ -1 +1,54 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Animation d'entrée de la bannière d'accueil : l'illustration glisse
|
||||||
|
légèrement depuis la droite en grossissant jusqu'à sa taille finale, et
|
||||||
|
les cartes de gains apparaissent juste après avec un léger décalage
|
||||||
|
(delay défini au cas par cas via [animation-delay:...]). */
|
||||||
|
@keyframes banner-image-enter {
|
||||||
|
0% { opacity: 0; transform: translateX(70px) scale(0.9); }
|
||||||
|
100% { opacity: 1; transform: translateX(0) scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes banner-badge-enter {
|
||||||
|
0% { opacity: 0; transform: translateY(16px) scale(0.9); }
|
||||||
|
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flottement continu des cartes de la bannière, une fois qu'elles sont
|
||||||
|
apparues (deux variantes légèrement différentes pour éviter un
|
||||||
|
mouvement trop synchronisé entre les deux cartes). Appliqué sur un
|
||||||
|
conteneur séparé de celui qui gère l'inclinaison au survol (tilt), pour
|
||||||
|
que les deux animations du `transform` ne se marchent pas dessus. */
|
||||||
|
@keyframes card-float-a {
|
||||||
|
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||||
|
50% { transform: translateY(-10px) rotate(1.5deg); }
|
||||||
|
}
|
||||||
|
@keyframes card-float-b {
|
||||||
|
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||||
|
50% { transform: translateY(-7px) rotate(-1.5deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dérive douce des particules de thé générées en JS (position, taille,
|
||||||
|
durée et délai tirés au sort côté composant, amplitude reçue ici via la
|
||||||
|
variable CSS --tea-drift posée en style inline par particule). */
|
||||||
|
@keyframes tea-drift {
|
||||||
|
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||||
|
50% { transform: translateY(calc(-1 * var(--tea-drift, 12px))) rotate(8deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-banner-image { animation: banner-image-enter 900ms cubic-bezier(0.22, 1, 0.36, 1) both; }
|
||||||
|
.animate-banner-badge { animation: banner-badge-enter 600ms cubic-bezier(0.22, 1, 0.36, 1) both; }
|
||||||
|
.animate-card-float-a { animation: card-float-a 4.5s ease-in-out infinite; }
|
||||||
|
.animate-card-float-b { animation: card-float-b 5.5s ease-in-out infinite; }
|
||||||
|
.animate-tea-drift { animation: tea-drift 6s ease-in-out infinite; }
|
||||||
|
|
||||||
|
/* Respecte les préférences d'accessibilité : pas d'animation pour les
|
||||||
|
personnes qui ont désactivé les mouvements dans leur OS. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.animate-banner-image,
|
||||||
|
.animate-banner-badge,
|
||||||
|
.animate-card-float-a,
|
||||||
|
.animate-card-float-b,
|
||||||
|
.animate-tea-drift {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
|
|
||||||
export default function Accessibilite() {
|
export default function Accessibilite() {
|
||||||
|
usePageMeta({
|
||||||
|
title: 'Déclaration d’accessibilité | Thé Tip Top',
|
||||||
|
description: "État de conformité RGAA du site Thé Tip Top et procédure pour signaler un problème d'accessibilité rencontré sur le site.",
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
||||||
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||||
@@ -31,7 +38,7 @@ export default function Accessibilite() {
|
|||||||
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
||||||
Si vous rencontrez un obstacle à l'accessibilité sur le site, vous pouvez nous en faire
|
Si vous rencontrez un obstacle à l'accessibilité sur le site, vous pouvez nous en faire
|
||||||
part afin que nous puissions y remédier :<br />
|
part afin que nous puissions y remédier :<br />
|
||||||
<strong>Email :</strong> contact@thetiptop.fr
|
<strong>Email :</strong> thetiptop.gp4@gmail.com
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,221 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
|
import useGameDates from '../hooks/useGameDates'
|
||||||
|
import { formatDay, formatRange } from '../utils/gameDates'
|
||||||
|
import bannerImg from '../assets/img/banner-accueil.webp'
|
||||||
|
|
||||||
|
const gains = [
|
||||||
|
{ label: 'Infuseur à thé', top: '26%', right: '26%', delay: 700, variant: 'a' },
|
||||||
|
{ label: 'Boîte 100 g détox ou infusion', top: '22%', right: '1%', delay: 850, variant: 'b' },
|
||||||
|
{ label: 'Boîte 100 g thé signature', top: '48%', right: '30%', delay: 1000, variant: 'a' },
|
||||||
|
{ label: 'Coffret découverte 39 €', top: '78%', right: '2%', delay: 1150, variant: 'b' },
|
||||||
|
{ label: 'Coffret découverte 69 €', top: '84%', right: '24%', delay: 1300, variant: 'a' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function handleCardTilt(e) {
|
||||||
|
const el = e.currentTarget
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
const px = (e.clientX - rect.left) / rect.width - 0.5
|
||||||
|
const py = (e.clientY - rect.top) / rect.height - 0.5
|
||||||
|
el.style.transform = `perspective(700px) rotateX(${(-py * 12).toFixed(2)}deg) rotateY(${(px * 12).toFixed(2)}deg) scale(1.04)`
|
||||||
|
}
|
||||||
|
function resetCardTilt(e) {
|
||||||
|
e.currentTarget.style.transform = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEA_EMOJI = ['🍃', '🍵', '🫖', '☕', '🥄', '✨']
|
||||||
|
|
||||||
|
function createTeaParticles(count) {
|
||||||
|
return Array.from({ length: count }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
emoji: TEA_EMOJI[i % TEA_EMOJI.length],
|
||||||
|
left: Math.round(Math.random() * 100),
|
||||||
|
top: Math.round(Math.random() * 100),
|
||||||
|
size: Math.round(20 + Math.random() * 26),
|
||||||
|
duration: +(4 + Math.random() * 4).toFixed(1),
|
||||||
|
delay: +(Math.random() * 4).toFixed(1),
|
||||||
|
opacity: +(0.18 + Math.random() * 0.22).toFixed(2),
|
||||||
|
drift: Math.round(12 + Math.random() * 20),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les dates viennent du tableau de bord admin (voir useGameDates).
|
||||||
|
const buildFaqs = ({ start, lastPlayDay, lastClaimDay }) => [
|
||||||
|
{
|
||||||
|
question: 'Comment obtenir un code ?',
|
||||||
|
answer: `Achetez pour plus de 49 € dans notre boutique Thé Tip Top de Nice, du ${formatRange(start, lastPlayDay)} : un code unique à 10 caractères figure sur votre ticket de caisse ou votre facture.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Quels sont les lots à gagner ?',
|
||||||
|
answer: (
|
||||||
|
<>
|
||||||
|
Tous les tickets sont gagnants ! Selon votre ticket, vous pouvez remporter :
|
||||||
|
<ul className="list-disc list-inside space-y-1 mt-2">
|
||||||
|
<li>Un infuseur à thé</li>
|
||||||
|
<li>Une boîte de 100 g de thé détox ou d'infusion</li>
|
||||||
|
<li>Une boîte de 100 g de thé signature</li>
|
||||||
|
<li>Un coffret découverte d'une valeur de 39 €</li>
|
||||||
|
<li>Un coffret découverte d'une valeur de 69 €</li>
|
||||||
|
</ul>
|
||||||
|
Un tirage au sort désigne en plus un grand gagnant qui remporte une année de thé d'une valeur de 360 €.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
plainAnswer: "Tous les tickets sont gagnants ! Selon votre ticket, vous pouvez remporter : un infuseur à thé, une boîte de 100 g de thé détox ou d'infusion, une boîte de 100 g de thé signature, un coffret découverte d'une valeur de 39 €, ou un coffret découverte d'une valeur de 69 €. Un tirage au sort désigne en plus un grand gagnant qui remporte une année de thé d'une valeur de 360 €.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Jusqu\'à quand puis-je jouer ?',
|
||||||
|
answer: `La saisie des codes est ouverte du ${formatRange(start, lastPlayDay)} inclus. Les lots sont ensuite à retirer jusqu'au ${formatDay(lastClaimDay)}.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Que faire si mon code n\'est pas reconnu ?',
|
||||||
|
answer: "Vérifiez que le code a été saisi sans espace ni erreur. Si le problème persiste, contactez-nous depuis la page Contact.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Où retirer mon lot ?',
|
||||||
|
answer: `Dans notre boutique de Nice, sur présentation de votre compte utilisateur validé et de la confirmation de gain affichée dans votre espace « Mes gains », jusqu'au ${formatDay(lastClaimDay)}.`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const buildFaqJsonLd = (faqs) => ({
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: faqs.map((faq) => ({
|
||||||
|
'@type': 'Question',
|
||||||
|
name: faq.question,
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: faq.plainAnswer || faq.answer,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
export default function Accueil() {
|
export default function Accueil() {
|
||||||
|
const gameDates = useGameDates()
|
||||||
|
const period = formatRange(gameDates.start, gameDates.lastPlayDay)
|
||||||
|
const faqs = useMemo(() => buildFaqs(gameDates), [gameDates])
|
||||||
|
const faqJsonLd = useMemo(() => buildFaqJsonLd(faqs), [faqs])
|
||||||
|
const bannerAlt = `Jeu-concours 100 % Gagnant Thé Tip Top, du ${period} : tentez de gagner un an de thé d'une valeur de 360 € pour l'ouverture de notre 10e boutique à Nice.`
|
||||||
|
|
||||||
|
usePageMeta({
|
||||||
|
title: `Jeu-concours 100 % Gagnant du ${formatRange(gameDates.start, gameDates.lastPlayDay, { withYear: false })} | Thé Tip Top`,
|
||||||
|
description: `Du ${formatRange(gameDates.start, gameDates.lastPlayDay, { withYear: false })}, tout ticket de plus de 49 € de notre boutique de Nice porte un code 100 % gagnant. Saisissez le vôtre et repartez avec votre lot.`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const teaParticles = useMemo(() => createTeaParticles(30), [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-50 min-h-screen">
|
<div className="bg-gray-50 min-h-screen">
|
||||||
|
|
||||||
{/* Hero */}
|
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }} />
|
||||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 py-12 sm:py-16 md:py-24">
|
|
||||||
<div className="text-center max-w-3xl mx-auto">
|
<section className="relative bg-[#006B6B] overflow-hidden">
|
||||||
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-[#006B6B] mb-4 sm:mb-6 leading-tight">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 relative py-10 lg:py-0 lg:h-[680px] xl:h-[780px]">
|
||||||
Bienvenue au jeu-concours Thé Tip Top
|
|
||||||
</h1>
|
<div
|
||||||
<p className="text-base sm:text-lg md:text-xl text-gray-600 mb-8 sm:mb-10">
|
className="hidden lg:block absolute right-[-10%] top-1/2 -translate-y-1/2 w-[75%] h-[75%] rounded-full bg-white/10 blur-3xl"
|
||||||
À l'occasion de l'ouverture de notre 10ᵉ boutique à Nice, participez à notre jeu-concours
|
aria-hidden="true"
|
||||||
et tentez de gagner des lots exceptionnels ! Tous les tickets sont gagnants, alors n'attendez
|
/>
|
||||||
plus pour découvrir votre lot.
|
|
||||||
|
<div className="pointer-events-none absolute inset-0 overflow-hidden z-0" aria-hidden="true">
|
||||||
|
{teaParticles.map((p) => (
|
||||||
|
<span
|
||||||
|
key={p.id}
|
||||||
|
className="absolute animate-tea-drift select-none"
|
||||||
|
style={{
|
||||||
|
left: `${p.left}%`,
|
||||||
|
top: `${p.top}%`,
|
||||||
|
fontSize: `${p.size}px`,
|
||||||
|
opacity: p.opacity,
|
||||||
|
animationDuration: `${p.duration}s`,
|
||||||
|
animationDelay: `${p.delay}s`,
|
||||||
|
'--tea-drift': `${p.drift}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.emoji}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="static lg:absolute lg:left-0 lg:top-10 max-w-full lg:max-w-md mb-6 lg:mb-0">
|
||||||
|
<p className="inline-block text-sm sm:text-base font-bold uppercase tracking-widest text-amber-300 border border-white/70 rounded-lg px-3.5 py-2 mb-3">
|
||||||
|
Du {formatRange(gameDates.start, gameDates.lastPlayDay, { padDay: true })}
|
||||||
|
</p>
|
||||||
|
<h1 className="text-3xl sm:text-5xl lg:text-6xl font-extrabold text-white leading-[1.05]">
|
||||||
|
Jeu-concours{' '}
|
||||||
|
<span className="text-amber-300">100 % Gagnant</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative -mx-4 sm:mx-0 lg:mx-0 mb-6 lg:mb-0 lg:contents">
|
||||||
|
<div className="relative aspect-[16/10] sm:aspect-[21/9] overflow-hidden lg:hidden">
|
||||||
|
<img
|
||||||
|
src={bannerImg}
|
||||||
|
alt={bannerAlt}
|
||||||
|
className="absolute inset-y-0 right-0 w-[210%] sm:w-[130%] max-w-none h-full object-cover object-right animate-banner-image"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
src={bannerImg}
|
||||||
|
alt={bannerAlt}
|
||||||
|
width={2048}
|
||||||
|
height={1152}
|
||||||
|
className="hidden lg:block lg:absolute lg:right-[-2%] lg:top-1/2 lg:-translate-y-1/2 lg:h-[145%] xl:h-[160%] lg:w-auto lg:max-w-none object-contain drop-shadow-2xl animate-banner-image"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="static lg:absolute lg:left-0 lg:top-1/2 lg:-translate-y-1/2 max-w-full lg:max-w-md text-lg sm:text-2xl lg:text-3xl font-semibold text-white leading-snug [text-wrap:balance] mb-6 lg:mb-0 bg-white/10 backdrop-blur-sm border-l-4 border-amber-300 rounded-2xl px-4 py-3 sm:px-6 sm:py-4 shadow-lg">
|
||||||
|
À l'occasion de l'ouverture de notre{' '}
|
||||||
|
<span className="text-amber-300">10<sup>e</sup> boutique</span> à Nice : chaque code
|
||||||
|
distribué correspond à un lot réel, attribué à l'avance.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row justify-center gap-3 sm:gap-4">
|
|
||||||
|
<div className="hidden lg:contents">
|
||||||
|
|
||||||
|
<div className="shrink-0 lg:absolute lg:right-[1%] lg:top-[45%] animate-banner-badge [animation-delay:500ms]">
|
||||||
|
<div className="animate-card-float-a">
|
||||||
|
<div
|
||||||
|
onMouseMove={handleCardTilt}
|
||||||
|
onMouseLeave={resetCardTilt}
|
||||||
|
className="bg-white rounded-2xl shadow-xl px-5 sm:px-7 py-4 sm:py-5 text-center transition-transform duration-200 ease-out will-change-transform cursor-default"
|
||||||
|
>
|
||||||
|
<p className="text-3xl sm:text-4xl lg:text-5xl font-extrabold text-gray-900 leading-none">
|
||||||
|
360 <span className="text-[#006B6B]">€</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs sm:text-sm font-semibold text-gray-800 whitespace-nowrap mt-1.5">de thé à gagner</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gains.map((gain) => (
|
||||||
|
<div
|
||||||
|
key={gain.label}
|
||||||
|
className="shrink-0 lg:absolute animate-banner-badge"
|
||||||
|
style={{ top: gain.top, right: gain.right, animationDelay: `${gain.delay}ms` }}
|
||||||
|
>
|
||||||
|
<div className={gain.variant === 'a' ? 'animate-card-float-a' : 'animate-card-float-b'}>
|
||||||
|
<div
|
||||||
|
onMouseMove={handleCardTilt}
|
||||||
|
onMouseLeave={resetCardTilt}
|
||||||
|
className="bg-white rounded-2xl shadow-xl px-3.5 py-2.5 text-left transition-transform duration-200 ease-out will-change-transform cursor-default"
|
||||||
|
>
|
||||||
|
<span className="text-xs sm:text-sm font-semibold text-gray-800 whitespace-nowrap">{gain.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="static lg:absolute lg:left-0 lg:bottom-24 xl:bottom-28 flex flex-col sm:flex-row gap-3 sm:gap-4">
|
||||||
<Link
|
<Link
|
||||||
to="/saisir-code-unique"
|
to="/saisir-code-unique"
|
||||||
className="bg-[#006B6B] hover:bg-[#005555] text-white font-semibold px-6 sm:px-8 py-3 sm:py-4 rounded-xl transition-colors text-sm sm:text-base"
|
className="bg-white hover:bg-gray-100 text-[#006B6B] font-semibold px-6 sm:px-8 py-3 sm:py-4 rounded-xl transition-colors text-sm sm:text-base text-center"
|
||||||
>
|
>
|
||||||
Tester mon code
|
Tester mon code
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/regles-jeu"
|
to="/regles-jeu"
|
||||||
className="border border-[#006B6B] text-[#006B6B] hover:bg-[#005555] hover:text-white font-semibold px-6 sm:px-8 py-3 sm:py-4 rounded-xl transition-colors text-sm sm:text-base"
|
className="border-2 border-white text-white hover:bg-white hover:text-[#006B6B] font-semibold px-6 sm:px-8 py-3 sm:py-4 rounded-xl transition-colors text-sm sm:text-base text-center"
|
||||||
>
|
>
|
||||||
Découvrir les règles
|
Découvrir les règles
|
||||||
</Link>
|
</Link>
|
||||||
@@ -32,22 +223,47 @@ export default function Accueil() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Comment participer */}
|
<section className="max-w-5xl mx-auto px-4 pt-6 sm:px-6 pb-12 sm:pb-20">
|
||||||
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-20">
|
|
||||||
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-6 sm:mb-8 text-center">
|
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-6 sm:mb-8 text-center">
|
||||||
Comment participer ?
|
Comment participer ?
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<ol className="grid grid-cols-1 sm:grid-cols-3 gap-4 list-none">
|
||||||
{[
|
{[
|
||||||
{ num: '1', text: 'Achat de 49 € minimum' },
|
{ num: '1', text: "Achat de 49 € dans notre boutique de Nice, code sur le ticket" },
|
||||||
{ num: '2', text: 'Recevez votre code unique' },
|
{ num: '2', text: 'Créez votre compte et saisissez le code' },
|
||||||
{ num: '3', text: 'Saisissez le code sur le site' },
|
{ num: '3', text: 'Découvrez et retirez votre lot en boutique' },
|
||||||
{ num: '4', text: 'Découvrez votre lot' },
|
|
||||||
].map((step) => (
|
].map((step) => (
|
||||||
<div key={step.num} className="bg-white rounded-2xl shadow p-4 sm:p-6 text-center">
|
<li key={step.num} className="bg-white rounded-2xl shadow p-4 sm:p-6 text-center">
|
||||||
<div className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-2 sm:mb-3">{step.num}</div>
|
<div className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-2 sm:mb-3">{step.num}</div>
|
||||||
<p className="text-sm sm:text-base text-gray-700">{step.text}</p>
|
<p className="text-sm sm:text-base text-gray-700">{step.text}</p>
|
||||||
</div>
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="max-w-3xl mx-auto px-4 sm:px-6 pb-16 sm:pb-24">
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-6 sm:mb-8 text-center">
|
||||||
|
Questions fréquentes
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{faqs.map((faq) => (
|
||||||
|
<details key={faq.question} className="group bg-white rounded-2xl shadow p-4 sm:p-6">
|
||||||
|
<summary className="flex items-center justify-between gap-3 cursor-pointer list-none font-semibold text-gray-800 text-sm sm:text-base">
|
||||||
|
{faq.question}
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-[#006B6B] flex-shrink-0 transition-transform group-open:rotate-180"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</summary>
|
||||||
|
<div className="text-gray-600 text-sm sm:text-base mt-3 leading-relaxed">
|
||||||
|
{faq.answer}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi } from '../services/admin'
|
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi, createEmployeeApi } from '../services/admin'
|
||||||
import { searchTicketApi, deliverClaimApi } from '../services/employee'
|
import EmployeeDeliveries from '../components/admin/EmployeeDeliveries'
|
||||||
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||||
|
import { formatDay, formatRange, gameDatesFromStart } from '../utils/gameDates'
|
||||||
|
|
||||||
function StatCard({ label, value, sub }) {
|
function StatCard({ label, value, sub }) {
|
||||||
return (
|
return (
|
||||||
@@ -39,6 +40,13 @@ const PHASE_TITLES = {
|
|||||||
CLOSED: 'Jeu-concours terminé',
|
CLOSED: 'Jeu-concours terminé',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Valeur attendue par <input type="datetime-local"> : heure locale, sans fuseau.
|
||||||
|
function toDateTimeLocalValue(dateStr) {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr) {
|
||||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||||
day: '2-digit', month: 'long', year: 'numeric',
|
day: '2-digit', month: 'long', year: 'numeric',
|
||||||
@@ -63,24 +71,25 @@ export default function Admin() {
|
|||||||
const [settingsError, setSettingsError] = useState('')
|
const [settingsError, setSettingsError] = useState('')
|
||||||
const [settingsOk, setSettingsOk] = useState('')
|
const [settingsOk, setSettingsOk] = useState('')
|
||||||
|
|
||||||
// Remise de lot
|
|
||||||
const [ticketCode, setTicketCode] = useState('')
|
|
||||||
const [ticketResult, setTicketResult] = useState(null)
|
|
||||||
const [ticketSearching, setTicketSearching] = useState(false)
|
|
||||||
const [ticketDelivering, setTicketDelivering] = useState(false)
|
|
||||||
const [ticketSearchErr, setTicketSearchErr] = useState('')
|
|
||||||
const [ticketDeliverErr, setTicketDeliverErr] = useState('')
|
|
||||||
const [ticketSuccess, setTicketSuccess] = useState(false)
|
|
||||||
|
|
||||||
// Contacts emailing
|
// Contacts emailing
|
||||||
const [emailingContacts, setEmailingContacts] = useState(null)
|
const [emailingContacts, setEmailingContacts] = useState(null)
|
||||||
const [emailingLoading, setEmailingLoading] = useState(false)
|
const [emailingLoading, setEmailingLoading] = useState(false)
|
||||||
const [emailingError, setEmailingError] = useState('')
|
const [emailingError, setEmailingError] = useState('')
|
||||||
|
|
||||||
|
// Création d'un compte employé
|
||||||
|
const [employeeForm, setEmployeeForm] = useState({ firstname: '', lastname: '', email: '' })
|
||||||
|
const [employeeSaving, setEmployeeSaving] = useState(false)
|
||||||
|
const [employeeError, setEmployeeError] = useState('')
|
||||||
|
const [employeeOk, setEmployeeOk] = useState('')
|
||||||
|
// Incrémenté à chaque création pour recharger la liste des employés.
|
||||||
|
const [employeesVersion, setEmployeesVersion] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([getStatsApi(), getUsersApi(), getWinnerApi()])
|
Promise.all([getStatsApi(), getUsersApi(), getWinnerApi()])
|
||||||
.then(([s, u, w]) => {
|
.then(([s, u, w]) => {
|
||||||
setStats(s)
|
setStats(s)
|
||||||
|
// Le champ part de la date enregistrée, celle affichée sur l'accueil.
|
||||||
|
if (s.gameWindow?.startDate) setStartDateInput(toDateTimeLocalValue(s.gameWindow.startDate))
|
||||||
setUsers(u.users)
|
setUsers(u.users)
|
||||||
setWinner(w)
|
setWinner(w)
|
||||||
})
|
})
|
||||||
@@ -100,8 +109,8 @@ export default function Admin() {
|
|||||||
// convertit en ISO pour que le serveur reçoive un instant sans ambiguïté.
|
// convertit en ISO pour que le serveur reçoive un instant sans ambiguïté.
|
||||||
const data = await setGameSettingsApi(new Date(startDateInput).toISOString())
|
const data = await setGameSettingsApi(new Date(startDateInput).toISOString())
|
||||||
setStats((prev) => (prev ? { ...prev, gameWindow: data.gameWindow } : prev))
|
setStats((prev) => (prev ? { ...prev, gameWindow: data.gameWindow } : prev))
|
||||||
setSettingsOk(data.message)
|
setSettingsOk(`${data.message} La page d'accueil affiche désormais ces dates.`)
|
||||||
setStartDateInput('')
|
setStartDateInput(toDateTimeLocalValue(data.gameWindow.startDate))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!handleAuthError(err)) setSettingsError(err.message)
|
if (!handleAuthError(err)) setSettingsError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -145,10 +154,7 @@ export default function Admin() {
|
|||||||
const csv = [header, ...rows]
|
const csv = [header, ...rows]
|
||||||
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(';'))
|
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(';'))
|
||||||
.join('\n')
|
.join('\n')
|
||||||
// Le BOM en tete du fichier est indispensable : sans lui, Excel ouvre le
|
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' })
|
||||||
// CSV en ANSI et casse tous les accents. Ne pas le supprimer.
|
|
||||||
// eslint-disable-next-line no-irregular-whitespace
|
|
||||||
const blob = new Blob([`${csv}`], { type: 'text/csv;charset=utf-8;' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
link.href = url
|
||||||
@@ -157,43 +163,20 @@ export default function Admin() {
|
|||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTicketSearch = async (e) => {
|
const handleCreateEmployee = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setTicketResult(null)
|
setEmployeeError('')
|
||||||
setTicketSearchErr('')
|
setEmployeeOk('')
|
||||||
setTicketDeliverErr('')
|
setEmployeeSaving(true)
|
||||||
setTicketSuccess(false)
|
|
||||||
setTicketSearching(true)
|
|
||||||
try {
|
try {
|
||||||
const data = await searchTicketApi(ticketCode.trim())
|
const data = await createEmployeeApi(employeeForm)
|
||||||
setTicketResult(data)
|
setEmployeeOk(`Compte créé pour ${data.employee.firstname} ${data.employee.lastname} : un email avec un mot de passe provisoire a été envoyé à ${data.employee.email}.`)
|
||||||
|
setEmployeeForm({ firstname: '', lastname: '', email: '' })
|
||||||
|
setEmployeesVersion((v) => v + 1)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!handleAuthError(err)) setTicketSearchErr(err.message)
|
if (!handleAuthError(err)) setEmployeeError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
setTicketSearching(false)
|
setEmployeeSaving(false)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTicketDeliver = async () => {
|
|
||||||
if (!ticketResult?.claim?.id) return
|
|
||||||
setTicketDeliverErr('')
|
|
||||||
setTicketDelivering(true)
|
|
||||||
try {
|
|
||||||
await deliverClaimApi(ticketResult.claim.id)
|
|
||||||
setTicketResult((prev) => ({
|
|
||||||
...prev,
|
|
||||||
claim: { ...prev.claim, status: 'DELIVERED', claimedAt: new Date().toISOString() },
|
|
||||||
}))
|
|
||||||
setTicketSuccess(true)
|
|
||||||
setStats((prev) => prev ? {
|
|
||||||
...prev,
|
|
||||||
claimsPending: Math.max(0, (prev.claimsPending ?? 1) - 1),
|
|
||||||
claimsDelivered: (prev.claimsDelivered ?? 0) + 1,
|
|
||||||
} : prev)
|
|
||||||
} catch (err) {
|
|
||||||
if (!handleAuthError(err)) setTicketDeliverErr(err.message)
|
|
||||||
} finally {
|
|
||||||
setTicketDelivering(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +194,15 @@ export default function Admin() {
|
|||||||
|
|
||||||
const winnerUser = winner?.winner?.user
|
const winnerUser = winner?.winner?.user
|
||||||
|
|
||||||
|
// Aperçu en direct des dates publiques pour la date saisie.
|
||||||
|
const startInputDate = startDateInput ? new Date(startDateInput) : null
|
||||||
|
const startPreview = startInputDate && !Number.isNaN(startInputDate.getTime())
|
||||||
|
? gameDatesFromStart(startInputDate)
|
||||||
|
: null
|
||||||
|
const savedStart = stats?.gameWindow?.startDate
|
||||||
|
const startPreviewUnsaved = Boolean(startPreview) &&
|
||||||
|
(!savedStart || toDateTimeLocalValue(savedStart) !== startDateInput)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 p-4 sm:p-6">
|
<div className="min-h-screen bg-gray-50 p-4 sm:p-6">
|
||||||
<div className="max-w-5xl mx-auto space-y-6">
|
<div className="max-w-5xl mx-auto space-y-6">
|
||||||
@@ -244,8 +236,11 @@ export default function Admin() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSaveGameSettings} className="mt-4 pt-4 border-t border-gray-200 flex flex-wrap items-end gap-3">
|
<form
|
||||||
<div className="flex-1 min-w-[220px]">
|
onSubmit={handleSaveGameSettings}
|
||||||
|
className="mt-4 pt-4 border-t border-gray-200 flex flex-col sm:flex-row sm:items-end gap-3"
|
||||||
|
>
|
||||||
|
<div className="w-full sm:flex-1 sm:min-w-[220px]">
|
||||||
<label htmlFor="game-start" className="block text-xs font-medium text-gray-700 mb-1">
|
<label htmlFor="game-start" className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
{stats.gameWindow.configured ? 'Corriger la date de début' : 'Date de début du jeu-concours'}
|
{stats.gameWindow.configured ? 'Corriger la date de début' : 'Date de début du jeu-concours'}
|
||||||
</label>
|
</label>
|
||||||
@@ -261,12 +256,23 @@ export default function Admin() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={settingsSaving || !startDateInput}
|
disabled={settingsSaving || !startDateInput}
|
||||||
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm"
|
className="w-full sm:w-auto bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{settingsSaving ? 'Enregistrement…' : stats.gameWindow.configured ? 'Mettre à jour' : 'Lancer le jeu'}
|
{settingsSaving ? 'Enregistrement…' : stats.gameWindow.configured ? 'Mettre à jour' : 'Lancer le jeu'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{startPreview && (
|
||||||
|
<p className="text-xs text-gray-700 mt-3" data-testid="homepage-dates-preview">
|
||||||
|
Affiché sur la page d'accueil :{' '}
|
||||||
|
<span className="font-semibold">
|
||||||
|
Du {formatRange(startPreview.start, startPreview.lastPlayDay, { padDay: true })}
|
||||||
|
</span>
|
||||||
|
, retrait des lots jusqu'au <span className="font-semibold">{formatDay(startPreview.lastClaimDay)}</span>
|
||||||
|
{startPreviewUnsaved && <span className="text-amber-700"> (non enregistré)</span>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
Les participations durent 30 jours, puis 30 jours supplémentaires sont laissés pour retirer les lots.
|
Les participations durent 30 jours, puis 30 jours supplémentaires sont laissés pour retirer les lots.
|
||||||
</p>
|
</p>
|
||||||
@@ -418,95 +424,66 @@ export default function Admin() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Remise de lot ── */}
|
{/* ── Création d'un compte employé ── */}
|
||||||
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||||
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Remise de lot</h2>
|
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-1">Créer un compte employé</h2>
|
||||||
|
<p className="text-xs sm:text-sm text-gray-500 mb-4">
|
||||||
|
Donne accès à l'espace employé (remise des lots en boutique). L'admin ne gère pas les remises lui-même.
|
||||||
|
L'employé reçoit par email un mot de passe provisoire, qu'il devra changer à sa première connexion.
|
||||||
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleTicketSearch} className="flex gap-2 mb-4">
|
<form onSubmit={handleCreateEmployee} className="grid sm:grid-cols-2 gap-3">
|
||||||
<label htmlFor="admin-ticket-code" className="sr-only">Code ticket</label>
|
<div>
|
||||||
<input
|
<label htmlFor="employee-firstname" className="block text-xs font-medium text-gray-700 mb-1">Prénom</label>
|
||||||
type="text"
|
<input
|
||||||
id="admin-ticket-code"
|
type="text"
|
||||||
value={ticketCode}
|
id="employee-firstname"
|
||||||
onChange={(e) => setTicketCode(e.target.value.toUpperCase())}
|
value={employeeForm.firstname}
|
||||||
placeholder="Code ticket (10 caractères)"
|
onChange={(e) => setEmployeeForm({ ...employeeForm, firstname: e.target.value })}
|
||||||
maxLength={10}
|
required
|
||||||
className="flex-1 min-w-0 border border-gray-200 rounded-lg px-4 py-2.5 text-sm font-mono uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={ticketSearching || ticketCode.trim().length === 0}
|
|
||||||
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2.5 rounded-lg transition-colors text-sm whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{ticketSearching ? 'Recherche…' : 'Rechercher'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{ticketSearchErr && (
|
|
||||||
<p className="text-red-500 text-sm mb-3">{ticketSearchErr}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ticketResult && (
|
|
||||||
<div className="border border-gray-100 rounded-xl overflow-hidden">
|
|
||||||
{ticketSuccess && (
|
|
||||||
<div className="bg-green-500 px-4 py-2.5 flex items-center gap-2">
|
|
||||||
<svg className="w-4 h-4 text-white flex-shrink-0" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
<p className="text-white text-sm font-medium">Lot remis avec succès.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="px-4 py-3 flex items-start justify-between gap-3 border-b border-gray-100">
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold text-gray-900 text-sm">{ticketResult.lot.name}</p>
|
|
||||||
<p className="text-gray-500 text-xs mt-0.5">{ticketResult.lot.description}</p>
|
|
||||||
{ticketResult.lot.value && <p className="text-[#006B6B] text-xs font-semibold mt-1">{ticketResult.lot.value} €</p>}
|
|
||||||
</div>
|
|
||||||
<span className={`flex-shrink-0 text-xs font-semibold px-2.5 py-1 rounded-full ${
|
|
||||||
ticketResult.claim?.status === 'DELIVERED' ? 'bg-green-100 text-green-700'
|
|
||||||
: ticketResult.claim?.status === 'CANCELLED' ? 'bg-gray-100 text-gray-500'
|
|
||||||
: 'bg-yellow-100 text-yellow-700'
|
|
||||||
}`}>
|
|
||||||
{ticketResult.claim?.status === 'DELIVERED' ? 'Remis'
|
|
||||||
: ticketResult.claim?.status === 'CANCELLED' ? 'Expiré'
|
|
||||||
: 'À remettre'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-4 py-3 flex items-center gap-3 border-b border-gray-100">
|
|
||||||
<div className="w-8 h-8 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
|
||||||
{(ticketResult.client.firstname?.[0] ?? '?').toUpperCase()}{(ticketResult.client.lastname?.[0] ?? '?').toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-800">{ticketResult.client.firstname} {ticketResult.client.lastname}</p>
|
|
||||||
<p className="text-xs text-gray-500">{ticketResult.client.email}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-4 py-3 space-y-2">
|
|
||||||
{ticketDeliverErr && <p className="text-red-500 text-xs">{ticketDeliverErr}</p>}
|
|
||||||
|
|
||||||
{ticketResult.claim?.status === 'PENDING' && !ticketSuccess && (
|
|
||||||
<button
|
|
||||||
onClick={handleTicketDeliver}
|
|
||||||
disabled={ticketDelivering}
|
|
||||||
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold py-2.5 rounded-lg transition-colors text-sm"
|
|
||||||
>
|
|
||||||
{ticketDelivering ? 'Confirmation…' : 'Confirmer la remise du lot'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => { setTicketCode(''); setTicketResult(null); setTicketSuccess(false); setTicketSearchErr(''); setTicketDeliverErr('') }}
|
|
||||||
className="w-full border border-gray-200 text-gray-500 hover:bg-gray-50 py-2 rounded-lg transition-colors text-sm"
|
|
||||||
>
|
|
||||||
Rechercher un autre code
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div>
|
||||||
|
<label htmlFor="employee-lastname" className="block text-xs font-medium text-gray-700 mb-1">Nom</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="employee-lastname"
|
||||||
|
value={employeeForm.lastname}
|
||||||
|
onChange={(e) => setEmployeeForm({ ...employeeForm, lastname: e.target.value })}
|
||||||
|
required
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="employee-email" className="block text-xs font-medium text-gray-700 mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="employee-email"
|
||||||
|
value={employeeForm.email}
|
||||||
|
onChange={(e) => setEmployeeForm({ ...employeeForm, email: e.target.value })}
|
||||||
|
required
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-2 flex items-center gap-3 pt-1">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={employeeSaving}
|
||||||
|
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
{employeeSaving ? 'Création…' : 'Créer le compte'}
|
||||||
|
</button>
|
||||||
|
{employeeError && <p className="text-red-500 text-xs sm:text-sm">{employeeError}</p>}
|
||||||
|
{employeeOk && <p className="text-green-600 text-xs sm:text-sm">{employeeOk}</p>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Employés et remises des lots ── */}
|
||||||
|
<EmployeeDeliveries refreshKey={employeesVersion} onAuthError={handleAuthError} />
|
||||||
|
|
||||||
{/* ── Tirage au sort ── */}
|
{/* ── Tirage au sort ── */}
|
||||||
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||||
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Grand tirage au sort</h2>
|
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Grand tirage au sort</h2>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
|
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { homeFor } from '../config/roles'
|
||||||
|
|
||||||
// Point d'arrivée après un aller-retour OAuth : le backend nous renvoie ici
|
// Point d'arrivée après un aller-retour OAuth : le backend nous renvoie ici
|
||||||
// avec le JWT et le profil encodés dans l'URL.
|
// avec le JWT et le profil encodés dans l'URL.
|
||||||
@@ -8,46 +9,40 @@ export default function AuthCallback() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
|
|
||||||
const errorParam = searchParams.get('error')
|
|
||||||
const [error, setError] = useState(errorParam || '')
|
|
||||||
|
|
||||||
// StrictMode monte deux fois en dev : on ne traite le token qu'une seule fois.
|
|
||||||
const handled = useRef(false)
|
const handled = useRef(false)
|
||||||
|
|
||||||
|
const errorParam = searchParams.get('error')
|
||||||
|
const token = searchParams.get('token')
|
||||||
|
const encodedUser = searchParams.get('user')
|
||||||
|
|
||||||
|
let error = ''
|
||||||
|
let user = null
|
||||||
|
|
||||||
|
if (errorParam) {
|
||||||
|
error = errorParam
|
||||||
|
} else if (!token || !encodedUser) {
|
||||||
|
error = 'Réponse de connexion incomplète.'
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
// atob renvoie des octets : on les relit en UTF-8.
|
||||||
|
const bytes = Uint8Array.from(
|
||||||
|
atob(encodedUser),
|
||||||
|
(c) => c.charCodeAt(0)
|
||||||
|
)
|
||||||
|
user = JSON.parse(new TextDecoder().decode(bytes))
|
||||||
|
} catch {
|
||||||
|
error = 'Réponse de connexion illisible.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (handled.current) return
|
if (handled.current || !user) return
|
||||||
|
|
||||||
handled.current = true
|
handled.current = true
|
||||||
|
|
||||||
if (errorParam) return
|
login(user, token)
|
||||||
|
navigate(homeFor(user), { replace: true })
|
||||||
const token = searchParams.get('token')
|
}, [user, token, login, navigate])
|
||||||
const encodedUser = searchParams.get('user')
|
|
||||||
|
|
||||||
if (!token || !encodedUser) {
|
|
||||||
setTimeout(() => {
|
|
||||||
setError('Réponse de connexion incomplète.')
|
|
||||||
}, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const user = JSON.parse(atob(encodedUser))
|
|
||||||
login(user, token)
|
|
||||||
|
|
||||||
if (user.role === 'ADMIN') {
|
|
||||||
navigate('/admin', { replace: true })
|
|
||||||
} else if (user.role === 'EMPLOYEE') {
|
|
||||||
navigate('/employe', { replace: true })
|
|
||||||
} else {
|
|
||||||
navigate('/mes-gains', { replace: true })
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setTimeout(() => {
|
|
||||||
setError('Réponse de connexion illisible.')
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
}, [searchParams, errorParam, login, navigate])
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
|
|
||||||
export default function Cgu() {
|
export default function Cgu() {
|
||||||
|
usePageMeta({
|
||||||
|
title: 'Conditions générales d’utilisation | Thé Tip Top',
|
||||||
|
description: "CGU du site Thé Tip Top : conditions d'accès au jeu-concours, inscription, participation et gestion de votre compte.",
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
||||||
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||||
@@ -30,13 +38,23 @@ export default function Cgu() {
|
|||||||
Les présentes Conditions Générales d'Utilisation (CGU) ont pour objet de définir
|
Les présentes Conditions Générales d'Utilisation (CGU) ont pour objet de définir
|
||||||
les modalités et conditions dans lesquelles la société Thé Tip Top met à
|
les modalités et conditions dans lesquelles la société Thé Tip Top met à
|
||||||
disposition des utilisateurs le site internet du jeu-concours « 100% Gagnant »,
|
disposition des utilisateurs le site internet du jeu-concours « 100% Gagnant »,
|
||||||
accessible à l'adresse https://www.thetiptop-concours.fr.
|
accessible à l'adresse https://www.dsp5-archi-o24a-4-5-g4.fr.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Toute utilisation du Site implique l'acceptation pleine et entière des présentes
|
Toute utilisation du Site implique l'acceptation pleine et entière des présentes
|
||||||
CGU. En cas de non-acceptation, l'utilisateur est invité à ne pas accéder au Site
|
CGU. En cas de non-acceptation, l'utilisateur est invité à ne pas accéder au Site
|
||||||
ni à utiliser ses services.
|
ni à utiliser ses services.
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
Le règlement complet du jeu-concours, déposé auprès de Maître Arnaud Rick,
|
||||||
|
Commissaire de Justice, est{' '}
|
||||||
|
<a
|
||||||
|
href="/reglement-jeu-concours-100-gagnant.pdf"
|
||||||
|
className="text-[#006B6B] hover:underline font-medium"
|
||||||
|
>
|
||||||
|
téléchargeable au format PDF
|
||||||
|
</a>.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -50,10 +68,11 @@ export default function Cgu() {
|
|||||||
non avenue. La société Thé Tip Top se réserve le droit de demander la fourniture
|
non avenue. La société Thé Tip Top se réserve le droit de demander la fourniture
|
||||||
d'un justificatif d'identité pour vérifier l'âge déclaré.
|
d'un justificatif d'identité pour vérifier l'âge déclaré.
|
||||||
</p>
|
</p>
|
||||||
<p><strong>Conditions géographiques —</strong> Le jeu-concours « 100% Gagnant » est
|
<p><strong>Conditions géographiques —</strong> La participation au jeu-concours
|
||||||
ouvert exclusivement aux résidents de France métropolitaine et des Départements et
|
« 100% Gagnant » est réservée aux personnes majeures résidant en France
|
||||||
Régions d'Outre-Mer (DROM). Sont exclus les ressortissants ou résidents de toute
|
métropolitaine. Seuls les tickets de caisse ou factures d'un montant supérieur à
|
||||||
autre zone géographique.
|
49 € émis dans la boutique Thé Tip Top de Nice, à l'occasion de son ouverture,
|
||||||
|
donnent droit à un code de participation.
|
||||||
</p>
|
</p>
|
||||||
<p><strong>Personnes exclues —</strong> Sont exclus de toute participation les membres
|
<p><strong>Personnes exclues —</strong> Sont exclus de toute participation les membres
|
||||||
du personnel de la société Thé Tip Top et de l'agence Furious Ducks, leurs
|
du personnel de la société Thé Tip Top et de l'agence Furious Ducks, leurs
|
||||||
@@ -77,16 +96,16 @@ export default function Cgu() {
|
|||||||
<h3 className="font-semibold text-gray-800 mb-1">3.1 – Services proposés</h3>
|
<h3 className="font-semibold text-gray-800 mb-1">3.1 – Services proposés</h3>
|
||||||
<p>
|
<p>
|
||||||
Le site du jeu-concours « 100% Gagnant » est un site internet dédié permettant
|
Le site du jeu-concours « 100% Gagnant » est un site internet dédié permettant
|
||||||
aux clients de la société Thé Tip Top de participer à un jeu-concours à
|
aux clients de la société Thé Tip Top de participer à un jeu-concours réservé à
|
||||||
l'occasion de l'ouverture de la 10ᵉ boutique de l'enseigne à Nice. Les services
|
la boutique de Nice, à l'occasion de son ouverture. Les services proposés
|
||||||
proposés incluent :
|
incluent :
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc list-inside space-y-2 mt-2">
|
<ul className="list-disc list-inside space-y-2 mt-2">
|
||||||
<li>La présentation du jeu-concours, de ses conditions et des lots à gagner</li>
|
<li>La présentation du jeu-concours, de ses conditions et des lots à gagner</li>
|
||||||
<li>La création d'un espace personnel pour chaque participant</li>
|
<li>La création d'un espace personnel pour chaque participant</li>
|
||||||
<li>La saisie et la validation des codes à 10 caractères figurant sur les tickets de caisse ou factures d'un montant supérieur à 49 €</li>
|
<li>La saisie et la validation des codes à 10 caractères figurant sur les tickets de caisse ou factures d'un montant supérieur à 49 € émis dans la boutique de Nice</li>
|
||||||
<li>La consultation de l'historique des participations et gains obtenus</li>
|
<li>La consultation de l'historique des participations et gains obtenus</li>
|
||||||
<li>La réclamation des lots gagnés, en ligne ou en boutique</li>
|
<li>La réclamation des lots gagnés dans la boutique de Nice</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -101,12 +120,19 @@ export default function Cgu() {
|
|||||||
caisse ou sa facture. Le Site valide le code et attribue immédiatement le lot
|
caisse ou sa facture. Le Site valide le code et attribue immédiatement le lot
|
||||||
correspondant selon la répartition préétablie :
|
correspondant selon la répartition préétablie :
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc list-inside space-y-1 mt-2">
|
<ul className="space-y-2 mt-2">
|
||||||
<li>60 % des tickets : infuseur à thé</li>
|
{[
|
||||||
<li>20 % des tickets : boîte de 100 g de thé détox ou d'infusion</li>
|
{ pct: '60 %', text: 'infuseur à thé' },
|
||||||
<li>10 % des tickets : boîte de 100 g de thé signature</li>
|
{ pct: '20 %', text: "boîte de 100 g de thé détox ou d'infusion" },
|
||||||
<li>6 % des tickets : coffret découverte d'une valeur de 39 €</li>
|
{ pct: '10 %', text: 'boîte de 100 g de thé signature' },
|
||||||
<li>4 % des tickets : coffret découverte d'une valeur de 69 €</li>
|
{ pct: '6 %', text: "coffret découverte d'une valeur de 39 €" },
|
||||||
|
{ pct: '4 %', text: "coffret découverte d'une valeur de 69 €" },
|
||||||
|
].map((lot) => (
|
||||||
|
<li key={lot.pct} className="flex gap-2">
|
||||||
|
<span className="font-semibold text-[#006B6B] flex-shrink-0">{lot.pct}</span>
|
||||||
|
<span className="min-w-0 flex-1">des tickets : {lot.text}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<p className="mt-2">
|
<p className="mt-2">
|
||||||
Un même utilisateur peut soumettre plusieurs codes distincts. La participation
|
Un même utilisateur peut soumettre plusieurs codes distincts. La participation
|
||||||
@@ -116,8 +142,9 @@ export default function Cgu() {
|
|||||||
Passé ce délai, les lots non réclamés sont définitivement perdus.
|
Passé ce délai, les lots non réclamés sont définitivement perdus.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2"><strong>Remise des lots —</strong> Les lots peuvent être réclamés
|
<p className="mt-2"><strong>Remise des lots —</strong> Les lots peuvent être réclamés
|
||||||
en boutique, sur présentation du compte utilisateur validé et de la confirmation
|
exclusivement dans la boutique Thé Tip Top de Nice, sur présentation du compte
|
||||||
de gain.
|
utilisateur validé et de la confirmation de gain. Aucun site marchand n'étant
|
||||||
|
ouvert, aucune remise ni expédition à distance n'est possible.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2">
|
<p className="mt-2">
|
||||||
À l'issue du jeu-concours, un tirage au sort est effectué parmi l'ensemble des
|
À l'issue du jeu-concours, un tirage au sort est effectué parmi l'ensemble des
|
||||||
@@ -207,11 +234,16 @@ export default function Cgu() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-gray-800 mb-1">6.2 – Suppression du compte</h3>
|
<h3 className="font-semibold text-gray-800 mb-1">6.2 – Suppression du compte</h3>
|
||||||
<p>
|
<p>
|
||||||
L'utilisateur peut demander la suppression de son compte à tout moment en
|
La suppression du compte n'est pas disponible en libre-service. L'utilisateur peut
|
||||||
contactant directement l'équipe via contact@thetiptop.fr. La suppression entraîne
|
la demander à tout moment en contactant directement l'équipe via
|
||||||
la perte définitive des participations et gains non encore réclamés. Les données
|
thetiptop.gp4@gmail.com, notamment depuis la rubrique dédiée de son{' '}
|
||||||
personnelles sont supprimées conformément aux dispositions de la Politique de
|
<Link to="/profile#suppression-compte" className="text-[#006B6B] hover:underline font-medium">
|
||||||
Confidentialité, sous réserve des obligations légales de conservation.
|
espace personnel
|
||||||
|
</Link>
|
||||||
|
. La suppression entraîne la perte définitive des participations et gains non
|
||||||
|
encore réclamés. Les données personnelles sont supprimées conformément aux
|
||||||
|
dispositions de la Politique de Confidentialité, sous réserve des obligations
|
||||||
|
légales de conservation.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
|
|
||||||
export default function Confidentialite() {
|
export default function Confidentialite() {
|
||||||
|
usePageMeta({
|
||||||
|
title: 'Politique de confidentialité | Thé Tip Top',
|
||||||
|
description: "Comment Thé Tip Top collecte et protège vos données personnelles dans le cadre du jeu-concours, et comment exercer vos droits RGPD.",
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="bg-[#F0F0F0] min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
<main className="bg-[#F0F0F0] min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
||||||
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||||
@@ -19,7 +26,7 @@ export default function Confidentialite() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
||||||
Le responsable du traitement des données collectées via le site du jeu-concours
|
Le responsable du traitement des données collectées via le site du jeu-concours
|
||||||
« 100% Gagnant » est la société Thé Tip Top, située au 18 rue Léon Frot, 75011 Paris.
|
« 100% Gagnant » est la société Thé Tip Top.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -89,7 +96,7 @@ export default function Confidentialite() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-700 text-sm sm:text-base">
|
<p className="text-gray-700 text-sm sm:text-base">
|
||||||
Pour toute question concernant vos données personnelles :<br />
|
Pour toute question concernant vos données personnelles :<br />
|
||||||
<strong>Email :</strong> contact@thetiptop.fr
|
<strong>Email :</strong> thetiptop.gp4@gmail.com
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { loginApi } from '../services/auth'
|
import { loginApi } from '../services/auth'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
|
import Captcha from '../components/Captcha'
|
||||||
|
import { CAPTCHA_ENABLED } from '../config/captcha'
|
||||||
|
import { homeFor } from '../config/roles'
|
||||||
|
|
||||||
export default function Connexion() {
|
export default function Connexion() {
|
||||||
|
usePageMeta({
|
||||||
|
title: 'Connexion | Thé Tip Top',
|
||||||
|
description: "Connectez-vous à votre espace participant Thé Tip Top pour saisir votre code et suivre vos gains au jeu-concours 100 % Gagnant.",
|
||||||
|
})
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
@@ -12,29 +21,34 @@ export default function Connexion() {
|
|||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [captchaToken, setCaptchaToken] = useState('')
|
||||||
|
const captchaRef = useRef()
|
||||||
|
|
||||||
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
||||||
|
|
||||||
// Navigation pleine page (et non fetch) : le flux OAuth passe par des
|
// Navigation pleine page (et non fetch) : le flux OAuth passe par des
|
||||||
// redirections vers Google puis retour sur /auth/callback.
|
// Navigation pleine page (et non fetch) : le flux OAuth passe par des
|
||||||
const API_URL = import.meta.env.VITE_API_URL || ''
|
// redirections vers Google/Facebook puis retour sur /auth/callback.
|
||||||
|
const handleOAuthLogin = (provider) => {
|
||||||
const handleGoogleLogin = () => {
|
const apiUrl = import.meta.env.VITE_API_URL || ''
|
||||||
window.location.href = `${API_URL}/api/auth/google`
|
window.location.href = `${apiUrl}/api/auth/${provider}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const { token, user } = await loginApi(form)
|
const { token, user } = await loginApi({ ...form, captchaToken })
|
||||||
login(user, token)
|
login(user, token)
|
||||||
if (user.role === 'ADMIN') navigate('/admin')
|
// Un employé qui se connecte avec son mot de passe provisoire est
|
||||||
else if (user.role === 'EMPLOYEE') navigate('/employe')
|
// envoyé directement sur le choix de son nouveau mot de passe.
|
||||||
else navigate('/mes-gains')
|
navigate(homeFor(user))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
|
// Le jeton a été consommé par le backend : on en redemande un.
|
||||||
|
captchaRef.current?.reset()
|
||||||
|
setCaptchaToken('')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -107,15 +121,21 @@ const handleGoogleLogin = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right mt-1">
|
<div className="text-right mt-1">
|
||||||
<button type="button" className="text-xs text-gray-500 hover:text-[#006B6B] hover:underline">
|
<Link
|
||||||
|
to="/mot-de-passe-oublie"
|
||||||
|
state={{ email: form.email }}
|
||||||
|
className="text-xs text-gray-500 hover:text-[#006B6B] hover:underline"
|
||||||
|
>
|
||||||
Mot de passe oublié ?
|
Mot de passe oublié ?
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Captcha ref={captchaRef} onToken={setCaptchaToken} />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading || (CAPTCHA_ENABLED && !captchaToken)}
|
||||||
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
|
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{loading ? 'Connexion...' : 'Se connecter'}
|
{loading ? 'Connexion...' : 'Se connecter'}
|
||||||
@@ -131,7 +151,7 @@ const handleGoogleLogin = () => {
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleGoogleLogin}
|
onClick={() => handleOAuthLogin('google')}
|
||||||
className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true">
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true">
|
||||||
@@ -144,9 +164,8 @@ const handleGoogleLogin = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled
|
onClick={() => handleOAuthLogin('facebook')}
|
||||||
title="Connexion Facebook bientôt disponible"
|
className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-500 cursor-not-allowed opacity-60"
|
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<circle cx="12" cy="12" r="12" fill="#1877F2" />
|
<circle cx="12" cy="12" r="12" fill="#1877F2" />
|
||||||
|
|||||||
@@ -1,42 +1,164 @@
|
|||||||
import imgContact from '../assets/img/img-contact.jpg'
|
import imgContact from '../assets/img/img-contact.webp'
|
||||||
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
|
|
||||||
export default function Contact() {
|
export default function Contact() {
|
||||||
|
usePageMeta({
|
||||||
|
title: 'Contact | Thé Tip Top',
|
||||||
|
description:
|
||||||
|
"Une question sur le jeu-concours, votre participation ou votre lot ? Contactez l'équipe Thé Tip Top par email, téléphone ou via le formulaire en ligne.",
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="bg-[#F0F0F0] px-4 sm:px-6 py-10 sm:py-16">
|
<main className="bg-[#F0F0F0] px-4 sm:px-6 py-10 sm:py-16">
|
||||||
<div className="max-w-6xl mx-auto grid md:grid-cols-2 gap-6 sm:gap-8">
|
<div className="max-w-6xl mx-auto">
|
||||||
|
|
||||||
{/* Informations */}
|
{/* Informations + image */}
|
||||||
<section className="bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
<div className="grid md:grid-cols-2 gap-6 sm:gap-8 mb-8">
|
||||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
|
||||||
Contact
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 leading-relaxed mb-6 sm:mb-8 text-sm sm:text-base">
|
|
||||||
Une question concernant le jeu-concours, votre participation ou votre lot ?
|
|
||||||
Notre équipe ThéTipTop est à votre écoute.
|
|
||||||
</p>
|
|
||||||
<div className="space-y-5 text-gray-700">
|
|
||||||
<div>
|
|
||||||
<h2 className="font-semibold text-[#006B6B] mb-1">Adresse</h2>
|
|
||||||
<p className="text-sm sm:text-base">ThéTipTop<br />18 rue Léon Frot<br />75011 Paris, France</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="font-semibold text-[#006B6B] mb-1">Email</h2>
|
|
||||||
<a href="mailto:contact@thetiptop.fr" className="text-sm sm:text-base hover:text-[#006B6B]">contact@thetiptop.fr</a>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="font-semibold text-[#006B6B] mb-1">Téléphone</h2>
|
|
||||||
<a href="tel:+33184802034" className="text-sm sm:text-base hover:text-[#006B6B]">01 84 80 20 34</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Image */}
|
{/* Informations */}
|
||||||
<section className="rounded-2xl shadow overflow-hidden h-64 sm:h-80 md:h-[468px] md:self-start">
|
<section className="bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||||
<img
|
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||||
src={imgContact}
|
Contact
|
||||||
alt="Une personne savourant un thé"
|
</h1>
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
<p className="text-gray-600 leading-relaxed mb-6 sm:mb-8 text-sm sm:text-base">
|
||||||
|
Une question concernant le jeu-concours, votre participation ou votre lot ?
|
||||||
|
Notre équipe Thé Tip Top est à votre écoute.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-5 text-gray-700">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-[#006B6B] mb-1">
|
||||||
|
Adresse
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm sm:text-base">
|
||||||
|
Thé Tip Top
|
||||||
|
<br />
|
||||||
|
18 rue Léon Frot
|
||||||
|
<br />
|
||||||
|
75011 Paris, France
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-[#006B6B] mb-1">
|
||||||
|
Email
|
||||||
|
</h2>
|
||||||
|
<a
|
||||||
|
href="mailto:contact@thetiptop.fr"
|
||||||
|
className="text-sm sm:text-base hover:text-[#006B6B]"
|
||||||
|
>
|
||||||
|
contact@thetiptop.fr
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-[#006B6B] mb-1">
|
||||||
|
Téléphone
|
||||||
|
</h2>
|
||||||
|
<a
|
||||||
|
href="tel:+33184802034"
|
||||||
|
className="text-sm sm:text-base hover:text-[#006B6B]"
|
||||||
|
>
|
||||||
|
01 84 80 20 34
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Image */}
|
||||||
|
<section className="rounded-2xl shadow overflow-hidden h-64 sm:h-80 md:h-full md:min-h-0">
|
||||||
|
<img
|
||||||
|
src={imgContact}
|
||||||
|
alt="Une personne savourant un thé"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Formulaire */}
|
||||||
|
<section className="bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10 max-w-4xl mx-auto">
|
||||||
|
<h2 className="text-xl sm:text-2xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||||
|
Envoyez-nous un message
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form className="space-y-4 sm:space-y-5">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="contact-name"
|
||||||
|
className="block text-gray-700 mb-1.5 text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Nom
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="contact-name"
|
||||||
|
placeholder="Votre nom"
|
||||||
|
className="w-full border border-gray-300 rounded-xl px-4 py-2.5 sm:py-3 text-sm sm:text-base focus:outline-none focus:ring-2 focus:ring-[#006B6B]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="contact-email"
|
||||||
|
className="block text-gray-700 mb-1.5 text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="contact-email"
|
||||||
|
placeholder="Votre email"
|
||||||
|
className="w-full border border-gray-300 rounded-xl px-4 py-2.5 sm:py-3 text-sm sm:text-base focus:outline-none focus:ring-2 focus:ring-[#006B6B]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="contact-subject"
|
||||||
|
className="block text-gray-700 mb-1.5 text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Sujet
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="contact-subject"
|
||||||
|
placeholder="Sujet de votre demande"
|
||||||
|
className="w-full border border-gray-300 rounded-xl px-4 py-2.5 sm:py-3 text-sm sm:text-base focus:outline-none focus:ring-2 focus:ring-[#006B6B]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="contact-message"
|
||||||
|
className="block text-gray-700 mb-1.5 text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Message
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
id="contact-message"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Votre message..."
|
||||||
|
className="w-full border border-gray-300 rounded-xl px-4 py-2.5 sm:py-3 text-sm sm:text-base focus:outline-none focus:ring-2 focus:ring-[#006B6B] resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-[#006B6B] hover:bg-[#005858] text-white font-semibold py-2.5 sm:py-3 rounded-xl transition text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Envoyer le message
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { resetPasswordApi } from '../services/auth'
|
import { resetPasswordApi } from '../services/auth'
|
||||||
|
import PasswordRules from '../components/PasswordRules'
|
||||||
|
import { isPasswordValid, PASSWORD_RULES_ERROR } from '../utils/passwordRules'
|
||||||
|
|
||||||
// Sert les deux parcours qui passent par un jeton envoyé par email :
|
// Sert les deux parcours qui passent par un jeton envoyé par email :
|
||||||
// la définition initiale du mot de passe après une inscription Google,
|
// la définition initiale du mot de passe après une inscription Google,
|
||||||
@@ -21,8 +23,8 @@ export default function DefinirMotDePasse() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
if (form.password.length < 8) {
|
if (!isPasswordValid(form.password)) {
|
||||||
setError('Le mot de passe doit contenir au moins 8 caractères.')
|
setError(PASSWORD_RULES_ERROR)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (form.password !== form.confirmPassword) {
|
if (form.password !== form.confirmPassword) {
|
||||||
@@ -58,15 +60,22 @@ export default function DefinirMotDePasse() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
|
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">Mot de passe</h1>
|
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">Nouveau mot de passe</h1>
|
||||||
|
{/* Texte neutre : la page sert au mot de passe oublié comme au
|
||||||
|
premier mot de passe d'un compte Google. */}
|
||||||
<p className="text-gray-500 mt-1 text-sm">
|
<p className="text-gray-500 mt-1 text-sm">
|
||||||
Choisissez un mot de passe pour accéder à votre compte sans passer par Google.
|
Choisissez le mot de passe que vous utiliserez pour vous connecter avec votre adresse email.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm" role="alert">
|
||||||
{error}
|
{error}
|
||||||
|
{/invalide ou expiré/i.test(error) && (
|
||||||
|
<Link to="/mot-de-passe-oublie" className="block mt-2 font-medium text-[#006B6B] hover:underline">
|
||||||
|
Demander un nouveau lien
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -82,10 +91,12 @@ export default function DefinirMotDePasse() {
|
|||||||
value={form.password}
|
value={form.password}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
minLength={8}
|
autoComplete="new-password"
|
||||||
placeholder="8 caractères minimum"
|
aria-describedby="password-rules"
|
||||||
|
placeholder="Votre nouveau mot de passe"
|
||||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||||
/>
|
/>
|
||||||
|
<PasswordRules id="password-rules" password={form.password} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -2,15 +2,37 @@ import { useState, useRef } from 'react'
|
|||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { registerApi, verifyCodeApi } from '../services/auth'
|
import { registerApi, verifyCodeApi } from '../services/auth'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
|
import Captcha from '../components/Captcha'
|
||||||
|
import { CAPTCHA_ENABLED } from '../config/captcha'
|
||||||
|
import { isPasswordValid, PASSWORD_RULES_ERROR } from '../utils/passwordRules'
|
||||||
|
import PasswordRules from '../components/PasswordRules'
|
||||||
|
|
||||||
// Une naissance ne peut pas être dans le futur : borne haute du sélecteur.
|
// Une naissance ne peut pas être dans le futur : borne haute du sélecteur.
|
||||||
const AUJOURD_HUI = new Date().toISOString().split('T')[0]
|
const AUJOURD_HUI = new Date().toISOString().split('T')[0]
|
||||||
|
|
||||||
|
// Le jeu est réservé aux personnes majeures.
|
||||||
|
function hasAtLeast18Years(birthdate) {
|
||||||
|
const birth = new Date(birthdate)
|
||||||
|
const today = new Date()
|
||||||
|
let age = today.getFullYear() - birth.getFullYear()
|
||||||
|
const monthDiff = today.getMonth() - birth.getMonth()
|
||||||
|
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
|
||||||
|
age--
|
||||||
|
}
|
||||||
|
return age >= 18
|
||||||
|
}
|
||||||
|
|
||||||
// Valeurs alignées sur le tableau de bord admin, qui compte exactement
|
// Valeurs alignées sur le tableau de bord admin, qui compte exactement
|
||||||
// "Homme" et "Femme" ; toute autre valeur retombe dans la catégorie "autre".
|
// "Homme" et "Femme" ; toute autre valeur retombe dans la catégorie "autre".
|
||||||
const GENRES = ['Femme', 'Homme', 'Autre']
|
const GENRES = ['Femme', 'Homme', 'Autre']
|
||||||
|
|
||||||
export default function Inscription() {
|
export default function Inscription() {
|
||||||
|
usePageMeta({
|
||||||
|
title: 'Créer un compte | Thé Tip Top',
|
||||||
|
description: "Inscrivez-vous en quelques instants pour participer au jeu-concours 100 % Gagnant Thé Tip Top et saisir le code de votre ticket de caisse.",
|
||||||
|
})
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
|
|
||||||
@@ -22,6 +44,7 @@ export default function Inscription() {
|
|||||||
lastName: '',
|
lastName: '',
|
||||||
firstName: '',
|
firstName: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
birthdate: '',
|
birthdate: '',
|
||||||
gender: '',
|
gender: '',
|
||||||
})
|
})
|
||||||
@@ -32,6 +55,8 @@ export default function Inscription() {
|
|||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [captchaToken, setCaptchaToken] = useState('')
|
||||||
|
const captchaRef = useRef()
|
||||||
|
|
||||||
// Code à 4 chiffres
|
// Code à 4 chiffres
|
||||||
const [digits, setDigits] = useState(['', '', '', ''])
|
const [digits, setDigits] = useState(['', '', '', ''])
|
||||||
@@ -41,15 +66,35 @@ export default function Inscription() {
|
|||||||
|
|
||||||
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
||||||
|
|
||||||
// Même route que la connexion : Google crée le compte à la première venue.
|
// Signalé dès que la confirmation est saisie, sans attendre l'envoi.
|
||||||
const handleGoogleSignup = () => {
|
const passwordMismatch = form.confirmPassword !== '' && form.password !== form.confirmPassword
|
||||||
window.location.href = '/api/auth/google'
|
|
||||||
|
// Même route que la connexion : le provider crée le compte à la première venue.
|
||||||
|
const handleOAuthSignup = (provider) => {
|
||||||
|
const apiUrl = import.meta.env.VITE_API_URL || ''
|
||||||
|
window.location.href = `${apiUrl}/api/auth/${provider}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Étape 1 — Inscription
|
// Étape 1 — Inscription
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
|
if (!hasAtLeast18Years(form.birthdate)) {
|
||||||
|
setError('Vous devez avoir au moins 18 ans pour vous inscrire.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPasswordValid(form.password)) {
|
||||||
|
setError(PASSWORD_RULES_ERROR)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.password !== form.confirmPassword) {
|
||||||
|
setError('Les deux mots de passe ne correspondent pas.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
await registerApi({
|
await registerApi({
|
||||||
@@ -57,14 +102,19 @@ export default function Inscription() {
|
|||||||
firstname: form.firstName,
|
firstname: form.firstName,
|
||||||
lastname: form.lastName,
|
lastname: form.lastName,
|
||||||
password: form.password,
|
password: form.password,
|
||||||
|
confirmPassword: form.confirmPassword,
|
||||||
birthdate: form.birthdate,
|
birthdate: form.birthdate,
|
||||||
gender: form.gender,
|
gender: form.gender,
|
||||||
marketingOptIn,
|
marketingOptIn,
|
||||||
|
captchaToken,
|
||||||
})
|
})
|
||||||
setEmail(form.email)
|
setEmail(form.email)
|
||||||
setStep('verify')
|
setStep('verify')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
|
// Le jeton a été consommé par le backend : on en redemande un.
|
||||||
|
captchaRef.current?.reset()
|
||||||
|
setCaptchaToken('')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -158,7 +208,7 @@ export default function Inscription() {
|
|||||||
Le code expire dans 15 minutes.{' '}
|
Le code expire dans 15 minutes.{' '}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setStep('form'); setDigits(['', '', '', '']); setCodeError('') }}
|
onClick={() => { setStep('form'); setDigits(['', '', '', '']); setCodeError(''); setCaptchaToken('') }}
|
||||||
className="text-[#006B6B] hover:underline"
|
className="text-[#006B6B] hover:underline"
|
||||||
>
|
>
|
||||||
Recommencer
|
Recommencer
|
||||||
@@ -202,9 +252,10 @@ export default function Inscription() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="reg-birthdate" className="block text-sm font-medium text-gray-800 mb-1">Date de naissance</label>
|
<label htmlFor="reg-birthdate" className="block text-sm font-medium text-gray-800 mb-1">Date de naissance <span className="text-gray-800">*</span></label>
|
||||||
<input type="date" id="reg-birthdate" name="birthdate" value={form.birthdate} onChange={handleChange} max={AUJOURD_HUI}
|
<input type="date" id="reg-birthdate" name="birthdate" value={form.birthdate} onChange={handleChange} required max={AUJOURD_HUI}
|
||||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Le jeu-concours est réservé aux personnes majeures.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -222,6 +273,7 @@ export default function Inscription() {
|
|||||||
<label htmlFor="reg-password" className="block text-sm font-medium text-gray-800 mb-1">Mot de passe <span className="text-gray-800">*</span></label>
|
<label htmlFor="reg-password" className="block text-sm font-medium text-gray-800 mb-1">Mot de passe <span className="text-gray-800">*</span></label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input type={showPassword ? 'text' : 'password'} id="reg-password" name="password" value={form.password} onChange={handleChange} required placeholder="Votre mot de passe"
|
<input type={showPassword ? 'text' : 'password'} id="reg-password" name="password" value={form.password} onChange={handleChange} required placeholder="Votre mot de passe"
|
||||||
|
autoComplete="new-password" aria-describedby="reg-password-rules"
|
||||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-12 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-12 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
||||||
<button type="button" onClick={() => setShowPassword(!showPassword)}
|
<button type="button" onClick={() => setShowPassword(!showPassword)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-600" aria-label="Afficher/masquer">
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-600" aria-label="Afficher/masquer">
|
||||||
@@ -230,6 +282,17 @@ export default function Inscription() {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<PasswordRules id="reg-password-rules" password={form.password} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="reg-confirm-password" className="block text-sm font-medium text-gray-800 mb-1">Confirmer le mot de passe <span className="text-gray-800">*</span></label>
|
||||||
|
<input type={showPassword ? 'text' : 'password'} id="reg-confirm-password" name="confirmPassword" value={form.confirmPassword} onChange={handleChange} required placeholder="Saisissez-le à nouveau"
|
||||||
|
autoComplete="new-password" aria-invalid={passwordMismatch} aria-describedby={passwordMismatch ? 'reg-confirm-password-error' : undefined}
|
||||||
|
className={`w-full border rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 ${passwordMismatch ? 'border-red-300 focus:ring-red-400 focus:border-red-400' : 'border-gray-200 focus:ring-[#006B6B] focus:border-[#006B6B]'}`} />
|
||||||
|
{passwordMismatch && (
|
||||||
|
<p id="reg-confirm-password-error" className="text-xs text-red-600 mt-1">Les deux mots de passe ne correspondent pas.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-start gap-3 cursor-pointer">
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
@@ -255,7 +318,9 @@ export default function Inscription() {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button type="submit" disabled={loading || !acceptedTerms}
|
<Captcha ref={captchaRef} onToken={setCaptchaToken} />
|
||||||
|
|
||||||
|
<button type="submit" disabled={loading || !acceptedTerms || (CAPTCHA_ENABLED && !captchaToken)}
|
||||||
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors">
|
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors">
|
||||||
{loading ? 'Inscription…' : "S'inscrire"}
|
{loading ? 'Inscription…' : "S'inscrire"}
|
||||||
</button>
|
</button>
|
||||||
@@ -268,7 +333,7 @@ export default function Inscription() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<button type="button" onClick={handleGoogleSignup} className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
|
<button type="button" onClick={() => handleOAuthSignup('google')} className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true">
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true">
|
||||||
<path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"></path>
|
<path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"></path>
|
||||||
<path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"></path>
|
<path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"></path>
|
||||||
@@ -277,7 +342,7 @@ export default function Inscription() {
|
|||||||
</svg>
|
</svg>
|
||||||
Google
|
Google
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled title="Inscription Facebook bientôt disponible" className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-500 cursor-not-allowed opacity-60">
|
<button type="button" onClick={() => handleOAuthSignup('facebook')} className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<circle cx="12" cy="12" r="12" fill="#1877F2" />
|
<circle cx="12" cy="12" r="12" fill="#1877F2" />
|
||||||
<path fill="#FFFFFF" d="M13.5 21v-8h2.7l.4-3h-3.1V8.1c0-.9.3-1.6 1.7-1.6h1.8V3.8c-.3 0-1.4-.1-2.6-.1-2.6 0-4.4 1.6-4.4 4.5V10H7.2v3H10v8h3.5z" />
|
<path fill="#FFFFFF" d="M13.5 21v-8h2.7l.4-3h-3.1V8.1c0-.9.3-1.6 1.7-1.6h1.8V3.8c-.3 0-1.4-.1-2.6-.1-2.6 0-4.4 1.6-4.4 4.5V10H7.2v3H10v8h3.5z" />
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
|
|
||||||
export default function MentionsLegales() {
|
export default function MentionsLegales() {
|
||||||
|
usePageMeta({
|
||||||
|
title: 'Mentions légales | Thé Tip Top',
|
||||||
|
description: "Mentions légales du site Thé Tip Top : éditeur, hébergeur, propriété intellectuelle et informations relatives au jeu-concours 100 % Gagnant.",
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
||||||
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||||
@@ -36,12 +43,12 @@ export default function MentionsLegales() {
|
|||||||
<p><strong>Directeur de la publication :</strong> M. Eric Bourdon, en sa qualité de Gérant</p>
|
<p><strong>Directeur de la publication :</strong> M. Eric Bourdon, en sa qualité de Gérant</p>
|
||||||
<p><strong>SIRET :</strong> 362 521 879 00054</p>
|
<p><strong>SIRET :</strong> 362 521 879 00054</p>
|
||||||
<p><strong>TVA intracommunautaire :</strong> FR83362521879</p>
|
<p><strong>TVA intracommunautaire :</strong> FR83362521879</p>
|
||||||
<p><strong>Email :</strong> contact@thetiptop.fr</p>
|
<p><strong>Email :</strong> thetiptop.gp4@gmail.com</p>
|
||||||
<p><strong>Téléphone :</strong> 01 84 80 20 34</p>
|
<p><strong>Téléphone :</strong> 01 84 80 20 34</p>
|
||||||
<p className="pt-2">
|
<p className="pt-2">
|
||||||
Le règlement du jeu-concours a été déposé auprès de Maître Arnaud Rick,
|
Le règlement du jeu-concours a été déposé auprès de Maître Arnaud Rick,
|
||||||
Commissaire de Justice. Il est consultable gratuitement sur simple demande
|
Commissaire de Justice. Il est consultable gratuitement sur simple demande
|
||||||
à l'adresse contact@thetiptop.fr.
|
à l'adresse thetiptop.gp4@gmail.com.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export default function MesGains() {
|
|||||||
}, [handleAuthError])
|
}, [handleAuthError])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-lg mx-auto">
|
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-xl mx-auto">
|
||||||
<UserNav active="gains" />
|
<UserNav active="gains" />
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
|||||||
117
frontend/src/pages/MotDePasseOublie.jsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
import { forgotPasswordApi } from '../services/auth'
|
||||||
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
|
import Captcha from '../components/Captcha'
|
||||||
|
import { CAPTCHA_ENABLED } from '../config/captcha'
|
||||||
|
|
||||||
|
// Demande d'un lien de réinitialisation. La réponse est volontairement la même
|
||||||
|
// que le compte existe ou non, pour ne pas révéler quels emails sont inscrits.
|
||||||
|
export default function MotDePasseOublie() {
|
||||||
|
usePageMeta({
|
||||||
|
title: 'Mot de passe oublié | Thé Tip Top',
|
||||||
|
description: 'Recevez par email un lien pour choisir un nouveau mot de passe pour votre compte Thé Tip Top.',
|
||||||
|
})
|
||||||
|
|
||||||
|
// L'email déjà saisi sur la page de connexion est repris.
|
||||||
|
const { state } = useLocation()
|
||||||
|
const [email, setEmail] = useState(state?.email ?? '')
|
||||||
|
const [sent, setSent] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [captchaToken, setCaptchaToken] = useState('')
|
||||||
|
const captchaRef = useRef()
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await forgotPasswordApi({ email, captchaToken })
|
||||||
|
setSent(data.message)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
// Le jeton a été consommé par le backend : on en redemande un.
|
||||||
|
captchaRef.current?.reset()
|
||||||
|
setCaptchaToken('')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sent) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto text-center">
|
||||||
|
<h1 className="text-3xl font-bold italic font-serif text-[#006B6B] mb-4">Vérifiez vos emails</h1>
|
||||||
|
<div className="mb-4 px-4 py-3 rounded-lg bg-green-50 border border-green-200 text-green-700 text-sm" role="status">
|
||||||
|
{sent}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
Le lien est valable 30 minutes. Pensez à regarder dans vos courriers indésirables.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setSent(''); setCaptchaToken('') }}
|
||||||
|
className="text-sm text-gray-500 hover:text-[#006B6B] hover:underline mb-3"
|
||||||
|
>
|
||||||
|
Je n'ai rien reçu, renvoyer un lien
|
||||||
|
</button>
|
||||||
|
<Link to="/connexion" className="text-sm text-[#006B6B] font-medium hover:underline">
|
||||||
|
Retour à la connexion
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">Mot de passe oublié</h1>
|
||||||
|
<p className="text-gray-500 mt-2 text-sm">
|
||||||
|
Saisissez l'adresse email de votre compte : nous vous enverrons un lien pour choisir un nouveau mot de passe.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="forgot-email" className="block text-sm font-medium text-gray-800 mb-1">
|
||||||
|
Adresse email <span className="text-gray-800">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="forgot-email"
|
||||||
|
name="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="Votre email"
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Captcha ref={captchaRef} onToken={setCaptchaToken} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || (CAPTCHA_ENABLED && !captchaToken)}
|
||||||
|
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Envoi…' : 'Recevoir le lien'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-gray-500 mt-8">
|
||||||
|
<Link to="/connexion" className="text-gray-700 font-medium hover:underline">
|
||||||
|
Retour à la connexion
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
145
frontend/src/pages/NotreConcept.jsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
|
import bannerImg from '../assets/img/banner-the-tip-top.webp'
|
||||||
|
|
||||||
|
export default function NotreConcept() {
|
||||||
|
usePageMeta({
|
||||||
|
title: 'Notre concept : un jeu-concours sans perdant | Thé Tip Top',
|
||||||
|
description: "Pourquoi un jeu 100 % gagnant ? Découvrez le concept, la répartition des lots et les garanties de loyauté du jeu-concours Thé Tip Top.",
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-50 min-h-screen">
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<section className="max-w-7xl mx-auto px-4 sm:px-6 py-12 sm:py-16 md:py-20">
|
||||||
|
<div className="text-center max-w-3xl mx-auto">
|
||||||
|
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||||
|
Le concept 100 % Gagnant : un jeu-concours sans perdant
|
||||||
|
</h1>
|
||||||
|
<p className="text-base sm:text-xl text-gray-600 mb-8 sm:mb-10">
|
||||||
|
La plupart des jeux-concours font gagner une personne et laissent les autres repartir
|
||||||
|
les mains vides. Nous avons choisi l'inverse : chaque code distribué correspond à
|
||||||
|
un lot réel, attribué à l'avance. Personne ne joue pour rien.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row justify-center gap-3 sm:gap-4">
|
||||||
|
<Link
|
||||||
|
to="/accueil"
|
||||||
|
className="bg-[#006B6B] hover:bg-[#005555] text-white font-semibold px-6 sm:px-8 py-3 rounded-xl transition-colors text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Voir le jeu-concours
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/regles-jeu"
|
||||||
|
className="border border-[#006B6B] text-[#006B6B] hover:bg-[#006B6B] hover:text-white font-semibold px-6 sm:px-8 py-3 rounded-xl transition-colors text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Consulter le règlement
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Visuel principal */}
|
||||||
|
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-16">
|
||||||
|
<div className="rounded-2xl shadow overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={bannerImg}
|
||||||
|
alt="Infuseur à thé Thé Tip Top, lot garanti du jeu-concours 100 % Gagnant"
|
||||||
|
className="w-full h-full object-cover object-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Pourquoi un jeu sans perdant */}
|
||||||
|
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-16">
|
||||||
|
<div className="bg-white rounded-2xl shadow p-6 sm:p-10">
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||||
|
Pourquoi un jeu sans perdant
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 text-sm sm:text-base leading-relaxed">
|
||||||
|
Un jeu classique récompense la chance. Celui-ci récompense la visite. Nous ouvrons une
|
||||||
|
nouvelle adresse et nous voulions que chaque personne qui pousse la porte reparte avec
|
||||||
|
quelque chose à goûter, pas avec un ticket de loterie. C'est aussi la raison pour
|
||||||
|
laquelle le lot se retire sur place : le jeu est un prétexte à une rencontre, pas à
|
||||||
|
une transaction de plus.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Comment la loyauté du jeu est garantie */}
|
||||||
|
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-16">
|
||||||
|
<div className="bg-white rounded-2xl shadow p-6 sm:p-10">
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||||
|
Comment la loyauté du jeu est garantie
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 text-sm sm:text-base leading-relaxed">
|
||||||
|
L'intégralité des codes et leur lot associé sont générés avant l'ouverture du jeu, puis
|
||||||
|
déposés auprès de Maître Arnaud Rick, commissaire de justice. Aucun lot ne peut être
|
||||||
|
modifié en cours d'opération. Le règlement complet est{' '}
|
||||||
|
<a
|
||||||
|
href="/reglement-jeu-concours-100-gagnant.pdf"
|
||||||
|
className="text-[#006B6B] font-semibold underline hover:no-underline"
|
||||||
|
>
|
||||||
|
consultable et téléchargeable
|
||||||
|
</a>{' '}
|
||||||
|
à tout moment. Le tirage au sort du lot principal est effectué sous le contrôle du même
|
||||||
|
commissaire de justice, et le nombre de participations n'augmente pas les chances de le
|
||||||
|
remporter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* La répartition des lots */}
|
||||||
|
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-16">
|
||||||
|
<div className="bg-white rounded-2xl shadow p-6 sm:p-10">
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||||
|
La répartition des lots, annoncée à l'avance
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 text-sm sm:text-base leading-relaxed">
|
||||||
|
Six codes sur dix donnent un infuseur, deux sur dix une boîte de 100 g, un sur dix une
|
||||||
|
boîte d'un mélange signature, et le solde un coffret découverte. Cette répartition est
|
||||||
|
publiée dès le premier jour et ne varie pas. Vous savez donc, avant même de jouer, ce
|
||||||
|
que vous pouvez obtenir et dans quelle proportion.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Un jeu conçu pour ne rien gaspiller */}
|
||||||
|
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-20">
|
||||||
|
<div className="bg-white rounded-2xl shadow p-6 sm:p-10">
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||||
|
Un jeu conçu pour ne rien gaspiller
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 text-sm sm:text-base leading-relaxed">
|
||||||
|
Le lot le plus distribué est un infuseur réutilisable, choisi pour remplacer durablement
|
||||||
|
les sachets jetables plutôt que pour faire nombre. La participation est entièrement
|
||||||
|
numérique, sans bulletin papier, et nos supports imprimés se limitent au strict
|
||||||
|
nécessaire d'un point de vente.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-20">
|
||||||
|
<div className="bg-[#006B6B] rounded-2xl p-6 sm:p-10 text-center text-white">
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-bold mb-3 sm:mb-4">
|
||||||
|
Une question sur le concept du jeu ?
|
||||||
|
</h2>
|
||||||
|
<p className="mb-6 sm:mb-8 text-base sm:text-lg">
|
||||||
|
Notre équipe reste à votre écoute pour tout ce qui concerne le jeu-concours et vos
|
||||||
|
lots.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Link
|
||||||
|
to="/contact"
|
||||||
|
className="bg-white text-[#006B6B] px-6 sm:px-8 py-3 rounded-xl font-semibold hover:bg-[#006B6B] hover:text-white border border-white text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Contactez-nous
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
139
frontend/src/pages/NouveauMotDePasse.jsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Navigate, useNavigate } from 'react-router-dom'
|
||||||
|
import { setInitialPasswordApi } from '../services/auth'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { homeFor } from '../config/roles'
|
||||||
|
import PasswordRules from '../components/PasswordRules'
|
||||||
|
import { isPasswordValid, PASSWORD_RULES_ERROR } from '../utils/passwordRules'
|
||||||
|
|
||||||
|
// Première connexion d'un employé créé par un admin : le mot de passe reçu
|
||||||
|
// par email est provisoire, il doit être remplacé avant tout accès.
|
||||||
|
export default function NouveauMotDePasse() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { user, login, logout } = useAuth()
|
||||||
|
|
||||||
|
const [form, setForm] = useState({ password: '', confirmPassword: '' })
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
if (!user) return <Navigate to="/connexion" replace />
|
||||||
|
if (!user.mustChangePassword) return <Navigate to={homeFor(user)} replace />
|
||||||
|
|
||||||
|
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
if (!isPasswordValid(form.password)) {
|
||||||
|
setError(PASSWORD_RULES_ERROR)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (form.password !== form.confirmPassword) {
|
||||||
|
setError('Les deux mots de passe ne correspondent pas.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await setInitialPasswordApi(form)
|
||||||
|
const updatedUser = { ...user, mustChangePassword: false }
|
||||||
|
login(updatedUser, localStorage.getItem('token'))
|
||||||
|
navigate(homeFor(updatedUser), { replace: true })
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 401) {
|
||||||
|
logout()
|
||||||
|
navigate('/connexion', { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(err.message)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout()
|
||||||
|
navigate('/connexion', { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">Nouveau mot de passe</h1>
|
||||||
|
<p className="text-gray-500 mt-2 text-sm">
|
||||||
|
Bienvenue {user.firstname} ! Vous vous êtes connecté avec un mot de passe provisoire.
|
||||||
|
Choisissez votre propre mot de passe pour accéder à votre espace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="new-password" className="block text-sm font-medium text-gray-800 mb-1">
|
||||||
|
Nouveau mot de passe <span className="text-gray-800">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
id="new-password"
|
||||||
|
name="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
aria-describedby="new-password-rules"
|
||||||
|
placeholder="Votre nouveau mot de passe"
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||||
|
/>
|
||||||
|
<PasswordRules id="new-password-rules" password={form.password} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirm-new-password" className="block text-sm font-medium text-gray-800 mb-1">
|
||||||
|
Confirmer le mot de passe <span className="text-gray-800">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
id="confirm-new-password"
|
||||||
|
name="confirmPassword"
|
||||||
|
value={form.confirmPassword}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="Saisissez-le à nouveau"
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showPassword}
|
||||||
|
onChange={(e) => setShowPassword(e.target.checked)}
|
||||||
|
className="w-5 h-5 accent-[#006B6B]"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-500">Afficher les mots de passe</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Enregistrement…' : 'Enregistrer mon mot de passe'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-gray-500 mt-8">
|
||||||
|
<button type="button" onClick={handleLogout} className="text-gray-700 font-medium hover:underline">
|
||||||
|
Se déconnecter
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +1,31 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
import UserNav from '../components/UserNav'
|
import UserNav from '../components/UserNav'
|
||||||
import { changePasswordApi, meApi, forgotPasswordApi } from '../services/auth'
|
import {
|
||||||
|
changePasswordApi,
|
||||||
|
meApi,
|
||||||
|
sendPasswordSetupLinkApi,
|
||||||
|
} from '../services/auth'
|
||||||
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||||
|
import PasswordRules from '../components/PasswordRules'
|
||||||
|
import { isPasswordValid, PASSWORD_RULES_ERROR } from '../utils/passwordRules'
|
||||||
|
|
||||||
export default function Profile() {
|
export default function Profile() {
|
||||||
const user = JSON.parse(localStorage.getItem('user') || '{}')
|
const user = JSON.parse(localStorage.getItem('user') || '{}')
|
||||||
const initials = `${(user.firstname || '?')[0]}${(user.lastname || '?')[0]}`.toUpperCase()
|
const initials = `${(user.firstname || '?')[0]}${(user.lastname || '?')[0]}`.toUpperCase()
|
||||||
const handleAuthError = useHandleAuthError()
|
const handleAuthError = useHandleAuthError()
|
||||||
|
const { hash } = useLocation()
|
||||||
|
|
||||||
|
const deleteSectionRef = useRef(null)
|
||||||
|
|
||||||
|
// Permet à un lien externe (ex. les CGU) de renvoyer directement sur cette
|
||||||
|
// section via /profile#suppression-compte, plutôt que d'obliger l'utilisateur
|
||||||
|
// à chercher l'information au milieu de la page.
|
||||||
|
useEffect(() => {
|
||||||
|
if (hash === '#suppression-compte') {
|
||||||
|
deleteSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
}, [hash])
|
||||||
|
|
||||||
const [pwForm, setPwForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' })
|
const [pwForm, setPwForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' })
|
||||||
const [showPw, setShowPw] = useState({ current: false, new: false, confirm: false })
|
const [showPw, setShowPw] = useState({ current: false, new: false, confirm: false })
|
||||||
@@ -34,7 +53,7 @@ export default function Profile() {
|
|||||||
setLinkError('')
|
setLinkError('')
|
||||||
setLinkSending(true)
|
setLinkSending(true)
|
||||||
try {
|
try {
|
||||||
await forgotPasswordApi(user.email)
|
await sendPasswordSetupLinkApi()
|
||||||
setLinkSent(true)
|
setLinkSent(true)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!handleAuthError(e)) setLinkError(e.message)
|
if (!handleAuthError(e)) setLinkError(e.message)
|
||||||
@@ -54,8 +73,8 @@ export default function Profile() {
|
|||||||
setPwError('Les nouveaux mots de passe ne correspondent pas.')
|
setPwError('Les nouveaux mots de passe ne correspondent pas.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (pwForm.newPassword.length < 8) {
|
if (!isPasswordValid(pwForm.newPassword)) {
|
||||||
setPwError('Le nouveau mot de passe doit faire au moins 8 caractères.')
|
setPwError(PASSWORD_RULES_ERROR)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +100,7 @@ export default function Profile() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-lg mx-auto">
|
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-xl mx-auto">
|
||||||
<UserNav active="profile" />
|
<UserNav active="profile" />
|
||||||
|
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
@@ -180,6 +199,8 @@ export default function Profile() {
|
|||||||
value={pwForm[name]}
|
value={pwForm[name]}
|
||||||
onChange={handlePwChange}
|
onChange={handlePwChange}
|
||||||
required
|
required
|
||||||
|
autoComplete={name === 'currentPassword' ? 'current-password' : 'new-password'}
|
||||||
|
aria-describedby={name === 'newPassword' ? 'profile-password-rules' : undefined}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-11 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-11 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||||
/>
|
/>
|
||||||
@@ -192,6 +213,9 @@ export default function Profile() {
|
|||||||
<EyeIcon visible={showPw[key]} />
|
<EyeIcon visible={showPw[key]} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{name === 'newPassword' && (
|
||||||
|
<PasswordRules id="profile-password-rules" password={pwForm.newPassword} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -205,6 +229,30 @@ export default function Profile() {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Séparateur */}
|
||||||
|
<div className="border-t border-gray-100 my-6" />
|
||||||
|
|
||||||
|
{/* Section suppression du compte */}
|
||||||
|
<div ref={deleteSectionRef} id="suppression-compte">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-800 mb-4">Supprimer mon compte</h2>
|
||||||
|
|
||||||
|
<div className="px-4 py-4 rounded-lg bg-gray-50 border border-gray-200">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Pour des raisons de sécurité, la suppression de compte n'est pas disponible en
|
||||||
|
libre-service. Envoyez-nous un email depuis votre adresse enregistrée
|
||||||
|
({user.email || '—'}) et notre équipe supprimera votre compte, vos participations et
|
||||||
|
vos gains non retirés.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="mailto:thetiptop.gp4@gmail.com?subject=Suppression%20de%20mon%20compte"
|
||||||
|
className="mt-3 inline-flex items-center justify-center bg-[#006B6B] hover:bg-[#005959] text-white font-medium px-4 py-2.5 rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Demander la suppression par email
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
|
|
||||||
export default function ReglesJeu() {
|
export default function ReglesJeu() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const isLoggedIn = !!user
|
const isLoggedIn = !!user
|
||||||
|
|
||||||
|
usePageMeta({
|
||||||
|
title: 'Règles du jeu-concours 100 % Gagnant | Thé Tip Top',
|
||||||
|
description: "Comment participer au jeu-concours Thé Tip Top : obtenir un code, créer un compte, découvrir votre lot et tenter de remporter le grand gagnant.",
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-50 min-h-screen">
|
<div className="bg-gray-50 min-h-screen">
|
||||||
|
|
||||||
@@ -15,7 +21,7 @@ export default function ReglesJeu() {
|
|||||||
Règles du jeu
|
Règles du jeu
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-base sm:text-xl text-gray-600">
|
<p className="text-base sm:text-xl text-gray-600">
|
||||||
Participez au jeu-concours ThéTipTop et découvrez immédiatement
|
Jeu-concours réservé à notre boutique de Nice : participez et découvrez immédiatement
|
||||||
le lot que vous avez gagné.
|
le lot que vous avez gagné.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,7 +37,7 @@ export default function ReglesJeu() {
|
|||||||
{
|
{
|
||||||
num: '1',
|
num: '1',
|
||||||
title: 'Obtenez un code',
|
title: 'Obtenez un code',
|
||||||
text: "Achetez un produit ThéTipTop d'une valeur supérieure à 49€ en ligne ou en magasin pour recevoir un code unique à 10 chiffres.",
|
text: "Achetez un produit ThéTipTop d'une valeur supérieure à 49€ dans notre boutique de Nice pour recevoir un code unique à 10 chiffres.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
num: '2',
|
num: '2',
|
||||||
@@ -59,17 +65,39 @@ export default function ReglesJeu() {
|
|||||||
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-4 sm:mb-6 text-center">
|
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-4 sm:mb-6 text-center">
|
||||||
Que pouvez-vous gagner ?
|
Que pouvez-vous gagner ?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 text-base sm:text-lg text-center leading-relaxed mb-6 sm:mb-8">
|
<p className="text-gray-600 text-base sm:text-lg text-center leading-relaxed mb-4 sm:mb-6">
|
||||||
Tous les billets sont gagnants ! Les récompenses peuvent aller
|
Tous les billets sont gagnants ! Selon votre ticket, vous remportez :
|
||||||
d'un infuseur à thé à un coffret découverte d'une valeur de 69€,
|
|
||||||
en passant par des thés détox et des thés signatures.
|
|
||||||
</p>
|
</p>
|
||||||
|
<ul className="text-gray-700 text-sm sm:text-base leading-relaxed space-y-2 max-w-md mx-auto mb-6 sm:mb-8">
|
||||||
|
{[
|
||||||
|
{ pct: '60 %', text: 'un infuseur à thé' },
|
||||||
|
{ pct: '20 %', text: "une boîte de 100 g de thé détox ou d'infusion" },
|
||||||
|
{ pct: '10 %', text: 'une boîte de 100 g de thé signature' },
|
||||||
|
{ pct: '6 %', text: "un coffret découverte d'une valeur de 39 €" },
|
||||||
|
{ pct: '4 %', text: "un coffret découverte d'une valeur de 69 €" },
|
||||||
|
].map((lot) => (
|
||||||
|
<li key={lot.pct} className="flex gap-2">
|
||||||
|
<span className="font-semibold text-[#006B6B] flex-shrink-0">{lot.pct}</span>
|
||||||
|
<span className="min-w-0 flex-1">des tickets : {lot.text}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
<div className="bg-gray-100 rounded-xl p-4 sm:p-6 text-center">
|
<div className="bg-gray-100 rounded-xl p-4 sm:p-6 text-center">
|
||||||
<h3 className="text-xl sm:text-2xl font-bold text-[#006B6B] mb-2">Grand gagnant</h3>
|
<h3 className="text-xl sm:text-2xl font-bold text-[#006B6B] mb-2">Grand gagnant</h3>
|
||||||
<p className="text-gray-700 text-sm sm:text-base">
|
<p className="text-gray-700 text-sm sm:text-base">
|
||||||
Le participant tiré au sort remportera une année de thé d'une valeur de 360€ !
|
Un tirage au sort, effectué sous le contrôle de Maître Arnaud Rick, Commissaire de
|
||||||
|
Justice, désignera le gagnant d'une année de thé d'une valeur de 360€ !
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center mt-6 sm:mt-8">
|
||||||
|
<a
|
||||||
|
href="/reglement-jeu-concours-100-gagnant.pdf"
|
||||||
|
className="inline-flex items-center justify-center gap-2 border border-[#006B6B] text-[#006B6B] hover:bg-[#006B6B] hover:text-white font-semibold px-6 py-3 rounded-xl transition-colors text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Télécharger le règlement (PDF)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -80,7 +108,7 @@ export default function ReglesJeu() {
|
|||||||
Ne manquez pas votre chance !
|
Ne manquez pas votre chance !
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-6 sm:mb-8 text-base sm:text-lg">
|
<p className="mb-6 sm:mb-8 text-base sm:text-lg">
|
||||||
Inscrivez-vous, entrez votre code de ticket et retirez votre lot en magasin ou en ligne.
|
Inscrivez-vous, entrez votre code de ticket et retirez votre lot dans notre boutique de Nice.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row justify-center gap-3 sm:gap-4">
|
<div className="flex flex-col sm:flex-row justify-center gap-3 sm:gap-4">
|
||||||
{!isLoggedIn && (
|
{!isLoggedIn && (
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function SaisirCodeUnique() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-lg mx-auto">
|
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-xl mx-auto">
|
||||||
<UserNav active="code" />
|
<UserNav active="code" />
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useAuth} from '../context/AuthContext'
|
import { useAuth} from '../context/AuthContext'
|
||||||
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
|
|
||||||
const sections = [
|
const sections = [
|
||||||
{
|
{
|
||||||
title: 'Navigation principale',
|
title: 'Navigation principale',
|
||||||
links: [
|
links: [
|
||||||
{ label: 'Accueil', to: '/accueil', desc: "Page d'accueil du jeu-concours" },
|
{ label: 'Accueil', to: '/accueil', desc: "Page d'accueil du jeu-concours" },
|
||||||
|
{ label: 'Notre concept', to: '/notre-concept', desc: 'Pourquoi un jeu-concours sans perdant' },
|
||||||
{ label: 'Règles du jeu', to: '/regles-jeu', desc: 'Comment participer et que gagner' },
|
{ label: 'Règles du jeu', to: '/regles-jeu', desc: 'Comment participer et que gagner' },
|
||||||
{ label: 'Contact', to: '/contact', desc: 'Nous contacter' },
|
{ label: 'Contact', to: '/contact', desc: 'Nous contacter' },
|
||||||
],
|
],
|
||||||
@@ -44,6 +46,11 @@ const sections = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function Sitemap() {
|
export default function Sitemap() {
|
||||||
|
usePageMeta({
|
||||||
|
title: 'Plan du site | Thé Tip Top',
|
||||||
|
description: "Retrouvez toutes les pages du site Thé Tip Top : jeu-concours, compte participant, informations légales et espaces professionnels.",
|
||||||
|
})
|
||||||
|
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const isAdmin = user?.role === 'ADMIN'
|
const isAdmin = user?.role === 'ADMIN'
|
||||||
const visibleSections = sections.filter(
|
const visibleSections = sections.filter(
|
||||||
|
|||||||
@@ -59,3 +59,38 @@ export async function setGameSettingsApi(startDate) {
|
|||||||
if (!res.ok) throw apiError(data.message || 'Erreur configuration du jeu.', res.status)
|
if (!res.ok) throw apiError(data.message || 'Erreur configuration du jeu.', res.status)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function getEmployeesApi() {
|
||||||
|
const res = await fetch(`${BASE}/employees`, { headers: authHeaders() })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur lors du chargement des employés.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEmployeeDeliveriesApi(employeeId) {
|
||||||
|
const res = await fetch(`${BASE}/employees/${employeeId}/deliveries`, { headers: authHeaders() })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur lors du chargement des remises.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDeliveryByTicketCodeApi(code) {
|
||||||
|
const res = await fetch(`${BASE}/deliveries/${encodeURIComponent(code.trim())}`, { headers: authHeaders() })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur lors de la recherche du ticket.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pas de mot de passe : le serveur en génère un provisoire et l'envoie par
|
||||||
|
// email à l'employé.
|
||||||
|
export async function createEmployeeApi({ firstname, lastname, email }) {
|
||||||
|
const res = await fetch(`${BASE}/employees`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: JSON.stringify({ firstname, lastname, email }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || "Erreur lors de la création de l'employé.", res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ function apiError(message, status) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginApi({ email, password }) {
|
export async function loginApi({ email, password, captchaToken }) {
|
||||||
const res = await fetch(`${BASE}/login`, {
|
const res = await fetch(`${BASE}/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password, captchaToken: captchaToken || undefined }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw apiError(data.message || 'Erreur de connexion.', res.status)
|
if (!res.ok) throw apiError(data.message || 'Erreur de connexion.', res.status)
|
||||||
@@ -23,9 +23,11 @@ export async function registerApi({
|
|||||||
firstname,
|
firstname,
|
||||||
lastname,
|
lastname,
|
||||||
password,
|
password,
|
||||||
|
confirmPassword,
|
||||||
birthdate,
|
birthdate,
|
||||||
gender,
|
gender,
|
||||||
marketingOptIn,
|
marketingOptIn,
|
||||||
|
captchaToken,
|
||||||
}) {
|
}) {
|
||||||
const res = await fetch(`${BASE}/register`, {
|
const res = await fetch(`${BASE}/register`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -37,13 +39,21 @@ export async function registerApi({
|
|||||||
firstname,
|
firstname,
|
||||||
lastname,
|
lastname,
|
||||||
password,
|
password,
|
||||||
|
confirmPassword,
|
||||||
birthdate: birthdate || undefined,
|
birthdate: birthdate || undefined,
|
||||||
gender: gender || undefined,
|
gender: gender || undefined,
|
||||||
marketingOptIn: marketingOptIn ?? undefined,
|
marketingOptIn: marketingOptIn ?? undefined,
|
||||||
|
captchaToken: captchaToken || undefined,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw apiError(data.message || "Erreur lors de l'inscription.", res.status)
|
if (!res.ok) {
|
||||||
|
// Le backend renvoie un message générique ("Données invalides") avec le
|
||||||
|
// détail par champ dans `errors` : on préfère afficher ce détail quand il
|
||||||
|
// existe (ex. l'âge minimum requis pour la date de naissance).
|
||||||
|
const fieldMessage = Object.values(data.errors || {})[0]?.[0]
|
||||||
|
throw apiError(fieldMessage || data.message || "Erreur lors de l'inscription.", res.status)
|
||||||
|
}
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,11 +68,23 @@ export async function verifyCodeApi({ email, code }) {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function forgotPasswordApi(email) {
|
export async function forgotPasswordApi({ email, captchaToken }) {
|
||||||
const res = await fetch(`${BASE}/forgot-password`, {
|
const res = await fetch(`${BASE}/forgot-password`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify({ email, captchaToken: captchaToken || undefined }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || "Erreur lors de l'envoi du lien.", res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utilisateur connecté : lien envoyé à l'adresse de son compte.
|
||||||
|
export async function sendPasswordSetupLinkApi() {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const res = await fetch(`${BASE}/password-setup-link`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw apiError(data.message || "Erreur lors de l'envoi du lien.", res.status)
|
if (!res.ok) throw apiError(data.message || "Erreur lors de l'envoi du lien.", res.status)
|
||||||
@@ -101,6 +123,21 @@ export async function deleteAccountApi() {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setInitialPasswordApi({ password, confirmPassword }) {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const res = await fetch(`${BASE}/initial-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ password, confirmPassword }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || "Erreur lors de l'enregistrement du mot de passe.", res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export async function changePasswordApi({ currentPassword, newPassword }) {
|
export async function changePasswordApi({ currentPassword, newPassword }) {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
const res = await fetch(`${BASE}/change-password`, {
|
const res = await fetch(`${BASE}/change-password`, {
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ function apiError(message, status) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Public : dates du jeu-concours fixées par l'admin.
|
||||||
|
export async function getGameWindowApi() {
|
||||||
|
const res = await fetch(`${BASE}/window`)
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur lors de la lecture des dates du jeu.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export async function myGainsApi() {
|
export async function myGainsApi() {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
const res = await fetch(`${BASE}/my-gains`, {
|
const res = await fetch(`${BASE}/my-gains`, {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import '@testing-library/jest-dom'
|
|||||||
import { afterEach } from 'vitest'
|
import { afterEach } from 'vitest'
|
||||||
import { cleanup } from '@testing-library/react'
|
import { cleanup } from '@testing-library/react'
|
||||||
|
|
||||||
|
// jsdom n'implémente pas le défilement, présent dans tous les navigateurs.
|
||||||
|
Element.prototype.scrollIntoView ??= function scrollIntoView() {}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup()
|
cleanup()
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
|
|||||||
43
frontend/src/test/fonctionnel/Accueil.test.jsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { screen } from '@testing-library/react'
|
||||||
|
import Accueil from '../../pages/Accueil'
|
||||||
|
import { getGameWindowApi } from '../../services/game'
|
||||||
|
import { renderWithProviders } from '../renderWithProviders'
|
||||||
|
|
||||||
|
vi.mock('../../services/game', () => ({
|
||||||
|
getGameWindowApi: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('Accueil - dates du jeu-concours', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it("affiche les dates fixées dans le tableau de bord admin", async () => {
|
||||||
|
getGameWindowApi.mockResolvedValue({
|
||||||
|
configured: true,
|
||||||
|
phase: 'SCHEDULED',
|
||||||
|
startDate: '2026-11-14T23:00:00.000Z',
|
||||||
|
participationDeadline: '2026-12-14T23:00:00.000Z',
|
||||||
|
claimDeadline: '2027-01-13T23:00:00.000Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders(<Accueil />, { route: '/accueil' })
|
||||||
|
|
||||||
|
expect(await screen.findByText('Du 15 novembre au 14 décembre 2026')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/ouverte du 15 novembre au 14 décembre 2026 inclus/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/retirer jusqu'au 13 janvier 2027/)).toBeInTheDocument()
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.title).toBe('Jeu-concours 100 % Gagnant du 15 novembre au 14 décembre | Thé Tip Top')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("affiche les dates par défaut (2 → 31 octobre 2026) si l'API ne répond pas", async () => {
|
||||||
|
getGameWindowApi.mockRejectedValue(new Error('réseau'))
|
||||||
|
|
||||||
|
renderWithProviders(<Accueil />, { route: '/accueil' })
|
||||||
|
|
||||||
|
expect(screen.getByText('Du 02 au 31 octobre 2026')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/retirer jusqu'au 30 novembre 2026/)).toBeInTheDocument()
|
||||||
|
await vi.waitFor(() => expect(getGameWindowApi).toHaveBeenCalled())
|
||||||
|
expect(screen.getByText('Du 02 au 31 octobre 2026')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -42,7 +42,8 @@ describe('Connexion', () => {
|
|||||||
await userEvent.click(screen.getByRole('button', { name: /se connecter/i }))
|
await userEvent.click(screen.getByRole('button', { name: /se connecter/i }))
|
||||||
|
|
||||||
expect(await screen.findByText('Email ou mot de passe incorrect.')).toBeInTheDocument()
|
expect(await screen.findByText('Email ou mot de passe incorrect.')).toBeInTheDocument()
|
||||||
expect(loginApi).toHaveBeenCalledWith({ email: 'test@example.com', password: 'mauvais-mdp' })
|
// captchaToken vide : aucun widget Turnstile en test (voir vite.config.js).
|
||||||
|
expect(loginApi).toHaveBeenCalledWith({ email: 'test@example.com', password: 'mauvais-mdp', captchaToken: '' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('connecte un client et redirige vers /mes-gains', async () => {
|
it('connecte un client et redirige vers /mes-gains', async () => {
|
||||||
|
|||||||
56
frontend/src/test/fonctionnel/DefinirMotDePasse.test.jsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import DefinirMotDePasse from '../../pages/DefinirMotDePasse'
|
||||||
|
import { resetPasswordApi } from '../../services/auth'
|
||||||
|
import { renderWithProviders } from '../renderWithProviders'
|
||||||
|
|
||||||
|
vi.mock('../../services/auth', () => ({
|
||||||
|
resetPasswordApi: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
async function remplir(password, confirmPassword = password) {
|
||||||
|
await userEvent.type(screen.getByLabelText(/^nouveau mot de passe/i), password)
|
||||||
|
await userEvent.type(screen.getByLabelText(/^confirmer/i), confirmPassword)
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /enregistrer/i }))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DefinirMotDePasse', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('affiche les règles du mot de passe', () => {
|
||||||
|
const { container } = renderWithProviders(<DefinirMotDePasse />, { route: '/definir-mot-de-passe/abc' })
|
||||||
|
|
||||||
|
expect(container.querySelector('[data-rule="length"]')).toBeInTheDocument()
|
||||||
|
expect(container.querySelector('[data-rule="special"]')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("refuse un mot de passe sans caractère spécial sans appeler l'API", async () => {
|
||||||
|
renderWithProviders(<DefinirMotDePasse />, { route: '/definir-mot-de-passe/abc' })
|
||||||
|
|
||||||
|
await remplir('MonMotDePasse1')
|
||||||
|
|
||||||
|
expect(screen.getByText(/dont un caractère spécial/i)).toBeInTheDocument()
|
||||||
|
expect(resetPasswordApi).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('propose de demander un nouveau lien quand celui-ci a expiré', async () => {
|
||||||
|
resetPasswordApi.mockRejectedValue(new Error('Lien de réinitialisation invalide ou expiré.'))
|
||||||
|
renderWithProviders(<DefinirMotDePasse />, { route: '/definir-mot-de-passe/abc' })
|
||||||
|
|
||||||
|
await remplir('MonMotDePasse1!')
|
||||||
|
|
||||||
|
expect(await screen.findByRole('link', { name: /demander un nouveau lien/i })).toHaveAttribute('href', '/mot-de-passe-oublie')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('envoie un mot de passe conforme', async () => {
|
||||||
|
resetPasswordApi.mockResolvedValue({ message: 'ok' })
|
||||||
|
renderWithProviders(<DefinirMotDePasse />, { route: '/definir-mot-de-passe/abc' })
|
||||||
|
|
||||||
|
await remplir('MonMotDePasse1!')
|
||||||
|
|
||||||
|
expect(resetPasswordApi).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ password: 'MonMotDePasse1!', confirmPassword: 'MonMotDePasse1!' })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||