diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..afd0660 Binary files /dev/null and b/.DS_Store differ diff --git a/backend/src/routes/employee.routes.js b/backend/src/routes/employee.routes.js index c5b203c..0665679 100644 --- a/backend/src/routes/employee.routes.js +++ b/backend/src/routes/employee.routes.js @@ -36,7 +36,7 @@ const router = express.Router(); router.get( "/ticket/:code", authMiddleware, - roleMiddleware("EMPLOYEE", "ADMIN"), + roleMiddleware("EMPLOYEE"), getGainByTicketCode ); @@ -69,7 +69,7 @@ router.get( router.patch( "/claim/:id/deliver", authMiddleware, - roleMiddleware("EMPLOYEE", "ADMIN"), + roleMiddleware("EMPLOYEE"), deliverClaim ); diff --git a/frontend/Dockerfile b/frontend/Dockerfile index f5e98df..f5f20fa 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,9 +1,10 @@ -FROM node:22-alpine AS build +FROM node:22-slim AS build WORKDIR /app COPY package*.json ./ RUN npm ci +RUN npx playwright install --with-deps chromium COPY . . diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index ea36dd3..444d863 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -8,6 +8,7 @@ export default defineConfig([ globalIgnores(['dist']), { files: ['**/*.{js,jsx}'], + ignores: ['scripts/**'], extends: [ js.configs.recommended, reactHooks.configs.flat.recommended, @@ -18,4 +19,13 @@ export default defineConfig([ 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, + }, + }, ]) diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 3aa17e6..5d8110f 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -6,6 +6,12 @@ server { index index.html; 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; } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 167614b..3da5c2b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -26,6 +26,7 @@ "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", "jsdom": "^29.1.1", + "playwright": "^1.62.1", "tailwindcss": "^4.3.0", "vite": "^8.0.12", "vitest": "^4.1.10" @@ -120,7 +121,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -441,7 +441,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -490,11 +489,33 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", @@ -1442,7 +1463,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/chai": { "version": "5.2.3", @@ -1489,7 +1511,6 @@ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1500,7 +1521,6 @@ "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -1650,7 +1670,6 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1691,6 +1710,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -1701,6 +1721,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -1794,7 +1815,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -1974,7 +1994,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/electron-to-chromium": { "version": "1.5.408", @@ -2046,7 +2067,6 @@ "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", "dev": true, "license": "MIT", - "peer": true, "workspaces": [ "packages/*" ], @@ -2923,6 +2943,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -3130,7 +3151,6 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3138,6 +3158,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -3183,6 +3250,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -3207,7 +3275,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3217,7 +3284,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3230,7 +3296,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-router": { "version": "7.18.2", @@ -3625,7 +3692,6 @@ "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", @@ -4183,7 +4249,6 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index 5fc8338..be58ffb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build", + "build": "vite build && node scripts/prerender.js", + "prerender": "node scripts/prerender.js", "lint": "eslint .", "preview": "vite preview", "test": "vitest run", @@ -30,6 +31,7 @@ "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", "jsdom": "^29.1.1", + "playwright": "^1.62.1", "tailwindcss": "^4.3.0", "vite": "^8.0.12", "vitest": "^4.1.10" diff --git a/frontend/scripts/prerender.js b/frontend/scripts/prerender.js new file mode 100644 index 0000000..3a8963b --- /dev/null +++ b/frontend/scripts/prerender.js @@ -0,0 +1,54 @@ +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() + 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'), `\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) +}) diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 9e40799..43380c7 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -1,6 +1,5 @@ import { useState, useEffect } from 'react' -import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi } from '../services/admin' -import { searchTicketApi, deliverClaimApi } from '../services/employee' +import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi, createEmployeeApi } from '../services/admin' import { useHandleAuthError } from '../hooks/useHandleAuthError' function StatCard({ label, value, sub }) { @@ -63,20 +62,17 @@ export default function Admin() { const [settingsError, setSettingsError] = 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 const [emailingContacts, setEmailingContacts] = useState(null) const [emailingLoading, setEmailingLoading] = useState(false) const [emailingError, setEmailingError] = useState('') + // Création d'un compte employé + const [employeeForm, setEmployeeForm] = useState({ firstname: '', lastname: '', email: '', password: '' }) + const [employeeSaving, setEmployeeSaving] = useState(false) + const [employeeError, setEmployeeError] = useState('') + const [employeeOk, setEmployeeOk] = useState('') + useEffect(() => { Promise.all([getStatsApi(), getUsersApi(), getWinnerApi()]) .then(([s, u, w]) => { @@ -154,43 +150,19 @@ export default function Admin() { URL.revokeObjectURL(url) } - const handleTicketSearch = async (e) => { + const handleCreateEmployee = async (e) => { e.preventDefault() - setTicketResult(null) - setTicketSearchErr('') - setTicketDeliverErr('') - setTicketSuccess(false) - setTicketSearching(true) + setEmployeeError('') + setEmployeeOk('') + setEmployeeSaving(true) try { - const data = await searchTicketApi(ticketCode.trim()) - setTicketResult(data) + const data = await createEmployeeApi(employeeForm) + setEmployeeOk(`Compte employé créé pour ${data.employee.firstname} ${data.employee.lastname} (${data.employee.email}).`) + setEmployeeForm({ firstname: '', lastname: '', email: '', password: '' }) } catch (err) { - if (!handleAuthError(err)) setTicketSearchErr(err.message) + if (!handleAuthError(err)) setEmployeeError(err.message) } finally { - setTicketSearching(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) + setEmployeeSaving(false) } } @@ -415,93 +387,73 @@ export default function Admin() { )} - {/* ── Remise de lot ── */} + {/* ── Création d'un compte employé ── */}
-

Remise de lot

+

Créer un compte employé

+

+ Donne accès à l'espace employé (remise des lots en boutique). L'admin ne gère pas les remises lui-même. +

-
- - setTicketCode(e.target.value.toUpperCase())} - placeholder="Code ticket (10 caractères)" - maxLength={10} - 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]" - /> - -
- - {ticketSearchErr && ( -

{ticketSearchErr}

- )} - - {ticketResult && ( -
- {ticketSuccess && ( -
- - - -

Lot remis avec succès.

-
- )} - -
-
-

{ticketResult.lot.name}

-

{ticketResult.lot.description}

- {ticketResult.lot.value &&

{ticketResult.lot.value} €

} -
- - {ticketResult.claim?.status === 'DELIVERED' ? 'Remis' - : ticketResult.claim?.status === 'CANCELLED' ? 'Expiré' - : 'À remettre'} - -
- -
-
- {(ticketResult.client.firstname?.[0] ?? '?').toUpperCase()}{(ticketResult.client.lastname?.[0] ?? '?').toUpperCase()} -
-
-

{ticketResult.client.firstname} {ticketResult.client.lastname}

-

{ticketResult.client.email}

-
-
- -
- {ticketDeliverErr &&

{ticketDeliverErr}

} - - {ticketResult.claim?.status === 'PENDING' && !ticketSuccess && ( - - )} - -
+
+
+ + setEmployeeForm({ ...employeeForm, firstname: 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]" + />
- )} +
+ + 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]" + /> +
+
+ + 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]" + /> +
+
+ + setEmployeeForm({ ...employeeForm, password: e.target.value })} + required + minLength={8} + placeholder="8 caractères minimum" + 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]" + /> +
+ +
+ + {employeeError &&

{employeeError}

} + {employeeOk &&

{employeeOk}

} +
+
{/* ── Tirage au sort ── */} diff --git a/frontend/src/pages/AuthCallback.jsx b/frontend/src/pages/AuthCallback.jsx index 574b4ae..3cb997f 100644 --- a/frontend/src/pages/AuthCallback.jsx +++ b/frontend/src/pages/AuthCallback.jsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef } from 'react' import { useNavigate, useSearchParams, Link } from 'react-router-dom' import { useAuth } from '../context/AuthContext' @@ -8,39 +8,40 @@ export default function AuthCallback() { const navigate = useNavigate() const [searchParams] = useSearchParams() const { login } = useAuth() - const [error, setError] = useState('') // StrictMode monte deux fois en dev : on ne traite le token qu'une seule fois. const handled = useRef(false) + // L'erreur (s'il y en a une) est entièrement dérivée de l'URL : pas besoin + // d'un state ni d'un effet pour la calculer, juste pour l'action à usage + // unique (login + redirection) une fois l'utilisateur décodé avec succès. + 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 { + user = JSON.parse(atob(encodedUser)) + } catch { + error = 'Réponse de connexion illisible.' + } + } + useEffect(() => { - if (handled.current) return + if (handled.current || !user) return handled.current = true - const errorParam = searchParams.get('error') - if (errorParam) { - setError(errorParam) - return - } + login(user, token) - const token = searchParams.get('token') - const encodedUser = searchParams.get('user') - - if (!token || !encodedUser) { - setError('Réponse de connexion incomplète.') - 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 { - setError('Réponse de connexion illisible.') - } - }, [searchParams, login, navigate]) + if (user.role === 'ADMIN') navigate('/admin', { replace: true }) + else if (user.role === 'EMPLOYEE') navigate('/employe', { replace: true }) + else navigate('/mes-gains', { replace: true }) + }, [user, token, login, navigate]) if (error) { return ( diff --git a/frontend/src/pages/Profile.jsx b/frontend/src/pages/Profile.jsx index f54c3e0..acfe59f 100644 --- a/frontend/src/pages/Profile.jsx +++ b/frontend/src/pages/Profile.jsx @@ -41,7 +41,7 @@ export default function Profile() { .catch((e) => { if (!handleAuthError(e)) setHasPassword(true) }) - }, []) + }, [handleAuthError]) const handleSendSetupLink = async () => { setLinkError('') diff --git a/frontend/src/services/admin.js b/frontend/src/services/admin.js index 2e6a3ba..1228ce3 100644 --- a/frontend/src/services/admin.js +++ b/frontend/src/services/admin.js @@ -60,3 +60,14 @@ export async function getWinnerApi() { if (!res.ok) throw apiError(data.message || 'Erreur gagnant.', res.status) return data } + +export async function createEmployeeApi({ firstname, lastname, email, password }) { + const res = await fetch(`${BASE}/employees`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ firstname, lastname, email, password }), + }) + 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 +}