Compare commits

...

8 commits

Author SHA1 Message Date
d5dca82af4 Merge pull request 'refactor(admin): fusionne apps/admin-web dans apps/web sous /admin/*' (#18) from feat/merge-admin-into-web into main
All checks were successful
CI / lint (push) Successful in 1m36s
CI / build (push) Successful in 2m57s
CI / e2e (push) Successful in 9m2s
CI / intent-service-test (push) Successful in 16m55s
CI / test (push) Successful in 27m19s
Reviewed-on: #18
2026-08-29 21:15:43 +02:00
ccec30b2e8 chore: re-declenche la CI
All checks were successful
CI / build (push) Successful in 2m36s
CI / lint (push) Successful in 2m39s
CI / e2e (push) Successful in 7m45s
CI / intent-service-test (push) Successful in 12m0s
CI / test (push) Successful in 20m12s
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 <noreply@anthropic.com>
2026-08-29 20:54:49 +02:00
1a28d67218 test(admin): fixe l'intercept monitoring (route page == chemin API)
Some checks failed
CI / build (push) Successful in 2m48s
CI / lint (push) Successful in 3m0s
CI / e2e (push) Successful in 12m51s
CI / intent-service-test (push) Successful in 16m53s
CI / test (push) Failing after 17m9s
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 <noreply@anthropic.com>
2026-08-29 19:28:53 +02:00
bbd9afe8aa refactor(admin): fusionne apps/admin-web dans apps/web sous /admin/*
Some checks failed
CI / lint (push) Failing after 23s
CI / build (push) Successful in 3m11s
CI / e2e (push) Failing after 8m16s
CI / intent-service-test (push) Successful in 12m49s
CI / test (push) Successful in 21m34s
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 <noreply@anthropic.com>
2026-08-29 17:02:29 +02:00
927051793c Merge pull request 'fix(tests): corrige 3 suites Mocha DB révélées par leur 1er run sur main' (#17) from fix/mocha-db-suites-first-run into main
All checks were successful
CI / lint (push) Successful in 1m40s
CI / build (push) Successful in 1m51s
CI / e2e (push) Successful in 7m29s
CI / intent-service-test (push) Successful in 11m4s
CI / test (push) Successful in 20m19s
Reviewed-on: #17
2026-08-29 12:02:03 +02:00
9532ea4e83 fix(tests): corrige 3 suites Mocha DB revelees par leur 1er run sur main
Some checks failed
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / intent-service-test (push) Has been cancelled
CI / build (push) Has been cancelled
Ces suites ont ete ecrites pendant le dev des features cooking / admin /
hors-catalogue mais jamais executees (pas de Postgres dans ces sessions).
Leur 1re execution reelle sur `main` echouait — bugs dans les tests, pas
dans le code merge.

- reference.test.ts : `GET /reference/ingredients` renvoie desormais
  `isPlaceholder` (toujours false) et `displayName` (toujours null) depuis
  la PR #16 (champs de `IngredientView`). L'assertion `to.have.keys([...])`
  exacte est mise a jour.
- cooking-session.test.ts : la fixture "pooling merged-prep" avait 2
  recettes symetriques (chop -> simmer) ; apres mise en commun du chop les
  deux simmer tournent dans l'unique phase de cuisson, donc aucun
  `background` possible (l'optimiseur est correct, cf. le test pur
  equivalent). « Tarte » recoit une etape active `mix` de plus pour que son
  simmer flotte en background pendant que « Soupe » est en hold.
- admin-tech-steps.test.ts : une requete supertest ne part qu'a l'`await`/
  `.then` ; la 1re requete /retrain concurrente n'etait jamais lancee, donc
  le verrou process n'etait jamais tenu et la 2e recevait 200 au lieu de
  409. Ajout d'un `.then(res => res, err => err)` pour la declencher avant
  l'attente de 100 ms.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 12:00:43 +02:00
a33f641dc7 Merge pull request 'feat(admin): application d'administration (auth, métriques, monitoring, tri des corrections)' (#15) from feat/admin-tech-steps into main
Some checks failed
CI / lint (push) Successful in 50s
CI / build (push) Successful in 1m22s
CI / e2e (push) Successful in 7m24s
CI / intent-service-test (push) Successful in 10m48s
CI / test (push) Has been cancelled
Reviewed-on: #15
2026-08-29 09:43:07 +02:00
f7aabd9dcc Merge pull request 'feat(recipes): ingrédients hors-catalogue (placeholders) + revue admin' (#16) from feat/off-catalog-ingredients into feat/admin-tech-steps
Some checks failed
CI / intent-service-test (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / build (push) Has been cancelled
CI / e2e (push) Has been cancelled
Reviewed-on: #16
2026-08-29 09:42:57 +02:00
65 changed files with 423 additions and 1104 deletions

View file

@ -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. # (docker-compose.yml), serving both the API and the built frontend.
# APP_PORT=3000 # 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 # 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 # 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 # 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 # end-user token can never be replayed against /admin/*. Generate your own
# the same way as JWT_SECRET above. # the same way as JWT_SECRET above.
# ADMIN_JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars # 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 / # Optional — read only by `src/scripts/create-admin.ts` when its --email /
# --password / --name flags are omitted (e.g. to bootstrap the first admin # --password / --name flags are omitted (e.g. to bootstrap the first admin
# from inside the container). Never read by the running server. # from inside the container). Never read by the running server.

View file

@ -179,10 +179,3 @@ jobs:
# `component.devServer`), unlike `e2e` above which needs the real app # `component.devServer`), unlike `e2e` above which needs the real app
# running first. # running first.
- run: pnpm --filter web cy:run:component - 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

View file

@ -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

View file

@ -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

View file

@ -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;
},
},
});

View file

@ -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 });
});

View file

@ -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 {};

View file

@ -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 `<name>.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");
});

View file

@ -1,12 +0,0 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>batchCooking — Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -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";
}
}

View file

@ -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"
}
}

View file

@ -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 (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
element={
<RequireAdmin>
<AdminLayout />
</RequireAdmin>
}
>
<Route path="/" element={<DashboardPage />} />
<Route path="/monitoring" element={<MonitoringPage />} />
<Route path="/corrections" element={<CorrectionsPage />} />
<Route path="/catalogue" element={<CatalogPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

View file

@ -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;

View file

@ -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<string, string> {
const fieldErrors = error.flatten().fieldErrors;
const firstMessagePerField: Record<string, string> = {};
for (const [field, messages] of Object.entries(fieldErrors)) {
if (messages?.[0]) {
firstMessagePerField[field] = messages[0];
}
}
return firstMessagePerField;
}

View file

@ -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)."
}
}
}

View file

@ -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(
<StrictMode>
<BrowserRouter>
<AdminAuthProvider>
<App />
</AdminAuthProvider>
</BrowserRouter>
</StrictMode>,
);

View file

@ -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();

View file

@ -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 `<html>`), 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);
}

View file

@ -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 <h1>) 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);
}

View file

@ -1,5 +0,0 @@
/// <reference types="vite/client" />
// Injected by `define` in vite.config.ts, sourced from package.json's
// version field — rendered in AdminLayout.tsx.
declare const __APP_VERSION__: string;

View file

@ -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"]
}

View file

@ -1,8 +0,0 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler"
},
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

View file

@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"composite": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}

View file

@ -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),
},
});

View file

@ -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 # Generate your own the same way as JWT_SECRET above; must match the
# worker's own INTERNAL_WORKER_SECRET. # worker's own INTERNAL_WORKER_SECRET.
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars # 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

View file

@ -34,18 +34,20 @@ export function createServer(): ExpressServer {
// pipeline (its "finish" listener still fires for a request that never // pipeline (its "finish" listener still fires for a request that never
// makes it past CORS/body-parsing, not just ones that reach a route). // makes it past CORS/body-parsing, not just ones that reach a route).
server.addMiddleware(requestLogger); server.addMiddleware(requestLogger);
// Two allowed origins: the main app (`CORS_ORIGIN`) and the separate // One allowed origin: the app (`CORS_ORIGIN`). The admin surface
// admin app (`ADMIN_CORS_ORIGIN`). The `cors` package matches an incoming // (`/admin/*`) is served by this same API and consumed by `apps/web`'s
// `Origin` against any entry of the list. // own `/admin/*` routes — same origin as the rest of the app, so no
server.setupCore({ corsOrigin: [env.CORS_ORIGIN, env.ADMIN_CORS_ORIGIN] }); // extra CORS entry.
server.setupCore({ corsOrigin: env.CORS_ORIGIN });
server.addRoute("get", "/health", (_req: Request, res: Response) => { server.addRoute("get", "/health", (_req: Request, res: Response) => {
res.status(200).json({ status: "ok" }); res.status(200).json({ status: "ok" });
}); });
server.mountRouter("/auth", authRouter); server.mountRouter("/auth", authRouter);
// Admin application surface (`apps/admin-web`) — its own auth // Admin application surface (`apps/web`'s `/admin/*` routes) — its own
// (`requireAdmin`, distinct cookie/secret), never the end-user session. // auth (`requireAdmin`, distinct cookie/secret), never the end-user
// session.
server.mountRouter("/admin", adminRouter); server.mountRouter("/admin", adminRouter);
server.mountRouter("/house", houseRouter); server.mountRouter("/house", houseRouter);
// Not user-facing — `services/tech-step-llm-worker` only, guarded by // Not user-facing — `services/tech-step-llm-worker` only, guarded by

View file

@ -105,8 +105,6 @@ const envSchema = z.object({
.optional(), .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. */ /** 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"), 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. */ /** 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_EMAIL: z.string().optional(),
ADMIN_INITIAL_PASSWORD: z.string().optional(), ADMIN_INITIAL_PASSWORD: z.string().optional(),

View file

@ -19,8 +19,8 @@ export interface AdminLocals {
} }
/** /**
* Express middleware guarding every `/admin/*` route the operations app * Express middleware guarding every `/admin/*` route the operations UI
* (`apps/admin-web`) authenticating as an `AdminUser`. Reads the admin * (`apps/web`'s `/admin/*` routes) authenticating as an `AdminUser`. Reads the admin
* session cookie (`ADMIN_COOKIE_NAME`, deliberately **not** the same cookie * session cookie (`ADMIN_COOKIE_NAME`, deliberately **not** the same cookie
* as end-user sessions), verifies the JWT against `ADMIN_JWT_SECRET` * as end-user sessions), verifies the JWT against `ADMIN_JWT_SECRET`
* (a different secret than `JWT_SECRET`), and re-checks `tokenVersion` * (a different secret than `JWT_SECRET`), and re-checks `tokenVersion`

View file

@ -7,8 +7,8 @@ import { adminTechStepsRouter } from "./admin-tech-steps.routes.js";
/** /**
* Aggregator for the admin application's API surface, mounted at `/admin` * Aggregator for the admin application's API surface, mounted at `/admin`
* in `app.ts`. Every sub-router here is for `apps/admin-web` only * in `app.ts`. Every sub-router here backs `apps/web`'s `/admin/*` routes
* `/admin/auth` is public (login), everything added later * only `/admin/auth` is public (login), everything added later
* (`/admin/metrics`, `/admin/monitoring`, `/admin/tech-steps/*`, * (`/admin/metrics`, `/admin/monitoring`, `/admin/tech-steps/*`,
* `/admin/catalog/*`) sits behind `requireAdmin` * `/admin/catalog/*`) sits behind `requireAdmin`
* (`middlewares/require-admin.ts`). * (`middlewares/require-admin.ts`).

View file

@ -286,8 +286,18 @@ describe("Admin tech-steps triage", () => {
} }
this.timeout(60000); this.timeout(60000);
const agent = await adminAgent(); const agent = await adminAgent();
const first = agent.post("/admin/tech-steps/retrain").send({}); // supertest requests are lazy — they only dispatch when awaited/then'd.
// Let the first handler acquire the process-wide lock before the second starts. // Attaching the `.then` here is what actually fires the first request,
// so its handler acquires the process-wide lock before the second one
// (100ms later) checks it. Swallow its result/rejection — this test
// only asserts on the second request.
const first = agent
.post("/admin/tech-steps/retrain")
.send({})
.then(
(res) => res,
(err) => err,
);
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
const second = await agent.post("/admin/tech-steps/retrain").send({}); const second = await agent.post("/admin/tech-steps/retrain").send({});
expect(second.status).to.equal(409); expect(second.status).to.equal(409);

View file

@ -105,52 +105,62 @@ describe("Cooking session", () => {
const pieceId = await unitId("piece"); const pieceId = await unitId("piece");
const chopId = await techStepId("chop"); const chopId = await techStepId("chop");
const simmerId = await techStepId("simmer"); const simmerId = await techStepId("simmer");
const mixId = await techStepId("mix");
/** A recipe: one pure-prep "chop onion" step, then one simmer step. */ /**
async function makeRecipe(name: string, onionQty: number) { * A recipe: one pure-prep "chop onion" step, then (optionally) an
* active "mix" step, then one simmer step. The `withActiveStep` recipe
* is still doing hands-on work in the phase after its simmer starts, so
* the other recipe's simmer floats into that phase as `background`.
*/
async function makeRecipe(name: string, onionQty: number, withActiveStep = false) {
const chopStep = {
order: 0,
description: "Émincer les oignons",
techSteps: {
create: [
{
techStepId: chopId,
order: 0,
ingredients: {
create: [
{
ingredientId: onionId,
quantity: onionQty,
unitId: pieceId,
start: 0,
end: 1,
},
],
},
},
],
},
};
const activeStep = {
order: 1,
description: "Mélanger l'appareil",
techSteps: { create: [{ techStepId: mixId, order: 0 }] },
};
const simmerStep = {
order: withActiveStep ? 2 : 1,
description: "Faire mijoter",
techSteps: { create: [{ techStepId: simmerId, order: 0 }] },
};
return prisma.recipe.create({ return prisma.recipe.create({
data: { data: {
name, name,
authorId, authorId,
portions: 4, portions: 4,
steps: { steps: {
create: [ create: withActiveStep ? [chopStep, activeStep, simmerStep] : [chopStep, simmerStep],
{
order: 0,
description: "Émincer les oignons",
techSteps: {
create: [
{
techStepId: chopId,
order: 0,
ingredients: {
create: [
{
ingredientId: onionId,
quantity: onionQty,
unitId: pieceId,
start: 0,
end: 1,
},
],
},
},
],
},
},
{
order: 1,
description: "Faire mijoter",
techSteps: { create: [{ techStepId: simmerId, order: 0 }] },
},
],
}, },
}, },
}); });
} }
const soupe = await makeRecipe("Soupe", 2); const soupe = await makeRecipe("Soupe", 2);
const tarte = await makeRecipe("Tarte", 3); const tarte = await makeRecipe("Tarte", 3, true);
const planning = await prisma.planning.create({ const planning = await prisma.planning.create({
data: { data: {

View file

@ -95,6 +95,8 @@ describe("Reference data", () => {
"reproducible", "reproducible",
"allergens", "allergens",
"diets", "diets",
"isPlaceholder",
"displayName",
]); ]);
}); });

