Compare commits
8 commits
feat/off-c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d5dca82af4 | |||
| ccec30b2e8 | |||
| 1a28d67218 | |||
| bbd9afe8aa | |||
| 927051793c | |||
| 9532ea4e83 | |||
| a33f641dc7 | |||
| f7aabd9dcc |
65 changed files with 423 additions and 1104 deletions
11
.env.example
11
.env.example
|
|
@ -15,20 +15,17 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
# (docker-compose.yml), serving both the API and the built frontend.
|
# (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.
|
||||||
|
|
|
||||||
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -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 });
|
|
||||||
});
|
|
||||||
|
|
@ -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 {};
|
|
||||||
|
|
@ -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");
|
|
||||||
});
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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)."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>,
|
|
||||||
);
|
|
||||||
|
|
@ -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();
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
5
apps/admin-web/src/vite-env.d.ts
vendored
5
apps/admin-web/src/vite-env.d.ts
vendored
|
|
@ -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;
|
|
||||||
|
|
@ -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"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "Bundler"
|
|
||||||
},
|
|
||||||
"files": [],
|
|
||||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "Bundler",
|
|
||||||
"composite": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"types": ["node"]
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
|
|
@ -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),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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`).
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,8 @@ describe("Reference data", () => {
|
||||||
"reproducible",
|
"reproducible",
|
||||||
"allergens",
|
"allergens",
|
||||||
"diets",
|
"diets",
|
||||||
|
"isPlaceholder",
|
||||||
|
"displayName",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
@ -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");
|
||||||
|
|
||||||
|
|
@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -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"
|
||||||
35
apps/web/cypress/e2e/admin-login.ts
Normal file
35
apps/web/cypress/e2e/admin-login.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
|
@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
);
|
);
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -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}</>;
|
||||||
}
|
}
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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)."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue