Compare commits

..

No commits in common. "main" and "feat/off-catalog-ingredients" have entirely different histories.

65 changed files with 1104 additions and 423 deletions

View file

@ -15,17 +15,20 @@ 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 surface (apps/web's /admin/* routes + the /admin/* API) -------- # --- Admin application (apps/admin-web + the /admin/* API surface) ---------
# 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. The admin UI is # leaving it out simply disables every /admin/* route.
# 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,3 +179,10 @@ 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

@ -0,0 +1,6 @@
# Vite only exposes vars prefixed with VITE_ to client code.
# Base URL of the API's /admin/* surface. Empty string = same origin as the
# page (correct behind a shared reverse proxy). Native dev overrides it in
# apps/admin-web/.env since the Vite dev server (5174) and the API (3000)
# are different origins.
VITE_ADMIN_API_URL=http://localhost:3000

25
apps/admin-web/Dockerfile Normal file
View file

@ -0,0 +1,25 @@
# Its own image (not built into apps/api's) — the admin app is deployed
# independently of the main app. Build stage compiles the Vite bundle from
# the monorepo; runtime is a plain static nginx serving that bundle.
#
# Build context is the repo root (like apps/api/Dockerfile) — the workspace
# packages (@batch-cooking/shared, @batch-cooking/date-tools) must resolve.
FROM node:22-slim AS build
RUN corepack enable
WORKDIR /repo
# Skip Cypress's Electron binary download — this image never runs it.
ENV CYPRESS_INSTALL_BINARY=0
COPY . .
RUN pnpm install --frozen-lockfile
# The admin bundle bakes in VITE_ADMIN_API_URL at build time. Default ""
# (same-origin — correct behind a shared reverse proxy); override with
# `--build-arg VITE_ADMIN_API_URL=https://api.example.com` when the admin
# app is served from a different origin than the API.
ARG VITE_ADMIN_API_URL=""
ENV VITE_ADMIN_API_URL=$VITE_ADMIN_API_URL
RUN pnpm --filter admin-web build
FROM nginx:alpine AS runtime
COPY apps/admin-web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /repo/apps/admin-web/dist /usr/share/nginx/html
EXPOSE 80

View file

@ -0,0 +1,28 @@
import { addCucumberPreprocessorPlugin } from "@badeball/cypress-cucumber-preprocessor";
import { createEsbuildPlugin } from "@badeball/cypress-cucumber-preprocessor/esbuild";
import createBundler from "@bahmutov/cypress-esbuild-preprocessor";
import { defineConfig } from "cypress";
// Disable GPU for headless/sandboxed environments where no GPU device is
// available — same helper as apps/web's cypress.config.ts.
function disableGpu(on: Cypress.PluginEvents) {
on("before:browser:launch", (browser, launchOptions) => {
if (browser.family === "chromium") {
launchOptions.args.push("--disable-gpu", "--no-sandbox");
}
return launchOptions;
});
}
export default defineConfig({
e2e: {
baseUrl: "http://localhost:5174",
specPattern: ["cypress/e2e/**/*.cy.ts", "cypress/e2e/**/*.feature"],
async setupNodeEvents(on, config) {
disableGpu(on);
await addCucumberPreprocessorPlugin(on, config);
on("file:preprocessor", createBundler({ plugins: [createEsbuildPlugin(config)] }));
return config;
},
},
});

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("/admin/monitoring"); cy.visit("/monitoring");
cy.url().should("include", "/admin/login"); cy.url().should("include", "/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("/admin"); cy.visit("/");
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", "/admin/monitoring"); cy.url().should("include", "/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", "/admin/corrections"); cy.url().should("include", "/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", "/admin/catalogue"); cy.url().should("include", "/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}/admin`); cy.url().should("eq", `${Cypress.config().baseUrl}/`);
}); });
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("/admin"); cy.visit("/");
// 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", "/admin/login"); cy.url().should("include", "/login");
}); });
}); });

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("/admin/catalogue"); cy.visit("/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("/admin/catalogue"); cy.visit("/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,19 +79,16 @@ describe("Admin catalog — off-catalog ingredients", () => {
}); });
it("switches to the reviewed archive tab", () => { it("switches to the reviewed archive tab", () => {
// Regex, not a glob: the two calls differ only by the `?reviewed=true` cy.intercept("GET", "**/admin/catalog/placeholders", {
// 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("/admin/catalogue"); cy.visit("/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("/admin/corrections"); cy.visit("/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("/admin/corrections"); cy.visit("/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("/admin/corrections"); cy.visit("/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("/admin/corrections"); cy.visit("/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("/admin"); cy.visit("/");
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("/admin"); cy.visit("/");
cy.contains("Impossible de charger").should("be.visible"); cy.contains("Impossible de charger").should("be.visible");
}); });
}); });

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 "/admin/login" When I visit "/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 "/admin/login" And the URL should include "/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 "/admin/login" When I visit "/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 "/admin/login" Then the URL should not include "/login"
And I should see the heading "Tableau de bord" And I should see the heading "Tableau de bord"

View file

@ -0,0 +1,24 @@
import { Given } from "@badeball/cypress-cucumber-preprocessor";
const adminBody = {
id: 1,
email: "ops@example.com",
name: "Ops",
createdAt: "2026-08-01T00:00:00.000Z",
lastLoginAt: "2026-08-28T09:00:00.000Z",
};
Given("admin login fails with invalid credentials", () => {
cy.intercept("POST", "**/admin/auth/login", {
statusCode: 401,
body: { code: 4010, message: "Invalid email or password" },
});
});
Given("admin login succeeds as {string}", (name: string) => {
const body = { ...adminBody, name, email: `${name.toLowerCase()}@example.com` };
cy.intercept("POST", "**/admin/auth/login", { statusCode: 200, body });
// After navigate("/"), RequireAdmin re-checks the session — from now on it
// must report authenticated (last matching intercept wins).
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body });
});

View file

@ -52,23 +52,11 @@ 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(monitoringApi, { cy.intercept("GET", "**/admin/monitoring", { statusCode: 200, body: monitoringFixture() }).as(
statusCode: 200, "getMonitoring",
body: monitoringFixture(), );
}).as("getMonitoring"); cy.visit("/monitoring");
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);
@ -85,11 +73,11 @@ describe("Admin monitoring", () => {
}); });
it("shows an error state when the request fails", () => { it("shows an error state when the request fails", () => {
cy.intercept(monitoringApi, { cy.intercept("GET", "**/admin/monitoring", {
statusCode: 500, statusCode: 500,
body: { code: 5000, message: "x" }, body: { code: 5000, message: "x" },
}); });
cy.visit("/admin/monitoring"); cy.visit("/monitoring");
cy.contains("Impossible de charger").should("be.visible"); cy.contains("Impossible de charger").should("be.visible");
}); });
}); });

View file

@ -0,0 +1,3 @@
// Cypress support file — global config and custom commands go here as the
// admin app grows. Same minimal starting point as apps/web's e2e.ts.
export {};

View file

@ -0,0 +1,54 @@
import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
// Steps shared across admin feature specs — navigation and generic UI
// assertions. Anything specific to one feature (its own API mocks, its own
// DOM structure) lives in that feature's own `<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");
});

12
apps/admin-web/index.html Normal file
View file

@ -0,0 +1,12 @@
<!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>

20
apps/admin-web/nginx.conf Normal file
View file

@ -0,0 +1,20 @@
# Static host for the built admin SPA. Client-side routing (react-router)
# means any unknown path must fall back to index.html rather than 404
# same reason apps/api serves its own SPA that way.
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# Long-cache the fingerprinted assets Vite emits; never cache the HTML
# entry point so a new deploy is picked up immediately.
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

View file

@ -0,0 +1,42 @@
{
"name": "admin-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5174",
"build": "tsc -b && vite build",
"preview": "vite preview --port 5174",
"test": "echo \"no unit tests yet\" && exit 0",
"cy:open": "cypress open",
"cy:run": "cypress run",
"e2e": "start-server-and-test dev http://localhost:5174 cy:run"
},
"dependencies": {
"@batch-cooking/date-tools": "workspace:*",
"@batch-cooking/shared": "workspace:*",
"i18next": "^26.3.6",
"lucide-react": "^1.32.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^17.0.11",
"react-router-dom": "^7.18.2",
"recharts": "^2.15.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@badeball/cypress-cucumber-preprocessor": "22.2.0",
"@bahmutov/cypress-esbuild-preprocessor": "2.2.8",
"@cypress/vite-dev-server": "5.2.1",
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"cypress": "13.17.0",
"esbuild": "0.21.5",
"sass": "^1.102.0",
"start-server-and-test": "^2.0.8",
"typescript": "^5.7.2",
"vite": "^5.4.11"
}
}

View file

@ -0,0 +1,36 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { RequireAdmin } from "./features/auth/RequireAdmin";
import { AdminLayout } from "./layouts/AdminLayout";
import { CatalogPage } from "./pages/catalog/CatalogPage";
import { CorrectionsPage } from "./pages/corrections/CorrectionsPage";
import { DashboardPage } from "./pages/dashboard/DashboardPage";
import { LoginPage } from "./pages/login/LoginPage";
import { MonitoringPage } from "./pages/monitoring/MonitoringPage";
/**
* Admin app route table. `/login` is the only unauthenticated route;
* everything else is nested under one `RequireAdmin` + `AdminLayout` parent
* (the guard + sidebar chrome applied once), same shape as apps/web's
* `App.tsx`. Unknown paths fall back to `/`, which redirects to `/login`
* when there's no admin session.
*/
export function App() {
return (
<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

@ -26,28 +26,28 @@ function query(params: Record<string, string | undefined>): string {
} }
/** /**
* Base URL of the API the same `VITE_API_URL` the user-facing * Base URL of the admin API surface, configurable via `VITE_ADMIN_API_URL`
* {@link apiClient} reads (see `api/client.ts`). Defaults to `""` (same * (see `.env.example`). Defaults to `""` (same origin) correct behind a
* origin) correct behind a shared reverse proxy; native dev overrides it * shared reverse proxy; native dev overrides it to `http://localhost:3000`
* to `http://localhost:3000` in `apps/web/.env`. The `/admin/*` surface is * in `apps/admin-web/.env` since the Vite dev server (5174) and the API
* served by the same API process as the rest of the app. * (3000) are different origins.
*/ */
const ADMIN_API_BASE_URL: string = import.meta.env.VITE_API_URL ?? ""; const ADMIN_API_BASE_URL: string = import.meta.env.VITE_ADMIN_API_URL ?? "";
/** /**
* Thrown by {@link AdminApiClient} on any non-2xx response carries the * Thrown by {@link AdminApiClient} on any non-2xx response carries the
* {@link ErrorCode} the API returned. Distinct from `api/client.ts`'s * same {@link ErrorCode} the API returned. Same shape as apps/web's
* `ApiError` (same shape) so a file importing both never has a name clash; * `ApiError`; kept separate rather than shared so the two apps' transport
* the admin transport layer stays independent of the user-facing one. * layers stay independent.
*/ */
export class AdminApiError extends Error { export class ApiError 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 = "AdminApiError"; this.name = "ApiError";
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 {AdminApiError} if the response status is not in the 2xx range. * @throws {ApiError} 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 AdminApiError( throw new ApiError(
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/admin-client"; import { adminApiClient } from "../../api/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,9 +5,8 @@ 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 `/admin/login`. Mirror * once resolved, renders `children` or redirects to `/login`. Mirror of
* of `RequireAuth` (`features/auth/`), but keyed to the separate admin * apps/web's `RequireAuth`.
* 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();
@ -16,7 +15,7 @@ export function RequireAdmin({ children }: { children: ReactNode }) {
return null; return null;
} }
if (!admin) { if (!admin) {
return <Navigate to="/admin/login" replace />; return <Navigate to="/login" replace />;
} }
return <>{children}</>; return <>{children}</>;
} }

View file

@ -0,0 +1,22 @@
import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import fr from "../locales/fr/translation.json";
/**
* i18next instance for the admin app, imported once for its side effect
* (`main.tsx`) before anything renders. Only French exists today same
* setup as apps/web's `i18n/i18n.ts`, its own separate locale file so the
* two apps' copy never has to be kept identical. `packages/shared`'s
* `ErrorCode` member names double as keys under the `errors` namespace
* (see `services/error-message.service.ts`).
*/
void i18next.use(initReactI18next).init({
resources: {
fr: { translation: fr },
},
lng: "fr",
fallbackLng: "fr",
interpolation: { escapeValue: false },
});
export default i18next;

View file

@ -2,20 +2,18 @@ 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/admin/AdminAuthContext"; import { useAdminAuth } from "../features/auth/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: "/admin", key: "dashboard", Icon: LayoutDashboard, end: true }, { to: "/", key: "dashboard", Icon: LayoutDashboard, end: true },
{ to: "/admin/monitoring", key: "monitoring", Icon: Activity, end: false }, { to: "/monitoring", key: "monitoring", Icon: Activity, end: false },
{ to: "/admin/corrections", key: "corrections", Icon: ListChecks, end: false }, { to: "/corrections", key: "corrections", Icon: ListChecks, end: false },
{ to: "/admin/catalogue", key: "catalog", Icon: PackageSearch, end: false }, { to: "/catalogue", key: "catalog", Icon: PackageSearch, end: false },
] as const; ] as const;
/** /**
@ -35,12 +33,12 @@ export function AdminLayout() {
setIsLoggingOut(true); setIsLoggingOut(true);
try { try {
await logout(); await logout();
void navigate("/admin/login"); void navigate("/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("/admin/login"); void navigate("/login");
} }
} }

View file

@ -0,0 +1,18 @@
import type { ZodError } from "zod";
/**
* Flattens a zod validation error into `{ fieldName: firstMessage }` for
* inline display under each form field verbatim copy of apps/web's
* `lib/zod-errors.ts` (only the first message per field, enough for the
* single-rule-per-field schemas used here).
*/
export function fieldErrorsFrom(error: ZodError): Record<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

@ -0,0 +1,148 @@
{
"errors": {
"VALIDATION_ERROR": "Erreur de validation",
"INVALID_CREDENTIALS": "Email ou mot de passe incorrect",
"NOT_AUTHENTICATED": "Vous devez être connecté",
"NOT_FOUND": "Ressource introuvable",
"TECH_STEP_NOT_FOUND": "Cette technique n'existe pas",
"RETRAIN_ALREADY_RUNNING": "Un ré-entraînement est déjà en cours",
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
},
"admin": {
"common": {
"comingSoon": "Section à venir.",
"loading": "Chargement…",
"loadError": "Impossible de charger les données, réessayez plus tard."
},
"login": {
"title": "Administration",
"emailLabel": "Email",
"passwordLabel": "Mot de passe",
"submit": "Se connecter",
"submitting": "Connexion…"
},
"nav": {
"dashboard": "Tableau de bord",
"monitoring": "Monitoring",
"corrections": "Corrections",
"catalog": "Catalogue"
},
"layout": {
"logout": "Se déconnecter"
},
"dashboard": {
"title": "Tableau de bord",
"lead": "Métriques d'utilisation de l'application.",
"windowTotal": "{{n}} sur 30 j",
"recipesBySource": "Recettes importées par source",
"noImports": "Aucune recette importée.",
"events": "Évènements enregistrés (30 j)",
"kpi": {
"users": "Utilisateurs",
"households": "Foyers",
"activeHouseholds": "Foyers actifs",
"recipes": "Recettes",
"recipesImported": "Recettes importées",
"plannings": "Plannings",
"planningItems": "Créneaux planifiés",
"favorites": "Favoris",
"corrections": "Corrections",
"correctionsUnconsumed": "Corrections à traiter",
"trainingSuggestions": "Suggestions d'entraînement",
"admins": "Administrateurs"
},
"series": {
"signups": "Inscriptions",
"recipesCreated": "Recettes créées",
"planningItemsAdded": "Ajouts au planning",
"correctionsSubmitted": "Corrections soumises",
"trainingSuggestions": "Suggestions générées"
}
},
"monitoring": {
"title": "Monitoring",
"lead": "Santé des microservices et de la base de données.",
"lastChecked": "Dernière vérification à {{time}}",
"latency": "Latence",
"detail": "Détail",
"lastRun": "Dernier job",
"never": "jamais",
"jobFailed": "échec",
"status": {
"up": "OK",
"degraded": "Dégradé",
"down": "Hors service",
"unknown": "Inconnu"
},
"service": {
"postgres": "Base de données",
"api": "API",
"intent-service": "Service NLP (spaCy)",
"tech-step-llm-worker": "Worker LLM"
}
},
"corrections": {
"title": "Corrections",
"lead": "Tri des corrections utilisateur pour le ré-entraînement NLP.",
"caveat": "Le gate F1 + backfill n'a de sens qu'APRÈS avoir édité training_data.py à la main et redémarré le service NLP (il ne s'entraîne qu'au démarrage). Cet écran ne peut faire ni l'un ni l'autre.",
"noSuggestions": "Aucune suggestion pour ces filtres.",
"synonyms": "Synonymes proposés (un par ligne)",
"utterances": "Phrases proposées (une par ligne)",
"save": "Enregistrer",
"apply": "Appliquer",
"reject": "Rejeter",
"tab": {
"suggestions": "Suggestions",
"corrections": "Corrections brutes"
},
"filter": {
"status": "Statut",
"source": "Source",
"consumed": "Consommée",
"hasCorrected": "Technique corrigée",
"any": "Toutes",
"yes": "Oui",
"no": "Non"
},
"snippet": {
"title": "Snippet training_data.py",
"help": "Agrège les synonymes/phrases des suggestions « applied » d'une technique, au format à coller dans training_data.py.",
"keyPlaceholder": "clé de technique (ex. simmer)",
"generate": "Générer"
},
"retrain": {
"title": "Gate F1 + backfill",
"help": "Lance l'évaluation de régression F1 puis, si elle passe, recalcule les techniques de toutes les étapes.",
"run": "Lancer",
"running": "En cours…",
"passed": "OK — {{changed}}/{{total}} étape(s) recalculée(s)",
"failed": "Échec du gate — aucun backfill"
},
"col": {
"clause": "Clause",
"change": "Changement",
"created": "Créée",
"consumed": "Consommée"
}
},
"catalog": {
"title": "Ingrédients hors-catalogue",
"lead": "Ingrédients saisis en texte libre par les utilisateurs parce que le catalogue ne les couvrait pas. Regroupés par nom normalisé — à promouvoir dans reference-seed-data.ts + les locales, à la main.",
"empty": "Aucun ingrédient hors-catalogue.",
"tab": {
"pending": "À traiter",
"reviewed": "Traités"
},
"recipeCount": "{{count}} recette(s)",
"alsoWritten": "Aussi écrit : {{variants}}",
"seenIn": "Vu dans :",
"firstSeen": "Première fois le {{date}}",
"markReviewed": "Marquer comme traité",
"marking": "…",
"pruneOrphans": "Purger les orphelins",
"pruning": "Purge…",
"prunedNone": "Aucun placeholder orphelin à purger.",
"pruned": "{{count}} placeholder(s) orphelin(s) supprimé(s)."
}
}
}

View file

@ -0,0 +1,24 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import { AdminAuthProvider } from "./features/auth/AdminAuthContext";
// Side-effect import: initializes i18next before anything renders.
import "./i18n/i18n";
// Global stylesheet (theme tokens + minimal reset) — the only non-colocated .scss import.
import "./styles/global.scss";
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Root element not found");
}
createRoot(rootElement).render(
<StrictMode>
<BrowserRouter>
<AdminAuthProvider>
<App />
</AdminAuthProvider>
</BrowserRouter>
</StrictMode>,
);

View file

@ -1,7 +1,7 @@
import type { CatalogPlaceholderGroupView } from "@batch-cooking/shared"; import type { CatalogPlaceholderGroupView } from "@batch-cooking/shared";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { adminApiClient } from "../../../api/admin-client"; import { adminApiClient } from "../../api/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,22 +29,12 @@ 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) => { .then((groups) => setState({ status: "loaded", groups }))
if (seq === requestSeq.current) setState({ status: "loaded", groups }); .catch(() => setState({ status: "error" }));
})
.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 { AdminApiError, adminApiClient } from "../../../api/admin-client"; import { ApiError, adminApiClient } from "../../api/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,9 +152,7 @@ function SuggestionCard({
onMutated(); onMutated();
} catch (err) { } catch (err) {
setError( setError(
errorMessageService.getLabel( errorMessageService.getLabel(err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR),
err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR,
),
); );
} finally { } finally {
setBusy(false); setBusy(false);
@ -234,9 +232,7 @@ function SnippetPanel() {
setSnippet(result.snippet); setSnippet(result.snippet);
} catch (err) { } catch (err) {
setError( setError(
errorMessageService.getLabel( errorMessageService.getLabel(err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR),
err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR,
),
); );
} }
} }
@ -277,9 +273,7 @@ function RetrainPanel() {
setResult(await adminApiClient.retrain({})); setResult(await adminApiClient.retrain({}));
} catch (err) { } catch (err) {
setError( setError(
errorMessageService.getLabel( errorMessageService.getLabel(err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR),
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/admin-client"; import { adminApiClient } from "../../api/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 { AdminApiError } from "../../../api/admin-client"; import { ApiError } from "../../api/client";
import { useAdminAuth } from "../../../features/admin/AdminAuthContext"; import { useAdminAuth } from "../../features/auth/AdminAuthContext";
import "../../../features/admin/admin-auth.scss"; import "../../features/auth/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 under `/admin`. * The admin login screen the only unauthenticated route. Client-side
* Client-side validation via the shared `adminLoginSchema` (same rules the * validation via the shared `adminLoginSchema` (same rules the API
* API enforces), then `POST /admin/auth/login`; any API failure is * enforces), then `POST /admin/auth/login`; any API failure is translated
* translated to a localized label via {@link ErrorMessageService}. Same * to a localized label via {@link ErrorMessageService}. Same structure as
* structure as the user-facing `LoginPage` (`pages/auth/`). * apps/web's `LoginPage`.
*/ */
export function AdminLoginPage() { export function LoginPage() {
const { login } = useAdminAuth(); const { login } = useAdminAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation(); const { t } = useTranslation();
@ -40,9 +40,9 @@ export function AdminLoginPage() {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await login(result.data); await login(result.data);
void navigate("/admin"); void navigate("/");
} catch (err) { } catch (err) {
const code = err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR; const code = err instanceof ApiError ? 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/admin-client"; import { adminApiClient } from "../../api/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

@ -0,0 +1,19 @@
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

@ -0,0 +1,171 @@
// 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

@ -0,0 +1,149 @@
// =============================================================================
// 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);
}

5
apps/admin-web/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
/// <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

@ -0,0 +1,14 @@
{
"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

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

View file

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

View file

@ -0,0 +1,19 @@
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,13 +25,3 @@ 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,20 +34,18 @@ 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);
// One allowed origin: the app (`CORS_ORIGIN`). The admin surface // Two allowed origins: the main app (`CORS_ORIGIN`) and the separate
// (`/admin/*`) is served by this same API and consumed by `apps/web`'s // admin app (`ADMIN_CORS_ORIGIN`). The `cors` package matches an incoming
// own `/admin/*` routes — same origin as the rest of the app, so no // `Origin` against any entry of the list.
// extra CORS entry. server.setupCore({ corsOrigin: [env.CORS_ORIGIN, env.ADMIN_CORS_ORIGIN] });
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/web`'s `/admin/*` routes) — its own // Admin application surface (`apps/admin-web`) — its own auth
// auth (`requireAdmin`, distinct cookie/secret), never the end-user // (`requireAdmin`, distinct cookie/secret), never the end-user session.
// 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,6 +105,8 @@ 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 UI * Express middleware guarding every `/admin/*` route the operations app
* (`apps/web`'s `/admin/*` routes) authenticating as an `AdminUser`. Reads the admin * (`apps/admin-web`) 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 backs `apps/web`'s `/admin/*` routes * in `app.ts`. Every sub-router here is for `apps/admin-web` only
* only `/admin/auth` is public (login), everything added later * `/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,18 +286,8 @@ describe("Admin tech-steps triage", () => {
} }
this.timeout(60000); this.timeout(60000);
const agent = await adminAgent(); const agent = await adminAgent();
// supertest requests are lazy — they only dispatch when awaited/then'd. const first = agent.post("/admin/tech-steps/retrain").send({});
// Attaching the `.then` here is what actually fires the first request, // Let the first handler acquire the process-wide lock before the second starts.
// 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,62 +105,52 @@ 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. */
* A recipe: one pure-prep "chop onion" step, then (optionally) an async function makeRecipe(name: string, onionQty: number) {
* 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: withActiveStep ? [chopStep, activeStep, simmerStep] : [chopStep, simmerStep], create: [
{
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, true); const tarte = await makeRecipe("Tarte", 3);
const planning = await prisma.planning.create({ const planning = await prisma.planning.create({
data: { data: {

View file

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

View file

@ -1,35 +0,0 @@
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

@ -22,7 +22,6 @@
"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,15 +1,7 @@
import { Navigate, Outlet, Route, Routes } from "react-router-dom"; import { Navigate, 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";
@ -51,15 +43,6 @@ 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 (
@ -139,28 +122,6 @@ 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

@ -1124,142 +1124,5 @@
"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

@ -48,12 +48,15 @@ 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 surface (apps/web's /admin/* routes + the API's /admin/*). # Admin application (apps/admin-web + /admin/*). Both unset by default:
# Unset by default: `requireAdmin` fails closed without # `requireAdmin` fails closed without ADMIN_JWT_SECRET, so a stack
# ADMIN_JWT_SECRET, so a stack that doesn't need the admin app simply # that doesn't run the admin app simply has every /admin/* route 401.
# has every /admin/* route 401. Must be a *different* secret than # Must be a *different* secret than JWT_SECRET.
# 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
@ -134,6 +137,24 @@ 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,6 +15,79 @@ 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':
@ -111,9 +184,6 @@ 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,11 +349,10 @@ identique (aucune conversion — même posture que `ShoppingListItemView`).
## `admin` — application d'administration (`/admin/*`) ## `admin` — application d'administration (`/admin/*`)
Surface d'exploitation consommée par les routes `/admin/*` de `apps/web` Surface d'exploitation servie à `apps/admin-web` (frontend Vite **séparé**,
(mêmes app/build/origine que le reste du frontend, montées sous `/admin` dans URL/déploiement propres). Vit dans `apps/api` (qui reste seul propriétaire du
`App.tsx`, enveloppées de `AdminAuthProvider` + `RequireAdmin`). Vit dans schéma) mais avec une **authentification totalement distincte** de celle des
`apps/api` (qui reste seul propriétaire du schéma) mais avec une utilisateurs.
**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
@ -362,8 +361,8 @@ Surface d'exploitation consommée par les routes `/admin/*` de `apps/web`
`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`. Pas de CORS dédié : l'UI admin est `res.locals.adminUser` typé `AdminLocals`. CORS : `setupCore` accepte
servie par la même origine que le reste de l'app (`CORS_ORIGIN` suffit). `string[]`, `app.ts` autorise `CORS_ORIGIN` + `ADMIN_CORS_ORIGIN`.
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).