View file

@ -45,7 +45,7 @@ describe("Admin catalog — off-catalog ingredients", () => {
statusCode: 200, statusCode: 200,
body: pendingGroups(), body: pendingGroups(),
}).as("getPlaceholders"); }).as("getPlaceholders");
cy.visit("/catalogue"); cy.visit("/admin/catalogue");
cy.wait("@getPlaceholders"); cy.wait("@getPlaceholders");
cy.get(".catalog-card").should("have.length", 2); cy.get(".catalog-card").should("have.length", 2);
@ -66,7 +66,7 @@ describe("Admin catalog — off-catalog ingredients", () => {
body: { reviewed: 1 }, body: { reviewed: 1 },
}).as("markReviewed"); }).as("markReviewed");
cy.visit("/catalogue"); cy.visit("/admin/catalogue");
cy.wait("@getPlaceholders"); cy.wait("@getPlaceholders");
cy.contains(".catalog-card", "Sumac").contains("button", "Marquer comme traité").click(); 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", () => { 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, statusCode: 200,
body: pendingGroups(), body: pendingGroups(),
}); });
cy.intercept("GET", "**/admin/catalog/placeholders?reviewed=true", { cy.intercept("GET", /\/admin\/catalog\/placeholders\?reviewed=true$/, {
statusCode: 200, statusCode: 200,
body: [], body: [],
}).as("getReviewed"); }).as("getReviewed");
cy.visit("/catalogue"); cy.visit("/admin/catalogue");
cy.contains(".catalog-tabs button", "Traités").click(); cy.contains(".catalog-tabs button", "Traités").click();
cy.wait("@getReviewed"); cy.wait("@getReviewed");
cy.contains("Aucun ingrédient hors-catalogue").should("be.visible"); cy.contains("Aucun ingrédient hors-catalogue").should("be.visible");

