From bbd9afe8aa0f3e2cb27dc9ca055c88d9e2a3d3ff Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 29 Aug 2026 17:02:29 +0200 Subject: [PATCH 1/3] refactor(admin): fusionne apps/admin-web dans apps/web sous /admin/* L'admin etait une 2e app front Vite independante (apps/admin-web, port 5174, Dockerfile nginx, service compose dedie, job CI propre) non demandee. Toute l'UI passe dans apps/web sous le prefixe /admin ; seul le frontend est fusionne, l'authentification admin reste entierement separee. Front (apps/web/src) : - pages -> pages/admin/{login,dashboard,monitoring,corrections,catalog}/, layout -> layouts/AdminLayout.tsx, contexte + garde -> features/admin/. - client API -> api/admin-client.ts : classe AdminApiError (evite la collision avec ApiError), lit VITE_API_URL (plus de VITE_ADMIN_API_URL). - routes /admin/* dans App.tsx, enveloppees d'AdminAuthProvider + RequireAdmin -> le probe GET /admin/auth/me ne tourne que sous /admin. - reutilise l'i18n, lib/zod-errors, services/error-message.service et le theme SCSS de apps/web ; bloc i18n admin.* fusionne dans la locale fr (les cles errors etaient deja toutes presentes). - corrige une race dans CatalogPage (reponse d'un onglet precedent qui ecrasait l'onglet courant, exposee par le double-mount StrictMode) via un ref requestSeq. Auth admin inchangee : table AdminUser, cookie admin_session, ADMIN_JWT_SECRET, script create-admin.ts. Infra : - docker-compose : service admin-web + ADMIN_WEB_PORT supprimes (l'app `app` sert deja le front construit). - ADMIN_CORS_ORIGIN retire (meme origine) : env.ts, app.ts, .env.example. - job CI "Run admin-web E2E tests" supprime ; les specs admin-* tournent dans le job web (apps/web/cypress/e2e/admin-*.{cy.ts,feature}). - apps/api/.env.example : ajout ADMIN_JWT_SECRET / ADMIN_INITIAL_*. - recharts ajoute a apps/web ; pnpm-lock regenere. - specs/backend-architecture.md : section admin mise a jour. Verifie : biome + tsc -b (web/api) + pnpm -r build verts ; Cypress web 102/103 (l'unique echec est le flake pre-existant recipe-form.feature "Preloads ..." de clipping headless, sans rapport) ; 16/16 specs admin ; 45/45 composants. Co-Authored-By: Claude Sonnet 5 --- .env.example | 11 +- .github/workflows/ci.yml | 7 - apps/admin-web/.env.example | 6 - apps/admin-web/Dockerfile | 25 --- apps/admin-web/cypress.config.ts | 28 --- apps/admin-web/cypress/e2e/login.ts | 24 --- apps/admin-web/cypress/support/e2e.ts | 3 - .../support/step_definitions/common.steps.ts | 54 ------ apps/admin-web/index.html | 12 -- apps/admin-web/nginx.conf | 20 -- apps/admin-web/package.json | 42 ----- apps/admin-web/src/App.tsx | 36 ---- apps/admin-web/src/i18n/i18n.ts | 22 --- apps/admin-web/src/lib/zod-errors.ts | 18 -- .../admin-web/src/locales/fr/translation.json | 148 --------------- apps/admin-web/src/main.tsx | 24 --- .../src/services/error-message.service.ts | 19 -- apps/admin-web/src/styles/_theme.scss | 171 ------------------ apps/admin-web/src/styles/global.scss | 149 --------------- apps/admin-web/src/vite-env.d.ts | 5 - apps/admin-web/tsconfig.app.json | 14 -- apps/admin-web/tsconfig.json | 8 - apps/admin-web/tsconfig.node.json | 12 -- apps/admin-web/vite.config.ts | 19 -- apps/api/.env.example | 10 + apps/api/src/app.ts | 14 +- apps/api/src/config/env.ts | 2 - apps/api/src/middlewares/require-admin.ts | 4 +- apps/api/src/modules/admin/admin.routes.ts | 4 +- .../cypress/e2e/admin-catalog.cy.ts} | 13 +- .../cypress/e2e/admin-corrections.cy.ts} | 8 +- .../cypress/e2e/admin-dashboard.cy.ts} | 4 +- .../cypress/e2e/admin-layout.cy.ts | 18 +- .../cypress/e2e/admin-login.feature} | 8 +- apps/web/cypress/e2e/admin-login.ts | 35 ++++ .../cypress/e2e/admin-monitoring.cy.ts} | 13 +- apps/web/package.json | 1 + apps/web/src/App.tsx | 41 ++++- .../client.ts => web/src/api/admin-client.ts} | 26 +-- .../src/features/admin}/AdminAuthContext.tsx | 2 +- .../src/features/admin}/RequireAdmin.tsx | 7 +- .../src/features/admin}/admin-auth.scss | 0 .../src/layouts/AdminLayout.scss | 0 .../src/layouts/AdminLayout.tsx | 16 +- apps/web/src/locales/fr/translation.json | 137 ++++++++++++++ .../src/pages/admin}/admin-page.scss | 0 .../src/pages/admin}/catalog/CatalogPage.tsx | 18 +- .../pages/admin}/catalog/catalog-page.scss | 0 .../src/pages/admin}/catalog/catalog.ts | 0 .../admin}/corrections/CorrectionsPage.tsx | 16 +- .../admin}/corrections/corrections-page.scss | 0 .../pages/admin}/corrections/corrections.ts | 0 .../pages/admin}/dashboard/DashboardPage.tsx | 2 +- .../admin}/dashboard/dashboard-page.scss | 0 .../src/pages/admin}/dashboard/dashboard.ts | 0 .../src/pages/admin/login/AdminLoginPage.tsx} | 26 +-- .../admin}/monitoring/MonitoringPage.tsx | 2 +- .../admin}/monitoring/monitoring-page.scss | 0 .../src/pages/admin}/monitoring/monitoring.ts | 0 docker-compose.yml | 31 +--- pnpm-lock.yaml | 76 +------- specs/backend-architecture.md | 13 +- 62 files changed, 355 insertions(+), 1069 deletions(-) delete mode 100644 apps/admin-web/.env.example delete mode 100644 apps/admin-web/Dockerfile delete mode 100644 apps/admin-web/cypress.config.ts delete mode 100644 apps/admin-web/cypress/e2e/login.ts delete mode 100644 apps/admin-web/cypress/support/e2e.ts delete mode 100644 apps/admin-web/cypress/support/step_definitions/common.steps.ts delete mode 100644 apps/admin-web/index.html delete mode 100644 apps/admin-web/nginx.conf delete mode 100644 apps/admin-web/package.json delete mode 100644 apps/admin-web/src/App.tsx delete mode 100644 apps/admin-web/src/i18n/i18n.ts delete mode 100644 apps/admin-web/src/lib/zod-errors.ts delete mode 100644 apps/admin-web/src/locales/fr/translation.json delete mode 100644 apps/admin-web/src/main.tsx delete mode 100644 apps/admin-web/src/services/error-message.service.ts delete mode 100644 apps/admin-web/src/styles/_theme.scss delete mode 100644 apps/admin-web/src/styles/global.scss delete mode 100644 apps/admin-web/src/vite-env.d.ts delete mode 100644 apps/admin-web/tsconfig.app.json delete mode 100644 apps/admin-web/tsconfig.json delete mode 100644 apps/admin-web/tsconfig.node.json delete mode 100644 apps/admin-web/vite.config.ts rename apps/{admin-web/cypress/e2e/catalog.cy.ts => web/cypress/e2e/admin-catalog.cy.ts} (86%) rename apps/{admin-web/cypress/e2e/corrections.cy.ts => web/cypress/e2e/admin-corrections.cy.ts} (96%) rename apps/{admin-web/cypress/e2e/dashboard.cy.ts => web/cypress/e2e/admin-dashboard.cy.ts} (98%) rename apps/{admin-web => web}/cypress/e2e/admin-layout.cy.ts (83%) rename apps/{admin-web/cypress/e2e/login.feature => web/cypress/e2e/admin-login.feature} (84%) create mode 100644 apps/web/cypress/e2e/admin-login.ts rename apps/{admin-web/cypress/e2e/monitoring.cy.ts => web/cypress/e2e/admin-monitoring.cy.ts} (88%) rename apps/{admin-web/src/api/client.ts => web/src/api/admin-client.ts} (88%) rename apps/{admin-web/src/features/auth => web/src/features/admin}/AdminAuthContext.tsx (97%) rename apps/{admin-web/src/features/auth => web/src/features/admin}/RequireAdmin.tsx (66%) rename apps/{admin-web/src/features/auth => web/src/features/admin}/admin-auth.scss (100%) rename apps/{admin-web => web}/src/layouts/AdminLayout.scss (100%) rename apps/{admin-web => web}/src/layouts/AdminLayout.tsx (81%) rename apps/{admin-web/src/pages => web/src/pages/admin}/admin-page.scss (100%) rename apps/{admin-web/src/pages => web/src/pages/admin}/catalog/CatalogPage.tsx (89%) rename apps/{admin-web/src/pages => web/src/pages/admin}/catalog/catalog-page.scss (100%) rename apps/{admin-web/src/pages => web/src/pages/admin}/catalog/catalog.ts (100%) rename apps/{admin-web/src/pages => web/src/pages/admin}/corrections/CorrectionsPage.tsx (96%) rename apps/{admin-web/src/pages => web/src/pages/admin}/corrections/corrections-page.scss (100%) rename apps/{admin-web/src/pages => web/src/pages/admin}/corrections/corrections.ts (100%) rename apps/{admin-web/src/pages => web/src/pages/admin}/dashboard/DashboardPage.tsx (98%) rename apps/{admin-web/src/pages => web/src/pages/admin}/dashboard/dashboard-page.scss (100%) rename apps/{admin-web/src/pages => web/src/pages/admin}/dashboard/dashboard.ts (100%) rename apps/{admin-web/src/pages/login/LoginPage.tsx => web/src/pages/admin/login/AdminLoginPage.tsx} (72%) rename apps/{admin-web/src/pages => web/src/pages/admin}/monitoring/MonitoringPage.tsx (98%) rename apps/{admin-web/src/pages => web/src/pages/admin}/monitoring/monitoring-page.scss (100%) rename apps/{admin-web/src/pages => web/src/pages/admin}/monitoring/monitoring.ts (100%) diff --git a/.env.example b/.env.example index 469a3e2..2e6090a 100644 --- a/.env.example +++ b/.env.example @@ -15,20 +15,17 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars # (docker-compose.yml), serving both the API and the built frontend. # APP_PORT=3000 -# --- Admin application (apps/admin-web + the /admin/* API surface) --------- +# --- Admin surface (apps/web's /admin/* routes + the /admin/* API) -------- # All optional: an instance that doesn't run the admin app needs none of # these. `requireAdmin` fails closed when ADMIN_JWT_SECRET is unset, so -# leaving it out simply disables every /admin/* route. +# leaving it out simply disables every /admin/* route. The admin UI is +# served by the same app/container as the rest of the frontend — no +# separate origin, so no CORS entry of its own. # # Secret for the admin session JWT — MUST be different from JWT_SECRET so an # end-user token can never be replayed against /admin/*. Generate your own # the same way as JWT_SECRET above. # ADMIN_JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars -# Origin apps/admin-web is served from, added to the CORS allow-list. -# ADMIN_CORS_ORIGIN=http://localhost:5174 -# Host port for the Docker `admin-web` service (static nginx serving the -# built admin frontend). -# ADMIN_WEB_PORT=3001 # Optional — read only by `src/scripts/create-admin.ts` when its --email / # --password / --name flags are omitted (e.g. to bootstrap the first admin # from inside the container). Never read by the running server. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38fbe84..9be7cad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,10 +179,3 @@ jobs: # `component.devServer`), unlike `e2e` above which needs the real app # running first. - run: pnpm --filter web cy:run:component - # The admin app's own Cypress suite (`apps/admin-web`) — its own dev - # server on :5174, all `/admin/*` calls mocked via `cy.intercept` - # (no live backend needed), same as the `web` e2e run above. - - name: Run admin-web E2E tests - env: - HOST: "0.0.0.0" - run: pnpm --filter admin-web e2e diff --git a/apps/admin-web/.env.example b/apps/admin-web/.env.example deleted file mode 100644 index 5559b08..0000000 --- a/apps/admin-web/.env.example +++ /dev/null @@ -1,6 +0,0 @@ -# Vite only exposes vars prefixed with VITE_ to client code. -# Base URL of the API's /admin/* surface. Empty string = same origin as the -# page (correct behind a shared reverse proxy). Native dev overrides it in -# apps/admin-web/.env since the Vite dev server (5174) and the API (3000) -# are different origins. -VITE_ADMIN_API_URL=http://localhost:3000 diff --git a/apps/admin-web/Dockerfile b/apps/admin-web/Dockerfile deleted file mode 100644 index bf8d9a4..0000000 --- a/apps/admin-web/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -# Its own image (not built into apps/api's) — the admin app is deployed -# independently of the main app. Build stage compiles the Vite bundle from -# the monorepo; runtime is a plain static nginx serving that bundle. -# -# Build context is the repo root (like apps/api/Dockerfile) — the workspace -# packages (@batch-cooking/shared, @batch-cooking/date-tools) must resolve. -FROM node:22-slim AS build -RUN corepack enable -WORKDIR /repo -# Skip Cypress's Electron binary download — this image never runs it. -ENV CYPRESS_INSTALL_BINARY=0 -COPY . . -RUN pnpm install --frozen-lockfile -# The admin bundle bakes in VITE_ADMIN_API_URL at build time. Default "" -# (same-origin — correct behind a shared reverse proxy); override with -# `--build-arg VITE_ADMIN_API_URL=https://api.example.com` when the admin -# app is served from a different origin than the API. -ARG VITE_ADMIN_API_URL="" -ENV VITE_ADMIN_API_URL=$VITE_ADMIN_API_URL -RUN pnpm --filter admin-web build - -FROM nginx:alpine AS runtime -COPY apps/admin-web/nginx.conf /etc/nginx/conf.d/default.conf -COPY --from=build /repo/apps/admin-web/dist /usr/share/nginx/html -EXPOSE 80 diff --git a/apps/admin-web/cypress.config.ts b/apps/admin-web/cypress.config.ts deleted file mode 100644 index e799b01..0000000 --- a/apps/admin-web/cypress.config.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { addCucumberPreprocessorPlugin } from "@badeball/cypress-cucumber-preprocessor"; -import { createEsbuildPlugin } from "@badeball/cypress-cucumber-preprocessor/esbuild"; -import createBundler from "@bahmutov/cypress-esbuild-preprocessor"; -import { defineConfig } from "cypress"; - -// Disable GPU for headless/sandboxed environments where no GPU device is -// available — same helper as apps/web's cypress.config.ts. -function disableGpu(on: Cypress.PluginEvents) { - on("before:browser:launch", (browser, launchOptions) => { - if (browser.family === "chromium") { - launchOptions.args.push("--disable-gpu", "--no-sandbox"); - } - return launchOptions; - }); -} - -export default defineConfig({ - e2e: { - baseUrl: "http://localhost:5174", - specPattern: ["cypress/e2e/**/*.cy.ts", "cypress/e2e/**/*.feature"], - async setupNodeEvents(on, config) { - disableGpu(on); - await addCucumberPreprocessorPlugin(on, config); - on("file:preprocessor", createBundler({ plugins: [createEsbuildPlugin(config)] })); - return config; - }, - }, -}); diff --git a/apps/admin-web/cypress/e2e/login.ts b/apps/admin-web/cypress/e2e/login.ts deleted file mode 100644 index 36baea8..0000000 --- a/apps/admin-web/cypress/e2e/login.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Given } from "@badeball/cypress-cucumber-preprocessor"; - -const adminBody = { - id: 1, - email: "ops@example.com", - name: "Ops", - createdAt: "2026-08-01T00:00:00.000Z", - lastLoginAt: "2026-08-28T09:00:00.000Z", -}; - -Given("admin login fails with invalid credentials", () => { - cy.intercept("POST", "**/admin/auth/login", { - statusCode: 401, - body: { code: 4010, message: "Invalid email or password" }, - }); -}); - -Given("admin login succeeds as {string}", (name: string) => { - const body = { ...adminBody, name, email: `${name.toLowerCase()}@example.com` }; - cy.intercept("POST", "**/admin/auth/login", { statusCode: 200, body }); - // After navigate("/"), RequireAdmin re-checks the session — from now on it - // must report authenticated (last matching intercept wins). - cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body }); -}); diff --git a/apps/admin-web/cypress/support/e2e.ts b/apps/admin-web/cypress/support/e2e.ts deleted file mode 100644 index 62aa998..0000000 --- a/apps/admin-web/cypress/support/e2e.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Cypress support file — global config and custom commands go here as the -// admin app grows. Same minimal starting point as apps/web's e2e.ts. -export {}; diff --git a/apps/admin-web/cypress/support/step_definitions/common.steps.ts b/apps/admin-web/cypress/support/step_definitions/common.steps.ts deleted file mode 100644 index 0ed80bf..0000000 --- a/apps/admin-web/cypress/support/step_definitions/common.steps.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor"; - -// Steps shared across admin feature specs — navigation and generic UI -// assertions. Anything specific to one feature (its own API mocks, its own -// DOM structure) lives in that feature's own `.ts` step file. -// -// Every admin API call is mocked via `cy.intercept` — the Cypress suite -// never runs a live backend; apps/api's own Mocha suite covers real -// `/admin/*` behaviour. - -Given("the admin session check returns unauthenticated", () => { - cy.intercept("GET", "**/admin/auth/me", { statusCode: 401, body: { code: 4011, message: "no" } }); -}); - -Given("I am signed in as admin {string}", (name: string) => { - cy.intercept("GET", "**/admin/auth/me", { - statusCode: 200, - body: { - id: 1, - email: `${name.toLowerCase()}@example.com`, - name, - createdAt: "2026-08-01T00:00:00.000Z", - lastLoginAt: "2026-08-28T09:00:00.000Z", - }, - }); -}); - -When("I visit {string}", (path: string) => { - cy.visit(path); -}); - -When("I fill in the {string} field with {string}", (fieldId: string, value: string) => { - cy.get(`#${fieldId}`).clear().type(value); -}); - -When("I click the button {string}", (text: string) => { - cy.contains("button", text).click(); -}); - -Then("the URL should include {string}", (fragment: string) => { - cy.url().should("include", fragment); -}); - -Then("the URL should not include {string}", (fragment: string) => { - cy.url().should("not.include", fragment); -}); - -Then("I should see {string}", (text: string) => { - cy.contains(text).should("be.visible"); -}); - -Then("I should see the heading {string}", (text: string) => { - cy.contains("h1", text).should("be.visible"); -}); diff --git a/apps/admin-web/index.html b/apps/admin-web/index.html deleted file mode 100644 index 8c44b55..0000000 --- a/apps/admin-web/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - batchCooking — Admin - - -
- - - diff --git a/apps/admin-web/nginx.conf b/apps/admin-web/nginx.conf deleted file mode 100644 index cf18ff6..0000000 --- a/apps/admin-web/nginx.conf +++ /dev/null @@ -1,20 +0,0 @@ -# Static host for the built admin SPA. Client-side routing (react-router) -# means any unknown path must fall back to index.html rather than 404 — -# same reason apps/api serves its own SPA that way. -server { - listen 80; - server_name _; - root /usr/share/nginx/html; - index index.html; - - location / { - try_files $uri $uri/ /index.html; - } - - # Long-cache the fingerprinted assets Vite emits; never cache the HTML - # entry point so a new deploy is picked up immediately. - location /assets/ { - expires 1y; - add_header Cache-Control "public, immutable"; - } -} diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json deleted file mode 100644 index fb4fabe..0000000 --- a/apps/admin-web/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "admin-web", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite --host 0.0.0.0 --port 5174", - "build": "tsc -b && vite build", - "preview": "vite preview --port 5174", - "test": "echo \"no unit tests yet\" && exit 0", - "cy:open": "cypress open", - "cy:run": "cypress run", - "e2e": "start-server-and-test dev http://localhost:5174 cy:run" - }, - "dependencies": { - "@batch-cooking/date-tools": "workspace:*", - "@batch-cooking/shared": "workspace:*", - "i18next": "^26.3.6", - "lucide-react": "^1.32.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-i18next": "^17.0.11", - "react-router-dom": "^7.18.2", - "recharts": "^2.15.0", - "zod": "^3.25.76" - }, - "devDependencies": { - "@badeball/cypress-cucumber-preprocessor": "22.2.0", - "@bahmutov/cypress-esbuild-preprocessor": "2.2.8", - "@cypress/vite-dev-server": "5.2.1", - "@types/node": "^22.9.0", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^4.3.3", - "cypress": "13.17.0", - "esbuild": "0.21.5", - "sass": "^1.102.0", - "start-server-and-test": "^2.0.8", - "typescript": "^5.7.2", - "vite": "^5.4.11" - } -} diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx deleted file mode 100644 index e37c1fa..0000000 --- a/apps/admin-web/src/App.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Navigate, Route, Routes } from "react-router-dom"; -import { RequireAdmin } from "./features/auth/RequireAdmin"; -import { AdminLayout } from "./layouts/AdminLayout"; -import { CatalogPage } from "./pages/catalog/CatalogPage"; -import { CorrectionsPage } from "./pages/corrections/CorrectionsPage"; -import { DashboardPage } from "./pages/dashboard/DashboardPage"; -import { LoginPage } from "./pages/login/LoginPage"; -import { MonitoringPage } from "./pages/monitoring/MonitoringPage"; - -/** - * Admin app route table. `/login` is the only unauthenticated route; - * everything else is nested under one `RequireAdmin` + `AdminLayout` parent - * (the guard + sidebar chrome applied once), same shape as apps/web's - * `App.tsx`. Unknown paths fall back to `/`, which redirects to `/login` - * when there's no admin session. - */ -export function App() { - return ( - - } /> - - - - } - > - } /> - } /> - } /> - } /> - - } /> - - ); -} diff --git a/apps/admin-web/src/i18n/i18n.ts b/apps/admin-web/src/i18n/i18n.ts deleted file mode 100644 index 96e7f52..0000000 --- a/apps/admin-web/src/i18n/i18n.ts +++ /dev/null @@ -1,22 +0,0 @@ -import i18next from "i18next"; -import { initReactI18next } from "react-i18next"; -import fr from "../locales/fr/translation.json"; - -/** - * i18next instance for the admin app, imported once for its side effect - * (`main.tsx`) before anything renders. Only French exists today — same - * setup as apps/web's `i18n/i18n.ts`, its own separate locale file so the - * two apps' copy never has to be kept identical. `packages/shared`'s - * `ErrorCode` member names double as keys under the `errors` namespace - * (see `services/error-message.service.ts`). - */ -void i18next.use(initReactI18next).init({ - resources: { - fr: { translation: fr }, - }, - lng: "fr", - fallbackLng: "fr", - interpolation: { escapeValue: false }, -}); - -export default i18next; diff --git a/apps/admin-web/src/lib/zod-errors.ts b/apps/admin-web/src/lib/zod-errors.ts deleted file mode 100644 index ab9174b..0000000 --- a/apps/admin-web/src/lib/zod-errors.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { ZodError } from "zod"; - -/** - * Flattens a zod validation error into `{ fieldName: firstMessage }` for - * inline display under each form field — verbatim copy of apps/web's - * `lib/zod-errors.ts` (only the first message per field, enough for the - * single-rule-per-field schemas used here). - */ -export function fieldErrorsFrom(error: ZodError): Record { - const fieldErrors = error.flatten().fieldErrors; - const firstMessagePerField: Record = {}; - for (const [field, messages] of Object.entries(fieldErrors)) { - if (messages?.[0]) { - firstMessagePerField[field] = messages[0]; - } - } - return firstMessagePerField; -} diff --git a/apps/admin-web/src/locales/fr/translation.json b/apps/admin-web/src/locales/fr/translation.json deleted file mode 100644 index 3b6487f..0000000 --- a/apps/admin-web/src/locales/fr/translation.json +++ /dev/null @@ -1,148 +0,0 @@ -{ - "errors": { - "VALIDATION_ERROR": "Erreur de validation", - "INVALID_CREDENTIALS": "Email ou mot de passe incorrect", - "NOT_AUTHENTICATED": "Vous devez être connecté", - "NOT_FOUND": "Ressource introuvable", - "TECH_STEP_NOT_FOUND": "Cette technique n'existe pas", - "RETRAIN_ALREADY_RUNNING": "Un ré-entraînement est déjà en cours", - "INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard" - }, - "admin": { - "common": { - "comingSoon": "Section à venir.", - "loading": "Chargement…", - "loadError": "Impossible de charger les données, réessayez plus tard." - }, - "login": { - "title": "Administration", - "emailLabel": "Email", - "passwordLabel": "Mot de passe", - "submit": "Se connecter", - "submitting": "Connexion…" - }, - "nav": { - "dashboard": "Tableau de bord", - "monitoring": "Monitoring", - "corrections": "Corrections", - "catalog": "Catalogue" - }, - "layout": { - "logout": "Se déconnecter" - }, - "dashboard": { - "title": "Tableau de bord", - "lead": "Métriques d'utilisation de l'application.", - "windowTotal": "{{n}} sur 30 j", - "recipesBySource": "Recettes importées par source", - "noImports": "Aucune recette importée.", - "events": "Évènements enregistrés (30 j)", - "kpi": { - "users": "Utilisateurs", - "households": "Foyers", - "activeHouseholds": "Foyers actifs", - "recipes": "Recettes", - "recipesImported": "Recettes importées", - "plannings": "Plannings", - "planningItems": "Créneaux planifiés", - "favorites": "Favoris", - "corrections": "Corrections", - "correctionsUnconsumed": "Corrections à traiter", - "trainingSuggestions": "Suggestions d'entraînement", - "admins": "Administrateurs" - }, - "series": { - "signups": "Inscriptions", - "recipesCreated": "Recettes créées", - "planningItemsAdded": "Ajouts au planning", - "correctionsSubmitted": "Corrections soumises", - "trainingSuggestions": "Suggestions générées" - } - }, - "monitoring": { - "title": "Monitoring", - "lead": "Santé des microservices et de la base de données.", - "lastChecked": "Dernière vérification à {{time}}", - "latency": "Latence", - "detail": "Détail", - "lastRun": "Dernier job", - "never": "jamais", - "jobFailed": "échec", - "status": { - "up": "OK", - "degraded": "Dégradé", - "down": "Hors service", - "unknown": "Inconnu" - }, - "service": { - "postgres": "Base de données", - "api": "API", - "intent-service": "Service NLP (spaCy)", - "tech-step-llm-worker": "Worker LLM" - } - }, - "corrections": { - "title": "Corrections", - "lead": "Tri des corrections utilisateur pour le ré-entraînement NLP.", - "caveat": "Le gate F1 + backfill n'a de sens qu'APRÈS avoir édité training_data.py à la main et redémarré le service NLP (il ne s'entraîne qu'au démarrage). Cet écran ne peut faire ni l'un ni l'autre.", - "noSuggestions": "Aucune suggestion pour ces filtres.", - "synonyms": "Synonymes proposés (un par ligne)", - "utterances": "Phrases proposées (une par ligne)", - "save": "Enregistrer", - "apply": "Appliquer", - "reject": "Rejeter", - "tab": { - "suggestions": "Suggestions", - "corrections": "Corrections brutes" - }, - "filter": { - "status": "Statut", - "source": "Source", - "consumed": "Consommée", - "hasCorrected": "Technique corrigée", - "any": "Toutes", - "yes": "Oui", - "no": "Non" - }, - "snippet": { - "title": "Snippet training_data.py", - "help": "Agrège les synonymes/phrases des suggestions « applied » d'une technique, au format à coller dans training_data.py.", - "keyPlaceholder": "clé de technique (ex. simmer)", - "generate": "Générer" - }, - "retrain": { - "title": "Gate F1 + backfill", - "help": "Lance l'évaluation de régression F1 puis, si elle passe, recalcule les techniques de toutes les étapes.", - "run": "Lancer", - "running": "En cours…", - "passed": "OK — {{changed}}/{{total}} étape(s) recalculée(s)", - "failed": "Échec du gate — aucun backfill" - }, - "col": { - "clause": "Clause", - "change": "Changement", - "created": "Créée", - "consumed": "Consommée" - } - }, - "catalog": { - "title": "Ingrédients hors-catalogue", - "lead": "Ingrédients saisis en texte libre par les utilisateurs parce que le catalogue ne les couvrait pas. Regroupés par nom normalisé — à promouvoir dans reference-seed-data.ts + les locales, à la main.", - "empty": "Aucun ingrédient hors-catalogue.", - "tab": { - "pending": "À traiter", - "reviewed": "Traités" - }, - "recipeCount": "{{count}} recette(s)", - "alsoWritten": "Aussi écrit : {{variants}}", - "seenIn": "Vu dans :", - "firstSeen": "Première fois le {{date}}", - "markReviewed": "Marquer comme traité", - "marking": "…", - "pruneOrphans": "Purger les orphelins", - "pruning": "Purge…", - "prunedNone": "Aucun placeholder orphelin à purger.", - "pruned": "{{count}} placeholder(s) orphelin(s) supprimé(s)." - } - } -} diff --git a/apps/admin-web/src/main.tsx b/apps/admin-web/src/main.tsx deleted file mode 100644 index a713866..0000000 --- a/apps/admin-web/src/main.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import { BrowserRouter } from "react-router-dom"; -import { App } from "./App"; -import { AdminAuthProvider } from "./features/auth/AdminAuthContext"; -// Side-effect import: initializes i18next before anything renders. -import "./i18n/i18n"; -// Global stylesheet (theme tokens + minimal reset) — the only non-colocated .scss import. -import "./styles/global.scss"; - -const rootElement = document.getElementById("root"); -if (!rootElement) { - throw new Error("Root element not found"); -} - -createRoot(rootElement).render( - - - - - - - , -); diff --git a/apps/admin-web/src/services/error-message.service.ts b/apps/admin-web/src/services/error-message.service.ts deleted file mode 100644 index e1ea838..0000000 --- a/apps/admin-web/src/services/error-message.service.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ErrorCode } from "@batch-cooking/shared"; -import i18n from "../i18n/i18n"; - -/** - * Localized label for an {@link ErrorCode} returned by the admin API — - * verbatim behaviour of apps/web's `ErrorMessageService`: reverse-maps the - * numeric enum value to its member name (`4010` → `"INVALID_CREDENTIALS"`) - * and looks it up under the `errors` namespace, falling back to - * `INTERNAL_ERROR` for a code this client doesn't recognise. - */ -export class ErrorMessageService { - public getLabel(code: ErrorCode): string { - const memberName = ErrorCode[code] ?? ErrorCode[ErrorCode.INTERNAL_ERROR]; - return i18n.t(`errors.${memberName}`); - } -} - -/** Single shared instance — stateless. */ -export const errorMessageService = new ErrorMessageService(); diff --git a/apps/admin-web/src/styles/_theme.scss b/apps/admin-web/src/styles/_theme.scss deleted file mode 100644 index 22b765d..0000000 --- a/apps/admin-web/src/styles/_theme.scss +++ /dev/null @@ -1,171 +0,0 @@ -// NOTE: verbatim copy of apps/web/src/styles/_theme.scss. Extracting these -// tokens into a shared package (consumed by both apps) is tracked separately -// — keep the two files in sync by hand until then. -// ============================================================================= -// Design tokens — the single source of truth for colors, spacing, typography -// and other reusable values across the whole app. -// -// Exposed as CSS custom properties on :root (not plain SCSS variables) so -// they're available at *runtime*, not just compile time — this is what lets -// dark mode work below by simply redefining these variables instead of -// rebuilding the stylesheet. Every other .scss file should reference -// `var(--token-name)`, never a hardcoded color/size. -// -// Palette name: "Mise en Place" — a kitchen-operations identity (batch -// cooking as logistics: everything labeled and in its place before you -// start) rather than a food-blog one. See the design proposal for the full -// rationale: https://claude.ai/code/artifact/1db63af0-cfd1-4f77-9369-71ca6accd06f -// -// Import this partial once, globally (see global.scss) — never re-import it -// from a component-level .scss file, `:root` only needs to be declared once. -// ============================================================================= - -:root { - // --- Surfaces & ink --------------------------------------------------- - // Neutral surface: page background ("porcelaine") vs. the card surface - // content sits on, plus a recessed variant for panels/table headers. - --color-background: #eef2ed; - --color-surface: #ffffff; - --color-surface-alt: #e2e8e0; - // Text. - --color-text: #1f2a22; - --color-text-muted: #57685a; - --color-border: #c7d0c4; - - // --- Brand accents, each with one job -------------------------------- - // Basil — primary actions, links, brand presence. - --color-primary: #2e6b4a; - --color-primary-hover: #244f38; - // Vermillion — secondary accent for urgency/strong calls to action - // (e.g. a timer, "start session"). Never reused for allergen alerts - // below — those need their own, unambiguous color. - --color-accent: #cc4b26; - --color-accent-hover: #a83c1c; - // Turmeric — category/classification tags. - --color-tag: #c98a1b; - --color-tag-ink: #3a2c05; // pairs with a solid --color-tag fill only. - - // --- Feedback ----------------------------------------------------------- - --color-success: #2e6b4a; - --color-warning: #c98a1b; - --color-error: #b3271e; - - // --- Allergens / intolerances -------------------------------------------- - // A 3-tier food-safety scale, kept distinct from --color-error so an - // allergen warning is never confused with a form validation error: - // - critical (declared allergen): its own color, solid/inverted fill - // - moderate (intolerance): reuses --color-warning, tinted fill - // - trace ("may contain traces of…"): neutral, dashed outline - // The severity is carried by the FILL TREATMENT, not the hue alone, so - // it stays legible for color-blind users. See the design proposal's - // "Alertes & allergènes" section for the full component set. - --color-allergen: #a8123f; - --color-allergen-ink: #ffe9ef; // pairs with a solid --color-allergen fill only. - - // --- Spacing scale --------------------------------------------------------- - // Multiples of a 4px base unit — use these instead of ad hoc px values so - // spacing stays visually consistent as the app grows. - --space-xs: 0.25rem; // 4px - --space-sm: 0.5rem; // 8px - --space-md: 1rem; // 16px - --space-lg: 1.5rem; // 24px - --space-xl: 2rem; // 32px - --space-2xl: 3rem; // 48px — inter-section spacing - - // --- Typography -------------------------------------------------------- - // System font stacks only — no remote webfont, so there's zero loading - // latency and no flash of unstyled text, which fits an app meant to be - // used quickly under time pressure. Three roles: a condensed "label" - // face for headings/eyebrows, a humanist face for body copy, and a - // monospace for anything that lines up in columns (times, quantities). - --font-display: "Bahnschrift", "Arial Narrow", "Segoe UI", sans-serif; - --font-body: "Segoe UI", "Helvetica Neue", Arial, sans-serif; - --font-mono: "Cascadia Mono", Consolas, "SF Mono", "Liberation Mono", monospace; - - --font-size-xs: 0.75rem; // 12px — captions, meta - --font-size-sm: 0.875rem; // 14px — labels, secondary text - --font-size-base: 1rem; // 16px — body - --font-size-md: 1.125rem; // 18px — lead paragraph - --font-size-lg: 1.375rem; // 22px — H3 / card titles - --font-size-xl: 1.75rem; // 28px — H2 / section titles - --font-size-2xl: 2.25rem; // 36px — H1 / page titles - --font-size-3xl: 3rem; // 48px — display, exceptional use only - - // --- Shape / elevation -------------------------------------------------- - --radius-base: 4px; // controls (inputs, buttons) — deliberately flat - --radius-md: 10px; // cards - --radius-lg: 18px; // panels, modals - --radius-pill: 999px; // tags, badges - --max-width-form: 22rem; - - --shadow-sm: 0 1px 2px rgba(31, 42, 34, 0.08), 0 1px 1px rgba(31, 42, 34, 0.06); - --shadow-md: 0 6px 16px rgba(31, 42, 34, 0.12), 0 2px 6px rgba(31, 42, 34, 0.08); -} - -// Lets the browser pick sensible default colors (form controls, scrollbars) -// for whichever mode the user ends up in. -:root { - color-scheme: light dark; -} - -// --- Dark mode --------------------------------------------------------- -// Follows the OS/browser preference by default. Guarded with -// `:root:not([data-theme="light"])` so an explicit "light" choice (see -// apps/web's `ThemeContext`, `SYSTEM` = no `data-theme` attribute at all — -// this block then decides) can override a dark OS setting. -@media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) { - --color-background: #14181a; - --color-surface: #1c221e; - --color-surface-alt: #262e27; - --color-text: #edf1ea; - --color-text-muted: #a9b5a6; - --color-border: #384038; - - --color-primary: #5fae7e; - --color-primary-hover: #7cc496; - --color-accent: #ea7a48; - --color-accent-hover: #f0946c; - --color-tag: #e8b84b; - --color-tag-ink: #2a2005; - - --color-success: #5fae7e; - --color-warning: #e8b84b; - --color-error: #e5675a; - - --color-allergen: #e2547b; - --color-allergen-ink: #3a0416; - - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35); - --shadow-md: 0 8px 20px rgba(0, 0, 0, 0.45); - } -} - -// Mirrors the block above for an explicit "dark" choice (`ThemeContext` -// sets `data-theme="dark"` on ``), so it wins over the OS setting in -// both directions. -:root[data-theme="dark"] { - --color-background: #14181a; - --color-surface: #1c221e; - --color-surface-alt: #262e27; - --color-text: #edf1ea; - --color-text-muted: #a9b5a6; - --color-border: #384038; - - --color-primary: #5fae7e; - --color-primary-hover: #7cc496; - --color-accent: #ea7a48; - --color-accent-hover: #f0946c; - --color-tag: #e8b84b; - --color-tag-ink: #2a2005; - - --color-success: #5fae7e; - --color-warning: #e8b84b; - --color-error: #e5675a; - - --color-allergen: #e2547b; - --color-allergen-ink: #3a0416; - - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35); - --shadow-md: 0 8px 20px rgba(0, 0, 0, 0.45); -} diff --git a/apps/admin-web/src/styles/global.scss b/apps/admin-web/src/styles/global.scss deleted file mode 100644 index dffae1e..0000000 --- a/apps/admin-web/src/styles/global.scss +++ /dev/null @@ -1,149 +0,0 @@ -// ============================================================================= -// Global stylesheet — imported exactly once, in main.tsx. Contains only -// truly app-wide rules: the theme tokens and a minimal reset/base styling -// that every page inherits. Anything specific to one component or page -// belongs in a .scss file colocated next to that component/page instead. -// ============================================================================= - -@use "./theme"; - -// Include borders/padding in an element's declared width/height everywhere, -// rather than the browser default of adding them on top. -*, -*::before, -*::after { - box-sizing: border-box; -} - -// Minimal reset: remove the default body margin so pages can control their -// own layout without fighting the browser's default 8px margin. -body { - margin: 0; - font-family: var(--font-body); - font-size: var(--font-size-base); - line-height: 1.55; - color: var(--color-text); - background: var(--color-background); -} - -// Headings use the condensed "label" face app-wide — see _theme.scss for -// the rationale. `text-wrap: balance` avoids a lone short word wrapping -// onto its own line in multi-line titles. -h1, -h2, -h3, -h4, -h5, -h6 { - margin: 0; - font-family: var(--font-display); - font-weight: 700; - text-wrap: balance; -} - -// Default to the page-title size; a heading used as a smaller component -// title (e.g. the auth card's

) overrides this in its own stylesheet. -h1 { - font-size: var(--font-size-2xl); -} - -// A visible, consistent focus ring for keyboard navigation — the browser -// default varies a lot between elements and browsers. -:focus-visible { - outline: 2px solid var(--color-accent); - outline-offset: 2px; -} - -// Checkbox/radio appearance, app-wide — "selectable card" style: the native -// control itself is visually hidden (still real, focusable and -// screen-reader-visible — see the `input[type=...]` rule below, not -// `display: none`) and the whole label row it lives in becomes the -// interactive surface instead: a flat bordered box that fills in with a -// tinted background + primary border once selected, with a checkmark -// fading in on the leading edge. -// -// The base (unselected) look below is detected structurally with `:has()` -// — safe, since "does this label contain a checkbox/radio" never changes -// after mount. The *selected* look is instead driven by the `is-selected` -// class {@link CheckboxOption}/{@link RadioOption} (components/ui/) toggle -// in JS from the same boolean their caller already passes to `checked` — -// chaining a second `:has(:checked)` to react to that live state turned -// out to be unreliable across browsers, so this only needs one -// always-true `:has()`. -// -// Every checkbox/radio in the app goes through this one place (the allergy -// grid, the theme picker, anywhere future) rather than each feature styling -// its own — see profile-forms.scss / settings-pages.scss, which only -// arrange these within their own layout (grid vs. stacked list) and -// intentionally don't re-style the control/label look itself. -label:has(> input[type="checkbox"]), -label:has(> input[type="radio"]) { - position: relative; - display: flex; - align-items: center; - gap: var(--space-xs); - padding: var(--space-xs) var(--space-sm); - // Overrides the generic `label { font-weight: 600 }` base rule - // (profile-forms.scss) — without this, an *unselected* row reads just as - // bold as a selected one (only `.allergy-select__option` happened to set - // its own 400 already; `.theme-select__option` didn't, so its rows were - // all permanently bold until this was centralized here). - font-weight: 400; - border: 1.5px solid var(--color-border); - border-radius: var(--radius-base); - background: var(--color-surface); - cursor: pointer; - transition: - background-color 0.15s ease, - border-color 0.15s ease; - - &:hover { - border-color: var(--color-primary); - } - - &.is-selected { - border-color: var(--color-primary); - background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface)); - color: var(--color-primary); - font-weight: 600; - } - - &:has(:focus-visible) { - outline: 2px solid var(--color-accent); - outline-offset: 2px; - } -} - -// The control itself is removed from the visual flow — hidden the -// "sr-only" way (not `display: none`) so it stays focusable/tabbable and -// announced correctly by screen readers; the label above carries the -// entire visible selected/unchecked look. -input[type="checkbox"], -input[type="radio"] { - position: absolute; - width: 1px; - height: 1px; - margin: 0; - opacity: 0; -} - -// The checkmark — a real element (see components/ui/Checkbox.tsx / -// Radio.tsx) shown via the same `is-selected` class as the label's own -// look above, not a separate CSS-only trigger. Scaled in from nothing so -// toggling has a bit of motion. Same mark for both checkbox and radio: one -// consistent "selected" language app-wide rather than a checkmark here and -// a dot there. Sits first in the row (before the label text, per DOM -// order) — a classic "control on the left" layout rather than trailing. -.check-mark { - flex: none; - width: 0.9rem; - height: 0.9rem; - background: var(--color-primary); - clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); - transform: scale(0); - transition: transform 0.1s ease; -} - -.is-selected .check-mark { - transform: scale(1); -} diff --git a/apps/admin-web/src/vite-env.d.ts b/apps/admin-web/src/vite-env.d.ts deleted file mode 100644 index a4754d7..0000000 --- a/apps/admin-web/src/vite-env.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/// - -// Injected by `define` in vite.config.ts, sourced from package.json's -// version field — rendered in AdminLayout.tsx. -declare const __APP_VERSION__: string; diff --git a/apps/admin-web/tsconfig.app.json b/apps/admin-web/tsconfig.app.json deleted file mode 100644 index af5b6cb..0000000 --- a/apps/admin-web/tsconfig.app.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "module": "ESNext", - "moduleResolution": "Bundler", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "jsx": "react-jsx", - "types": ["vite/client"], - "noEmit": true, - "composite": true, - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo" - }, - "include": ["src"] -} diff --git a/apps/admin-web/tsconfig.json b/apps/admin-web/tsconfig.json deleted file mode 100644 index 558996e..0000000 --- a/apps/admin-web/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "compilerOptions": { - "module": "ESNext", - "moduleResolution": "Bundler" - }, - "files": [], - "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] -} diff --git a/apps/admin-web/tsconfig.node.json b/apps/admin-web/tsconfig.node.json deleted file mode 100644 index a0686f8..0000000 --- a/apps/admin-web/tsconfig.node.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "composite": true, - "noEmit": true, - "skipLibCheck": true, - "types": ["node"] - }, - "include": ["vite.config.ts"] -} diff --git a/apps/admin-web/vite.config.ts b/apps/admin-web/vite.config.ts deleted file mode 100644 index 9a52da9..0000000 --- a/apps/admin-web/vite.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { readFileSync } from "node:fs"; -import react from "@vitejs/plugin-react"; -import { defineConfig } from "vite"; - -// Read once at config-eval time — same trick as apps/web's vite.config.ts. -const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf-8")); - -export default defineConfig({ - plugins: [react()], - server: { port: 5174 }, - css: { - preprocessorOptions: { - scss: { api: "modern-compiler" }, - }, - }, - define: { - __APP_VERSION__: JSON.stringify(pkg.version), - }, -}); diff --git a/apps/api/.env.example b/apps/api/.env.example index 7273c4f..ba85c71 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -25,3 +25,13 @@ INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars # Generate your own the same way as JWT_SECRET above; must match the # worker's own INTERNAL_WORKER_SECRET. # INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars + +# Only needed to use the admin surface (apps/web's /admin/* routes) — every +# /admin/* request 401s while unset (`requireAdmin` fails closed). MUST be a +# different value than JWT_SECRET. Generate your own the same way. +# ADMIN_JWT_SECRET=changeme-a-different-random-secret-at-least-32-chars +# Read only by `src/scripts/create-admin.ts` when its --email/--password/ +# --name flags are omitted — never by the running server. +# ADMIN_INITIAL_EMAIL=ops@example.com +# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars +# ADMIN_INITIAL_NAME=Ops diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index b4b2fb2..569c361 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -34,18 +34,20 @@ export function createServer(): ExpressServer { // pipeline (its "finish" listener still fires for a request that never // makes it past CORS/body-parsing, not just ones that reach a route). server.addMiddleware(requestLogger); - // Two allowed origins: the main app (`CORS_ORIGIN`) and the separate - // admin app (`ADMIN_CORS_ORIGIN`). The `cors` package matches an incoming - // `Origin` against any entry of the list. - server.setupCore({ corsOrigin: [env.CORS_ORIGIN, env.ADMIN_CORS_ORIGIN] }); + // One allowed origin: the app (`CORS_ORIGIN`). The admin surface + // (`/admin/*`) is served by this same API and consumed by `apps/web`'s + // own `/admin/*` routes — same origin as the rest of the app, so no + // extra CORS entry. + server.setupCore({ corsOrigin: env.CORS_ORIGIN }); server.addRoute("get", "/health", (_req: Request, res: Response) => { res.status(200).json({ status: "ok" }); }); server.mountRouter("/auth", authRouter); - // Admin application surface (`apps/admin-web`) — its own auth - // (`requireAdmin`, distinct cookie/secret), never the end-user session. + // Admin application surface (`apps/web`'s `/admin/*` routes) — its own + // auth (`requireAdmin`, distinct cookie/secret), never the end-user + // session. server.mountRouter("/admin", adminRouter); server.mountRouter("/house", houseRouter); // Not user-facing — `services/tech-step-llm-worker` only, guarded by diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 578c9de..af65dab 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -105,8 +105,6 @@ const envSchema = z.object({ .optional(), /** Name of the httpOnly cookie carrying the admin session JWT — must differ from `AUTH_COOKIE_NAME` so the two sessions coexist in one browser. */ ADMIN_COOKIE_NAME: z.string().default("admin_session"), - /** Origin `apps/admin-web` is served from — added to the CORS allow-list alongside `CORS_ORIGIN`. */ - ADMIN_CORS_ORIGIN: z.string().default("http://localhost:5174"), /** Optional seed values read by `src/scripts/create-admin.ts` when its `--email`/`--password`/`--name` flags are omitted — never used by the running server. */ ADMIN_INITIAL_EMAIL: z.string().optional(), ADMIN_INITIAL_PASSWORD: z.string().optional(), diff --git a/apps/api/src/middlewares/require-admin.ts b/apps/api/src/middlewares/require-admin.ts index daf0569..e20f24f 100644 --- a/apps/api/src/middlewares/require-admin.ts +++ b/apps/api/src/middlewares/require-admin.ts @@ -19,8 +19,8 @@ export interface AdminLocals { } /** - * Express middleware guarding every `/admin/*` route — the operations app - * (`apps/admin-web`) authenticating as an `AdminUser`. Reads the admin + * Express middleware guarding every `/admin/*` route — the operations UI + * (`apps/web`'s `/admin/*` routes) authenticating as an `AdminUser`. Reads the admin * session cookie (`ADMIN_COOKIE_NAME`, deliberately **not** the same cookie * as end-user sessions), verifies the JWT against `ADMIN_JWT_SECRET` * (a different secret than `JWT_SECRET`), and re-checks `tokenVersion` diff --git a/apps/api/src/modules/admin/admin.routes.ts b/apps/api/src/modules/admin/admin.routes.ts index 75eeb8e..5b65cc2 100644 --- a/apps/api/src/modules/admin/admin.routes.ts +++ b/apps/api/src/modules/admin/admin.routes.ts @@ -7,8 +7,8 @@ import { adminTechStepsRouter } from "./admin-tech-steps.routes.js"; /** * Aggregator for the admin application's API surface, mounted at `/admin` - * in `app.ts`. Every sub-router here is for `apps/admin-web` only — - * `/admin/auth` is public (login), everything added later + * in `app.ts`. Every sub-router here backs `apps/web`'s `/admin/*` routes + * only — `/admin/auth` is public (login), everything added later * (`/admin/metrics`, `/admin/monitoring`, `/admin/tech-steps/*`, * `/admin/catalog/*`) sits behind `requireAdmin` * (`middlewares/require-admin.ts`). diff --git a/apps/admin-web/cypress/e2e/catalog.cy.ts b/apps/web/cypress/e2e/admin-catalog.cy.ts similarity index 86% rename from apps/admin-web/cypress/e2e/catalog.cy.ts rename to apps/web/cypress/e2e/admin-catalog.cy.ts index 7bf00ed..34b9537 100644 --- a/apps/admin-web/cypress/e2e/catalog.cy.ts +++ b/apps/web/cypress/e2e/admin-catalog.cy.ts @@ -45,7 +45,7 @@ describe("Admin catalog — off-catalog ingredients", () => { statusCode: 200, body: pendingGroups(), }).as("getPlaceholders"); - cy.visit("/catalogue"); + cy.visit("/admin/catalogue"); cy.wait("@getPlaceholders"); cy.get(".catalog-card").should("have.length", 2); @@ -66,7 +66,7 @@ describe("Admin catalog — off-catalog ingredients", () => { body: { reviewed: 1 }, }).as("markReviewed"); - cy.visit("/catalogue"); + cy.visit("/admin/catalogue"); cy.wait("@getPlaceholders"); cy.contains(".catalog-card", "Sumac").contains("button", "Marquer comme traité").click(); @@ -79,16 +79,19 @@ describe("Admin catalog — off-catalog ingredients", () => { }); it("switches to the reviewed archive tab", () => { - cy.intercept("GET", "**/admin/catalog/placeholders", { + // Regex, not a glob: the two calls differ only by the `?reviewed=true` + // query, and minimatch's `?` is itself a wildcard — a glob can't tell + // them apart reliably. + cy.intercept("GET", /\/admin\/catalog\/placeholders$/, { statusCode: 200, body: pendingGroups(), }); - cy.intercept("GET", "**/admin/catalog/placeholders?reviewed=true", { + cy.intercept("GET", /\/admin\/catalog\/placeholders\?reviewed=true$/, { statusCode: 200, body: [], }).as("getReviewed"); - cy.visit("/catalogue"); + cy.visit("/admin/catalogue"); cy.contains(".catalog-tabs button", "Traités").click(); cy.wait("@getReviewed"); cy.contains("Aucun ingrédient hors-catalogue").should("be.visible"); diff --git a/apps/admin-web/cypress/e2e/corrections.cy.ts b/apps/web/cypress/e2e/admin-corrections.cy.ts similarity index 96% rename from apps/admin-web/cypress/e2e/corrections.cy.ts rename to apps/web/cypress/e2e/admin-corrections.cy.ts index e38cd98..1195e7c 100644 --- a/apps/admin-web/cypress/e2e/corrections.cy.ts +++ b/apps/web/cypress/e2e/admin-corrections.cy.ts @@ -87,7 +87,7 @@ describe("Admin corrections triage", () => { body: { ...suggestionGroups()[0].suggestions[0], status: "applied" }, }).as("patch"); - cy.visit("/corrections"); + cy.visit("/admin/corrections"); cy.wait("@getSuggestions"); cy.contains(".corrections-caveat", "training_data.py").should("be.visible"); @@ -116,7 +116,7 @@ describe("Admin corrections triage", () => { }, }).as("getSnippet"); - cy.visit("/corrections"); + cy.visit("/admin/corrections"); cy.get(".corrections-panel input").type("simmer"); cy.contains(".corrections-panel button", "Générer").click(); cy.wait("@getSnippet"); @@ -137,7 +137,7 @@ describe("Admin corrections triage", () => { }, }).as("retrain"); - cy.visit("/corrections"); + cy.visit("/admin/corrections"); cy.contains(".corrections-panel--retrain button", "Lancer").click(); cy.wait("@retrain"); cy.contains(".retrain-result", "F1 0.830") @@ -146,7 +146,7 @@ describe("Admin corrections triage", () => { }); it("lists raw corrections including the removals, on the second tab", () => { - cy.visit("/corrections"); + cy.visit("/admin/corrections"); cy.contains(".corrections-tabs button", "Corrections brutes").click(); cy.wait("@getCorrections"); diff --git a/apps/admin-web/cypress/e2e/dashboard.cy.ts b/apps/web/cypress/e2e/admin-dashboard.cy.ts similarity index 98% rename from apps/admin-web/cypress/e2e/dashboard.cy.ts rename to apps/web/cypress/e2e/admin-dashboard.cy.ts index e731c23..b7487b1 100644 --- a/apps/admin-web/cypress/e2e/dashboard.cy.ts +++ b/apps/web/cypress/e2e/admin-dashboard.cy.ts @@ -65,7 +65,7 @@ describe("Admin dashboard", () => { cy.intercept("GET", "**/admin/metrics*", { statusCode: 200, body: metricsFixture() }).as( "getMetrics", ); - cy.visit("/"); + cy.visit("/admin"); cy.wait("@getMetrics").its("request.url").should("include", "days=30"); // KPI tiles — value + label. @@ -89,7 +89,7 @@ describe("Admin dashboard", () => { statusCode: 500, body: { code: 5000, message: "x" }, }); - cy.visit("/"); + cy.visit("/admin"); cy.contains("Impossible de charger").should("be.visible"); }); }); diff --git a/apps/admin-web/cypress/e2e/admin-layout.cy.ts b/apps/web/cypress/e2e/admin-layout.cy.ts similarity index 83% rename from apps/admin-web/cypress/e2e/admin-layout.cy.ts rename to apps/web/cypress/e2e/admin-layout.cy.ts index 68d936b..4e4d34e 100644 --- a/apps/admin-web/cypress/e2e/admin-layout.cy.ts +++ b/apps/web/cypress/e2e/admin-layout.cy.ts @@ -15,40 +15,40 @@ describe("Admin layout", () => { statusCode: 401, body: { code: 4011, message: "no" }, }); - cy.visit("/monitoring"); - cy.url().should("include", "/login"); + cy.visit("/admin/monitoring"); + cy.url().should("include", "/admin/login"); cy.contains("h1", "Administration").should("be.visible"); }); it("shows the sidebar and navigates between the sections", () => { cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody }); - cy.visit("/"); + cy.visit("/admin"); cy.contains("h1", "Tableau de bord").should("be.visible"); cy.contains(".admin-sidebar__who", "Ops").should("be.visible"); cy.contains("nav a", "Monitoring").click(); - cy.url().should("include", "/monitoring"); + cy.url().should("include", "/admin/monitoring"); cy.contains("h1", "Monitoring").should("be.visible"); cy.contains("nav a", "Monitoring").should("have.class", "active"); cy.contains("nav a", "Corrections").click(); - cy.url().should("include", "/corrections"); + cy.url().should("include", "/admin/corrections"); cy.contains("h1", "Corrections").should("be.visible"); cy.intercept("GET", "**/admin/catalog/placeholders*", { statusCode: 200, body: [] }); cy.contains("nav a", "Catalogue").click(); - cy.url().should("include", "/catalogue"); + cy.url().should("include", "/admin/catalogue"); cy.contains("h1", "Ingrédients hors-catalogue").should("be.visible"); cy.contains("nav a", "Tableau de bord").click(); - cy.url().should("eq", `${Cypress.config().baseUrl}/`); + cy.url().should("eq", `${Cypress.config().baseUrl}/admin`); }); it("logs out back to /login", () => { cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody }); cy.intercept("POST", "**/admin/auth/logout", { statusCode: 204 }); - cy.visit("/"); + cy.visit("/admin"); // Wait until the guarded layout has actually mounted before acting. cy.contains("h1", "Tableau de bord").should("be.visible"); @@ -57,6 +57,6 @@ describe("Admin layout", () => { // guard to /login — no fresh `me` round-trip involved, so nothing to // re-stub here. cy.contains("button", "Se déconnecter").click(); - cy.url().should("include", "/login"); + cy.url().should("include", "/admin/login"); }); }); diff --git a/apps/admin-web/cypress/e2e/login.feature b/apps/web/cypress/e2e/admin-login.feature similarity index 84% rename from apps/admin-web/cypress/e2e/login.feature rename to apps/web/cypress/e2e/admin-login.feature index 27ba050..0ed3e8a 100644 --- a/apps/admin-web/cypress/e2e/login.feature +++ b/apps/web/cypress/e2e/admin-login.feature @@ -6,19 +6,19 @@ Feature: Admin login Scenario: A wrong password shows a translated error, no redirect Given the admin session check returns unauthenticated And admin login fails with invalid credentials - When I visit "/login" + When I visit "/admin/login" And I fill in the "email" field with "ops@example.com" And I fill in the "password" field with "wrong" And I click the button "Se connecter" Then I should see "Email ou mot de passe incorrect" - And the URL should include "/login" + And the URL should include "/admin/login" Scenario: A correct login lands on the dashboard Given the admin session check returns unauthenticated And admin login succeeds as "Ops" - When I visit "/login" + When I visit "/admin/login" And I fill in the "email" field with "ops@example.com" And I fill in the "password" field with "correct-horse" And I click the button "Se connecter" - Then the URL should not include "/login" + Then the URL should not include "/admin/login" And I should see the heading "Tableau de bord" diff --git a/apps/web/cypress/e2e/admin-login.ts b/apps/web/cypress/e2e/admin-login.ts new file mode 100644 index 0000000..0d11ebc --- /dev/null +++ b/apps/web/cypress/e2e/admin-login.ts @@ -0,0 +1,35 @@ +import { Given } from "@badeball/cypress-cucumber-preprocessor"; + +// Admin-specific steps for `admin-login.feature`. Generic navigation / form +// steps ("I visit", "I fill in the … field", "I click the button", "I +// should see …") are reused from `support/step_definitions/common.steps.ts`; +// only the `/admin/*` API mocks live here. Every admin call is stubbed via +// `cy.intercept` — this suite never runs a live backend (apps/api's Mocha +// suite covers real `/admin/*` behaviour). + +const adminBody = { + id: 1, + email: "ops@example.com", + name: "Ops", + createdAt: "2026-08-01T00:00:00.000Z", + lastLoginAt: "2026-08-28T09:00:00.000Z", +}; + +Given("the admin session check returns unauthenticated", () => { + cy.intercept("GET", "**/admin/auth/me", { statusCode: 401, body: { code: 4011, message: "no" } }); +}); + +Given("admin login fails with invalid credentials", () => { + cy.intercept("POST", "**/admin/auth/login", { + statusCode: 401, + body: { code: 4010, message: "Invalid email or password" }, + }); +}); + +Given("admin login succeeds as {string}", (name: string) => { + const body = { ...adminBody, name, email: `${name.toLowerCase()}@example.com` }; + cy.intercept("POST", "**/admin/auth/login", { statusCode: 200, body }); + // After navigate("/admin"), RequireAdmin re-checks the session — from now + // on it must report authenticated (last matching intercept wins). + cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body }); +}); diff --git a/apps/admin-web/cypress/e2e/monitoring.cy.ts b/apps/web/cypress/e2e/admin-monitoring.cy.ts similarity index 88% rename from apps/admin-web/cypress/e2e/monitoring.cy.ts rename to apps/web/cypress/e2e/admin-monitoring.cy.ts index 4b14571..81d93bd 100644 --- a/apps/admin-web/cypress/e2e/monitoring.cy.ts +++ b/apps/web/cypress/e2e/admin-monitoring.cy.ts @@ -53,10 +53,11 @@ describe("Admin monitoring", () => { }); it("renders one card per service with its status and details", () => { - cy.intercept("GET", "**/admin/monitoring", { statusCode: 200, body: monitoringFixture() }).as( - "getMonitoring", - ); - cy.visit("/monitoring"); + cy.intercept("GET", "http://localhost:3000/admin/monitoring", { + statusCode: 200, + body: monitoringFixture(), + }).as("getMonitoring"); + cy.visit("/admin/monitoring"); cy.wait("@getMonitoring"); cy.get(".monitoring-card").should("have.length", 4); @@ -73,11 +74,11 @@ describe("Admin monitoring", () => { }); it("shows an error state when the request fails", () => { - cy.intercept("GET", "**/admin/monitoring", { + cy.intercept("GET", "http://localhost:3000/admin/monitoring", { statusCode: 500, body: { code: 5000, message: "x" }, }); - cy.visit("/monitoring"); + cy.visit("/admin/monitoring"); cy.contains("Impossible de charger").should("be.visible"); }); }); diff --git a/apps/web/package.json b/apps/web/package.json index 294a384..57dd08d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,6 +22,7 @@ "react-dom": "^18.3.1", "react-i18next": "^17.0.11", "react-router-dom": "^7.18.2", + "recharts": "^2.15.0", "zod": "^3.25.76" }, "devDependencies": { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index d318330..cb00316 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,7 +1,15 @@ -import { Navigate, Route, Routes } from "react-router-dom"; +import { Navigate, Outlet, Route, Routes } from "react-router-dom"; +import { AdminAuthProvider } from "./features/admin/AdminAuthContext"; +import { RequireAdmin } from "./features/admin/RequireAdmin"; import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated"; import { RequireAuth } from "./features/auth/RequireAuth"; +import { AdminLayout } from "./layouts/AdminLayout"; import { AppLayout } from "./layouts/AppLayout"; +import { CatalogPage as AdminCatalogPage } from "./pages/admin/catalog/CatalogPage"; +import { CorrectionsPage as AdminCorrectionsPage } from "./pages/admin/corrections/CorrectionsPage"; +import { DashboardPage as AdminDashboardPage } from "./pages/admin/dashboard/DashboardPage"; +import { AdminLoginPage } from "./pages/admin/login/AdminLoginPage"; +import { MonitoringPage as AdminMonitoringPage } from "./pages/admin/monitoring/MonitoringPage"; import { LoginPage } from "./pages/auth/LoginPage"; import { SignupPage } from "./pages/auth/SignupPage"; import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage"; @@ -43,6 +51,15 @@ import { ShoppingListPage } from "./pages/shopping-list/ShoppingListPage"; * `/onboarding/sources` is conditional — only reached when the `foyer` step * created/joined a household (see `OnboardingHouseholdPage`'s `goToNextStep`); * skipped otherwise, straight to `/onboarding/allergenes`. + * + * `/admin/*` is the internal admin surface (metrics, monitoring, correction + * triage, off-catalog ingredient review). It's its own top-level group, + * wrapped in {@link AdminAuthProvider} so the `GET /admin/auth/me` session + * probe only runs under `/admin` — the admin session (cookie `admin_session`, + * `ADMIN_JWT_SECRET`) is entirely separate from the end-user one, so this is + * never nested under `RequireAuth`. `/admin/login` is the only + * unauthenticated admin route; everything else sits under `RequireAdmin` + + * `AdminLayout`. */ export function App() { return ( @@ -122,6 +139,28 @@ export function App() { } /> + + + + } + > + } /> + + + + } + > + } /> + } /> + } /> + } /> + + } /> ); diff --git a/apps/admin-web/src/api/client.ts b/apps/web/src/api/admin-client.ts similarity index 88% rename from apps/admin-web/src/api/client.ts rename to apps/web/src/api/admin-client.ts index f96d980..37947e0 100644 --- a/apps/admin-web/src/api/client.ts +++ b/apps/web/src/api/admin-client.ts @@ -26,28 +26,28 @@ function query(params: Record): string { } /** - * Base URL of the admin API surface, configurable via `VITE_ADMIN_API_URL` - * (see `.env.example`). Defaults to `""` (same origin) — correct behind a - * shared reverse proxy; native dev overrides it to `http://localhost:3000` - * in `apps/admin-web/.env` since the Vite dev server (5174) and the API - * (3000) are different origins. + * Base URL of the API — the same `VITE_API_URL` the user-facing + * {@link apiClient} reads (see `api/client.ts`). Defaults to `""` (same + * origin) — correct behind a shared reverse proxy; native dev overrides it + * to `http://localhost:3000` in `apps/web/.env`. The `/admin/*` surface is + * served by the same API process as the rest of the app. */ -const ADMIN_API_BASE_URL: string = import.meta.env.VITE_ADMIN_API_URL ?? ""; +const ADMIN_API_BASE_URL: string = import.meta.env.VITE_API_URL ?? ""; /** * Thrown by {@link AdminApiClient} on any non-2xx response — carries the - * same {@link ErrorCode} the API returned. Same shape as apps/web's - * `ApiError`; kept separate rather than shared so the two apps' transport - * layers stay independent. + * {@link ErrorCode} the API returned. Distinct from `api/client.ts`'s + * `ApiError` (same shape) so a file importing both never has a name clash; + * the admin transport layer stays independent of the user-facing one. */ -export class ApiError extends Error { +export class AdminApiError extends Error { public readonly status: number; public readonly code: ErrorCode; public readonly fieldErrors?: Record; public constructor(status: number, body: ApiErrorResponse) { super(body.message); - this.name = "ApiError"; + this.name = "AdminApiError"; this.status = status; this.code = body.code; this.fieldErrors = body.details; @@ -64,7 +64,7 @@ export class AdminApiClient { /** * Performs a JSON request against the admin API and returns the parsed body. * - * @throws {ApiError} if the response status is not in the 2xx range. + * @throws {AdminApiError} if the response status is not in the 2xx range. */ private async _request( path: string, @@ -79,7 +79,7 @@ export class AdminApiClient { if (!response.ok) { const body = (await response.json().catch(() => null)) as ApiErrorResponse | null; - throw new ApiError( + throw new AdminApiError( response.status, body ?? { code: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" }, ); diff --git a/apps/admin-web/src/features/auth/AdminAuthContext.tsx b/apps/web/src/features/admin/AdminAuthContext.tsx similarity index 97% rename from apps/admin-web/src/features/auth/AdminAuthContext.tsx rename to apps/web/src/features/admin/AdminAuthContext.tsx index 12611bd..2decd90 100644 --- a/apps/admin-web/src/features/auth/AdminAuthContext.tsx +++ b/apps/web/src/features/admin/AdminAuthContext.tsx @@ -1,6 +1,6 @@ import type { AdminLoginInput, AdminUserView } from "@batch-cooking/shared"; import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react"; -import { adminApiClient } from "../../api/client"; +import { adminApiClient } from "../../api/admin-client"; /** Auth state/actions exposed via {@link useAdminAuth} — the admin-app counterpart of apps/web's `AuthContext`. */ interface AdminAuthContextValue { diff --git a/apps/admin-web/src/features/auth/RequireAdmin.tsx b/apps/web/src/features/admin/RequireAdmin.tsx similarity index 66% rename from apps/admin-web/src/features/auth/RequireAdmin.tsx rename to apps/web/src/features/admin/RequireAdmin.tsx index 19e3fce..ba7e4bb 100644 --- a/apps/admin-web/src/features/auth/RequireAdmin.tsx +++ b/apps/web/src/features/admin/RequireAdmin.tsx @@ -5,8 +5,9 @@ import { useAdminAuth } from "./AdminAuthContext"; /** * Route guard for every admin page. Renders nothing while the initial * `GET /admin/auth/me` check is pending (avoids a flash-then-redirect); - * once resolved, renders `children` or redirects to `/login`. Mirror of - * apps/web's `RequireAuth`. + * once resolved, renders `children` or redirects to `/admin/login`. Mirror + * of `RequireAuth` (`features/auth/`), but keyed to the separate admin + * session (`admin_session` cookie), not the user one. */ export function RequireAdmin({ children }: { children: ReactNode }) { const { admin, isLoading } = useAdminAuth(); @@ -15,7 +16,7 @@ export function RequireAdmin({ children }: { children: ReactNode }) { return null; } if (!admin) { - return ; + return ; } return <>{children}; } diff --git a/apps/admin-web/src/features/auth/admin-auth.scss b/apps/web/src/features/admin/admin-auth.scss similarity index 100% rename from apps/admin-web/src/features/auth/admin-auth.scss rename to apps/web/src/features/admin/admin-auth.scss diff --git a/apps/admin-web/src/layouts/AdminLayout.scss b/apps/web/src/layouts/AdminLayout.scss similarity index 100% rename from apps/admin-web/src/layouts/AdminLayout.scss rename to apps/web/src/layouts/AdminLayout.scss diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/web/src/layouts/AdminLayout.tsx similarity index 81% rename from apps/admin-web/src/layouts/AdminLayout.tsx rename to apps/web/src/layouts/AdminLayout.tsx index 1a0eb27..43213ed 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/web/src/layouts/AdminLayout.tsx @@ -2,18 +2,20 @@ import { Activity, LayoutDashboard, ListChecks, PackageSearch } from "lucide-rea import { useState } from "react"; import { useTranslation } from "react-i18next"; import { NavLink, Outlet, useNavigate } from "react-router-dom"; -import { useAdminAuth } from "../features/auth/AdminAuthContext"; +import { useAdminAuth } from "../features/admin/AdminAuthContext"; import "./AdminLayout.scss"; /** * One entry in the admin sidebar's nav. `key` maps to `admin.nav.` in * the locale file — adding a section is one array entry plus one locale key. + * Paths are absolute under `/admin` (the admin route group lives inside + * `apps/web`'s `App.tsx`, mounted at `/admin`). */ const NAV_ITEMS = [ - { to: "/", key: "dashboard", Icon: LayoutDashboard, end: true }, - { to: "/monitoring", key: "monitoring", Icon: Activity, end: false }, - { to: "/corrections", key: "corrections", Icon: ListChecks, end: false }, - { to: "/catalogue", key: "catalog", Icon: PackageSearch, end: false }, + { to: "/admin", key: "dashboard", Icon: LayoutDashboard, end: true }, + { to: "/admin/monitoring", key: "monitoring", Icon: Activity, end: false }, + { to: "/admin/corrections", key: "corrections", Icon: ListChecks, end: false }, + { to: "/admin/catalogue", key: "catalog", Icon: PackageSearch, end: false }, ] as const; /** @@ -33,12 +35,12 @@ export function AdminLayout() { setIsLoggingOut(true); try { await logout(); - void navigate("/login"); + void navigate("/admin/login"); } catch { // Even if the network call failed, the local session state was // cleared optimistically enough for the guard to bounce to /login; // nothing useful to show the operator here. - void navigate("/login"); + void navigate("/admin/login"); } } diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 1232d18..de38753 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -1124,5 +1124,142 @@ "palmSugar": "Sucre de palme", "caneSyrup": "Sirop de sucre de canne" } + }, + "admin": { + "common": { + "comingSoon": "Section à venir.", + "loading": "Chargement…", + "loadError": "Impossible de charger les données, réessayez plus tard." + }, + "login": { + "title": "Administration", + "emailLabel": "Email", + "passwordLabel": "Mot de passe", + "submit": "Se connecter", + "submitting": "Connexion…" + }, + "nav": { + "dashboard": "Tableau de bord", + "monitoring": "Monitoring", + "corrections": "Corrections", + "catalog": "Catalogue" + }, + "layout": { + "logout": "Se déconnecter" + }, + "dashboard": { + "title": "Tableau de bord", + "lead": "Métriques d'utilisation de l'application.", + "windowTotal": "{{n}} sur 30 j", + "recipesBySource": "Recettes importées par source", + "noImports": "Aucune recette importée.", + "events": "Évènements enregistrés (30 j)", + "kpi": { + "users": "Utilisateurs", + "households": "Foyers", + "activeHouseholds": "Foyers actifs", + "recipes": "Recettes", + "recipesImported": "Recettes importées", + "plannings": "Plannings", + "planningItems": "Créneaux planifiés", + "favorites": "Favoris", + "corrections": "Corrections", + "correctionsUnconsumed": "Corrections à traiter", + "trainingSuggestions": "Suggestions d'entraînement", + "admins": "Administrateurs" + }, + "series": { + "signups": "Inscriptions", + "recipesCreated": "Recettes créées", + "planningItemsAdded": "Ajouts au planning", + "correctionsSubmitted": "Corrections soumises", + "trainingSuggestions": "Suggestions générées" + } + }, + "monitoring": { + "title": "Monitoring", + "lead": "Santé des microservices et de la base de données.", + "lastChecked": "Dernière vérification à {{time}}", + "latency": "Latence", + "detail": "Détail", + "lastRun": "Dernier job", + "never": "jamais", + "jobFailed": "échec", + "status": { + "up": "OK", + "degraded": "Dégradé", + "down": "Hors service", + "unknown": "Inconnu" + }, + "service": { + "postgres": "Base de données", + "api": "API", + "intent-service": "Service NLP (spaCy)", + "tech-step-llm-worker": "Worker LLM" + } + }, + "corrections": { + "title": "Corrections", + "lead": "Tri des corrections utilisateur pour le ré-entraînement NLP.", + "caveat": "Le gate F1 + backfill n'a de sens qu'APRÈS avoir édité training_data.py à la main et redémarré le service NLP (il ne s'entraîne qu'au démarrage). Cet écran ne peut faire ni l'un ni l'autre.", + "noSuggestions": "Aucune suggestion pour ces filtres.", + "synonyms": "Synonymes proposés (un par ligne)", + "utterances": "Phrases proposées (une par ligne)", + "save": "Enregistrer", + "apply": "Appliquer", + "reject": "Rejeter", + "tab": { + "suggestions": "Suggestions", + "corrections": "Corrections brutes" + }, + "filter": { + "status": "Statut", + "source": "Source", + "consumed": "Consommée", + "hasCorrected": "Technique corrigée", + "any": "Toutes", + "yes": "Oui", + "no": "Non" + }, + "snippet": { + "title": "Snippet training_data.py", + "help": "Agrège les synonymes/phrases des suggestions « applied » d'une technique, au format à coller dans training_data.py.", + "keyPlaceholder": "clé de technique (ex. simmer)", + "generate": "Générer" + }, + "retrain": { + "title": "Gate F1 + backfill", + "help": "Lance l'évaluation de régression F1 puis, si elle passe, recalcule les techniques de toutes les étapes.", + "run": "Lancer", + "running": "En cours…", + "passed": "OK — {{changed}}/{{total}} étape(s) recalculée(s)", + "failed": "Échec du gate — aucun backfill" + }, + "col": { + "clause": "Clause", + "change": "Changement", + "created": "Créée", + "consumed": "Consommée" + } + }, + "catalog": { + "title": "Ingrédients hors-catalogue", + "lead": "Ingrédients saisis en texte libre par les utilisateurs parce que le catalogue ne les couvrait pas. Regroupés par nom normalisé — à promouvoir dans reference-seed-data.ts + les locales, à la main.", + "empty": "Aucun ingrédient hors-catalogue.", + "tab": { + "pending": "À traiter", + "reviewed": "Traités" + }, + "recipeCount": "{{count}} recette(s)", + "alsoWritten": "Aussi écrit : {{variants}}", + "seenIn": "Vu dans :", + "firstSeen": "Première fois le {{date}}", + "markReviewed": "Marquer comme traité", + "marking": "…", + "pruneOrphans": "Purger les orphelins", + "pruning": "Purge…", + "prunedNone": "Aucun placeholder orphelin à purger.", + "pruned": "{{count}} placeholder(s) orphelin(s) supprimé(s)." + } } } diff --git a/apps/admin-web/src/pages/admin-page.scss b/apps/web/src/pages/admin/admin-page.scss similarity index 100% rename from apps/admin-web/src/pages/admin-page.scss rename to apps/web/src/pages/admin/admin-page.scss diff --git a/apps/admin-web/src/pages/catalog/CatalogPage.tsx b/apps/web/src/pages/admin/catalog/CatalogPage.tsx similarity index 89% rename from apps/admin-web/src/pages/catalog/CatalogPage.tsx rename to apps/web/src/pages/admin/catalog/CatalogPage.tsx index 9aaa028..9087f28 100644 --- a/apps/admin-web/src/pages/catalog/CatalogPage.tsx +++ b/apps/web/src/pages/admin/catalog/CatalogPage.tsx @@ -1,7 +1,7 @@ import type { CatalogPlaceholderGroupView } from "@batch-cooking/shared"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { adminApiClient } from "../../api/client"; +import { adminApiClient } from "../../../api/admin-client"; import "../admin-page.scss"; import "./catalog-page.scss"; import { type CatalogTab, formatDate, reviewedParam, splitSpellings } from "./catalog"; @@ -29,12 +29,22 @@ export function CatalogPage() { const [pruneMessage, setPruneMessage] = useState(null); const [isPruning, setIsPruning] = useState(false); + // Monotonic id of the most recent `load()` call — a response from an + // earlier one (switching tabs fast, or React 18 StrictMode's double-mount + // firing the effect twice) must not clobber the current tab's data. + const requestSeq = useRef(0); + const load = useCallback((forTab: CatalogTab) => { + const seq = ++requestSeq.current; setState({ status: "loading" }); adminApiClient .getPlaceholders(reviewedParam(forTab)) - .then((groups) => setState({ status: "loaded", groups })) - .catch(() => setState({ status: "error" })); + .then((groups) => { + if (seq === requestSeq.current) setState({ status: "loaded", groups }); + }) + .catch(() => { + if (seq === requestSeq.current) setState({ status: "error" }); + }); }, []); useEffect(() => { diff --git a/apps/admin-web/src/pages/catalog/catalog-page.scss b/apps/web/src/pages/admin/catalog/catalog-page.scss similarity index 100% rename from apps/admin-web/src/pages/catalog/catalog-page.scss rename to apps/web/src/pages/admin/catalog/catalog-page.scss diff --git a/apps/admin-web/src/pages/catalog/catalog.ts b/apps/web/src/pages/admin/catalog/catalog.ts similarity index 100% rename from apps/admin-web/src/pages/catalog/catalog.ts rename to apps/web/src/pages/admin/catalog/catalog.ts diff --git a/apps/admin-web/src/pages/corrections/CorrectionsPage.tsx b/apps/web/src/pages/admin/corrections/CorrectionsPage.tsx similarity index 96% rename from apps/admin-web/src/pages/corrections/CorrectionsPage.tsx rename to apps/web/src/pages/admin/corrections/CorrectionsPage.tsx index 02c8c59..331f591 100644 --- a/apps/admin-web/src/pages/corrections/CorrectionsPage.tsx +++ b/apps/web/src/pages/admin/corrections/CorrectionsPage.tsx @@ -7,8 +7,8 @@ import { } from "@batch-cooking/shared"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { ApiError, adminApiClient } from "../../api/client"; -import { errorMessageService } from "../../services/error-message.service"; +import { AdminApiError, adminApiClient } from "../../../api/admin-client"; +import { errorMessageService } from "../../../services/error-message.service"; import "../admin-page.scss"; import "./corrections-page.scss"; import { linesToList, listsDiffer, listToLines } from "./corrections"; @@ -152,7 +152,9 @@ function SuggestionCard({ onMutated(); } catch (err) { setError( - errorMessageService.getLabel(err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR), + errorMessageService.getLabel( + err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR, + ), ); } finally { setBusy(false); @@ -232,7 +234,9 @@ function SnippetPanel() { setSnippet(result.snippet); } catch (err) { setError( - errorMessageService.getLabel(err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR), + errorMessageService.getLabel( + err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR, + ), ); } } @@ -273,7 +277,9 @@ function RetrainPanel() { setResult(await adminApiClient.retrain({})); } catch (err) { setError( - errorMessageService.getLabel(err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR), + errorMessageService.getLabel( + err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR, + ), ); } finally { setBusy(false); diff --git a/apps/admin-web/src/pages/corrections/corrections-page.scss b/apps/web/src/pages/admin/corrections/corrections-page.scss similarity index 100% rename from apps/admin-web/src/pages/corrections/corrections-page.scss rename to apps/web/src/pages/admin/corrections/corrections-page.scss diff --git a/apps/admin-web/src/pages/corrections/corrections.ts b/apps/web/src/pages/admin/corrections/corrections.ts similarity index 100% rename from apps/admin-web/src/pages/corrections/corrections.ts rename to apps/web/src/pages/admin/corrections/corrections.ts diff --git a/apps/admin-web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/admin/dashboard/DashboardPage.tsx similarity index 98% rename from apps/admin-web/src/pages/dashboard/DashboardPage.tsx rename to apps/web/src/pages/admin/dashboard/DashboardPage.tsx index 56f66e8..9515ba8 100644 --- a/apps/admin-web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/admin/dashboard/DashboardPage.tsx @@ -10,7 +10,7 @@ import { XAxis, YAxis, } from "recharts"; -import { adminApiClient } from "../../api/client"; +import { adminApiClient } from "../../../api/admin-client"; import "../admin-page.scss"; import "./dashboard-page.scss"; import { formatCount, kpiTiles, seriesTotal, shortDay } from "./dashboard"; diff --git a/apps/admin-web/src/pages/dashboard/dashboard-page.scss b/apps/web/src/pages/admin/dashboard/dashboard-page.scss similarity index 100% rename from apps/admin-web/src/pages/dashboard/dashboard-page.scss rename to apps/web/src/pages/admin/dashboard/dashboard-page.scss diff --git a/apps/admin-web/src/pages/dashboard/dashboard.ts b/apps/web/src/pages/admin/dashboard/dashboard.ts similarity index 100% rename from apps/admin-web/src/pages/dashboard/dashboard.ts rename to apps/web/src/pages/admin/dashboard/dashboard.ts diff --git a/apps/admin-web/src/pages/login/LoginPage.tsx b/apps/web/src/pages/admin/login/AdminLoginPage.tsx similarity index 72% rename from apps/admin-web/src/pages/login/LoginPage.tsx rename to apps/web/src/pages/admin/login/AdminLoginPage.tsx index f984391..4ac0f24 100644 --- a/apps/admin-web/src/pages/login/LoginPage.tsx +++ b/apps/web/src/pages/admin/login/AdminLoginPage.tsx @@ -2,20 +2,20 @@ import { adminLoginSchema, ErrorCode } from "@batch-cooking/shared"; import { type FormEvent, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { ApiError } from "../../api/client"; -import { useAdminAuth } from "../../features/auth/AdminAuthContext"; -import "../../features/auth/admin-auth.scss"; -import { fieldErrorsFrom } from "../../lib/zod-errors"; -import { errorMessageService } from "../../services/error-message.service"; +import { AdminApiError } from "../../../api/admin-client"; +import { useAdminAuth } from "../../../features/admin/AdminAuthContext"; +import "../../../features/admin/admin-auth.scss"; +import { fieldErrorsFrom } from "../../../lib/zod-errors"; +import { errorMessageService } from "../../../services/error-message.service"; /** - * The admin login screen — the only unauthenticated route. Client-side - * validation via the shared `adminLoginSchema` (same rules the API - * enforces), then `POST /admin/auth/login`; any API failure is translated - * to a localized label via {@link ErrorMessageService}. Same structure as - * apps/web's `LoginPage`. + * The admin login screen — the only unauthenticated route under `/admin`. + * Client-side validation via the shared `adminLoginSchema` (same rules the + * API enforces), then `POST /admin/auth/login`; any API failure is + * translated to a localized label via {@link ErrorMessageService}. Same + * structure as the user-facing `LoginPage` (`pages/auth/`). */ -export function LoginPage() { +export function AdminLoginPage() { const { login } = useAdminAuth(); const navigate = useNavigate(); const { t } = useTranslation(); @@ -40,9 +40,9 @@ export function LoginPage() { setIsSubmitting(true); try { await login(result.data); - void navigate("/"); + void navigate("/admin"); } catch (err) { - const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR; + const code = err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR; setFormError(errorMessageService.getLabel(code)); } finally { setIsSubmitting(false); diff --git a/apps/admin-web/src/pages/monitoring/MonitoringPage.tsx b/apps/web/src/pages/admin/monitoring/MonitoringPage.tsx similarity index 98% rename from apps/admin-web/src/pages/monitoring/MonitoringPage.tsx rename to apps/web/src/pages/admin/monitoring/MonitoringPage.tsx index 302c93f..0e1564b 100644 --- a/apps/admin-web/src/pages/monitoring/MonitoringPage.tsx +++ b/apps/web/src/pages/admin/monitoring/MonitoringPage.tsx @@ -1,7 +1,7 @@ import type { MonitoringView, ServiceHealthView } from "@batch-cooking/shared"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { adminApiClient } from "../../api/client"; +import { adminApiClient } from "../../../api/admin-client"; import "../admin-page.scss"; import "./monitoring-page.scss"; import { clockTime, POLL_INTERVAL_MS, statusModifier } from "./monitoring"; diff --git a/apps/admin-web/src/pages/monitoring/monitoring-page.scss b/apps/web/src/pages/admin/monitoring/monitoring-page.scss similarity index 100% rename from apps/admin-web/src/pages/monitoring/monitoring-page.scss rename to apps/web/src/pages/admin/monitoring/monitoring-page.scss diff --git a/apps/admin-web/src/pages/monitoring/monitoring.ts b/apps/web/src/pages/admin/monitoring/monitoring.ts similarity index 100% rename from apps/admin-web/src/pages/monitoring/monitoring.ts rename to apps/web/src/pages/admin/monitoring/monitoring.ts diff --git a/docker-compose.yml b/docker-compose.yml index 64a8a02..80a861d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,15 +48,12 @@ services: # default: `/internal/tech-steps/*` fails closed rather than open # for a deployment that doesn't run the worker at all. INTERNAL_WORKER_SECRET: ${INTERNAL_WORKER_SECRET:-} - # Admin application (apps/admin-web + /admin/*). Both unset by default: - # `requireAdmin` fails closed without ADMIN_JWT_SECRET, so a stack - # that doesn't run the admin app simply has every /admin/* route 401. - # Must be a *different* secret than JWT_SECRET. + # Admin surface (apps/web's /admin/* routes + the API's /admin/*). + # Unset by default: `requireAdmin` fails closed without + # ADMIN_JWT_SECRET, so a stack that doesn't need the admin app simply + # has every /admin/* route 401. Must be a *different* secret than + # JWT_SECRET. ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET:-} - # Public origin apps/admin-web is served from, added to the CORS - # allow-list alongside the main app. Defaults to the compose - # `admin-web` service's mapped host port. - ADMIN_CORS_ORIGIN: ${ADMIN_CORS_ORIGIN:-http://localhost:3001} # Compose network service name, not localhost — same reasoning as # DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback: # tech-step-intent-service is a core dependency (see its own entry @@ -137,24 +134,6 @@ services: # Dockerfile doc comment on its VOLUME declaration. - tech_step_llm_worker_models:/worker/models - # The admin application's frontend (apps/admin-web) — a static nginx image, - # entirely independent of `app` (its own build, its own URL). Talks to - # `app`'s /admin/* surface. Optional: a stack that doesn't need the admin - # app just omits this service. `VITE_ADMIN_API_URL` is baked in at build - # time — set it as a build arg when the admin app and the API sit on - # different public origins (default "" = same origin, for a shared proxy). - admin-web: - build: - context: . - dockerfile: apps/admin-web/Dockerfile - args: - VITE_ADMIN_API_URL: ${VITE_ADMIN_API_URL:-} - restart: unless-stopped - depends_on: - - app - ports: - - "${ADMIN_WEB_PORT:-3001}:80" - volumes: postgres_data: tech_step_llm_worker_models: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f970c4..a23699f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,79 +15,6 @@ importers: specifier: ^5.7.2 version: 5.9.3 - apps/admin-web: - dependencies: - '@batch-cooking/date-tools': - specifier: workspace:* - version: link:../../packages/date-tools - '@batch-cooking/shared': - specifier: workspace:* - version: link:../../packages/shared - i18next: - specifier: ^26.3.6 - version: 26.3.6(typescript@5.9.3) - lucide-react: - specifier: ^1.32.0 - version: 1.32.0(react@18.3.1) - react: - specifier: ^18.3.1 - version: 18.3.1 - react-dom: - specifier: ^18.3.1 - version: 18.3.1(react@18.3.1) - react-i18next: - specifier: ^17.0.11 - version: 17.0.11(i18next@26.3.6(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - react-router-dom: - specifier: ^7.18.2 - version: 7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - recharts: - specifier: ^2.15.0 - version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - zod: - specifier: ^3.25.76 - version: 3.25.76 - devDependencies: - '@badeball/cypress-cucumber-preprocessor': - specifier: 22.2.0 - version: 22.2.0(@babel/core@7.29.7)(cypress@13.17.0)(typescript@5.9.3) - '@bahmutov/cypress-esbuild-preprocessor': - specifier: 2.2.8 - version: 2.2.8(esbuild@0.21.5) - '@cypress/vite-dev-server': - specifier: 5.2.1 - version: 5.2.1 - '@types/node': - specifier: ^22.9.0 - version: 22.20.1 - '@types/react': - specifier: ^18.3.12 - version: 18.3.31 - '@types/react-dom': - specifier: ^18.3.1 - version: 18.3.7(@types/react@18.3.31) - '@vitejs/plugin-react': - specifier: ^4.3.3 - version: 4.7.0(vite@5.4.21(@types/node@22.20.1)(sass@1.102.0)) - cypress: - specifier: 13.17.0 - version: 13.17.0 - esbuild: - specifier: 0.21.5 - version: 0.21.5 - sass: - specifier: ^1.102.0 - version: 1.102.0 - start-server-and-test: - specifier: ^2.0.8 - version: 2.1.5 - typescript: - specifier: ^5.7.2 - version: 5.9.3 - vite: - specifier: ^5.4.11 - version: 5.4.21(@types/node@22.20.1)(sass@1.102.0) - apps/api: dependencies: '@batch-cooking/date-tools': @@ -184,6 +111,9 @@ importers: react-router-dom: specifier: ^7.18.2 version: 7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + recharts: + specifier: ^2.15.0 + version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) zod: specifier: ^3.25.76 version: 3.25.76 diff --git a/specs/backend-architecture.md b/specs/backend-architecture.md index 28e811e..f431a7e 100644 --- a/specs/backend-architecture.md +++ b/specs/backend-architecture.md @@ -349,10 +349,11 @@ identique (aucune conversion — même posture que `ShoppingListItemView`). ## `admin` — application d'administration (`/admin/*`) -Surface d'exploitation servie à `apps/admin-web` (frontend Vite **séparé**, -URL/déploiement propres). Vit dans `apps/api` (qui reste seul propriétaire du -schéma) mais avec une **authentification totalement distincte** de celle des -utilisateurs. +Surface d'exploitation consommée par les routes `/admin/*` de `apps/web` +(mêmes app/build/origine que le reste du frontend, montées sous `/admin` dans +`App.tsx`, enveloppées de `AdminAuthProvider` + `RequireAdmin`). Vit dans +`apps/api` (qui reste seul propriétaire du schéma) mais avec une +**authentification totalement distincte** de celle des utilisateurs. **Auth** (`middlewares/require-admin.ts`, `lib/admin-jwt.ts`) — table `AdminUser` isolée (aucune relation vers `UserProfile`), cookie @@ -361,8 +362,8 @@ utilisateurs. `requireAuth`, **échoue fermé** si `ADMIN_JWT_SECRET` est absent (posture `requireInternalWorker`). Aucun signup exposé — le 1ᵉʳ admin est créé hors-bande par `src/scripts/create-admin.ts` (flags ou `ADMIN_INITIAL_*`). -`res.locals.adminUser` typé `AdminLocals`. CORS : `setupCore` accepte -`string[]`, `app.ts` autorise `CORS_ORIGIN` + `ADMIN_CORS_ORIGIN`. +`res.locals.adminUser` typé `AdminLocals`. Pas de CORS dédié : l'UI admin est +servie par la même origine que le reste de l'app (`CORS_ORIGIN` suffit). Router agrégateur `modules/admin/admin.routes.ts` monté `/admin` : `/admin/auth` (`login`/`logout`/`me`), `/admin/metrics` (ci-dessous). -- 2.45.2 From 1a28d67218dc8a08c4e720b6ad44cb040cee36aa Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 29 Aug 2026 19:28:53 +0200 Subject: [PATCH 2/3] test(admin): fixe l'intercept monitoring (route page == chemin API) La route page `/admin/monitoring` et le chemin API `GET /admin/monitoring` sont identiques : un glob `**/admin/monitoring` capturait aussi la requete document du `cy.visit()`. Le hardcode `http://localhost:3000/...` marchait en local (VITE_API_URL dans apps/web/.env) mais pas en CI (pas de .env -> API en meme origine sur :5173, donc collision totale) : `cy.wait (@getMonitoring)` timeout. `resourceType: "fetch"` epingle l'intercept sur le seul XHR d'AdminApiClient. Verifie en simulant la CI (sans apps/web/.env) : 16/16 specs admin verts. Co-Authored-By: Claude Sonnet 5 --- apps/web/cypress/e2e/admin-monitoring.cy.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/web/cypress/e2e/admin-monitoring.cy.ts b/apps/web/cypress/e2e/admin-monitoring.cy.ts index 81d93bd..fd6ba78 100644 --- a/apps/web/cypress/e2e/admin-monitoring.cy.ts +++ b/apps/web/cypress/e2e/admin-monitoring.cy.ts @@ -52,8 +52,19 @@ describe("Admin monitoring", () => { cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody }); }); + // The page route `/admin/monitoring` and the API path `GET /admin/monitoring` + // are identical — a plain `**/admin/monitoring` glob would also match the + // `cy.visit()` document request (and whether that's same-origin or not + // depends on `VITE_API_URL`, which isn't set in CI). `resourceType: "fetch"` + // pins the intercept to the `AdminApiClient` XHR only. + const monitoringApi = { + method: "GET", + url: "**/admin/monitoring", + resourceType: "fetch", + } as const; + it("renders one card per service with its status and details", () => { - cy.intercept("GET", "http://localhost:3000/admin/monitoring", { + cy.intercept(monitoringApi, { statusCode: 200, body: monitoringFixture(), }).as("getMonitoring"); @@ -74,7 +85,7 @@ describe("Admin monitoring", () => { }); it("shows an error state when the request fails", () => { - cy.intercept("GET", "http://localhost:3000/admin/monitoring", { + cy.intercept(monitoringApi, { statusCode: 500, body: { code: 5000, message: "x" }, }); -- 2.45.2 From ccec30b2e8dd969724ef8dbf9f6d80a14741ceef Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 29 Aug 2026 20:54:49 +0200 Subject: [PATCH 3/3] chore: re-declenche la CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le job `test` du run precedent a echoue sur un flake du conteneur Postgres `services:` du runner act (`relation "user_profile_allergy" does not exist` juste apres « All migrations successfully applied », 0 test execute). Aucun fichier lie a la DB n'a change dans cette PR, et le superset feat/temperature- metadata a fait tourner `test` vert (516 passing). Co-Authored-By: Claude Sonnet 5 -- 2.45.2