diff --git a/.env.example b/.env.example index accdc43..469a3e2 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,27 @@ 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) --------- +# 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. +# +# 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. +# ADMIN_INITIAL_EMAIL=ops@example.com +# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars +# ADMIN_INITIAL_NAME=Ops + # Optional — only set this to false if THIS deployment is served over # plain HTTP (no TLS in front of it). Left unset, the session cookie # requires HTTPS (Secure attribute) as it should for a real deployment; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d128f7b..38fbe84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,10 @@ env: # exercise the success path (matching secret), not just the "unset" # rejection every environment that doesn't set this gets by default. INTERNAL_WORKER_SECRET: "ci-only-worker-secret-not-used-anywhere-else-32chars+" + # Same reasoning — lets admin-auth.test.ts exercise the admin login + # success path + the requireAdmin-guarded routes, not just the "no admin + # secret configured -> 401" path. + ADMIN_JWT_SECRET: "ci-only-admin-secret-not-used-anywhere-else-32chars+" # Shared between the `test` job's own uvicorn step (below) and apps/api's # IntentServiceClient — see the `test` job for why this can't be a # `services:` container like postgres above (GitHub Actions can only pull @@ -175,3 +179,10 @@ 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/.gitignore b/.gitignore index 0df7f12..bdcfe13 100644 --- a/.gitignore +++ b/.gitignore @@ -163,3 +163,6 @@ tmp-mockups/ apps/web/cypress/screenshots/ apps/web/cypress/videos/ apps/web/cypress/downloads/ +apps/admin-web/cypress/screenshots/ +apps/admin-web/cypress/videos/ +apps/admin-web/cypress/downloads/ diff --git a/apps/admin-web/.env.example b/apps/admin-web/.env.example new file mode 100644 index 0000000..5559b08 --- /dev/null +++ b/apps/admin-web/.env.example @@ -0,0 +1,6 @@ +# 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 new file mode 100644 index 0000000..bf8d9a4 --- /dev/null +++ b/apps/admin-web/Dockerfile @@ -0,0 +1,25 @@ +# 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 new file mode 100644 index 0000000..e799b01 --- /dev/null +++ b/apps/admin-web/cypress.config.ts @@ -0,0 +1,28 @@ +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/admin-layout.cy.ts b/apps/admin-web/cypress/e2e/admin-layout.cy.ts new file mode 100644 index 0000000..68d936b --- /dev/null +++ b/apps/admin-web/cypress/e2e/admin-layout.cy.ts @@ -0,0 +1,62 @@ +// Mocks the admin API via cy.intercept — no 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", +}; + +describe("Admin layout", () => { + it("redirects to /login when there is no admin session", () => { + cy.intercept("GET", "**/admin/auth/me", { + statusCode: 401, + body: { code: 4011, message: "no" }, + }); + cy.visit("/monitoring"); + cy.url().should("include", "/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.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.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.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.contains("h1", "Ingrédients hors-catalogue").should("be.visible"); + + cy.contains("nav a", "Tableau de bord").click(); + cy.url().should("eq", `${Cypress.config().baseUrl}/`); + }); + + 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("/"); + + // Wait until the guarded layout has actually mounted before acting. + cy.contains("h1", "Tableau de bord").should("be.visible"); + + // Logout clears the in-memory admin state, which is what bounces the + // 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"); + }); +}); diff --git a/apps/admin-web/cypress/e2e/catalog.cy.ts b/apps/admin-web/cypress/e2e/catalog.cy.ts new file mode 100644 index 0000000..7bf00ed --- /dev/null +++ b/apps/admin-web/cypress/e2e/catalog.cy.ts @@ -0,0 +1,96 @@ +// Mocks the admin API via cy.intercept — no live backend. + +const adminBody = { + id: 1, + email: "ops@example.com", + name: "Ops", + createdAt: "2026-08-01T00:00:00.000Z", + lastLoginAt: "2026-08-28T09:00:00.000Z", +}; + +function pendingGroups() { + return [ + { + normalizedName: "piment d espelette", + displayNames: ["Piment d'Espelette", "piment d espelette"], + ingredientIds: [11, 12], + recipeCount: 2, + sampleRecipes: [ + { id: 1, name: "Poulet basquaise" }, + { id: 2, name: "Piperade" }, + ], + firstSeenAt: "2026-08-20T10:00:00.000Z", + allReviewed: false, + }, + { + normalizedName: "sumac", + displayNames: ["Sumac"], + ingredientIds: [13], + recipeCount: 1, + sampleRecipes: [{ id: 3, name: "Fattoush" }], + firstSeenAt: "2026-08-22T10:00:00.000Z", + allReviewed: false, + }, + ]; +} + +describe("Admin catalog — off-catalog ingredients", () => { + beforeEach(() => { + cy.viewport(1400, 900); + cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody }); + }); + + it("lists placeholder groups newest-impact first with their recipe count and spelling variants", () => { + cy.intercept("GET", "**/admin/catalog/placeholders*", { + statusCode: 200, + body: pendingGroups(), + }).as("getPlaceholders"); + cy.visit("/catalogue"); + cy.wait("@getPlaceholders"); + + cy.get(".catalog-card").should("have.length", 2); + cy.get(".catalog-card").first().should("contain.text", "Piment d'Espelette"); + cy.contains(".catalog-card", "Piment d'Espelette") + .should("contain.text", "2 recette") + .and("contain.text", "piment d espelette") + .and("contain.text", "Poulet basquaise"); + }); + + it("marks a group reviewed and reloads the list", () => { + cy.intercept("GET", "**/admin/catalog/placeholders*", { + statusCode: 200, + body: pendingGroups(), + }).as("getPlaceholders"); + cy.intercept("PATCH", "**/admin/catalog/placeholders/mark-reviewed", { + statusCode: 200, + body: { reviewed: 1 }, + }).as("markReviewed"); + + cy.visit("/catalogue"); + cy.wait("@getPlaceholders"); + + cy.contains(".catalog-card", "Sumac").contains("button", "Marquer comme traité").click(); + + cy.wait("@markReviewed") + .its("request.body") + .should("deep.equal", { ingredientIds: [13] }); + // The page re-fetches the list after the PATCH. + cy.get("@getPlaceholders.all").should("have.length.greaterThan", 1); + }); + + it("switches to the reviewed archive tab", () => { + cy.intercept("GET", "**/admin/catalog/placeholders", { + statusCode: 200, + body: pendingGroups(), + }); + cy.intercept("GET", "**/admin/catalog/placeholders?reviewed=true", { + statusCode: 200, + body: [], + }).as("getReviewed"); + + cy.visit("/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/admin-web/cypress/e2e/corrections.cy.ts new file mode 100644 index 0000000..e38cd98 --- /dev/null +++ b/apps/admin-web/cypress/e2e/corrections.cy.ts @@ -0,0 +1,159 @@ +// Mocks the admin API via cy.intercept — no live backend. + +const adminBody = { + id: 1, + email: "ops@example.com", + name: "Ops", + createdAt: "2026-08-01T00:00:00.000Z", + lastLoginAt: "2026-08-28T09:00:00.000Z", +}; + +function suggestionGroups() { + return [ + { + techStepKey: "simmer", + suggestions: [ + { + id: 11, + techStepKey: "simmer", + locale: "fr", + suggestedSynonyms: ["frémir"], + suggestedUtterances: ["laisser cuire tout doucement"], + sourceType: "correction", + status: "pending", + createdAt: "2026-08-20T00:00:00.000Z", + sourceCorrection: { + id: 5, + recipeId: 2, + stepId: 7, + clauseText: "faire mijoter la sauce", + previousTechStepKey: "cook", + correctedTechStepKey: "simmer", + }, + }, + ], + }, + ]; +} + +function corrections() { + return [ + { + id: 5, + recipeId: 2, + stepId: 7, + stepDescription: "Faire mijoter la sauce 20 min.", + clauseText: "faire mijoter la sauce", + start: 0, + end: 21, + previousTechStepKey: "cook", + correctedTechStepKey: "simmer", + createdAt: "2026-08-20T00:00:00.000Z", + consumedAt: null, + }, + { + id: 6, + recipeId: 3, + stepId: 9, + stepDescription: "Réserver au frais.", + clauseText: "Réserver au frais", + start: 0, + end: 17, + previousTechStepKey: "setAside", + correctedTechStepKey: null, + createdAt: "2026-08-19T00:00:00.000Z", + consumedAt: null, + }, + ]; +} + +describe("Admin corrections triage", () => { + beforeEach(() => { + cy.viewport(1400, 1000); + cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody }); + cy.intercept("GET", "**/admin/tech-steps/suggestions*", { + statusCode: 200, + body: suggestionGroups(), + }).as("getSuggestions"); + cy.intercept("GET", "**/admin/tech-steps/corrections*", { + statusCode: 200, + body: corrections(), + }).as("getCorrections"); + }); + + it("shows the caveat, groups suggestions by technique, and applies one", () => { + cy.intercept("PATCH", "**/admin/tech-steps/suggestions/11", { + statusCode: 200, + body: { ...suggestionGroups()[0].suggestions[0], status: "applied" }, + }).as("patch"); + + cy.visit("/corrections"); + cy.wait("@getSuggestions"); + + cy.contains(".corrections-caveat", "training_data.py").should("be.visible"); + cy.contains(".suggestion-group h2", "simmer").should("be.visible"); + cy.contains(".suggestion-card", "faire mijoter la sauce").should( + "contain.text", + "cook → simmer", + ); + + cy.contains(".suggestion-card button", "Appliquer").click(); + cy.wait("@patch").its("request.body").should("deep.equal", { status: "applied" }); + }); + + it("generates a training_data.py snippet", () => { + cy.intercept("GET", "**/admin/tech-steps/training-data-snippet*", { + statusCode: 200, + body: { + techStepKey: "simmer", + locale: "fr", + status: "applied", + suggestionCount: 2, + synonyms: ["frémir", "réduire"], + utterances: [], + snippet: + '# simmer (fr) — 2 suggestion(s) "applied"\n"synonyms": [\n "frémir",\n "réduire",\n],', + }, + }).as("getSnippet"); + + cy.visit("/corrections"); + cy.get(".corrections-panel input").type("simmer"); + cy.contains(".corrections-panel button", "Générer").click(); + cy.wait("@getSnippet"); + cy.get(".corrections-snippet").should("contain.value", '"synonyms": ['); + }); + + it("runs the F1 gate and shows the result", () => { + cy.intercept("POST", "**/admin/tech-steps/retrain", { + statusCode: 200, + body: { + f1: 0.83, + precision: 0.8, + recall: 0.86, + minF1: 0.8, + gatePassed: true, + backfilled: { total: 120, changed: 4 }, + marked: { applied: 0, rejected: 0 }, + }, + }).as("retrain"); + + cy.visit("/corrections"); + cy.contains(".corrections-panel--retrain button", "Lancer").click(); + cy.wait("@retrain"); + cy.contains(".retrain-result", "F1 0.830") + .should("have.class", "retrain-result--ok") + .and("contain.text", "4/120"); + }); + + it("lists raw corrections including the removals, on the second tab", () => { + cy.visit("/corrections"); + cy.contains(".corrections-tabs button", "Corrections brutes").click(); + cy.wait("@getCorrections"); + + cy.get(".corrections-table tbody tr").should("have.length", 2); + cy.contains(".corrections-table tr", "Réserver au frais").should( + "contain.text", + "setAside → ∅", + ); + }); +}); diff --git a/apps/admin-web/cypress/e2e/dashboard.cy.ts b/apps/admin-web/cypress/e2e/dashboard.cy.ts new file mode 100644 index 0000000..e731c23 --- /dev/null +++ b/apps/admin-web/cypress/e2e/dashboard.cy.ts @@ -0,0 +1,95 @@ +// Mocks the admin API via cy.intercept — no live backend. + +const adminBody = { + id: 1, + email: "ops@example.com", + name: "Ops", + createdAt: "2026-08-01T00:00:00.000Z", + lastLoginAt: "2026-08-28T09:00:00.000Z", +}; + +/** A 3-day series helper for the fixture. */ +function series(counts: number[]) { + return counts.map((count, i) => ({ + date: `2026-08-${String(10 + i).padStart(2, "0")}`, + count, + })); +} + +function metricsFixture() { + return { + generatedAt: "2026-08-28T09:00:00.000Z", + rangeDays: 30, + snapshot: { + admins: 2, + users: 42, + households: 15, + activeHouseholds: 9, + recipes: 120, + recipesManual: 30, + recipesImported: 90, + recipesBySource: [ + { key: "themealdb", label: "TheMealDB", count: 60 }, + { key: "marmiton", label: "Marmiton", count: 30 }, + ], + plannings: 18, + planningItems: 210, + steps: 640, + detectedTechniques: 900, + favorites: 55, + corrections: 12, + correctionsUnconsumed: 4, + correctionsRemoval: 2, + trainingSuggestions: 8, + trainingSuggestionsByStatus: [{ key: "pending", label: "pending", count: 8 }], + trainingSuggestionsBySourceType: [{ key: "correction", label: "correction", count: 8 }], + }, + series: { + signups: series([1, 3, 2]), + recipesCreated: series([0, 2, 1]), + planningItemsAdded: series([4, 1, 5]), + correctionsSubmitted: series([0, 0, 1]), + trainingSuggestions: series([0, 1, 0]), + }, + events: [{ type: "user.signup", buckets: series([1, 3, 2]) }], + }; +} + +describe("Admin dashboard", () => { + beforeEach(() => { + cy.viewport(1400, 900); + cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody }); + }); + + it("renders KPI tiles, a chart per series, and the breakdown lists", () => { + cy.intercept("GET", "**/admin/metrics*", { statusCode: 200, body: metricsFixture() }).as( + "getMetrics", + ); + cy.visit("/"); + cy.wait("@getMetrics").its("request.url").should("include", "days=30"); + + // KPI tiles — value + label. + cy.contains(".kpi-tile", "Utilisateurs").should("contain.text", "42"); + cy.contains(".kpi-tile", "Recettes importées").should("contain.text", "90"); + cy.contains(".kpi-tile", "Corrections à traiter").should("contain.text", "4"); + + // One chart card per instrumented series. + cy.get(".chart-card").should("have.length", 5); + cy.contains(".chart-card", "Inscriptions").should("contain.text", "6 sur 30 j"); + + // Breakdown lists. + cy.contains(".breakdown", "Recettes importées par source") + .should("contain.text", "TheMealDB") + .and("contain.text", "Marmiton"); + cy.contains(".breakdown", "Évènements enregistrés").should("contain.text", "user.signup"); + }); + + it("shows an error state when the metrics request fails", () => { + cy.intercept("GET", "**/admin/metrics*", { + statusCode: 500, + body: { code: 5000, message: "x" }, + }); + cy.visit("/"); + cy.contains("Impossible de charger").should("be.visible"); + }); +}); diff --git a/apps/admin-web/cypress/e2e/login.feature b/apps/admin-web/cypress/e2e/login.feature new file mode 100644 index 0000000..27ba050 --- /dev/null +++ b/apps/admin-web/cypress/e2e/login.feature @@ -0,0 +1,24 @@ +Feature: Admin login + As an operator + I want to sign in to the admin application + So that I can reach the metrics, monitoring and correction-triage sections + + 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" + 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" + + 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" + 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" + And I should see the heading "Tableau de bord" diff --git a/apps/admin-web/cypress/e2e/login.ts b/apps/admin-web/cypress/e2e/login.ts new file mode 100644 index 0000000..36baea8 --- /dev/null +++ b/apps/admin-web/cypress/e2e/login.ts @@ -0,0 +1,24 @@ +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/e2e/monitoring.cy.ts b/apps/admin-web/cypress/e2e/monitoring.cy.ts new file mode 100644 index 0000000..4b14571 --- /dev/null +++ b/apps/admin-web/cypress/e2e/monitoring.cy.ts @@ -0,0 +1,83 @@ +// Mocks the admin API via cy.intercept — no live backend. + +const adminBody = { + id: 1, + email: "ops@example.com", + name: "Ops", + createdAt: "2026-08-01T00:00:00.000Z", + lastLoginAt: "2026-08-28T09:00:00.000Z", +}; + +function monitoringFixture() { + return { + generatedAt: "2026-08-28T09:15:00.000Z", + services: [ + { + key: "postgres", + status: "up", + latencyMs: 3.2, + detail: null, + checkedAt: "2026-08-28T09:15:00.000Z", + }, + { + key: "api", + status: "up", + latencyMs: 0, + detail: "uptime 3 h 12 min · RSS 120 Mo", + checkedAt: "2026-08-28T09:15:00.000Z", + }, + { + key: "intent-service", + status: "down", + latencyMs: null, + detail: "fetch failed", + checkedAt: "2026-08-28T09:15:00.000Z", + }, + { + key: "tech-step-llm-worker", + status: "degraded", + latencyMs: null, + detail: "dernier battement il y a 9 j", + checkedAt: "2026-08-28T09:15:00.000Z", + lastRunAt: "2026-08-19T03:00:00.000Z", + lastResult: { job: "audit-low-confidence", ok: true, counts: { suggestions: 2 } }, + }, + ], + }; +} + +describe("Admin monitoring", () => { + beforeEach(() => { + cy.viewport(1400, 900); + cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody }); + }); + + 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.wait("@getMonitoring"); + + cy.get(".monitoring-card").should("have.length", 4); + + cy.contains(".monitoring-card", "Base de données") + .should("have.class", "monitoring-card--up") + .and("contain.text", "3.2 ms"); + cy.contains(".monitoring-card", "Service NLP (spaCy)") + .should("have.class", "monitoring-card--down") + .and("contain.text", "Hors service"); + cy.contains(".monitoring-card", "Worker LLM") + .should("have.class", "monitoring-card--degraded") + .and("contain.text", "audit-low-confidence"); + }); + + it("shows an error state when the request fails", () => { + cy.intercept("GET", "**/admin/monitoring", { + statusCode: 500, + body: { code: 5000, message: "x" }, + }); + cy.visit("/monitoring"); + cy.contains("Impossible de charger").should("be.visible"); + }); +}); diff --git a/apps/admin-web/cypress/support/e2e.ts b/apps/admin-web/cypress/support/e2e.ts new file mode 100644 index 0000000..62aa998 --- /dev/null +++ b/apps/admin-web/cypress/support/e2e.ts @@ -0,0 +1,3 @@ +// 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 new file mode 100644 index 0000000..0ed80bf --- /dev/null +++ b/apps/admin-web/cypress/support/step_definitions/common.steps.ts @@ -0,0 +1,54 @@ +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 new file mode 100644 index 0000000..8c44b55 --- /dev/null +++ b/apps/admin-web/index.html @@ -0,0 +1,12 @@ + + + + + + batchCooking — Admin + + +
+ + + diff --git a/apps/admin-web/nginx.conf b/apps/admin-web/nginx.conf new file mode 100644 index 0000000..cf18ff6 --- /dev/null +++ b/apps/admin-web/nginx.conf @@ -0,0 +1,20 @@ +# 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 new file mode 100644 index 0000000..fb4fabe --- /dev/null +++ b/apps/admin-web/package.json @@ -0,0 +1,42 @@ +{ + "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 new file mode 100644 index 0000000..e37c1fa --- /dev/null +++ b/apps/admin-web/src/App.tsx @@ -0,0 +1,36 @@ +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/api/client.ts b/apps/admin-web/src/api/client.ts new file mode 100644 index 0000000..f96d980 --- /dev/null +++ b/apps/admin-web/src/api/client.ts @@ -0,0 +1,192 @@ +import { + type AdminLoginInput, + type AdminUserView, + type ApiErrorResponse, + type CatalogPlaceholderGroupView, + type CorrectionAdminView, + ErrorCode, + type MarkPlaceholdersReviewedInput, + type MetricsView, + type MonitoringView, + type PruneOrphansResultView, + type RetrainRequestInput, + type RetrainResultView, + type TrainingDataSnippetView, + type TrainingSuggestionAdminView, + type TrainingSuggestionGroupView, + type UpdateTrainingSuggestionInput, +} from "@batch-cooking/shared"; + +/** Builds a `?a=b&c=d` string from defined values only. */ +function query(params: Record): string { + const entries = Object.entries(params).filter( + (entry): entry is [string, string] => entry[1] !== undefined && entry[1] !== "", + ); + return entries.length === 0 ? "" : `?${new URLSearchParams(entries).toString()}`; +} + +/** + * 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. + */ +const ADMIN_API_BASE_URL: string = import.meta.env.VITE_ADMIN_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. + */ +export class ApiError 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.status = status; + this.code = body.code; + this.fieldErrors = body.details; + } +} + +/** + * Thin fetch wrapper around the `/admin/*` endpoints — same design as + * apps/web's `ApiClient` (a class for cohesion/extensibility, one shared + * stateless instance). Every request sends credentials so the + * `admin_session` httpOnly cookie round-trips. + */ +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. + */ + private async _request( + path: string, + options: RequestInit = {}, + ): Promise { + try { + const response = await fetch(`${ADMIN_API_BASE_URL}${path}`, { + ...options, + credentials: "include", + headers: { "Content-Type": "application/json", ...options.headers }, + }); + + if (!response.ok) { + const body = (await response.json().catch(() => null)) as ApiErrorResponse | null; + throw new ApiError( + response.status, + body ?? { code: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" }, + ); + } + + if (response.status === 204) { + return undefined as TResponseBody; + } + return (await response.json()) as TResponseBody; + } catch (err) { + // Rethrown as-is — callers surface it their own way; this is just the + // one place the fetch/`await` sits in a try/catch per the repo's rule. + throw err; + } + } + + /** Verifies admin credentials and starts an admin session. */ + public login(input: AdminLoginInput): Promise { + return this._request("/admin/auth/login", { method: "POST", body: JSON.stringify(input) }); + } + + /** Ends the current admin session. */ + public logout(): Promise { + return this._request("/admin/auth/logout", { method: "POST" }); + } + + /** Fetches the currently authenticated admin — rejects with `NOT_AUTHENTICATED` if there's no session. */ + public me(): Promise { + return this._request("/admin/auth/me"); + } + + /** Usage metrics for the dashboard — snapshot totals + `days` (7–365) of daily time series. */ + public getMetrics(days: number): Promise { + return this._request(`/admin/metrics?days=${days}`); + } + + /** Live health of Postgres, the API, the intent-service and the LLM worker — polled by the monitoring board. */ + public getMonitoring(): Promise { + return this._request("/admin/monitoring"); + } + + /** Training suggestions, grouped by technique, filtered by the given (all-optional) criteria. */ + public getSuggestions(filters: { + status?: string; + sourceType?: string; + techStepKey?: string; + locale?: string; + }): Promise { + return this._request(`/admin/tech-steps/suggestions${query(filters)}`); + } + + /** Raw user corrections, including the "no technique here" removals. */ + public getCorrections(filters: { + consumed?: string; + hasCorrectedTechStep?: string; + }): Promise { + return this._request(`/admin/tech-steps/corrections${query(filters)}`); + } + + /** Edits a suggestion's proposed synonyms/utterances and/or its status. */ + public updateSuggestion( + id: number, + body: UpdateTrainingSuggestionInput, + ): Promise { + return this._request(`/admin/tech-steps/suggestions/${id}`, { + method: "PATCH", + body: JSON.stringify(body), + }); + } + + /** The ready-to-paste `training_data.py` block aggregating suggestions for one technique/locale/status. */ + public getTrainingDataSnippet(params: { + techStepKey: string; + locale?: string; + status?: string; + }): Promise { + return this._request(`/admin/tech-steps/training-data-snippet${query(params)}`); + } + + /** Runs the F1 gate + backfill (+ marks suggestion ids). Rejects with `RETRAIN_ALREADY_RUNNING` if one is in flight. */ + public retrain(body: RetrainRequestInput): Promise { + return this._request("/admin/tech-steps/retrain", { + method: "POST", + body: JSON.stringify(body), + }); + } + + /** Off-catalog ingredient "placeholders" users typed, grouped by normalized name. `reviewed` omitted/`"false"` = the still-to-triage list, `"true"` = the archive. */ + public getPlaceholders(reviewed?: "true" | "false"): Promise { + return this._request(`/admin/catalog/placeholders${query({ reviewed })}`); + } + + /** Marks the given placeholder ingredient ids as triaged (`reviewedAt`). */ + public markPlaceholdersReviewed( + body: MarkPlaceholdersReviewedInput, + ): Promise<{ reviewed: number }> { + return this._request("/admin/catalog/placeholders/mark-reviewed", { + method: "PATCH", + body: JSON.stringify(body), + }); + } + + /** Deletes placeholder rows no recipe references any more. */ + public pruneOrphanPlaceholders(): Promise { + return this._request("/admin/catalog/placeholders/prune-orphans", { method: "POST" }); + } +} + +/** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */ +export const adminApiClient = new AdminApiClient(); diff --git a/apps/admin-web/src/features/auth/AdminAuthContext.tsx b/apps/admin-web/src/features/auth/AdminAuthContext.tsx new file mode 100644 index 0000000..12611bd --- /dev/null +++ b/apps/admin-web/src/features/auth/AdminAuthContext.tsx @@ -0,0 +1,71 @@ +import type { AdminLoginInput, AdminUserView } from "@batch-cooking/shared"; +import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react"; +import { adminApiClient } from "../../api/client"; + +/** Auth state/actions exposed via {@link useAdminAuth} — the admin-app counterpart of apps/web's `AuthContext`. */ +interface AdminAuthContextValue { + /** Currently authenticated admin, or `null` if no active session. */ + admin: AdminUserView | null; + /** True only while the initial `GET /admin/auth/me` check is pending — lets `RequireAdmin` avoid a premature redirect. */ + isLoading: boolean; + /** Verifies credentials and updates `admin` on success. Throws `ApiError` on failure. */ + login: (input: AdminLoginInput) => Promise; + /** Ends the session and clears `admin`. */ + logout: () => Promise; +} + +const AdminAuthContext = createContext(null); + +/** + * Provides admin authentication state to the whole app. On mount, calls + * `GET /admin/auth/me` once to restore the session from the `admin_session` + * httpOnly cookie (if any) — same "reload keeps you logged in" behaviour as + * apps/web's `AuthProvider`. + */ +export function AdminAuthProvider({ children }: { children: ReactNode }) { + const [admin, setAdmin] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + adminApiClient + .me() + .then(setAdmin) + // No/invalid session — the normal state for a first visit, not an error. + .catch(() => setAdmin(null)) + .finally(() => setIsLoading(false)); + }, []); + + const login = useCallback(async (input: AdminLoginInput) => { + try { + setAdmin(await adminApiClient.login(input)); + } catch (err) { + // Rethrown as-is — `LoginPage`'s submit handler catches and displays + // it; this callback just isn't allowed a bare `await`. + throw err; + } + }, []); + + const logout = useCallback(async () => { + try { + await adminApiClient.logout(); + setAdmin(null); + } catch (err) { + throw err; // see login()'s catch comment + } + }, []); + + return ( + + {children} + + ); +} + +/** Reads the current admin auth state/actions. Must be called within an {@link AdminAuthProvider}. */ +export function useAdminAuth(): AdminAuthContextValue { + const ctx = useContext(AdminAuthContext); + if (!ctx) { + throw new Error("useAdminAuth must be used within an AdminAuthProvider"); + } + return ctx; +} diff --git a/apps/admin-web/src/features/auth/RequireAdmin.tsx b/apps/admin-web/src/features/auth/RequireAdmin.tsx new file mode 100644 index 0000000..19e3fce --- /dev/null +++ b/apps/admin-web/src/features/auth/RequireAdmin.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from "react"; +import { Navigate } from "react-router-dom"; +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`. + */ +export function RequireAdmin({ children }: { children: ReactNode }) { + const { admin, isLoading } = useAdminAuth(); + + if (isLoading) { + return null; + } + if (!admin) { + return ; + } + return <>{children}; +} diff --git a/apps/admin-web/src/features/auth/admin-auth.scss b/apps/admin-web/src/features/auth/admin-auth.scss new file mode 100644 index 0000000..a47fb41 --- /dev/null +++ b/apps/admin-web/src/features/auth/admin-auth.scss @@ -0,0 +1,84 @@ +// ============================================================================= +// Admin login card — the only unauthenticated screen. A centered card on a +// plain background, same language as apps/web's auth-form.scss (kept its own +// copy rather than shared, the two apps' chrome is independent). +// ============================================================================= + +.admin-auth-page { + min-height: 100vh; + display: grid; + place-items: center; + padding: var(--space-lg); + background: var(--color-background); +} + +.admin-auth-card { + width: 100%; + max-width: var(--max-width-form); + display: flex; + flex-direction: column; + gap: var(--space-sm); + padding: var(--space-xl); + background: var(--color-surface); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + + h1 { + font-size: var(--font-size-xl); + margin-bottom: var(--space-sm); + } + + label { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--color-text-muted); + } + + input { + padding: var(--space-sm); + font-size: var(--font-size-base); + font-family: var(--font-body); + border: 1.5px solid var(--color-border); + border-radius: var(--radius-base); + background: var(--color-surface); + color: var(--color-text); + + &:focus-visible { + border-color: var(--color-primary); + } + } + + button[type="submit"] { + margin-top: var(--space-sm); + padding: var(--space-sm) var(--space-md); + font-size: var(--font-size-base); + font-weight: 600; + font-family: var(--font-body); + color: var(--color-surface); + background: var(--color-primary); + border: none; + border-radius: var(--radius-base); + cursor: pointer; + + &:hover { + background: var(--color-primary-hover); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } + + .field-error { + margin: 0; + font-size: var(--font-size-xs); + color: var(--color-error); + } + + .form-error { + margin: var(--space-xs) 0 0; + font-size: var(--font-size-sm); + color: var(--color-error); + } +} diff --git a/apps/admin-web/src/i18n/i18n.ts b/apps/admin-web/src/i18n/i18n.ts new file mode 100644 index 0000000..96e7f52 --- /dev/null +++ b/apps/admin-web/src/i18n/i18n.ts @@ -0,0 +1,22 @@ +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/layouts/AdminLayout.scss b/apps/admin-web/src/layouts/AdminLayout.scss new file mode 100644 index 0000000..ca14d96 --- /dev/null +++ b/apps/admin-web/src/layouts/AdminLayout.scss @@ -0,0 +1,108 @@ +// ============================================================================= +// Admin app shell — a fixed left sidebar + scrollable main content area. +// Simpler than apps/web's AppLayout (no collapsible rail, no nested submenu) +// — an internal ops tool, three sections. +// ============================================================================= + +.admin-layout { + display: flex; + min-height: 100vh; +} + +.admin-sidebar { + flex-shrink: 0; + width: 15rem; + display: flex; + flex-direction: column; + padding: var(--space-lg) var(--space-md); + background: var(--color-surface); + border-right: 1px solid var(--color-border); + + &__brand { + font-family: var(--font-display); + font-weight: 700; + font-size: var(--font-size-lg); + color: var(--color-text); + margin-bottom: var(--space-lg); + + span { + color: var(--color-accent); + } + } + + &__nav { + display: flex; + flex-direction: column; + gap: 0.15rem; + + a { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm); + border-radius: var(--radius-base); + color: var(--color-text-muted); + text-decoration: none; + font-size: var(--font-size-sm); + font-weight: 600; + + &:hover { + background: var(--color-surface-alt); + color: var(--color-text); + } + + &.active { + background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface)); + color: var(--color-primary); + } + } + } + + &__footer { + margin-top: auto; + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding-top: var(--space-md); + border-top: 1px solid var(--color-border); + } + + &__who { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--color-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__footer button { + padding: var(--space-xs) var(--space-sm); + font-size: var(--font-size-sm); + font-family: var(--font-body); + color: var(--color-text-muted); + background: none; + border: 1px solid var(--color-border); + border-radius: var(--radius-base); + cursor: pointer; + text-align: left; + + &:hover { + color: var(--color-text); + border-color: var(--color-primary); + } + } + + &__version { + margin: 0; + font-size: var(--font-size-xs); + color: var(--color-text-muted); + } +} + +.admin-content { + flex: 1; + min-width: 0; + padding: var(--space-xl); + overflow: auto; +} diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx new file mode 100644 index 0000000..1a0eb27 --- /dev/null +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -0,0 +1,84 @@ +import { Activity, LayoutDashboard, ListChecks, PackageSearch } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { NavLink, Outlet, useNavigate } from "react-router-dom"; +import { useAdminAuth } from "../features/auth/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. + */ +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 }, +] as const; + +/** + * Shell for every authenticated admin page: a fixed sidebar (brand, section + * nav, the signed-in admin's name + logout) plus a main area rendering the + * matched child route via ``. Mounted once as the parent of the + * whole `RequireAdmin`-guarded route group (see `App.tsx`), so `admin` is + * guaranteed non-null here. + */ +export function AdminLayout() { + const { t } = useTranslation(); + const { admin, logout } = useAdminAuth(); + const navigate = useNavigate(); + const [isLoggingOut, setIsLoggingOut] = useState(false); + + async function handleLogout() { + setIsLoggingOut(true); + try { + await logout(); + void navigate("/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"); + } + } + + return ( +
+ + +
+ +
+
+ ); +} diff --git a/apps/admin-web/src/lib/zod-errors.ts b/apps/admin-web/src/lib/zod-errors.ts new file mode 100644 index 0000000..ab9174b --- /dev/null +++ b/apps/admin-web/src/lib/zod-errors.ts @@ -0,0 +1,18 @@ +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 new file mode 100644 index 0000000..3b6487f --- /dev/null +++ b/apps/admin-web/src/locales/fr/translation.json @@ -0,0 +1,148 @@ +{ + "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 new file mode 100644 index 0000000..a713866 --- /dev/null +++ b/apps/admin-web/src/main.tsx @@ -0,0 +1,24 @@ +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/pages/admin-page.scss b/apps/admin-web/src/pages/admin-page.scss new file mode 100644 index 0000000..34eefbe --- /dev/null +++ b/apps/admin-web/src/pages/admin-page.scss @@ -0,0 +1,26 @@ +// ============================================================================= +// Shared chrome for every routed admin page — a page title and an optional +// lead paragraph. Individual pages add their own colocated .scss for their +// specific content (charts, tables, status board) on top. +// ============================================================================= + +.admin-page { + &__title { + font-size: var(--font-size-2xl); + margin-bottom: var(--space-xs); + } + + &__lead { + margin: 0 0 var(--space-lg); + color: var(--color-text-muted); + font-size: var(--font-size-md); + } + + &__placeholder { + padding: var(--space-xl); + border: 1px dashed var(--color-border); + border-radius: var(--radius-md); + color: var(--color-text-muted); + text-align: center; + } +} diff --git a/apps/admin-web/src/pages/catalog/CatalogPage.tsx b/apps/admin-web/src/pages/catalog/CatalogPage.tsx new file mode 100644 index 0000000..9aaa028 --- /dev/null +++ b/apps/admin-web/src/pages/catalog/CatalogPage.tsx @@ -0,0 +1,169 @@ +import type { CatalogPlaceholderGroupView } from "@batch-cooking/shared"; +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { adminApiClient } from "../../api/client"; +import "../admin-page.scss"; +import "./catalog-page.scss"; +import { type CatalogTab, formatDate, reviewedParam, splitSpellings } from "./catalog"; + +type CatalogState = + | { status: "loading" } + | { status: "loaded"; groups: CatalogPlaceholderGroupView[] } + | { status: "error" }; + +/** + * Off-catalog ingredient review. Lists every placeholder `Ingredient` (the + * free text users typed when the seeded catalog fell short — see + * `Ingredient.isPlaceholder` in the API schema), grouped by normalized + * name, so a maintainer sees what the catalog is missing and how many + * recipes are waiting on it. Actions are deliberately minimal: mark a gap + * as handled, or purge rows no recipe references any more. Actually adding + * the catalog entry stays a manual edit of `reference-seed-data.ts` + the + * locale files. + */ +export function CatalogPage() { + const { t } = useTranslation(); + const [tab, setTab] = useState("pending"); + const [state, setState] = useState({ status: "loading" }); + const [pendingIds, setPendingIds] = useState(null); + const [pruneMessage, setPruneMessage] = useState(null); + const [isPruning, setIsPruning] = useState(false); + + const load = useCallback((forTab: CatalogTab) => { + setState({ status: "loading" }); + adminApiClient + .getPlaceholders(reviewedParam(forTab)) + .then((groups) => setState({ status: "loaded", groups })) + .catch(() => setState({ status: "error" })); + }, []); + + useEffect(() => { + load(tab); + }, [tab, load]); + + function markReviewed(group: CatalogPlaceholderGroupView) { + setPendingIds(group.ingredientIds); + adminApiClient + .markPlaceholdersReviewed({ ingredientIds: group.ingredientIds }) + .then(() => load(tab)) + .catch(() => load(tab)) + .finally(() => setPendingIds(null)); + } + + function pruneOrphans() { + setIsPruning(true); + setPruneMessage(null); + adminApiClient + .pruneOrphanPlaceholders() + .then(({ deleted }) => { + setPruneMessage( + deleted === 0 + ? t("admin.catalog.prunedNone") + : t("admin.catalog.pruned", { count: deleted }), + ); + load(tab); + }) + .catch(() => setPruneMessage(t("admin.common.loadError"))) + .finally(() => setIsPruning(false)); + } + + return ( +
+

{t("admin.catalog.title")}

+

{t("admin.catalog.lead")}

+ +
+
+ {(["pending", "reviewed"] as const).map((value) => ( + + ))} +
+ +
+ {pruneMessage &&

{pruneMessage}

} + + {state.status === "loading" && ( +

{t("admin.common.loading")}

+ )} + {state.status === "error" && ( +

{t("admin.common.loadError")}

+ )} + {state.status === "loaded" && + (state.groups.length === 0 ? ( +

{t("admin.catalog.empty")}

+ ) : ( +
    + {state.groups.map((group) => ( + markReviewed(group)} + /> + ))} +
+ ))} +
+ ); +} + +function PlaceholderGroupCard({ + group, + busy, + onMarkReviewed, +}: { + group: CatalogPlaceholderGroupView; + busy: boolean; + onMarkReviewed: () => void; +}) { + const { t } = useTranslation(); + const { headline, variants } = splitSpellings(group); + const firstSeen = formatDate(group.firstSeenAt); + + return ( +
  • +
    +

    {headline}

    + + {t("admin.catalog.recipeCount", { count: group.recipeCount })} + +
    + + {variants.length > 0 && ( +

    + {t("admin.catalog.alsoWritten", { variants: variants.join(" · ") })} +

    + )} + + {group.sampleRecipes.length > 0 && ( +

    + {t("admin.catalog.seenIn")} {group.sampleRecipes.map((recipe) => recipe.name).join(", ")} +

    + )} + +
    + {firstSeen && ( + + {t("admin.catalog.firstSeen", { date: firstSeen })} + + )} + {!group.allReviewed && ( + + )} +
    +
  • + ); +} diff --git a/apps/admin-web/src/pages/catalog/catalog-page.scss b/apps/admin-web/src/pages/catalog/catalog-page.scss new file mode 100644 index 0000000..43f6d94 --- /dev/null +++ b/apps/admin-web/src/pages/catalog/catalog-page.scss @@ -0,0 +1,144 @@ +// ============================================================================= +// CatalogPage — the off-catalog ingredient review: a pending/reviewed tab +// switch + "purge orphans" action, then one card per grouped placeholder. +// Mirrors CorrectionsPage's tab/card vocabulary so the admin app stays +// visually consistent. +// ============================================================================= + +.catalog-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + margin-bottom: var(--space-md); + + // Right-hand "purge orphans" button — a secondary/destructive action, so + // outlined rather than filled like the primary actions elsewhere. + > button { + padding: var(--space-xs) var(--space-md); + font-family: var(--font-body); + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--color-text-muted); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-base); + cursor: pointer; + + &:hover { + border-color: var(--color-error); + color: var(--color-error); + } + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } +} + +.catalog-tabs { + display: flex; + gap: var(--space-xs); + + button { + padding: var(--space-sm) var(--space-md); + font-family: var(--font-body); + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--color-text-muted); + background: none; + border: 1px solid var(--color-border); + border-radius: var(--radius-base); + cursor: pointer; + + &.active { + color: var(--color-primary); + border-color: var(--color-primary); + background: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface)); + } + } +} + +.catalog-prune-message { + margin: 0 0 var(--space-md); + font-size: var(--font-size-sm); + color: var(--color-text-muted); +} + +.catalog-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.catalog-card { + padding: var(--space-md); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-left: 4px solid var(--color-accent); + border-radius: var(--radius-md); + + &__head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-sm); + } + + &__name { + font-size: var(--font-size-md); + margin: 0; + } + + &__count { + flex-shrink: 0; + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--color-text-muted); + } + + &__variants, + &__recipes { + margin: var(--space-xs) 0 0; + font-size: var(--font-size-sm); + color: var(--color-text-muted); + } + + &__foot { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); + margin-top: var(--space-sm); + } + + &__seen { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + } + + &__foot button { + padding: var(--space-xs) var(--space-md); + font-family: var(--font-body); + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--color-surface); + background: var(--color-primary); + border: none; + border-radius: var(--radius-base); + cursor: pointer; + + &:hover { + background: var(--color-primary-hover); + } + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } +} diff --git a/apps/admin-web/src/pages/catalog/catalog.ts b/apps/admin-web/src/pages/catalog/catalog.ts new file mode 100644 index 0000000..8e5eec6 --- /dev/null +++ b/apps/admin-web/src/pages/catalog/catalog.ts @@ -0,0 +1,34 @@ +import type { CatalogPlaceholderGroupView } from "@batch-cooking/shared"; + +/** + * Pure helpers for `CatalogPage` — kept out of the `.tsx` per repo + * convention, unit-tested on their own. + */ + +/** Which server-side list a UI tab maps to — `pending` sends no `reviewed` param (the working list), `reviewed` sends `reviewed=true` (the archive). */ +export type CatalogTab = "pending" | "reviewed"; + +/** `CatalogTab` → the `reviewed` query value `adminApiClient.getPlaceholders` expects. */ +export function reviewedParam(tab: CatalogTab): "true" | undefined { + return tab === "reviewed" ? "true" : undefined; +} + +/** + * Splits a group's spellings into the one to show as the card title and the + * rest to list as "aussi écrit : …". The API already sorts `displayNames` + * alphabetically and none is more canonical than another, so the first is + * as good a headline as any — the point of the group is that they're the + * same missing ingredient. + */ +export function splitSpellings(group: CatalogPlaceholderGroupView): { + headline: string; + variants: string[]; +} { + const [headline = group.normalizedName, ...variants] = group.displayNames; + return { headline, variants }; +} + +/** `"2026-08-28T09:00:00.000Z"` → `"28/08/2026"` for the "première fois le …" line. `null` → `null`. */ +export function formatDate(iso: string | null): string | null { + return iso === null ? null : new Date(iso).toLocaleDateString("fr-FR"); +} diff --git a/apps/admin-web/src/pages/corrections/CorrectionsPage.tsx b/apps/admin-web/src/pages/corrections/CorrectionsPage.tsx new file mode 100644 index 0000000..02c8c59 --- /dev/null +++ b/apps/admin-web/src/pages/corrections/CorrectionsPage.tsx @@ -0,0 +1,395 @@ +import { + type CorrectionAdminView, + ErrorCode, + type RetrainResultView, + type TrainingSuggestionAdminView, + type TrainingSuggestionGroupView, +} 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 "../admin-page.scss"; +import "./corrections-page.scss"; +import { linesToList, listsDiffer, listToLines } from "./corrections"; + +type Tab = "suggestions" | "corrections"; + +/** + * Tech-step correction triage. Two tabs — curated `TrainingSuggestion`s and + * raw `StepTechStepCorrection`s — plus the snippet generator and the F1 + * gate + backfill trigger. Replaces the `list-pending-training-suggestions.ts` + * / `retrain-tech-steps.ts` CLI pair. + */ +export function CorrectionsPage() { + const { t } = useTranslation(); + const [tab, setTab] = useState("suggestions"); + + return ( +
    +

    {t("admin.corrections.title")}

    +

    {t("admin.corrections.lead")}

    + +

    {t("admin.corrections.caveat")}

    + +
    + + +
    + + {tab === "suggestions" ? : } +
    + ); +} + +// --- Suggestions tab ------------------------------------------------------- + +type SuggestionsState = + | { status: "loading" } + | { status: "loaded"; groups: TrainingSuggestionGroupView[] } + | { status: "error" }; + +function SuggestionsTab() { + const { t } = useTranslation(); + const [statusFilter, setStatusFilter] = useState(""); + const [sourceFilter, setSourceFilter] = useState(""); + const [state, setState] = useState({ status: "loading" }); + + const load = useCallback(() => { + setState({ status: "loading" }); + adminApiClient + .getSuggestions({ + status: statusFilter || undefined, + sourceType: sourceFilter || undefined, + }) + .then((groups) => setState({ status: "loaded", groups })) + .catch(() => setState({ status: "error" })); + }, [statusFilter, sourceFilter]); + + useEffect(load, [load]); + + return ( +
    + + + +
    + + +
    + + {state.status === "loading" && ( +

    {t("admin.common.loading")}

    + )} + {state.status === "error" && ( +

    {t("admin.common.loadError")}

    + )} + {state.status === "loaded" && state.groups.length === 0 && ( +

    {t("admin.corrections.noSuggestions")}

    + )} + {state.status === "loaded" && + state.groups.map((group) => ( +
    +

    {group.techStepKey}

    + {group.suggestions.map((suggestion) => ( + + ))} +
    + ))} +
    + ); +} + +function SuggestionCard({ + suggestion, + onMutated, +}: { + suggestion: TrainingSuggestionAdminView; + onMutated: () => void; +}) { + const { t } = useTranslation(); + const [synonyms, setSynonyms] = useState(listToLines(suggestion.suggestedSynonyms)); + const [utterances, setUtterances] = useState(listToLines(suggestion.suggestedUtterances)); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const dirty = + listsDiffer(linesToList(synonyms), suggestion.suggestedSynonyms) || + listsDiffer(linesToList(utterances), suggestion.suggestedUtterances); + + async function patch(body: Parameters[1]) { + setBusy(true); + setError(null); + try { + await adminApiClient.updateSuggestion(suggestion.id, body); + onMutated(); + } catch (err) { + setError( + errorMessageService.getLabel(err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR), + ); + } finally { + setBusy(false); + } + } + + return ( +
    +
    + + #{suggestion.id} · {suggestion.locale} · {suggestion.sourceType} ·{" "} + {suggestion.status} + +
    + + {suggestion.sourceCorrection && ( +

    + + « {suggestion.sourceCorrection.clauseText} » + {" "} + {suggestion.sourceCorrection.previousTechStepKey ?? "∅"} →{" "} + {suggestion.sourceCorrection.correctedTechStepKey ?? "∅"} +

    + )} + +