View file

@ -87,7 +87,7 @@ describe("Admin corrections triage", () => {
body: { ...suggestionGroups()[0].suggestions[0], status: "applied" }, body: { ...suggestionGroups()[0].suggestions[0], status: "applied" },
}).as("patch"); }).as("patch");
cy.visit("/corrections"); cy.visit("/admin/corrections");
cy.wait("@getSuggestions"); cy.wait("@getSuggestions");
cy.contains(".corrections-caveat", "training_data.py").should("be.visible"); cy.contains(".corrections-caveat", "training_data.py").should("be.visible");
@ -116,7 +116,7 @@ describe("Admin corrections triage", () => {
}, },
}).as("getSnippet"); }).as("getSnippet");
cy.visit("/corrections"); cy.visit("/admin/corrections");
cy.get(".corrections-panel input").type("simmer"); cy.get(".corrections-panel input").type("simmer");
cy.contains(".corrections-panel button", "Générer").click(); cy.contains(".corrections-panel button", "Générer").click();
cy.wait("@getSnippet"); cy.wait("@getSnippet");
@ -137,7 +137,7 @@ describe("Admin corrections triage", () => {
}, },
}).as("retrain"); }).as("retrain");
cy.visit("/corrections"); cy.visit("/admin/corrections");
cy.contains(".corrections-panel--retrain button", "Lancer").click(); cy.contains(".corrections-panel--retrain button", "Lancer").click();
cy.wait("@retrain"); cy.wait("@retrain");
cy.contains(".retrain-result", "F1 0.830") 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", () => { 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.contains(".corrections-tabs button", "Corrections brutes").click();
cy.wait("@getCorrections"); cy.wait("@getCorrections");

View file

@ -65,7 +65,7 @@ describe("Admin dashboard", () => {
cy.intercept("GET", "**/admin/metrics*", { statusCode: 200, body: metricsFixture() }).as( cy.intercept("GET", "**/admin/metrics*", { statusCode: 200, body: metricsFixture() }).as(
"getMetrics", "getMetrics",
); );
cy.visit("/"); cy.visit("/admin");
cy.wait("@getMetrics").its("request.url").should("include", "days=30"); cy.wait("@getMetrics").its("request.url").should("include", "days=30");
// KPI tiles — value + label. // KPI tiles — value + label.
@ -89,7 +89,7 @@ describe("Admin dashboard", () => {
statusCode: 500, statusCode: 500,
body: { code: 5000, message: "x" }, body: { code: 5000, message: "x" },
}); });
cy.visit("/"); cy.visit("/admin");
cy.contains("Impossible de charger").should("be.visible"); cy.contains("Impossible de charger").should("be.visible");
}); });
}); });

View file

@ -15,40 +15,40 @@ describe("Admin layout", () => {
statusCode: 401, statusCode: 401,
body: { code: 4011, message: "no" }, body: { code: 4011, message: "no" },
}); });
cy.visit("/monitoring"); cy.visit("/admin/monitoring");
cy.url().should("include", "/login"); cy.url().should("include", "/admin/login");
cy.contains("h1", "Administration").should("be.visible"); cy.contains("h1", "Administration").should("be.visible");
}); });
it("shows the sidebar and navigates between the sections", () => { it("shows the sidebar and navigates between the sections", () => {
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody }); 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("h1", "Tableau de bord").should("be.visible");
cy.contains(".admin-sidebar__who", "Ops").should("be.visible"); cy.contains(".admin-sidebar__who", "Ops").should("be.visible");
cy.contains("nav a", "Monitoring").click(); 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("h1", "Monitoring").should("be.visible");
cy.contains("nav a", "Monitoring").should("have.class", "active"); cy.contains("nav a", "Monitoring").should("have.class", "active");
cy.contains("nav a", "Corrections").click(); 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.contains("h1", "Corrections").should("be.visible");
cy.intercept("GET", "**/admin/catalog/placeholders*", { statusCode: 200, body: [] }); cy.intercept("GET", "**/admin/catalog/placeholders*", { statusCode: 200, body: [] });
cy.contains("nav a", "Catalogue").click(); 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("h1", "Ingrédients hors-catalogue").should("be.visible");
cy.contains("nav a", "Tableau de bord").click(); 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", () => { it("logs out back to /login", () => {
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody }); cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody });
cy.intercept("POST", "**/admin/auth/logout", { statusCode: 204 }); cy.intercept("POST", "**/admin/auth/logout", { statusCode: 204 });
cy.visit("/"); cy.visit("/admin");
// Wait until the guarded layout has actually mounted before acting. // Wait until the guarded layout has actually mounted before acting.
cy.contains("h1", "Tableau de bord").should("be.visible"); 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 // guard to /login — no fresh `me` round-trip involved, so nothing to
// re-stub here. // re-stub here.
cy.contains("button", "Se déconnecter").click(); cy.contains("button", "Se déconnecter").click();
cy.url().should("include", "/login"); cy.url().should("include", "/admin/login");
}); });
}); });

View file

@ -6,19 +6,19 @@ Feature: Admin login
Scenario: A wrong password shows a translated error, no redirect Scenario: A wrong password shows a translated error, no redirect
Given the admin session check returns unauthenticated Given the admin session check returns unauthenticated
And admin login fails with invalid credentials 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 "email" field with "ops@example.com"
And I fill in the "password" field with "wrong" And I fill in the "password" field with "wrong"
And I click the button "Se connecter" And I click the button "Se connecter"
Then I should see "Email ou mot de passe incorrect" 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 Scenario: A correct login lands on the dashboard
Given the admin session check returns unauthenticated Given the admin session check returns unauthenticated
And admin login succeeds as "Ops" 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 "email" field with "ops@example.com"
And I fill in the "password" field with "correct-horse" And I fill in the "password" field with "correct-horse"
And I click the button "Se connecter" 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" And I should see the heading "Tableau de bord"

View file

@ -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 });
});

View file

@ -52,11 +52,23 @@ describe("Admin monitoring", () => {
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody }); 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", () => { it("renders one card per service with its status and details", () => {
cy.intercept("GET", "**/admin/monitoring", { statusCode: 200, body: monitoringFixture() }).as( cy.intercept(monitoringApi, {
"getMonitoring", statusCode: 200,
); body: monitoringFixture(),
cy.visit("/monitoring"); }).as("getMonitoring");
cy.visit("/admin/monitoring");
cy.wait("@getMonitoring"); cy.wait("@getMonitoring");
cy.get(".monitoring-card").should("have.length", 4); cy.get(".monitoring-card").should("have.length", 4);
@ -73,11 +85,11 @@ describe("Admin monitoring", () => {
}); });
it("shows an error state when the request fails", () => { it("shows an error state when the request fails", () => {
cy.intercept("GET", "**/admin/monitoring", { cy.intercept(monitoringApi, {
statusCode: 500, statusCode: 500,
body: { code: 5000, message: "x" }, body: { code: 5000, message: "x" },
}); });
cy.visit("/monitoring"); cy.visit("/admin/monitoring");
cy.contains("Impossible de charger").should("be.visible"); cy.contains("Impossible de charger").should("be.visible");
}); });
}); });

View file

@ -22,6 +22,7 @@
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-i18next": "^17.0.11", "react-i18next": "^17.0.11",
"react-router-dom": "^7.18.2", "react-router-dom": "^7.18.2",
"recharts": "^2.15.0",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {

View file

@ -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 { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated";
import { RequireAuth } from "./features/auth/RequireAuth"; import { RequireAuth } from "./features/auth/RequireAuth";
import { AdminLayout } from "./layouts/AdminLayout";
import { AppLayout } from "./layouts/AppLayout"; 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 { LoginPage } from "./pages/auth/LoginPage";
import { SignupPage } from "./pages/auth/SignupPage"; import { SignupPage } from "./pages/auth/SignupPage";
import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage"; 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 * `/onboarding/sources` is conditional only reached when the `foyer` step
* created/joined a household (see `OnboardingHouseholdPage`'s `goToNextStep`); * created/joined a household (see `OnboardingHouseholdPage`'s `goToNextStep`);
* skipped otherwise, straight to `/onboarding/allergenes`. * 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() { export function App() {
return ( return (
@ -122,6 +139,28 @@ export function App() {
</RedirectIfAuthenticated> </RedirectIfAuthenticated>
} }
/> />
<Route
element={
<AdminAuthProvider>
<Outlet />
</AdminAuthProvider>
}
>
<Route path="/admin/login" element={<AdminLoginPage />} />
<Route
path="/admin"
element={
<RequireAdmin>
<AdminLayout />
</RequireAdmin>
}
>
<Route index element={<AdminDashboardPage />} />
<Route path="monitoring" element={<AdminMonitoringPage />} />
<Route path="corrections" element={<AdminCorrectionsPage />} />
<Route path="catalogue" element={<AdminCatalogPage />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
); );

View file

@ -26,28 +26,28 @@ function query(params: Record<string, string | undefined>): string {
} }
/** /**
* Base URL of the admin API surface, configurable via `VITE_ADMIN_API_URL` * Base URL of the API the same `VITE_API_URL` the user-facing
* (see `.env.example`). Defaults to `""` (same origin) correct behind a * {@link apiClient} reads (see `api/client.ts`). Defaults to `""` (same
* shared reverse proxy; native dev overrides it to `http://localhost:3000` * origin) correct behind a shared reverse proxy; native dev overrides it
* in `apps/admin-web/.env` since the Vite dev server (5174) and the API * to `http://localhost:3000` in `apps/web/.env`. The `/admin/*` surface is
* (3000) are different origins. * 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 * Thrown by {@link AdminApiClient} on any non-2xx response carries the
* same {@link ErrorCode} the API returned. Same shape as apps/web's * {@link ErrorCode} the API returned. Distinct from `api/client.ts`'s
* `ApiError`; kept separate rather than shared so the two apps' transport * `ApiError` (same shape) so a file importing both never has a name clash;
* layers stay independent. * 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 status: number;
public readonly code: ErrorCode; public readonly code: ErrorCode;
public readonly fieldErrors?: Record<string, string[] | undefined>; public readonly fieldErrors?: Record<string, string[] | undefined>;
public constructor(status: number, body: ApiErrorResponse) { public constructor(status: number, body: ApiErrorResponse) {
super(body.message); super(body.message);
this.name = "ApiError"; this.name = "AdminApiError";
this.status = status; this.status = status;
this.code = body.code; this.code = body.code;
this.fieldErrors = body.details; this.fieldErrors = body.details;
@ -64,7 +64,7 @@ export class AdminApiClient {
/** /**
* Performs a JSON request against the admin API and returns the parsed body. * 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<TResponseBody>( private async _request<TResponseBody>(
path: string, path: string,
@ -79,7 +79,7 @@ export class AdminApiClient {
if (!response.ok) { if (!response.ok) {
const body = (await response.json().catch(() => null)) as ApiErrorResponse | null; const body = (await response.json().catch(() => null)) as ApiErrorResponse | null;
throw new ApiError( throw new AdminApiError(
response.status, response.status,
body ?? { code: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" }, body ?? { code: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" },
); );

View file

@ -1,6 +1,6 @@
import type { AdminLoginInput, AdminUserView } from "@batch-cooking/shared"; import type { AdminLoginInput, AdminUserView } from "@batch-cooking/shared";
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react"; 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`. */ /** Auth state/actions exposed via {@link useAdminAuth} — the admin-app counterpart of apps/web's `AuthContext`. */
interface AdminAuthContextValue { interface AdminAuthContextValue {

View file

@ -5,8 +5,9 @@ import { useAdminAuth } from "./AdminAuthContext";
/** /**
* Route guard for every admin page. Renders nothing while the initial * Route guard for every admin page. Renders nothing while the initial
* `GET /admin/auth/me` check is pending (avoids a flash-then-redirect); * `GET /admin/auth/me` check is pending (avoids a flash-then-redirect);
* once resolved, renders `children` or redirects to `/login`. Mirror of * once resolved, renders `children` or redirects to `/admin/login`. Mirror
* apps/web's `RequireAuth`. * of `RequireAuth` (`features/auth/`), but keyed to the separate admin
* session (`admin_session` cookie), not the user one.
*/ */
export function RequireAdmin({ children }: { children: ReactNode }) { export function RequireAdmin({ children }: { children: ReactNode }) {
const { admin, isLoading } = useAdminAuth(); const { admin, isLoading } = useAdminAuth();
@ -15,7 +16,7 @@ export function RequireAdmin({ children }: { children: ReactNode }) {
return null; return null;
} }
if (!admin) { if (!admin) {
return <Navigate to="/login" replace />; return <Navigate to="/admin/login" replace />;
} }
return <>{children}</>; return <>{children}</>;
} }

View file

@ -2,18 +2,20 @@ import { Activity, LayoutDashboard, ListChecks, PackageSearch } from "lucide-rea
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useAdminAuth } from "../features/auth/AdminAuthContext"; import { useAdminAuth } from "../features/admin/AdminAuthContext";
import "./AdminLayout.scss"; import "./AdminLayout.scss";
/** /**
* One entry in the admin sidebar's nav. `key` maps to `admin.nav.<key>` in * One entry in the admin sidebar's nav. `key` maps to `admin.nav.<key>` in
* the locale file adding a section is one array entry plus one locale key. * 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 = [ const NAV_ITEMS = [
{ to: "/", key: "dashboard", Icon: LayoutDashboard, end: true }, { to: "/admin", key: "dashboard", Icon: LayoutDashboard, end: true },
{ to: "/monitoring", key: "monitoring", Icon: Activity, end: false }, { to: "/admin/monitoring", key: "monitoring", Icon: Activity, end: false },
{ to: "/corrections", key: "corrections", Icon: ListChecks, end: false }, { to: "/admin/corrections", key: "corrections", Icon: ListChecks, end: false },
{ to: "/catalogue", key: "catalog", Icon: PackageSearch, end: false }, { to: "/admin/catalogue", key: "catalog", Icon: PackageSearch, end: false },
] as const; ] as const;
/** /**
@ -33,12 +35,12 @@ export function AdminLayout() {
setIsLoggingOut(true); setIsLoggingOut(true);
try { try {
await logout(); await logout();
void navigate("/login"); void navigate("/admin/login");
} catch { } catch {
// Even if the network call failed, the local session state was // Even if the network call failed, the local session state was
// cleared optimistically enough for the guard to bounce to /login; // cleared optimistically enough for the guard to bounce to /login;
// nothing useful to show the operator here. // nothing useful to show the operator here.
void navigate("/login"); void navigate("/admin/login");
} }
} }

View file

@ -1124,5 +1124,142 @@
"palmSugar": "Sucre de palme", "palmSugar": "Sucre de palme",
"caneSyrup": "Sirop de sucre de canne" "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)."
}
} }
} }

View file

@ -1,7 +1,7 @@
import type { CatalogPlaceholderGroupView } from "@batch-cooking/shared"; 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 { useTranslation } from "react-i18next";
import { adminApiClient } from "../../api/client"; import { adminApiClient } from "../../../api/admin-client";
import "../admin-page.scss"; import "../admin-page.scss";
import "./catalog-page.scss"; import "./catalog-page.scss";
import { type CatalogTab, formatDate, reviewedParam, splitSpellings } from "./catalog"; import { type CatalogTab, formatDate, reviewedParam, splitSpellings } from "./catalog";
@ -29,12 +29,22 @@ export function CatalogPage() {
const [pruneMessage, setPruneMessage] = useState<string | null>(null); const [pruneMessage, setPruneMessage] = useState<string | null>(null);
const [isPruning, setIsPruning] = useState(false); 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 load = useCallback((forTab: CatalogTab) => {
const seq = ++requestSeq.current;
setState({ status: "loading" }); setState({ status: "loading" });
adminApiClient adminApiClient
.getPlaceholders(reviewedParam(forTab)) .getPlaceholders(reviewedParam(forTab))
.then((groups) => setState({ status: "loaded", groups })) .then((groups) => {
.catch(() => setState({ status: "error" })); if (seq === requestSeq.current) setState({ status: "loaded", groups });
})
.catch(() => {
if (seq === requestSeq.current) setState({ status: "error" });
});
}, []); }, []);
useEffect(() => { useEffect(() => {

View file

@ -7,8 +7,8 @@ import {
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ApiError, adminApiClient } from "../../api/client"; import { AdminApiError, adminApiClient } from "../../../api/admin-client";
import { errorMessageService } from "../../services/error-message.service"; import { errorMessageService } from "../../../services/error-message.service";
import "../admin-page.scss"; import "../admin-page.scss";
import "./corrections-page.scss"; import "./corrections-page.scss";
import { linesToList, listsDiffer, listToLines } from "./corrections"; import { linesToList, listsDiffer, listToLines } from "./corrections";
@ -152,7 +152,9 @@ function SuggestionCard({
onMutated(); onMutated();
} catch (err) { } catch (err) {
setError( setError(
errorMessageService.getLabel(err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR), errorMessageService.getLabel(
err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR,
),
); );
} finally { } finally {
setBusy(false); setBusy(false);
@ -232,7 +234,9 @@ function SnippetPanel() {
setSnippet(result.snippet); setSnippet(result.snippet);
} catch (err) { } catch (err) {
setError( 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({})); setResult(await adminApiClient.retrain({}));
} catch (err) { } catch (err) {
setError( setError(
errorMessageService.getLabel(err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR), errorMessageService.getLabel(
err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR,
),
); );
} finally { } finally {
setBusy(false); setBusy(false);

View file

@ -10,7 +10,7 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { adminApiClient } from "../../api/client"; import { adminApiClient } from "../../../api/admin-client";
import "../admin-page.scss"; import "../admin-page.scss";
import "./dashboard-page.scss"; import "./dashboard-page.scss";
import { formatCount, kpiTiles, seriesTotal, shortDay } from "./dashboard"; import { formatCount, kpiTiles, seriesTotal, shortDay } from "./dashboard";

View file

@ -2,20 +2,20 @@ import { adminLoginSchema, ErrorCode } from "@batch-cooking/shared";
import { type FormEvent, useState } from "react"; import { type FormEvent, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ApiError } from "../../api/client"; import { AdminApiError } from "../../../api/admin-client";
import { useAdminAuth } from "../../features/auth/AdminAuthContext"; import { useAdminAuth } from "../../../features/admin/AdminAuthContext";
import "../../features/auth/admin-auth.scss"; import "../../../features/admin/admin-auth.scss";
import { fieldErrorsFrom } from "../../lib/zod-errors"; import { fieldErrorsFrom } from "../../../lib/zod-errors";
import { errorMessageService } from "../../services/error-message.service"; import { errorMessageService } from "../../../services/error-message.service";
/** /**
* The admin login screen the only unauthenticated route. Client-side * The admin login screen the only unauthenticated route under `/admin`.
* validation via the shared `adminLoginSchema` (same rules the API * Client-side validation via the shared `adminLoginSchema` (same rules the
* enforces), then `POST /admin/auth/login`; any API failure is translated * API enforces), then `POST /admin/auth/login`; any API failure is
* to a localized label via {@link ErrorMessageService}. Same structure as * translated to a localized label via {@link ErrorMessageService}. Same
* apps/web's `LoginPage`. * structure as the user-facing `LoginPage` (`pages/auth/`).
*/ */
export function LoginPage() { export function AdminLoginPage() {
const { login } = useAdminAuth(); const { login } = useAdminAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation(); const { t } = useTranslation();
@ -40,9 +40,9 @@ export function LoginPage() {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await login(result.data); await login(result.data);
void navigate("/"); void navigate("/admin");
} catch (err) { } 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)); setFormError(errorMessageService.getLabel(code));
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);

View file

@ -1,7 +1,7 @@
import type { MonitoringView, ServiceHealthView } from "@batch-cooking/shared"; import type { MonitoringView, ServiceHealthView } from "@batch-cooking/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { adminApiClient } from "../../api/client"; import { adminApiClient } from "../../../api/admin-client";
import "../admin-page.scss"; import "../admin-page.scss";
import "./monitoring-page.scss"; import "./monitoring-page.scss";
import { clockTime, POLL_INTERVAL_MS, statusModifier } from "./monitoring"; import { clockTime, POLL_INTERVAL_MS, statusModifier } from "./monitoring";

View file

@ -48,15 +48,12 @@ services:
# default: `/internal/tech-steps/*` fails closed rather than open # default: `/internal/tech-steps/*` fails closed rather than open
# for a deployment that doesn't run the worker at all. # for a deployment that doesn't run the worker at all.
INTERNAL_WORKER_SECRET: ${INTERNAL_WORKER_SECRET:-} INTERNAL_WORKER_SECRET: ${INTERNAL_WORKER_SECRET:-}
# Admin application (apps/admin-web + /admin/*). Both unset by default: # Admin surface (apps/web's /admin/* routes + the API's /admin/*).
# `requireAdmin` fails closed without ADMIN_JWT_SECRET, so a stack # Unset by default: `requireAdmin` fails closed without
# that doesn't run the admin app simply has every /admin/* route 401. # ADMIN_JWT_SECRET, so a stack that doesn't need the admin app simply
# Must be a *different* secret than JWT_SECRET. # has every /admin/* route 401. Must be a *different* secret than
# JWT_SECRET.
ADMIN_JWT_SECRET: ${ADMIN_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 # Compose network service name, not localhost — same reasoning as
# DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback: # DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback:
# tech-step-intent-service is a core dependency (see its own entry # tech-step-intent-service is a core dependency (see its own entry
@ -137,24 +134,6 @@ services:
# Dockerfile doc comment on its VOLUME declaration. # Dockerfile doc comment on its VOLUME declaration.
- tech_step_llm_worker_models:/worker/models - 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: volumes:
postgres_data: postgres_data:
tech_step_llm_worker_models: tech_step_llm_worker_models:

View file

@ -15,79 +15,6 @@ importers:
specifier: ^5.7.2 specifier: ^5.7.2
version: 5.9.3 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: apps/api:
dependencies: dependencies:
'@batch-cooking/date-tools': '@batch-cooking/date-tools':
@ -184,6 +111,9 @@ importers:
react-router-dom: react-router-dom:
specifier: ^7.18.2 specifier: ^7.18.2
version: 7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) 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: zod:
specifier: ^3.25.76 specifier: ^3.25.76
version: 3.25.76 version: 3.25.76

View file

@ -349,10 +349,11 @@ identique (aucune conversion — même posture que `ShoppingListItemView`).
## `admin` — application d'administration (`/admin/*`) ## `admin` — application d'administration (`/admin/*`)
Surface d'exploitation servie à `apps/admin-web` (frontend Vite **séparé**, Surface d'exploitation consommée par les routes `/admin/*` de `apps/web`
URL/déploiement propres). Vit dans `apps/api` (qui reste seul propriétaire du (mêmes app/build/origine que le reste du frontend, montées sous `/admin` dans
schéma) mais avec une **authentification totalement distincte** de celle des `App.tsx`, enveloppées de `AdminAuthProvider` + `RequireAdmin`). Vit dans
utilisateurs. `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 **Auth** (`middlewares/require-admin.ts`, `lib/admin-jwt.ts`) — table
`AdminUser` isolée (aucune relation vers `UserProfile`), cookie `AdminUser` isolée (aucune relation vers `UserProfile`), cookie
@ -361,8 +362,8 @@ utilisateurs.
`requireAuth`, **échoue fermé** si `ADMIN_JWT_SECRET` est absent (posture `requireAuth`, **échoue fermé** si `ADMIN_JWT_SECRET` est absent (posture
`requireInternalWorker`). Aucun signup exposé — le 1ᵉʳ admin est créé `requireInternalWorker`). Aucun signup exposé — le 1ᵉʳ admin est créé
hors-bande par `src/scripts/create-admin.ts` (flags ou `ADMIN_INITIAL_*`). hors-bande par `src/scripts/create-admin.ts` (flags ou `ADMIN_INITIAL_*`).
`res.locals.adminUser` typé `AdminLocals`. CORS : `setupCore` accepte `res.locals.adminUser` typé `AdminLocals`. Pas de CORS dédié : l'UI admin est
`string[]`, `app.ts` autorise `CORS_ORIGIN` + `ADMIN_CORS_ORIGIN`. 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` : Router agrégateur `modules/admin/admin.routes.ts` monté `/admin` :
`/admin/auth` (`login`/`logout`/`me`), `/admin/metrics` (ci-dessous). `/admin/auth` (`login`/`logout`/`me`), `/admin/metrics` (ci-dessous).