diff --git a/README.md b/README.md
index c845a53..d5bfbc7 100644
--- a/README.md
+++ b/README.md
@@ -8,8 +8,23 @@ Monorepo pnpm workspaces :
- `apps/web` — frontend React/Vite/TypeScript, prêt à être embarqué par Capacitor plus tard.
Page de connexion/inscription en place ; le reste est encore un squelette générique.
- `packages/shared` — code partagé entre `api` et `web` : schémas zod (`signupSchema`,
- `loginSchema`) et types (`SafeUserProfile`) — même règles de validation des deux côtés,
- pas de risque de dérive entre front et back.
+ `loginSchema`), types (`SafeUserProfile`), et le contrat d'erreurs (`ErrorCode`
+ numérique, `ApiErrorResponse`, voir [specs/error-handling.md](specs/error-handling.md)) —
+ même règles des deux côtés, pas de risque de dérive entre front et back.
+- `packages/error-tools` — gestion des erreurs, **indépendante de tout framework
+ HTTP** (n'importe pas `express`) : `HttpError`, `ErrorHandlerService`. Séparé
+ d'`express-tools` précisément parce que rien ici ne dépend d'Express. Détail :
+ [specs/error-handling.md](specs/error-handling.md).
+- `packages/express-tools` — outillage Express générique et réutilisable : `ExpressServer`
+ (init serveur, routes, middlewares), `wrapAsyncHandler`, `createErrorMiddleware`
+ (adapte `ErrorHandlerService` de `error-tools` à Express) — séparé d'`apps/api`,
+ pas de logique métier. Détail : [specs/backend-architecture.md](specs/backend-architecture.md).
+
+`packages/shared`, `packages/error-tools` et `packages/express-tools` ont un vrai
+build (`tsc` → `dist/`, voir leur `package.json`) : consommés en JS compilé, pas en
+TS brut — nécessaire pour un runtime Node pur (Docker, pas de transpilation à la
+volée), voir la note dans
+[specs/frontend-architecture.md](specs/frontend-architecture.md#note-sur-les-fichiers-dts).
## Prérequis
@@ -155,20 +170,69 @@ provisionne un vrai Postgres de service (`.github/workflows/ci.yml`) et exécute
## Page de connexion / inscription (apps/web)
-- `src/api/client.ts` — client fetch vers l'API (`credentials: "include"`, requis pour
- que le cookie de session httpOnly parte/revienne — l'API et le front sont sur des
- origines différentes). URL configurable via `VITE_API_URL` (voir `.env.example`).
+- `src/api/client.ts` — `ApiClient` (classe, instance unique exportée `apiClient`) :
+ enveloppe `fetch` vers l'API (`credentials: "include"`, requis pour que le cookie
+ de session httpOnly parte/revienne — l'API et le front sont sur des origines
+ différentes). URL configurable via `VITE_API_URL` (voir `.env.example`).
- `src/features/auth/AuthContext.tsx` — état d'auth global ; appelle `GET /auth/me` au
chargement pour restaurer la session depuis le cookie.
- `src/features/auth/RequireAuth.tsx` / `RedirectIfAuthenticated.tsx` — gardes de route
(react-router-dom) : `/` exige d'être connecté, `/login` et `/signup` redirigent vers
`/` si on l'est déjà.
- `src/pages/{Login,Signup,Home}Page.tsx` — validation client instantanée via les
- schémas zod partagés (`packages/shared`), erreurs API affichées telles quelles
- (messages déjà en français côté serveur).
+ schémas zod partagés (`packages/shared`), erreurs API traduites via
+ `ErrorMessageService` (voir ci-dessous).
+
+Détail de l'organisation complète (dossiers, routing, SCSS/theming) :
+[specs/frontend-architecture.md](specs/frontend-architecture.md).
Tests Cypress (`apps/web/cypress/e2e/`) : `smoke.cy.ts` + `auth.cy.ts` mockent l'API via
`cy.intercept` plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne
provisionne pas de Postgres/API, seulement le serveur de dev Vite. Le comportement
réel de l'API est couvert par les suites Mocha/Cucumber d'`apps/api` (contre une vraie
base).
+
+## Gestion des erreurs (API ↔ web)
+
+Contrat d'erreurs partagé via `packages/shared` (`ErrorCode`, énumération
+**numérique** groupée par famille — `4000` validation, `401x` auth, `404x` not
+found, `500x` interne — et `ApiErrorResponse`) : l'API renvoie toujours
+`{ code, message, details? }` (message en anglais, dev-facing — jamais affiché tel
+quel), et le client traduit `code` en libellé français via **i18next**
+(`ErrorMessageService`, `apps/web/src/services/error-message.service.ts` →
+`apps/web/src/locales/fr/translation.json`). Côté API, `ErrorHandlerService`
+(`packages/error-tools`) et `createErrorMiddleware` (`packages/express-tools`)
+centralisent la transformation de toute erreur levée en réponse HTTP conforme —
+aucune valeur `ErrorCode` codée en dur nulle part (toujours `ErrorCode.XXX`, y
+compris dans les mocks Cypress).
+
+Détail complet (schéma, exemples, comment ajouter un nouveau code d'erreur) :
+[specs/error-handling.md](specs/error-handling.md).
+
+Le profil authentifié (`requireAuth`) passe par `res.locals.userProfile`
+(typé via `AuthLocals`), pas par une augmentation du namespace global Express —
+voir [specs/backend-architecture.md](specs/backend-architecture.md) pour le détail
+et le pourquoi.
+
+`packages/shared` fournit aussi `assertIsNever` (vérification d'exhaustivité de
+switch/if-chain sur une union, erreur de **compilation** si un cas est oublié) —
+voir [specs/backend-architecture.md](specs/backend-architecture.md#packagesshared--assertisnever).
+
+## i18n
+
+**i18next** + **react-i18next** — tout le texte affiché (formulaires, boutons,
+erreurs) vient de fichiers de locale JSON (`apps/web/src/locales//translation.json`),
+jamais codé en dur dans un composant. Une seule langue existe aujourd'hui (`fr`) ;
+en ajouter une est une question de fichier de locale, pas de code. Détail :
+[specs/frontend-architecture.md](specs/frontend-architecture.md#i18n-internationalisation).
+
+## Données de test (faker.js)
+
+`apps/api` utilise [`@faker-js/faker`](https://fakerjs.dev/) pour toutes les données
+de test dans `test/auth.test.ts` (Mocha) et le "bruit" (prénom/nom de remplissage)
+des steps Cucumber — jamais de nom/email qui ressemble à une vraie personne en dur
+dans un fixture. Les valeurs *littérales* des scénarios `.feature` eux-mêmes
+(ex. `alice@example.com`) restent volontairement statiques : c'est le point des
+scénarios Gherkin lisibles (exemples illustratifs conventionnels en BDD, pas des
+données réelles) — seules les données de remplissage hors du texte lisible du
+scénario sont générées.
diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile
index c51a35a..1d5778d 100644
--- a/apps/api/Dockerfile
+++ b/apps/api/Dockerfile
@@ -24,6 +24,8 @@ COPY --from=build /repo/node_modules ./node_modules
COPY --from=build /repo/package.json ./package.json
COPY --from=build /repo/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=build /repo/packages/shared ./packages/shared
+COPY --from=build /repo/packages/error-tools ./packages/error-tools
+COPY --from=build /repo/packages/express-tools ./packages/express-tools
COPY --from=build /repo/apps/api/node_modules ./apps/api/node_modules
COPY --from=build /repo/apps/api/dist ./apps/api/dist
COPY --from=build /repo/apps/api/prisma ./apps/api/prisma
diff --git a/apps/api/features/auth.feature b/apps/api/features/auth.feature
index cb71a22..e08fe1a 100644
--- a/apps/api/features/auth.feature
+++ b/apps/api/features/auth.feature
@@ -20,6 +20,7 @@ Feature: Account creation and login
| email | alice@example.com |
| password | correct-horse-battery-staple |
Then the response status should be 409
+ And the response error code should be "EMAIL_ALREADY_IN_USE"
Scenario: A registered user logs in with correct credentials
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
@@ -31,3 +32,4 @@ Feature: Account creation and login
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
When I log in with email "alice@example.com" and password "wrong-password"
Then the response status should be 401
+ And the response error code should be "INVALID_CREDENTIALS"
diff --git a/apps/api/features/step-definitions/auth.steps.ts b/apps/api/features/step-definitions/auth.steps.ts
index eda2041..64149e5 100644
--- a/apps/api/features/step-definitions/auth.steps.ts
+++ b/apps/api/features/step-definitions/auth.steps.ts
@@ -1,22 +1,33 @@
import assert from "node:assert/strict";
import type { DataTable } from "@cucumber/cucumber";
import { Given, Then, When } from "@cucumber/cucumber";
+import { faker } from "@faker-js/faker";
import { signup } from "../../src/modules/auth/auth.service.js";
import type { CustomWorld } from "../support/world.js";
+// firstName/lastName/password below are filler for background state the
+// scenario doesn't actually read (only the emails in the .feature file are
+// part of what's being tested) — faker-generated rather than hardcoded so
+// no test fixture ever looks like a real person's data.
+
Given("a profile already exists with email {string}", async (email: string) => {
await signup({
- firstName: "Existing",
- lastName: "User",
+ firstName: faker.person.firstName(),
+ lastName: faker.person.lastName(),
email,
- password: "some-existing-password",
+ password: faker.internet.password({ length: 16 }),
});
});
Given(
"a profile already exists with email {string} and password {string}",
async (email: string, password: string) => {
- await signup({ firstName: "Existing", lastName: "User", email, password });
+ await signup({
+ firstName: faker.person.firstName(),
+ lastName: faker.person.lastName(),
+ email,
+ password,
+ });
},
);
diff --git a/apps/api/features/step-definitions/health.steps.ts b/apps/api/features/step-definitions/health.steps.ts
index 296733f..c69fc92 100644
--- a/apps/api/features/step-definitions/health.steps.ts
+++ b/apps/api/features/step-definitions/health.steps.ts
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
+import { ErrorCode } from "@batch-cooking/shared";
import { Then, When } from "@cucumber/cucumber";
import request from "supertest";
import type { CustomWorld } from "../support/world.js";
@@ -14,3 +15,17 @@ Then("the response status should be {int}", function (this: CustomWorld, status:
Then("the response body should be:", function (this: CustomWorld, expectedJson: string) {
assert.deepEqual(this.response.body, JSON.parse(expectedJson));
});
+
+// Generic enough to be reused by any feature asserting on the shared
+// ApiErrorResponse contract's `code` field — not health-specific, but this
+// file is where the other generic response-assertion steps already live.
+//
+// `code` here is the enum *member name* (readable in the .feature file,
+// e.g. "EMAIL_ALREADY_IN_USE") — ErrorCode[name] resolves it to the real
+// numeric value via TypeScript's reverse enum lookup, so this never
+// compares against a hardcoded number.
+Then("the response error code should be {string}", function (this: CustomWorld, code: string) {
+ const expected = ErrorCode[code as keyof typeof ErrorCode];
+ assert.notEqual(expected, undefined, `Unknown ErrorCode member: "${code}"`);
+ assert.equal(this.response.body.code, expected);
+});
diff --git a/apps/api/package.json b/apps/api/package.json
index 26287d5..c7860cc 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -14,11 +14,11 @@
"postinstall": "prisma generate"
},
"dependencies": {
+ "@batch-cooking/error-tools": "workspace:*",
+ "@batch-cooking/express-tools": "workspace:*",
"@batch-cooking/shared": "workspace:*",
"@prisma/client": "^5.22.0",
"argon2": "0.31.2",
- "cookie-parser": "^1.4.7",
- "cors": "^2.8.6",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"jsonwebtoken": "^9.0.3",
@@ -26,8 +26,7 @@
},
"devDependencies": {
"@cucumber/cucumber": "^13.2.1",
- "@types/cookie-parser": "^1.4.10",
- "@types/cors": "^2.8.19",
+ "@faker-js/faker": "^10.6.0",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^22.9.0",
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index 4d6a230..8e5c08a 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -1,42 +1,49 @@
-import cookieParser from "cookie-parser";
-import cors from "cors";
-import express, { type NextFunction, type Request, type Response } from "express";
-import { ZodError } from "zod";
+import { errorHandlerService } from "@batch-cooking/error-tools";
+import { ExpressServer, createErrorMiddleware } from "@batch-cooking/express-tools";
+import { ErrorCode } from "@batch-cooking/shared";
+import type { Express, Request, Response } from "express";
import { env } from "./config/env.js";
-import { HttpError } from "./lib/http-error.js";
import { authRouter } from "./modules/auth/auth.routes.js";
-// Application factory. Feature modules are added under src/modules/* as
-// specs land; auth is the first one (login page / profile creation).
-export function createApp() {
- const app = express();
+/**
+ * Builds the API's `ExpressServer`: standard middleware, routes, and the
+ * final error handler, in that order. Returns the `ExpressServer` wrapper
+ * (not just the raw Express app) so `server.ts` can call `.listen()` on
+ * it — {@link createApp} below is the thinner entry point that exposes
+ * just the raw `Express` instance, for test tooling (supertest) that
+ * expects one.
+ */
+export function createServer(): ExpressServer {
+ const server = new ExpressServer();
+ server.setupCore({ corsOrigin: env.CORS_ORIGIN });
- app.use(cors({ origin: env.CORS_ORIGIN, credentials: true }));
- app.use(express.json());
- app.use(cookieParser());
-
- app.get("/health", (_req: Request, res: Response) => {
+ server.addRoute("get", "/health", (_req: Request, res: Response) => {
res.status(200).json({ status: "ok" });
});
- app.use("/auth", authRouter);
+ server.mountRouter("/auth", authRouter);
- app.use((_req: Request, res: Response) => {
- res.status(404).json({ error: "Ressource introuvable" });
+ // No route matched — same shape as every other error response, via the
+ // shared ErrorCode contract, so clients never special-case 404s.
+ server.addMiddleware((_req: Request, res: Response) => {
+ res.status(404).json({ code: ErrorCode.NOT_FOUND, message: "Not found" });
});
- app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
- if (err instanceof ZodError) {
- res.status(400).json({ error: "Erreur de validation", details: err.flatten() });
- return;
- }
- if (err instanceof HttpError) {
- res.status(err.status).json({ error: err.message });
- return;
- }
- console.error(err);
- res.status(500).json({ error: "Erreur interne du serveur" });
- });
+ // Final error-handling middleware: every thrown/`next(err)`-ed error in
+ // the app ends up here. All the "what status/body does this error map
+ // to" logic lives in ErrorHandlerService, from @batch-cooking/error-tools
+ // — this stays a thin adapter.
+ server.setErrorHandler(createErrorMiddleware(errorHandlerService));
- return app;
+ return server;
+}
+
+/**
+ * Builds a fresh Express application instance (no shared mutable state
+ * between calls — used both indirectly by the real server entrypoint
+ * (`server.ts`, via {@link createServer}) and directly by tests, which
+ * each get their own app via supertest).
+ */
+export function createApp(): Express {
+ return createServer().instance;
}
diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts
index 05ffff9..0ae287c 100644
--- a/apps/api/src/config/env.ts
+++ b/apps/api/src/config/env.ts
@@ -1,17 +1,30 @@
import "dotenv/config";
import { z } from "zod";
+/**
+ * Schema for every environment variable the API reads. Parsing (below)
+ * fails fast at startup if something required is missing/invalid, instead
+ * of surfacing as a confusing runtime error later.
+ */
const envSchema = z.object({
+ /** Runtime mode — also toggles test-only behavior (e.g. cheaper argon2 cost, see auth.service.ts). */
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
+ /** Port the HTTP server listens on. */
PORT: z.coerce.number().int().positive().default(3000),
+ /** Postgres connection string, consumed by Prisma. */
DATABASE_URL: z.string().url().optional(),
// Auth — no default on purpose, same reasoning as docker-compose.yml's
// POSTGRES_USER/PASSWORD: a secret must never have a working fallback
// baked into committed code.
+ /** Secret used to sign/verify session JWTs. Required, no default — see comment above. */
JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
+ /** JWT expiry, in `jsonwebtoken`'s duration string format (e.g. "7d"). */
JWT_EXPIRES_IN: z.string().default("7d"),
+ /** Name of the httpOnly cookie carrying the session JWT. */
AUTH_COOKIE_NAME: z.string().default("session"),
+ /** Origin allowed by CORS — must match wherever apps/web is served from. */
CORS_ORIGIN: z.string().default("http://localhost:5173"),
});
+/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
export const env = envSchema.parse(process.env);
diff --git a/apps/api/src/db/prisma.ts b/apps/api/src/db/prisma.ts
index 19fb86f..f54b78c 100644
--- a/apps/api/src/db/prisma.ts
+++ b/apps/api/src/db/prisma.ts
@@ -1,5 +1,9 @@
import { PrismaClient } from "@prisma/client";
-// Single shared instance — Prisma manages its own connection pool
-// internally, a new PrismaClient per request would exhaust connections.
+/**
+ * Single shared Prisma client instance for the whole process. Prisma
+ * manages its own connection pool internally — instantiating a new
+ * `PrismaClient` per request would exhaust database connections instead of
+ * reusing them.
+ */
export const prisma = new PrismaClient();
diff --git a/apps/api/src/lib/http-error.ts b/apps/api/src/lib/http-error.ts
deleted file mode 100644
index b1931ad..0000000
--- a/apps/api/src/lib/http-error.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-/** Typed error carrying the HTTP status it should map to, so the central
- * error handler in app.ts can respond correctly instead of always 500ing. */
-export class HttpError extends Error {
- status: number;
-
- constructor(status: number, message: string) {
- super(message);
- this.name = "HttpError";
- this.status = status;
- }
-}
diff --git a/apps/api/src/lib/jwt.ts b/apps/api/src/lib/jwt.ts
index bcd2c2d..f2270e2 100644
--- a/apps/api/src/lib/jwt.ts
+++ b/apps/api/src/lib/jwt.ts
@@ -1,11 +1,19 @@
import jwt from "jsonwebtoken";
import { env } from "../config/env.js";
+/** Decoded contents of a session JWT, once verified. */
export interface AuthTokenPayload {
+ /** UserProfile.id this token authenticates. */
userProfileId: number;
+ /** Snapshot of UserProfile.tokenVersion at sign time — checked against the current DB value on every request (see requireAuth) to allow server-side invalidation. */
tokenVersion: number;
}
+/**
+ * Signs a new session JWT for the given profile, expiring per
+ * `JWT_EXPIRES_IN`. The resulting string is what gets set as the session
+ * cookie's value.
+ */
export function signAuthToken(payload: AuthTokenPayload): string {
// "sub" follows the JWT convention (RFC 7519) of identifying the
// principal as a string; userProfileId/tokenVersion are our own claims.
@@ -16,6 +24,13 @@ export function signAuthToken(payload: AuthTokenPayload): string {
);
}
+/**
+ * Verifies a session JWT's signature/expiry and decodes it back into an
+ * {@link AuthTokenPayload}.
+ *
+ * @throws {Error} if the token is invalid/expired (from `jwt.verify`) or
+ * structurally malformed (missing/wrong-typed claims).
+ */
export function verifyAuthToken(token: string): AuthTokenPayload {
const decoded = jwt.verify(token, env.JWT_SECRET);
const userProfileId = typeof decoded === "object" ? Number(decoded.sub) : Number.NaN;
diff --git a/apps/api/src/middlewares/require-auth.ts b/apps/api/src/middlewares/require-auth.ts
index a745996..ba32431 100644
--- a/apps/api/src/middlewares/require-auth.ts
+++ b/apps/api/src/middlewares/require-auth.ts
@@ -1,35 +1,68 @@
+import { HttpError } from "@batch-cooking/error-tools";
+import { ErrorCode, type SafeUserProfile } from "@batch-cooking/shared";
import type { NextFunction, Request, Response } from "express";
import { env } from "../config/env.js";
import { prisma } from "../db/prisma.js";
-import { HttpError } from "../lib/http-error.js";
import { verifyAuthToken } from "../lib/jwt.js";
-/** Reads the session cookie, verifies the JWT, and re-checks tokenVersion
- * against the database (so a password change / logout-everywhere can
- * invalidate previously-issued tokens despite JWT being stateless). */
-export async function requireAuth(req: Request, _res: Response, next: NextFunction) {
+/**
+ * Shape of `res.locals` once {@link requireAuth} has run successfully. Type
+ * a route handler's response as `Response` (see
+ * `auth.routes.ts`'s `/me` handler) to read `res.locals.userProfile` fully
+ * typed, no cast needed.
+ */
+export interface AuthLocals {
+ /** The authenticated profile, resolved from the session cookie's JWT. */
+ userProfile: SafeUserProfile;
+}
+
+/**
+ * Express middleware guarding routes that require an authenticated
+ * profile. Reads the session cookie, verifies the JWT, and re-checks
+ * `tokenVersion` against the database — so a stateless JWT can still be
+ * invalidated server-side (e.g. on password change / logout-everywhere,
+ * once that feature exists) despite carrying no server-side session.
+ *
+ * On success, attaches the resolved profile to `res.locals.userProfile`
+ * (typed via {@link AuthLocals}) for downstream handlers to use.
+ * Deliberately `res.locals` rather than augmenting Express's global
+ * `Request` type via `declare global`: `res.locals` is Express's own
+ * built-in mechanism for exactly this (passing data from a middleware to
+ * the next handler), typed per-route through a generic parameter — no
+ * project-wide ambient augmentation silently changing every `Request` in
+ * the codebase, whether or not it went through this middleware.
+ *
+ * @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing
+ * cookie, malformed/expired JWT, unknown profile, or stale tokenVersion.
+ * Never distinguishes the reason to the client.
+ */
+export async function requireAuth(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+) {
try {
const token = req.cookies?.[env.AUTH_COOKIE_NAME];
if (typeof token !== "string") {
- throw new HttpError(401, "Non authentifié");
+ throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
}
const payload = verifyAuthToken(token);
const profile = await prisma.userProfile.findUnique({ where: { id: payload.userProfileId } });
if (!profile || profile.tokenVersion !== payload.tokenVersion) {
- throw new HttpError(401, "Non authentifié");
+ throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
}
const { passwordHash: _passwordHash, ...safeProfile } = profile;
- req.userProfile = safeProfile;
+ res.locals.userProfile = safeProfile;
next();
} catch (err) {
if (err instanceof HttpError) {
next(err);
} else {
// Covers jwt.verify failures (expired/invalid/malformed token).
- next(new HttpError(401, "Non authentifié"));
+ next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
}
}
}
diff --git a/apps/api/src/modules/auth/auth.routes.ts b/apps/api/src/modules/auth/auth.routes.ts
index ee6c5e6..044a8ab 100644
--- a/apps/api/src/modules/auth/auth.routes.ts
+++ b/apps/api/src/modules/auth/auth.routes.ts
@@ -1,51 +1,62 @@
+import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { loginSchema, signupSchema } from "@batch-cooking/shared";
import { Router } from "express";
-import type { CookieOptions } from "express";
+import type { CookieOptions, Response } from "express";
import { env } from "../../config/env.js";
-import { requireAuth } from "../../middlewares/require-auth.js";
+import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { login, signup } from "./auth.service.js";
+/** Router mounted at `/auth` in app.ts — signup, login, logout, current-profile. */
export const authRouter = Router();
-// Independent from JWT_EXPIRES_IN on purpose (see auth.routes.ts) — the JWT's
-// own expiry is what's actually enforced by requireAuth, this only bounds
-// how long the browser keeps sending the cookie.
+// Deliberately independent from JWT_EXPIRES_IN: the JWT's own expiry is
+// what's actually enforced by requireAuth (a request with an expired JWT
+// is rejected regardless of the cookie still being present) — this only
+// bounds how long the browser keeps *sending* the cookie at all.
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
+/** Cookie options shared by every route that sets/clears the session cookie. */
const cookieOptions: CookieOptions = {
httpOnly: true,
+ // Only require HTTPS in production — local dev/CI serve over plain HTTP.
secure: env.NODE_ENV === "production",
sameSite: "lax",
maxAge: SEVEN_DAYS_MS,
};
-authRouter.post("/signup", async (req, res, next) => {
- try {
+/**
+ * Creates a profile (+ its household) and logs the new user in
+ * immediately. `wrapAsyncHandler` forwards a thrown/rejected error to
+ * Express's error middleware automatically — no manual try/catch needed.
+ */
+authRouter.post(
+ "/signup",
+ wrapAsyncHandler(async (req, res) => {
const input = signupSchema.parse(req.body);
const { profile, token } = await signup(input);
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
res.status(201).json(profile);
- } catch (err) {
- next(err);
- }
-});
+ }),
+);
-authRouter.post("/login", async (req, res, next) => {
- try {
+/** Verifies credentials and starts a new session. */
+authRouter.post(
+ "/login",
+ wrapAsyncHandler(async (req, res) => {
const input = loginSchema.parse(req.body);
const { profile, token } = await login(input);
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
res.status(200).json(profile);
- } catch (err) {
- next(err);
- }
-});
+ }),
+);
+/** Ends the current session by clearing the cookie. Stateless JWT, so there's nothing to revoke server-side (yet — see tokenVersion). */
authRouter.post("/logout", (_req, res) => {
res.clearCookie(env.AUTH_COOKIE_NAME, cookieOptions);
res.status(204).end();
});
-authRouter.get("/me", requireAuth, (req, res) => {
- res.status(200).json(req.userProfile);
+/** Returns the currently authenticated profile. Behind requireAuth — 401s if there's no valid session. */
+authRouter.get("/me", requireAuth, (_req, res: Response) => {
+ res.status(200).json(res.locals.userProfile);
});
diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts
index e5044ce..5fb5a3b 100644
--- a/apps/api/src/modules/auth/auth.service.ts
+++ b/apps/api/src/modules/auth/auth.service.ts
@@ -1,13 +1,22 @@
-import type { LoginInput, SignupInput } from "@batch-cooking/shared";
+import { HttpError } from "@batch-cooking/error-tools";
+import { ErrorCode, type LoginInput, type SignupInput } from "@batch-cooking/shared";
import type { UserProfile } from "@prisma/client";
import argon2 from "argon2";
import { env } from "../../config/env.js";
import { prisma } from "../../db/prisma.js";
-import { HttpError } from "../../lib/http-error.js";
import { signAuthToken } from "../../lib/jwt.js";
+/** A UserProfile as it's safe to hand back to a client — never the password hash. */
type SafeProfile = Omit;
+/** Result of a successful signup/login: the safe profile plus the signed session JWT to set as a cookie. */
+interface AuthResult {
+ /** The authenticated profile, safe to hand back to the client. */
+ profile: SafeProfile;
+ /** Signed session JWT — the caller sets this as the session cookie's value. */
+ token: string;
+}
+
// argon2's defaults (64 MB memory, 3 passes) are deliberately expensive —
// that's the point, for real passwords. In tests we hash/verify dozens of
// times per run against throwaway data, so a much cheaper cost keeps the
@@ -16,15 +25,22 @@ type SafeProfile = Omit;
const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 };
const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
+/** Strips `passwordHash` off a Prisma UserProfile before it's ever sent to a client. */
function toSafeProfile(profile: UserProfile): SafeProfile {
const { passwordHash: _passwordHash, ...safeProfile } = profile;
return safeProfile;
}
-export async function signup(input: SignupInput): Promise<{ profile: SafeProfile; token: string }> {
+/**
+ * Creates a new household (`house`) and profile (`user_profiles`) together
+ * in one transaction, hashes the password, and issues a session token.
+ *
+ * @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken.
+ */
+export async function signup(input: SignupInput): Promise {
const existing = await prisma.userProfile.findUnique({ where: { email: input.email } });
if (existing) {
- throw new HttpError(409, "Cet email est déjà utilisé");
+ throw new HttpError(409, ErrorCode.EMAIL_ALREADY_IN_USE, "Email already in use");
}
const passwordHash = await argon2.hash(input.password, hashOptions);
@@ -51,13 +67,18 @@ export async function signup(input: SignupInput): Promise<{ profile: SafeProfile
return { profile: toSafeProfile(profile), token };
}
-export async function login(input: LoginInput): Promise<{ profile: SafeProfile; token: string }> {
+/**
+ * Verifies credentials and issues a fresh session token.
+ *
+ * @throws {HttpError} `401 INVALID_CREDENTIALS` for either an unknown email
+ * or a wrong password — deliberately the same error either way, so a
+ * caller can never learn whether a given email has an account.
+ */
+export async function login(input: LoginInput): Promise {
const profile = await prisma.userProfile.findUnique({ where: { email: input.email } });
- // Deliberately generic error/message for both "no such email" and "wrong
- // password" — don't leak which one it was.
if (!profile || !(await argon2.verify(profile.passwordHash, input.password))) {
- throw new HttpError(401, "Email ou mot de passe incorrect");
+ throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password");
}
const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion });
diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts
index 89dabe6..f7753cc 100644
--- a/apps/api/src/server.ts
+++ b/apps/api/src/server.ts
@@ -1,8 +1,8 @@
-import { createApp } from "./app.js";
+import { createServer } from "./app.js";
import { env } from "./config/env.js";
-const app = createApp();
+const server = createServer();
-app.listen(env.PORT, () => {
+server.listen(env.PORT, () => {
console.log(`API listening on http://localhost:${env.PORT}`);
});
diff --git a/apps/api/src/types/express.d.ts b/apps/api/src/types/express.d.ts
deleted file mode 100644
index a3db624..0000000
--- a/apps/api/src/types/express.d.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import type { UserProfile } from "@prisma/client";
-
-declare global {
- namespace Express {
- interface Request {
- /** Set by requireAuth once the session cookie's JWT has been verified. */
- userProfile?: Omit;
- }
- }
-}
diff --git a/apps/api/test/auth.test.ts b/apps/api/test/auth.test.ts
index a3523bc..c071e25 100644
--- a/apps/api/test/auth.test.ts
+++ b/apps/api/test/auth.test.ts
@@ -1,15 +1,30 @@
+import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
+import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { resetDatabase } from "../test-support/reset-db.js";
-const validSignup = {
- firstName: "Nicolas",
- lastName: "Lefevre",
- email: "nicolas@example.com",
- password: "correct-horse-battery-staple",
-};
+/**
+ * Builds a fresh, fake (never real-looking) signup payload. Called anew per
+ * test rather than sharing one module-level constant, so tests never
+ * accidentally depend on a specific fixture value and each run exercises
+ * different data — closer to how the app actually gets used.
+ */
+function buildSignupPayload(): SignupInput {
+ const firstName = faker.person.firstName();
+ const lastName = faker.person.lastName();
+ return {
+ firstName,
+ lastName,
+ // Lowercased to match what signupSchema/loginSchema normalize the email
+ // to (`.toLowerCase()`) — faker sometimes capitalizes parts of it, and
+ // without this the fixture value stops matching what's actually stored.
+ email: faker.internet.email({ firstName, lastName }).toLowerCase(),
+ password: faker.internet.password({ length: 16 }),
+ };
+}
describe("Auth", () => {
const app = createApp();
@@ -24,80 +39,96 @@ describe("Auth", () => {
describe("POST /auth/signup", () => {
it("creates a profile and its house, and sets a session cookie", async () => {
- const res = await request(app).post("/auth/signup").send(validSignup);
+ const payload = buildSignupPayload();
+ const res = await request(app).post("/auth/signup").send(payload);
expect(res.status).to.equal(201);
expect(res.body).to.include({
- firstName: "Nicolas",
- lastName: "Lefevre",
- email: "nicolas@example.com",
+ firstName: payload.firstName,
+ lastName: payload.lastName,
+ email: payload.email,
});
expect(res.body).to.not.have.property("passwordHash");
expect(res.body.houseId).to.be.a("number");
expect(res.headers["set-cookie"]?.[0]).to.include("session=");
});
- it("rejects a duplicate email with 409", async () => {
- await request(app).post("/auth/signup").send(validSignup);
- const res = await request(app).post("/auth/signup").send(validSignup);
+ it("rejects a duplicate email with 409 EMAIL_ALREADY_IN_USE", async () => {
+ const payload = buildSignupPayload();
+ await request(app).post("/auth/signup").send(payload);
+ const res = await request(app).post("/auth/signup").send(payload);
expect(res.status).to.equal(409);
+ expect(res.body.code).to.equal(ErrorCode.EMAIL_ALREADY_IN_USE);
});
- it("rejects an invalid payload with 400", async () => {
- const res = await request(app)
- .post("/auth/signup")
- .send({ firstName: "X", lastName: "Y", email: "not-an-email", password: "short" });
+ it("rejects an invalid payload with 400 VALIDATION_ERROR", async () => {
+ const res = await request(app).post("/auth/signup").send({
+ firstName: faker.person.firstName(),
+ lastName: faker.person.lastName(),
+ email: "not-an-email",
+ password: "short",
+ });
expect(res.status).to.equal(400);
+ expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
+ expect(res.body.details).to.have.keys(["email", "password"]);
});
});
describe("POST /auth/login", () => {
+ let payload: SignupInput;
+
beforeEach(async () => {
- await request(app).post("/auth/signup").send(validSignup);
+ payload = buildSignupPayload();
+ await request(app).post("/auth/signup").send(payload);
});
it("logs in with correct credentials", async () => {
const res = await request(app)
.post("/auth/login")
- .send({ email: validSignup.email, password: validSignup.password });
+ .send({ email: payload.email, password: payload.password });
expect(res.status).to.equal(200);
- expect(res.body.email).to.equal(validSignup.email);
+ expect(res.body.email).to.equal(payload.email);
});
- it("rejects a wrong password with 401", async () => {
+ it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => {
const res = await request(app)
.post("/auth/login")
- .send({ email: validSignup.email, password: "wrong-password" });
+ .send({ email: payload.email, password: faker.internet.password({ length: 16 }) });
expect(res.status).to.equal(401);
+ expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
});
- it("rejects an unknown email with 401", async () => {
+ it("rejects an unknown email with 401 INVALID_CREDENTIALS", async () => {
const res = await request(app)
.post("/auth/login")
- .send({ email: "nobody@example.com", password: validSignup.password });
+ .send({ email: faker.internet.email(), password: payload.password });
expect(res.status).to.equal(401);
+ expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
});
});
describe("GET /auth/me", () => {
- it("rejects requests without a session cookie", async () => {
+ it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/auth/me");
+
expect(res.status).to.equal(401);
+ expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("returns the current profile when authenticated", async () => {
+ const payload = buildSignupPayload();
const agent = request.agent(app);
- await agent.post("/auth/signup").send(validSignup);
+ await agent.post("/auth/signup").send(payload);
const res = await agent.get("/auth/me");
expect(res.status).to.equal(200);
- expect(res.body.email).to.equal(validSignup.email);
+ expect(res.body.email).to.equal(payload.email);
});
});
});
diff --git a/apps/web/cypress/e2e/auth.cy.ts b/apps/web/cypress/e2e/auth.cy.ts
index 3b486f6..825ddb0 100644
--- a/apps/web/cypress/e2e/auth.cy.ts
+++ b/apps/web/cypress/e2e/auth.cy.ts
@@ -1,3 +1,5 @@
+import { ErrorCode } from "@batch-cooking/shared";
+
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
// .github/workflows/ci.yml), and it keeps these specs focused on frontend
// behavior. Backend behavior itself is covered by apps/api's Mocha/Cucumber
@@ -50,7 +52,7 @@ describe("Signup", () => {
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
cy.intercept("POST", "**/auth/signup", {
statusCode: 409,
- body: { error: "Cet email est déjà utilisé" },
+ body: { code: ErrorCode.EMAIL_ALREADY_IN_USE, message: "Email already in use" },
}).as("signup");
cy.visit("/signup");
@@ -94,7 +96,7 @@ describe("Login", () => {
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
cy.intercept("POST", "**/auth/login", {
statusCode: 401,
- body: { error: "Email ou mot de passe incorrect" },
+ body: { code: ErrorCode.INVALID_CREDENTIALS, message: "Invalid email or password" },
}).as("login");
cy.visit("/login");
diff --git a/apps/web/cypress/e2e/smoke.cy.ts b/apps/web/cypress/e2e/smoke.cy.ts
index 7d4d225..5333aa7 100644
--- a/apps/web/cypress/e2e/smoke.cy.ts
+++ b/apps/web/cypress/e2e/smoke.cy.ts
@@ -1,6 +1,11 @@
+import { ErrorCode } from "@batch-cooking/shared";
+
describe("smoke test", () => {
it("redirects an unauthenticated visitor to the login page", () => {
- cy.intercept("GET", "**/auth/me", { statusCode: 401, body: { error: "Non authentifié" } });
+ cy.intercept("GET", "**/auth/me", {
+ statusCode: 401,
+ body: { code: ErrorCode.NOT_AUTHENTICATED, message: "Not authenticated" },
+ });
cy.visit("/");
cy.url().should("include", "/login");
cy.contains("h1", "Se connecter").should("be.visible");
diff --git a/apps/web/package.json b/apps/web/package.json
index 484ffbe..890c1fc 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -14,8 +14,10 @@
},
"dependencies": {
"@batch-cooking/shared": "workspace:*",
+ "i18next": "^26.3.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-i18next": "^17.0.11",
"react-router-dom": "^7.18.2",
"zod": "^3.25.76"
},
@@ -25,6 +27,7 @@
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"cypress": "^13.15.2",
+ "sass": "^1.102.0",
"start-server-and-test": "^2.0.8",
"typescript": "^5.7.2",
"vite": "^5.4.11"
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index b48c2af..2d62989 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -5,6 +5,12 @@ import { HomePage } from "./pages/HomePage";
import { LoginPage } from "./pages/LoginPage";
import { SignupPage } from "./pages/SignupPage";
+/**
+ * Top-level route table. `/` requires an authenticated session (see
+ * {@link RequireAuth}); `/login` and `/signup` redirect an already-logged-in
+ * visitor to `/` instead (see {@link RedirectIfAuthenticated}). Anything
+ * else falls back to `/`, which itself redirects to `/login` if needed.
+ */
export function App() {
return (
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index 5886f32..49e4779 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -1,51 +1,99 @@
-import type { LoginInput, SafeUserProfile, SignupInput } from "@batch-cooking/shared";
+import {
+ type ApiErrorResponse,
+ ErrorCode,
+ type LoginInput,
+ type SafeUserProfile,
+ type SignupInput,
+} from "@batch-cooking/shared";
-const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3000";
+/** Base URL of the API, configurable via `VITE_API_URL` (see `.env.example`). */
+const API_BASE_URL: string = import.meta.env.VITE_API_URL ?? "http://localhost:3000";
+/**
+ * Thrown by {@link ApiClient} whenever the API responds with a non-2xx
+ * status. Carries the same {@link ErrorCode} the API returned, so callers
+ * can branch on `error.code` (and UI code can look up its label via
+ * `ErrorMessageService.getLabel(error.code)`) instead of parsing text.
+ */
export class ApiError extends Error {
- status: number;
- fieldErrors?: Record;
+ /** HTTP status code of the failed response. */
+ public readonly status: number;
+ /** Machine-readable error code — see {@link ErrorCode}. */
+ public readonly code: ErrorCode;
+ /** Per-field validation messages, present only when `code` is `VALIDATION_ERROR`. */
+ public readonly fieldErrors?: Record;
- constructor(status: number, message: string, fieldErrors?: Record) {
- super(message);
+ public constructor(status: number, body: ApiErrorResponse) {
+ super(body.message);
this.name = "ApiError";
this.status = status;
- this.fieldErrors = fieldErrors;
+ this.code = body.code;
+ this.fieldErrors = body.details;
}
}
-async function request(path: string, options: RequestInit = {}): Promise {
- const res = await fetch(`${API_URL}${path}`, {
- ...options,
- // Required for the httpOnly session cookie to be sent/received —
- // the API and the web app run on different origins.
- credentials: "include",
- headers: { "Content-Type": "application/json", ...options.headers },
- });
+/**
+ * Thin fetch wrapper around the auth endpoints. A class (rather than plain
+ * functions) so it reads as a cohesive service and stays easy to extend
+ * (e.g. swapping the transport, adding request interceptors) without
+ * touching every call site. Used as a single shared instance (`apiClient`,
+ * exported below) — it's stateless, so there's no reason for more than one.
+ */
+export class ApiClient {
+ /**
+ * Performs a JSON request against the API and returns the parsed body.
+ *
+ * @throws {ApiError} if the response status is not in the 2xx range.
+ */
+ private async request(
+ path: string,
+ options: RequestInit = {},
+ ): Promise {
+ const response = await fetch(`${API_BASE_URL}${path}`, {
+ ...options,
+ // Required for the httpOnly session cookie to be sent/received — the
+ // API and the web app run on different origins.
+ credentials: "include",
+ headers: { "Content-Type": "application/json", ...options.headers },
+ });
- if (!res.ok) {
- const body = await res.json().catch(() => ({}));
- throw new ApiError(res.status, body.error ?? "Something went wrong", body.details?.fieldErrors);
+ if (!response.ok) {
+ const body = (await response.json().catch(() => null)) as ApiErrorResponse | null;
+ // Fallback for a response that couldn't even be parsed as JSON — no
+ // hardcoded string, always the real enum member.
+ throw new ApiError(
+ response.status,
+ body ?? { code: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" },
+ );
+ }
+
+ // 204 No Content (e.g. logout) has no body to parse.
+ if (response.status === 204) {
+ return undefined as TResponseBody;
+ }
+ return response.json() as Promise;
}
- if (res.status === 204) {
- return undefined as T;
+ /** Creates a profile (+ household) and starts a session. */
+ public signup(input: SignupInput): Promise {
+ return this.request("/auth/signup", { method: "POST", body: JSON.stringify(input) });
+ }
+
+ /** Verifies credentials and starts a session. */
+ public login(input: LoginInput): Promise {
+ return this.request("/auth/login", { method: "POST", body: JSON.stringify(input) });
+ }
+
+ /** Ends the current session. */
+ public logout(): Promise {
+ return this.request("/auth/logout", { method: "POST" });
+ }
+
+ /** Fetches the currently authenticated profile — rejects with `NOT_AUTHENTICATED` if there's no session. */
+ public me(): Promise {
+ return this.request("/auth/me");
}
- return res.json() as Promise;
}
-export function signup(input: SignupInput): Promise {
- return request("/auth/signup", { method: "POST", body: JSON.stringify(input) });
-}
-
-export function login(input: LoginInput): Promise {
- return request("/auth/login", { method: "POST", body: JSON.stringify(input) });
-}
-
-export function logout(): Promise {
- return request("/auth/logout", { method: "POST" });
-}
-
-export function me(): Promise {
- return request("/auth/me");
-}
+/** Single shared instance — this client is stateless, no need for one per caller. */
+export const apiClient = new ApiClient();
diff --git a/apps/web/src/features/auth/AuthContext.tsx b/apps/web/src/features/auth/AuthContext.tsx
index 3d7f50b..a63bf00 100644
--- a/apps/web/src/features/auth/AuthContext.tsx
+++ b/apps/web/src/features/auth/AuthContext.tsx
@@ -1,40 +1,53 @@
import type { LoginInput, SafeUserProfile, SignupInput } from "@batch-cooking/shared";
import { type ReactNode, createContext, useCallback, useContext, useEffect, useState } from "react";
-import * as api from "../../api/client";
+import { apiClient } from "../../api/client";
+/** Shape of the auth state/actions exposed via {@link useAuth}. */
interface AuthContextValue {
+ /** Currently authenticated profile, or `null` if no active session. */
user: SafeUserProfile | null;
- /** True only while the initial /auth/me check (on app load) is pending. */
+ /** True only while the initial `/auth/me` check (on app load) is pending — lets route guards avoid a premature redirect. */
isLoading: boolean;
+ /** Creates a profile (+ household) and updates `user` on success. Throws `ApiError` on failure. */
signup: (input: SignupInput) => Promise;
+ /** Verifies credentials and updates `user` on success. Throws `ApiError` on failure. */
login: (input: LoginInput) => Promise;
+ /** Ends the session and clears `user`. */
logout: () => Promise;
}
+/** React context carrying {@link AuthContextValue} — always accessed through {@link useAuth}, never directly. */
const AuthContext = createContext(null);
+/**
+ * Provides authentication state to the whole app. On mount, calls
+ * `GET /auth/me` once to restore the session from the httpOnly cookie (if
+ * any) — this is what lets a page reload keep the user logged in.
+ */
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
- api
+ apiClient
.me()
.then(setUser)
+ // No session cookie (or it's invalid/expired) — that's the normal
+ // state for a first-time visitor, not an error to surface.
.catch(() => setUser(null))
.finally(() => setIsLoading(false));
}, []);
const signup = useCallback(async (input: SignupInput) => {
- setUser(await api.signup(input));
+ setUser(await apiClient.signup(input));
}, []);
const login = useCallback(async (input: LoginInput) => {
- setUser(await api.login(input));
+ setUser(await apiClient.login(input));
}, []);
const logout = useCallback(async () => {
- await api.logout();
+ await apiClient.logout();
setUser(null);
}, []);
@@ -45,6 +58,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
);
}
+/** Reads the current auth state/actions. Must be called from within an {@link AuthProvider}. */
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
diff --git a/apps/web/src/features/auth/RedirectIfAuthenticated.tsx b/apps/web/src/features/auth/RedirectIfAuthenticated.tsx
index e1a2f7a..d4540b5 100644
--- a/apps/web/src/features/auth/RedirectIfAuthenticated.tsx
+++ b/apps/web/src/features/auth/RedirectIfAuthenticated.tsx
@@ -2,7 +2,11 @@ import type { ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "./AuthContext";
-/** Sends already-logged-in visitors away from /login and /signup. */
+/**
+ * Route guard for pages that make no sense to an already-authenticated
+ * visitor (`/login`, `/signup`). Mirrors {@link RequireAuth}'s waiting
+ * behavior while the initial session check is pending.
+ */
export function RedirectIfAuthenticated({ children }: { children: ReactNode }) {
const { user, isLoading } = useAuth();
diff --git a/apps/web/src/features/auth/RequireAuth.tsx b/apps/web/src/features/auth/RequireAuth.tsx
index 0996c0e..50059d3 100644
--- a/apps/web/src/features/auth/RequireAuth.tsx
+++ b/apps/web/src/features/auth/RequireAuth.tsx
@@ -2,11 +2,18 @@ import type { ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "./AuthContext";
-/** Redirects to /login if there's no authenticated session. */
+/**
+ * Route guard for pages that require an authenticated session (e.g. the
+ * home page). Renders nothing while the initial session check is pending,
+ * to avoid a flash-then-redirect; once resolved, either renders `children`
+ * or redirects to `/login`.
+ */
export function RequireAuth({ children }: { children: ReactNode }) {
const { user, isLoading } = useAuth();
if (isLoading) {
+ // Initial GET /auth/me still in flight — don't redirect yet, we don't
+ // know the auth state.
return null;
}
if (!user) {
diff --git a/apps/web/src/features/auth/auth-form.scss b/apps/web/src/features/auth/auth-form.scss
new file mode 100644
index 0000000..f7e840d
--- /dev/null
+++ b/apps/web/src/features/auth/auth-form.scss
@@ -0,0 +1,88 @@
+// =============================================================================
+// Styles shared by LoginPage and SignupPage — both render the same card/form
+// layout, so this lives in features/auth/ (the concern both pages share)
+// rather than being duplicated in each page's own stylesheet. Imported by
+// both LoginPage.tsx and SignupPage.tsx.
+// =============================================================================
+
+// No `@use` of the theme partial needed here: every design token below is a
+// CSS custom property (--color-*, --space-*...) declared once on :root in
+// styles/global.scss and available globally at runtime — not a Sass-level
+// variable/mixin that would require an explicit compile-time import.
+
+// Full-viewport centering wrapper for the auth card.
+.auth-page {
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-md);
+}
+
+// The form itself: a vertically-stacked card, capped width so it stays
+// readable on wide screens.
+.auth-card {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+ width: 100%;
+ max-width: var(--max-width-form);
+
+ // Field labels sit directly above their input, with a little breathing
+ // room from the previous field.
+ label {
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+ margin-top: var(--space-sm);
+ }
+
+ input {
+ padding: var(--space-sm);
+ font-size: var(--font-size-base);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-base);
+ }
+
+ // Submit button: full-width, visually separated from the fields above it.
+ button {
+ margin-top: var(--space-md);
+ padding: 0.6rem;
+ font-size: var(--font-size-base);
+ cursor: pointer;
+ border-radius: var(--radius-base);
+ border: none;
+ background: var(--color-primary);
+ color: white;
+
+ &:hover:not(:disabled) {
+ background: var(--color-primary-hover);
+ }
+
+ &:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+ }
+}
+
+// Per-field validation message (client-side, from zod) — sits directly
+// under its input.
+.field-error {
+ color: var(--color-error);
+ font-size: var(--font-size-xs);
+ margin: 0;
+}
+
+// Whole-form error message (from the API, e.g. wrong credentials) — sits
+// above the submit button.
+.form-error {
+ color: var(--color-error);
+ font-size: var(--font-size-sm);
+}
+
+// "Already have an account? / No account yet?" link row under the form.
+.auth-switch {
+ font-size: var(--font-size-sm);
+ margin-top: var(--space-md);
+ text-align: center;
+}
diff --git a/apps/web/src/i18n/i18n.ts b/apps/web/src/i18n/i18n.ts
new file mode 100644
index 0000000..014bc37
--- /dev/null
+++ b/apps/web/src/i18n/i18n.ts
@@ -0,0 +1,26 @@
+import i18next from "i18next";
+import { initReactI18next } from "react-i18next";
+import fr from "../locales/fr/translation.json";
+
+/**
+ * i18next instance for the whole app, imported once for its side effect
+ * (`main.tsx`) before anything renders. Only French exists today —
+ * `packages/shared`'s `ErrorCode` enum members double as translation keys
+ * under the `errors` namespace (see `services/error-message.service.ts`).
+ *
+ * Adding a language later is "add a `resources.` entry pointing at a
+ * new locale file", not touching a single component.
+ */
+void i18next.use(initReactI18next).init({
+ resources: {
+ fr: { translation: fr },
+ },
+ lng: "fr",
+ fallbackLng: "fr",
+ // React already escapes interpolated values when rendering JSX — letting
+ // i18next also HTML-escape them would double-escape (e.g. turn "é" text
+ // into visible "é" in some setups).
+ interpolation: { escapeValue: false },
+});
+
+export default i18next;
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
deleted file mode 100644
index b11ea88..0000000
--- a/apps/web/src/index.css
+++ /dev/null
@@ -1,64 +0,0 @@
-:root {
- font-family: system-ui, sans-serif;
- color-scheme: light dark;
-}
-
-body {
- margin: 0;
-}
-
-.auth-page,
-.home-page {
- min-height: 100vh;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 1rem;
- padding: 1rem;
-}
-
-.auth-card {
- display: flex;
- flex-direction: column;
- gap: 0.35rem;
- width: 100%;
- max-width: 22rem;
-}
-
-.auth-card label {
- font-size: 0.875rem;
- font-weight: 600;
- margin-top: 0.5rem;
-}
-
-.auth-card input {
- padding: 0.5rem;
- font-size: 1rem;
- border: 1px solid #888;
- border-radius: 4px;
-}
-
-.auth-card button {
- margin-top: 1rem;
- padding: 0.6rem;
- font-size: 1rem;
- cursor: pointer;
-}
-
-.field-error {
- color: #c0392b;
- font-size: 0.8rem;
- margin: 0;
-}
-
-.form-error {
- color: #c0392b;
- font-size: 0.9rem;
-}
-
-.auth-switch {
- font-size: 0.875rem;
- margin-top: 1rem;
- text-align: center;
-}
diff --git a/apps/web/src/lib/zod-errors.ts b/apps/web/src/lib/zod-errors.ts
index 146f615..1d45e14 100644
--- a/apps/web/src/lib/zod-errors.ts
+++ b/apps/web/src/lib/zod-errors.ts
@@ -1,13 +1,18 @@
import type { ZodError } from "zod";
-/** First error message per field, for simple inline form display. */
+/**
+ * Flattens a zod validation error into `{ fieldName: firstMessage }`, for
+ * simple inline display under each form field (only the first message per
+ * field is shown — good enough for the single-rule-per-field schemas this
+ * app uses today).
+ */
export function fieldErrorsFrom(error: ZodError): Record {
- const flat = error.flatten().fieldErrors;
- const result: Record = {};
- for (const [key, messages] of Object.entries(flat)) {
+ const fieldErrors = error.flatten().fieldErrors;
+ const firstMessagePerField: Record = {};
+ for (const [field, messages] of Object.entries(fieldErrors)) {
if (messages?.[0]) {
- result[key] = messages[0];
+ firstMessagePerField[field] = messages[0];
}
}
- return result;
+ return firstMessagePerField;
}
diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json
new file mode 100644
index 0000000..b343580
--- /dev/null
+++ b/apps/web/src/locales/fr/translation.json
@@ -0,0 +1,36 @@
+{
+ "errors": {
+ "VALIDATION_ERROR": "Erreur de validation",
+ "EMAIL_ALREADY_IN_USE": "Cet email est déjà utilisé",
+ "INVALID_CREDENTIALS": "Email ou mot de passe incorrect",
+ "NOT_AUTHENTICATED": "Vous devez être connecté",
+ "NOT_FOUND": "Ressource introuvable",
+ "INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
+ },
+ "auth": {
+ "login": {
+ "title": "Se connecter",
+ "emailLabel": "Email",
+ "passwordLabel": "Mot de passe",
+ "submit": "Se connecter",
+ "submitting": "Connexion…",
+ "noAccount": "Pas encore de compte ?",
+ "createProfileLink": "Créer un profil"
+ },
+ "signup": {
+ "title": "Créer un profil",
+ "firstNameLabel": "Prénom",
+ "lastNameLabel": "Nom",
+ "emailLabel": "Email",
+ "passwordLabel": "Mot de passe",
+ "submit": "Créer mon profil",
+ "submitting": "Création…",
+ "hasAccount": "Déjà un compte ?",
+ "loginLink": "Se connecter"
+ }
+ },
+ "home": {
+ "greeting": "Bonjour {{firstName}} {{lastName}} 👋",
+ "logout": "Se déconnecter"
+ }
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 2838b41..7416923 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -3,7 +3,12 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import { AuthProvider } from "./features/auth/AuthContext";
-import "./index.css";
+// Side-effect import: initializes the i18next instance before anything
+// renders (react-i18next reads it via context under the hood). See i18n/i18n.ts.
+import "./i18n/i18n";
+// Global stylesheet (theme tokens + minimal reset) — the only .scss import
+// that isn't colocated with a specific component/page. See styles/global.scss.
+import "./styles/global.scss";
const rootElement = document.getElementById("root");
if (!rootElement) {
diff --git a/apps/web/src/pages/HomePage.scss b/apps/web/src/pages/HomePage.scss
new file mode 100644
index 0000000..dd6c5fe
--- /dev/null
+++ b/apps/web/src/pages/HomePage.scss
@@ -0,0 +1,35 @@
+// =============================================================================
+// Styles specific to HomePage — colocated next to HomePage.tsx since nothing
+// else uses these classes.
+// =============================================================================
+
+// No `@use` of the theme partial needed here: every design token below is a
+// CSS custom property (--color-*, --space-*...) declared once on :root in
+// styles/global.scss and available globally at runtime — not a Sass-level
+// variable/mixin that would require an explicit compile-time import.
+
+// Full-viewport centering wrapper, mirroring .auth-page's layout so the app
+// doesn't visually jump between the login/signup screens and the home page.
+.home-page {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-md);
+ padding: var(--space-md);
+
+ button {
+ padding: 0.6rem var(--space-md);
+ font-size: var(--font-size-base);
+ cursor: pointer;
+ border-radius: var(--radius-base);
+ border: 1px solid var(--color-border);
+ background: var(--color-surface);
+ color: var(--color-text);
+
+ &:hover {
+ background: var(--color-background);
+ }
+ }
+}
diff --git a/apps/web/src/pages/HomePage.tsx b/apps/web/src/pages/HomePage.tsx
index 0777d62..895abca 100644
--- a/apps/web/src/pages/HomePage.tsx
+++ b/apps/web/src/pages/HomePage.tsx
@@ -1,10 +1,19 @@
+import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../features/auth/AuthContext";
+import "./HomePage.scss";
+/**
+ * Landing page for an authenticated visitor. Behind {@link RequireAuth} —
+ * `user` is guaranteed non-null by the time this renders. Static copy
+ * comes from i18next (`locales/fr/translation.json`, `home` namespace).
+ */
export function HomePage() {
const { user, logout } = useAuth();
const navigate = useNavigate();
+ const { t } = useTranslation();
+ /** Ends the session and returns to the login page. */
async function handleLogout() {
await logout();
navigate("/login");
@@ -13,11 +22,9 @@ export function HomePage() {
return (
);
diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx
index 8929538..98a931b 100644
--- a/apps/web/src/pages/LoginPage.tsx
+++ b/apps/web/src/pages/LoginPage.tsx
@@ -1,20 +1,41 @@
-import { loginSchema } from "@batch-cooking/shared";
+import { ErrorCode, loginSchema } from "@batch-cooking/shared";
import { type FormEvent, useState } from "react";
+import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { ApiError } from "../api/client";
import { useAuth } from "../features/auth/AuthContext";
+// Shared with SignupPage — see the file for why it's colocated in
+// features/auth/ rather than duplicated per page.
+import "../features/auth/auth-form.scss";
import { fieldErrorsFrom } from "../lib/zod-errors";
+import { errorMessageService } from "../services/error-message.service";
+/**
+ * Login form. Validates client-side first (via the shared `loginSchema`,
+ * same rules the API enforces) for instant feedback with no network round
+ * trip; only calls the API once the payload is locally valid, and
+ * translates any API failure into a localized label via
+ * {@link ErrorMessageService}. All static copy comes from i18next
+ * (`locales/fr/translation.json`, `auth.login` namespace) via
+ * {@link useTranslation}, not hardcoded JSX text.
+ */
export function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
+ const { t } = useTranslation();
+ // Controlled form fields.
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
+
+ // Per-field validation messages (client-side, from zod) and a whole-form
+ // error message (from the API), kept separate since they're displayed
+ // in different places and cleared at different times.
const [fieldErrors, setFieldErrors] = useState>({});
const [formError, setFormError] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
+ /** Validates, then submits the form; navigates home on success. */
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setFormError(null);
@@ -31,7 +52,11 @@ export function LoginPage() {
await login(result.data);
navigate("/");
} catch (err) {
- setFormError(err instanceof ApiError ? err.message : "Something went wrong");
+ // ApiError.code is looked up through ErrorMessageService so the
+ // label is centralized and localized — never display err.message
+ // directly, it's the API's developer-facing (English) text.
+ const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
+ setFormError(errorMessageService.getLabel(code));
} finally {
setIsSubmitting(false);
}
@@ -40,9 +65,9 @@ export function LoginPage() {
return (
}
- Pas encore de compte ? Créer un profil
+ {t("auth.login.noAccount")} {t("auth.login.createProfileLink")}
diff --git a/apps/web/src/pages/SignupPage.tsx b/apps/web/src/pages/SignupPage.tsx
index 9c3f448..662d839 100644
--- a/apps/web/src/pages/SignupPage.tsx
+++ b/apps/web/src/pages/SignupPage.tsx
@@ -1,22 +1,43 @@
-import { signupSchema } from "@batch-cooking/shared";
+import { ErrorCode, signupSchema } from "@batch-cooking/shared";
import { type FormEvent, useState } from "react";
+import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { ApiError } from "../api/client";
import { useAuth } from "../features/auth/AuthContext";
+// Shared with LoginPage — see the file for why it's colocated in
+// features/auth/ rather than duplicated per page.
+import "../features/auth/auth-form.scss";
import { fieldErrorsFrom } from "../lib/zod-errors";
+import { errorMessageService } from "../services/error-message.service";
+/**
+ * Signup form (profile creation). Validates client-side first (via the
+ * shared `signupSchema`, same rules the API enforces) for instant
+ * feedback with no network round trip; only calls the API once the
+ * payload is locally valid, and translates any API failure (e.g. email
+ * already taken) into a localized label via {@link ErrorMessageService}.
+ * All static copy comes from i18next (`locales/fr/translation.json`,
+ * `auth.signup` namespace) via {@link useTranslation}, not hardcoded JSX text.
+ */
export function SignupPage() {
const { signup } = useAuth();
const navigate = useNavigate();
+ const { t } = useTranslation();
+ // Controlled form fields.
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
+
+ // Per-field validation messages (client-side, from zod) and a whole-form
+ // error message (from the API), kept separate since they're displayed
+ // in different places and cleared at different times.
const [fieldErrors, setFieldErrors] = useState>({});
const [formError, setFormError] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
+ /** Validates, then submits the form; navigates home on success. */
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setFormError(null);
@@ -33,7 +54,11 @@ export function SignupPage() {
await signup(result.data);
navigate("/");
} catch (err) {
- setFormError(err instanceof ApiError ? err.message : "Something went wrong");
+ // ApiError.code is looked up through ErrorMessageService so the
+ // label is centralized and localized — never display err.message
+ // directly, it's the API's developer-facing (English) text.
+ const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
+ setFormError(errorMessageService.getLabel(code));
} finally {
setIsSubmitting(false);
}
@@ -42,9 +67,9 @@ export function SignupPage() {
return (
diff --git a/apps/web/src/services/error-message.service.ts b/apps/web/src/services/error-message.service.ts
new file mode 100644
index 0000000..c2d7a73
--- /dev/null
+++ b/apps/web/src/services/error-message.service.ts
@@ -0,0 +1,32 @@
+import { ErrorCode } from "@batch-cooking/shared";
+import i18n from "../i18n/i18n";
+
+/**
+ * Centralizes lookup of the user-facing label for a given {@link ErrorCode},
+ * delegating the actual translation storage/lookup to i18next (see
+ * `i18n/i18n.ts` and `locales/fr/translation.json`) — components never
+ * hardcode error text, and adding a language is a locale file, not a
+ * code change.
+ *
+ * A numeric `ErrorCode` value isn't a valid i18next key by itself (and
+ * numeric JSON keys would be far less readable in the locale file than
+ * names), so this reverse-maps the enum value to its member name (e.g.
+ * `4001` → `"EMAIL_ALREADY_IN_USE"`) via TypeScript's numeric-enum reverse
+ * mapping, then looks that name up under the `errors` namespace.
+ */
+export class ErrorMessageService {
+ /**
+ * Returns the localized, user-facing label for a given error code.
+ *
+ * @param code - Error code as returned by the API. An unrecognized value
+ * (e.g. the client is older than the API and doesn't know a newer code)
+ * falls back to the generic `INTERNAL_ERROR` label instead of throwing.
+ */
+ public getLabel(code: ErrorCode): string {
+ const memberName = ErrorCode[code] ?? ErrorCode[ErrorCode.INTERNAL_ERROR];
+ return i18n.t(`errors.${memberName}`);
+ }
+}
+
+/** Single shared instance — this service is stateless, no need for one per caller. */
+export const errorMessageService = new ErrorMessageService();
diff --git a/apps/web/src/styles/_theme.scss b/apps/web/src/styles/_theme.scss
new file mode 100644
index 0000000..386743b
--- /dev/null
+++ b/apps/web/src/styles/_theme.scss
@@ -0,0 +1,55 @@
+// =============================================================================
+// 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
+// would let a future dark-mode toggle (or any theme switch) just redefine
+// these variables instead of rebuilding the stylesheet. Every other .scss
+// file should reference `var(--token-name)`, never a hardcoded color/size.
+//
+// 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 {
+ // --- Color palette --------------------------------------------------------
+ // Neutral surface: page background vs. the "card" surface content sits on.
+ --color-background: #ffffff;
+ --color-surface: #ffffff;
+ // Text.
+ --color-text: #1a1a1a;
+ --color-text-muted: #555555;
+ // Brand/accent — used for primary buttons and links.
+ --color-primary: #2f6f4f;
+ --color-primary-hover: #24573e;
+ // Feedback.
+ --color-error: #c0392b;
+ --color-border: #888888;
+
+ // --- 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
+
+ // --- Typography --------------------------------------------------------
+ --font-family-base: system-ui, sans-serif;
+ --font-size-base: 1rem;
+ --font-size-sm: 0.875rem;
+ --font-size-xs: 0.8rem;
+
+ // --- Shape / misc --------------------------------------------------------
+ --radius-base: 4px;
+ --max-width-form: 22rem;
+}
+
+// Lets the browser pick sensible default colors (form controls, scrollbars)
+// for whichever mode (light/dark) the user's OS is in, until this app has
+// its own explicit dark theme wired to the tokens above.
+:root {
+ color-scheme: light dark;
+}
diff --git a/apps/web/src/styles/global.scss b/apps/web/src/styles/global.scss
new file mode 100644
index 0000000..94c22ff
--- /dev/null
+++ b/apps/web/src/styles/global.scss
@@ -0,0 +1,17 @@
+// =============================================================================
+// 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";
+
+// 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-family-base);
+ color: var(--color-text);
+ background: var(--color-background);
+}
diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts
deleted file mode 100644
index 11f02fe..0000000
--- a/apps/web/src/vite-env.d.ts
+++ /dev/null
@@ -1 +0,0 @@
-///
diff --git a/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json
index 31fce0b..af5b6cb 100644
--- a/apps/web/tsconfig.app.json
+++ b/apps/web/tsconfig.app.json
@@ -5,6 +5,7 @@
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
+ "types": ["vite/client"],
"noEmit": true,
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 58676f7..a4886c6 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -3,4 +3,14 @@ import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
+ css: {
+ preprocessorOptions: {
+ // Opts into Dart Sass's modern API — avoids the "legacy-js-api"
+ // deprecation warning on every build (Vite still defaults to the
+ // legacy API for backward compatibility).
+ scss: {
+ api: "modern-compiler",
+ },
+ },
+ },
});
diff --git a/packages/error-tools/package.json b/packages/error-tools/package.json
new file mode 100644
index 0000000..ea2f5af
--- /dev/null
+++ b/packages/error-tools/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "@batch-cooking/error-tools",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "scripts": {
+ "test": "echo \"no tests yet\" && exit 0",
+ "build": "tsc -p tsconfig.json",
+ "postinstall": "tsc -p tsconfig.json"
+ },
+ "devDependencies": {
+ "@types/node": "^22.9.0",
+ "typescript": "^5.7.2"
+ },
+ "dependencies": {
+ "@batch-cooking/shared": "workspace:*",
+ "zod": "^3.25.76"
+ }
+}
diff --git a/packages/error-tools/src/error-handler.service.ts b/packages/error-tools/src/error-handler.service.ts
new file mode 100644
index 0000000..a4f0b6e
--- /dev/null
+++ b/packages/error-tools/src/error-handler.service.ts
@@ -0,0 +1,76 @@
+import { type ApiErrorResponse, ErrorCode } from "@batch-cooking/shared";
+import { ZodError } from "zod";
+import { HttpError } from "./http-error.js";
+
+/** Return value of {@link ErrorHandlerService.handle}: everything a caller needs to send an HTTP response. */
+export interface ErrorHandlingResult {
+ /** HTTP status code to respond with. */
+ status: number;
+ /** JSON body to respond with — matches the shared {@link ApiErrorResponse} contract. */
+ body: ApiErrorResponse;
+}
+
+/**
+ * Centralizes every "how do we turn a thrown error into an HTTP response"
+ * decision in one place, so route handlers and framework-specific
+ * middleware (e.g. `createErrorMiddleware` in `@batch-cooking/express-tools`)
+ * never duplicate this logic. Framework-agnostic on purpose — it only maps
+ * an error to `{ status, body }` and has zero dependency on Express or any
+ * other HTTP framework.
+ *
+ * Recognizes three error shapes today (zod validation failures, our own
+ * `HttpError`, and anything else) and always falls back to a safe, generic
+ * 500 for the unknown case — a caller of `handle()` never needs its own
+ * fallback branch.
+ */
+export class ErrorHandlerService {
+ /**
+ * Maps any thrown value into a status + body pair ready to send to the
+ * client. Always succeeds — an error that doesn't match a known shape
+ * becomes a generic {@link ErrorCode.INTERNAL_ERROR} and is logged.
+ */
+ public handle(error: unknown): ErrorHandlingResult {
+ if (error instanceof ZodError) {
+ return this.fromZodError(error);
+ }
+ if (error instanceof HttpError) {
+ return this.fromHttpError(error);
+ }
+ return this.fromUnknownError(error);
+ }
+
+ /** Request body/query failed schema validation — always a 400. */
+ private fromZodError(error: ZodError): ErrorHandlingResult {
+ return {
+ status: 400,
+ body: {
+ code: ErrorCode.VALIDATION_ERROR,
+ message: "Validation error",
+ details: error.flatten().fieldErrors,
+ },
+ };
+ }
+
+ /** Our own typed error — status/code were decided by whoever threw it. */
+ private fromHttpError(error: HttpError): ErrorHandlingResult {
+ return {
+ status: error.status,
+ body: { code: error.code, message: error.message },
+ };
+ }
+
+ /**
+ * Anything unrecognized: logged server-side (so it's still diagnosable)
+ * but never leaks internal details to the client — always a generic 500.
+ */
+ private fromUnknownError(error: unknown): ErrorHandlingResult {
+ console.error(error);
+ return {
+ status: 500,
+ body: { code: ErrorCode.INTERNAL_ERROR, message: "Internal server error" },
+ };
+ }
+}
+
+/** Single shared instance — this service is stateless, no need for one per request. */
+export const errorHandlerService = new ErrorHandlerService();
diff --git a/packages/error-tools/src/http-error.ts b/packages/error-tools/src/http-error.ts
new file mode 100644
index 0000000..e5f30e2
--- /dev/null
+++ b/packages/error-tools/src/http-error.ts
@@ -0,0 +1,30 @@
+import type { ErrorCode } from "@batch-cooking/shared";
+
+/**
+ * Typed error carrying both the HTTP status it should map to and the
+ * business {@link ErrorCode} that identifies *why* it happened.
+ *
+ * Route handlers throw this (or let it bubble from a service call) instead
+ * of manually setting a status/body — {@link ErrorHandlerService} is the
+ * single place that turns it into an actual HTTP response, so every error
+ * path in an app built with these tools is shaped consistently.
+ */
+export class HttpError extends Error {
+ /** HTTP status code to respond with (e.g. 401, 404, 409). */
+ public readonly status: number;
+ /** Machine-readable error code, shared with the client — see {@link ErrorCode}. */
+ public readonly code: ErrorCode;
+
+ /**
+ * @param status - HTTP status code to respond with.
+ * @param code - Business error code identifying the failure (shared with the client).
+ * @param message - Developer-facing description (English). Logged/used for
+ * debugging only; end-user-facing text is derived client-side from `code`.
+ */
+ public constructor(status: number, code: ErrorCode, message: string) {
+ super(message);
+ this.name = "HttpError";
+ this.status = status;
+ this.code = code;
+ }
+}
diff --git a/packages/error-tools/src/index.ts b/packages/error-tools/src/index.ts
new file mode 100644
index 0000000..f1faeac
--- /dev/null
+++ b/packages/error-tools/src/index.ts
@@ -0,0 +1,11 @@
+// Public entry point of the framework-agnostic error-handling tooling
+// shared across apps in this monorepo. Everything here — HttpError,
+// ErrorHandlerService — has zero dependency on Express or any other HTTP
+// framework; it only knows how to map an error to a {status, body} pair.
+//
+// Framework-specific adapters (e.g. Express's `createErrorMiddleware`) live
+// in their own package (`@batch-cooking/express-tools`) and consume these
+// types instead of duplicating the mapping logic.
+
+export * from "./error-handler.service.js";
+export * from "./http-error.js";
diff --git a/packages/error-tools/tsconfig.json b/packages/error-tools/tsconfig.json
new file mode 100644
index 0000000..3cf7309
--- /dev/null
+++ b/packages/error-tools/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "declaration": true,
+ "outDir": "dist",
+ "rootDir": "src",
+ "types": ["node"]
+ },
+ "include": ["src"]
+}
diff --git a/packages/express-tools/package.json b/packages/express-tools/package.json
new file mode 100644
index 0000000..d128000
--- /dev/null
+++ b/packages/express-tools/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "@batch-cooking/express-tools",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "scripts": {
+ "test": "echo \"no tests yet\" && exit 0",
+ "build": "tsc -p tsconfig.json",
+ "postinstall": "tsc -p tsconfig.json"
+ },
+ "devDependencies": {
+ "@types/cookie-parser": "^1.4.10",
+ "@types/cors": "^2.8.19",
+ "@types/express": "^4.17.21",
+ "@types/node": "^22.9.0",
+ "typescript": "^5.7.2"
+ },
+ "dependencies": {
+ "@batch-cooking/error-tools": "workspace:*",
+ "@batch-cooking/shared": "workspace:*",
+ "cookie-parser": "^1.4.7",
+ "cors": "^2.8.6",
+ "express": "^4.21.1"
+ }
+}
diff --git a/packages/express-tools/src/async-handler.ts b/packages/express-tools/src/async-handler.ts
new file mode 100644
index 0000000..1dcd692
--- /dev/null
+++ b/packages/express-tools/src/async-handler.ts
@@ -0,0 +1,34 @@
+import type { NextFunction, Request, RequestHandler, Response } from "express";
+
+/**
+ * An Express route handler whose body is `async` (returns a `Promise`).
+ * Only `ResBody`/`Locals` are made generic (what this codebase actually
+ * varies per-route) — params/request-body/query stay at Express's own
+ * internal defaults, same as an unparameterized `Request`.
+ */
+export type AsyncRequestHandler<
+ ResBody = unknown,
+ Locals extends Record = Record,
+> = (req: Request, res: Response, next: NextFunction) => Promise;
+
+/**
+ * Wraps an async Express handler so a thrown error or rejected promise is
+ * forwarded to `next(err)` automatically. Without this, an unhandled
+ * rejection inside an `async` route handler never reaches Express's error
+ * middleware — every route ends up needing its own `try { ... } catch (err)
+ * { next(err); }` boilerplate, which this removes.
+ *
+ * @example
+ * router.post("/signup", wrapAsyncHandler(async (req, res) => {
+ * const profile = await signup(req.body);
+ * res.status(201).json(profile);
+ * }));
+ */
+export function wrapAsyncHandler<
+ ResBody = unknown,
+ Locals extends Record = Record,
+>(handler: AsyncRequestHandler): RequestHandler {
+ return (req, res, next) => {
+ handler(req, res as Response, next).catch(next);
+ };
+}
diff --git a/packages/express-tools/src/error-middleware.ts b/packages/express-tools/src/error-middleware.ts
new file mode 100644
index 0000000..dc303b3
--- /dev/null
+++ b/packages/express-tools/src/error-middleware.ts
@@ -0,0 +1,27 @@
+import type { ErrorHandlerService } from "@batch-cooking/error-tools";
+import type { NextFunction, Request, Response } from "express";
+
+/** Express error-handling middleware signature (the 4-arg form Express detects as an error handler). */
+type ExpressErrorMiddleware = (
+ err: unknown,
+ req: Request,
+ res: Response,
+ next: NextFunction,
+) => void;
+
+/**
+ * Builds the final Express error-handling middleware for an app: every
+ * thrown/`next(err)`-ed error ends up here, gets mapped by the given
+ * {@link ErrorHandlerService}, and sent as the response. Keeps the actual
+ * "what does this error mean" logic in the service, testable on its own —
+ * this factory is just the thin Express adapter.
+ *
+ * @example
+ * app.use(createErrorMiddleware(errorHandlerService));
+ */
+export function createErrorMiddleware(errorHandler: ErrorHandlerService): ExpressErrorMiddleware {
+ return (err, _req, res, _next) => {
+ const { status, body } = errorHandler.handle(err);
+ res.status(status).json(body);
+ };
+}
diff --git a/packages/express-tools/src/express-server.ts b/packages/express-tools/src/express-server.ts
new file mode 100644
index 0000000..1e95541
--- /dev/null
+++ b/packages/express-tools/src/express-server.ts
@@ -0,0 +1,96 @@
+import cookieParser from "cookie-parser";
+import cors from "cors";
+import express, {
+ type ErrorRequestHandler,
+ type Express,
+ type RequestHandler,
+ type Router,
+} from "express";
+
+/** HTTP verbs {@link ExpressServer.addRoute} accepts. */
+export type HttpMethod = "get" | "post" | "put" | "patch" | "delete";
+
+/** Options for {@link ExpressServer.setupCore}. */
+export interface ExpressServerCoreOptions {
+ /** Origin allowed by CORS — must match wherever the frontend is served from. */
+ corsOrigin: string;
+}
+
+/**
+ * Thin wrapper around an Express application: bundles the common
+ * "set up the standard middleware stack, register routes without
+ * duplicates, wire the error handler, start listening" concerns behind a
+ * small typed API, instead of every service in the monorepo repeating the
+ * same raw `express()` setup by hand.
+ *
+ * Framework-specific on purpose — unlike `ErrorHandlerService` (which has
+ * no Express dependency at all), this class *is* the Express integration
+ * layer. Business/domain code should never import `express` directly;
+ * it goes through this instead.
+ */
+export class ExpressServer {
+ /** The underlying Express application. */
+ private readonly app: Express;
+ /** Tracks `"METHOD path"` keys already registered via {@link addRoute}, to warn instead of silently double-registering a route. */
+ private readonly registeredRoutes = new Set();
+
+ public constructor() {
+ this.app = express();
+ }
+
+ /** The underlying Express application — needed by test tooling (e.g. supertest) that expects a raw `Express` instance. */
+ public get instance(): Express {
+ return this.app;
+ }
+
+ /**
+ * Registers the standard middleware stack every service in this
+ * monorepo needs: CORS (with credentials, for the session cookie),
+ * JSON body parsing, and cookie parsing. Call once, before registering
+ * any route.
+ */
+ public setupCore(options: ExpressServerCoreOptions): void {
+ this.app.use(cors({ origin: options.corsOrigin, credentials: true }));
+ this.app.use(express.json());
+ this.app.use(cookieParser());
+ }
+
+ /** Registers a middleware that runs on every request (e.g. logging, a catch-all 404 handler). */
+ public addMiddleware(middleware: RequestHandler): void {
+ this.app.use(middleware);
+ }
+
+ /**
+ * Registers the final Express error-handling middleware (the 4-argument
+ * form). Must be added last — Express only treats a middleware as an
+ * error handler by its arity, and only the last matching one runs.
+ */
+ public setErrorHandler(middleware: ErrorRequestHandler): void {
+ this.app.use(middleware);
+ }
+
+ /** Mounts a whole `express.Router` under a base path (e.g. `mountRouter("/auth", authRouter)`). */
+ public mountRouter(basePath: string, router: Router): void {
+ this.app.use(basePath, router);
+ }
+
+ /**
+ * Registers a single route with its handler(s). Warns and skips instead
+ * of registering if the same method+path was already added — catches a
+ * copy-paste mistake at startup instead of silently shadowing a route.
+ */
+ public addRoute(method: HttpMethod, path: string, ...handlers: RequestHandler[]): void {
+ const key = `${method.toUpperCase()} ${path}`;
+ if (this.registeredRoutes.has(key)) {
+ console.warn(`[ExpressServer] Route already registered, skipping: ${key}`);
+ return;
+ }
+ this.registeredRoutes.add(key);
+ this.app[method](path, ...handlers);
+ }
+
+ /** Starts listening on the given port. `onListening` is called once the server is up (e.g. to log the URL). */
+ public listen(port: number, onListening?: () => void): void {
+ this.app.listen(port, onListening);
+ }
+}
diff --git a/packages/express-tools/src/index.ts b/packages/express-tools/src/index.ts
new file mode 100644
index 0000000..2adf219
--- /dev/null
+++ b/packages/express-tools/src/index.ts
@@ -0,0 +1,13 @@
+// Public entry point of the Express-specific tooling shared across any
+// Express app in this monorepo (currently apps/api). Generic HTTP/Express
+// infrastructure lives here — domain-specific code (auth, business logic)
+// stays in the consuming app.
+//
+// Framework-agnostic error-handling pieces (ErrorHandlerService, HttpError)
+// live in `@batch-cooking/error-tools` instead, since they have zero
+// dependency on Express. `createErrorMiddleware` here is the thin Express
+// adapter that wires that service into an Express app.
+
+export * from "./async-handler.js";
+export * from "./error-middleware.js";
+export * from "./express-server.js";
diff --git a/packages/express-tools/tsconfig.json b/packages/express-tools/tsconfig.json
new file mode 100644
index 0000000..3cf7309
--- /dev/null
+++ b/packages/express-tools/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "declaration": true,
+ "outDir": "dist",
+ "rootDir": "src",
+ "types": ["node"]
+ },
+ "include": ["src"]
+}
diff --git a/packages/shared/src/errors/error-codes.ts b/packages/shared/src/errors/error-codes.ts
new file mode 100644
index 0000000..52a99a3
--- /dev/null
+++ b/packages/shared/src/errors/error-codes.ts
@@ -0,0 +1,55 @@
+/**
+ * Enumeration of every business/domain error code the API can return.
+ *
+ * This is the single source of truth for error identification across the
+ * whole monorepo: `apps/api` throws errors carrying one of these codes,
+ * and `apps/web` maps each code to a localized, user-facing label (see
+ * `apps/web/src/services/error-message.service.ts`, backed by i18next
+ * locale files under `apps/web/src/locales/`). Neither side should ever
+ * hardcode a raw error value that the other side has to guess at — always
+ * reference `ErrorCode.XXX`, never a bare number/string.
+ *
+ * Numeric values (not string codes): grouped by category so the number
+ * itself hints at the kind of failure, similar in spirit to HTTP status
+ * code families —
+ * - `4000`–`4099`: request validation
+ * - `4010`–`4019`: authentication
+ * - `4040`–`4049`: not found
+ * - `5000`–`5099`: internal/unexpected
+ *
+ * When adding a new failure case in the API:
+ * 1. Add a new member here, in the right range, with the next free number.
+ * 2. Throw it via `HttpError` (`@batch-cooking/express-tools`).
+ * 3. Add its translation key to every locale file under
+ * `apps/web/src/locales` (one `translation.json` per language).
+ */
+export enum ErrorCode {
+ /** Request body/query failed zod schema validation. */
+ VALIDATION_ERROR = 4000,
+ /** Signup attempted with an email that already has a profile. */
+ EMAIL_ALREADY_IN_USE = 4001,
+ /** Login failed — wrong email or wrong password (never say which). */
+ INVALID_CREDENTIALS = 4010,
+ /** Request required a session cookie/JWT that is missing, invalid, or stale. */
+ NOT_AUTHENTICATED = 4011,
+ /** No route/resource matches the request. */
+ NOT_FOUND = 4040,
+ /** Unexpected/unhandled failure — the catch-all, always logged server-side. */
+ INTERNAL_ERROR = 5000,
+}
+
+/**
+ * Shape of every JSON error body the API returns, whatever the failure.
+ * Kept intentionally small and stable: `code` is what clients should
+ * branch on, `message` is a human-readable (English, developer-facing)
+ * description useful for logs/debugging — never shown to end users as-is,
+ * since end-user-facing text is localized client-side from `code`.
+ */
+export interface ApiErrorResponse {
+ /** Machine-readable error identifier — see {@link ErrorCode}. */
+ code: ErrorCode;
+ /** Developer-facing description (English). Not localized, not for UI display. */
+ message: string;
+ /** Present only for VALIDATION_ERROR: per-field error messages from zod. */
+ details?: Record;
+}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 57162f1..98ed583 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -1,2 +1,9 @@
+// Public entry point of the code shared between apps/api and apps/web.
+// Anything exported here is part of the cross-app contract — keep it
+// intentional (types, validation schemas, error codes), not an implementation
+// detail specific to one side.
+
+export * from "./errors/error-codes.js";
export * from "./schemas/auth.js";
+export * from "./tools/assert-is-never.js";
export * from "./types/user-profile.js";
diff --git a/packages/shared/src/schemas/auth.ts b/packages/shared/src/schemas/auth.ts
index 41f4674..d632e61 100644
--- a/packages/shared/src/schemas/auth.ts
+++ b/packages/shared/src/schemas/auth.ts
@@ -3,9 +3,13 @@ import { z } from "zod";
// Shared between apps/api (server-side validation, source of truth) and
// apps/web (client-side validation for instant feedback before the round
// trip) — one set of rules, no risk of the two drifting apart.
-// Messages are in French: this is the only place end users ever see zod's
-// text (surfaced as-is in apps/web's forms), and the whole UI is French.
+// Zod's own `.min()`/`.email()` messages are in French: this is the only
+// place end users ever see them (surfaced as-is in apps/web's forms), and
+// the whole UI is French. This is distinct from the ErrorCode-based i18n
+// used for *API* errors (see error-codes.ts) — these are purely
+// client-side, pre-submit validation messages that never leave the browser.
+/** Payload accepted by `POST /auth/signup`. */
export const signupSchema = z.object({
firstName: z.string().trim().min(1, "Le prénom est requis").max(100),
lastName: z.string().trim().min(1, "Le nom est requis").max(100),
@@ -15,10 +19,13 @@ export const signupSchema = z.object({
// complexity rules mostly push users toward predictable patterns.
password: z.string().min(8, "8 caractères minimum").max(200),
});
+/** Inferred TS type for {@link signupSchema}'s validated output. */
export type SignupInput = z.infer;
+/** Payload accepted by `POST /auth/login`. */
export const loginSchema = z.object({
email: z.string().trim().toLowerCase().email("Email invalide"),
password: z.string().min(1, "Le mot de passe est requis"),
});
+/** Inferred TS type for {@link loginSchema}'s validated output. */
export type LoginInput = z.infer;
diff --git a/packages/shared/src/tools/assert-is-never.ts b/packages/shared/src/tools/assert-is-never.ts
new file mode 100644
index 0000000..8e59742
--- /dev/null
+++ b/packages/shared/src/tools/assert-is-never.ts
@@ -0,0 +1,35 @@
+/**
+ * Exhaustiveness check for a `switch`/`if`-chain over a union type. Call
+ * this in the `default` case (or final `else`) with the value being
+ * switched on: if every member of the union has been handled by an
+ * earlier branch, TypeScript narrows that value to `never` there, and the
+ * call type-checks. If a new member is later added to the union and a
+ * branch is forgotten, `value` is no longer `never` at that point — the
+ * call becomes a **compile error**, catching the missing case before it
+ * ships, instead of silently falling through at runtime.
+ *
+ * @example
+ * type Shape = { kind: "circle"; radius: number } | { kind: "square"; side: number };
+ *
+ * function area(shape: Shape): number {
+ * switch (shape.kind) {
+ * case "circle":
+ * return Math.PI * shape.radius ** 2;
+ * case "square":
+ * return shape.side ** 2;
+ * default:
+ * // Compile error here if a new `Shape` variant is added without a
+ * // matching case above — `shape` wouldn't be `never` anymore.
+ * return assertIsNever(shape);
+ * }
+ * }
+ *
+ * @param value - The value that should be `never` at this point (compile-time check).
+ * @param message - Optional custom error message; defaults to including the offending value.
+ * @throws Always throws — this is also a real runtime safety net for a
+ * value that reaches here despite the type system (e.g. unvalidated
+ * external data cast to the union type).
+ */
+export function assertIsNever(value: never, message?: string): never {
+ throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
+}
diff --git a/packages/shared/src/types/user-profile.ts b/packages/shared/src/types/user-profile.ts
index e002cae..c221579 100644
--- a/packages/shared/src/types/user-profile.ts
+++ b/packages/shared/src/types/user-profile.ts
@@ -1,12 +1,22 @@
-// Mirrors apps/api's Omit — declared by
-// hand rather than derived from the Prisma type, since apps/web must not
-// depend on @prisma/client.
+/**
+ * Public shape of a user profile, as returned by the API (never includes
+ * the password hash). Mirrors apps/api's `Omit` — declared by hand rather than derived from the Prisma
+ * type, since apps/web must not depend on `@prisma/client`.
+ */
export interface SafeUserProfile {
+ /** Primary key. */
id: number;
+ /** First name. */
firstName: string;
+ /** Last name. */
lastName: string;
+ /** Email address — unique, used as the login identifier. */
email: string;
+ /** Incremented server-side to invalidate previously-issued JWTs (e.g. on password change). Not used directly by the client. */
tokenVersion: number;
+ /** FK to the household this profile belongs to, or `null` if not yet assigned to one. */
houseId: number | null;
+ /** FK to this profile's diet preference, or `null` if unset. */
dietId: number | null;
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 119c394..b2efbb3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -17,6 +17,12 @@ importers:
apps/api:
dependencies:
+ '@batch-cooking/error-tools':
+ specifier: workspace:*
+ version: link:../../packages/error-tools
+ '@batch-cooking/express-tools':
+ specifier: workspace:*
+ version: link:../../packages/express-tools
'@batch-cooking/shared':
specifier: workspace:*
version: link:../../packages/shared
@@ -26,12 +32,6 @@ importers:
argon2:
specifier: 0.31.2
version: 0.31.2
- cookie-parser:
- specifier: ^1.4.7
- version: 1.4.7
- cors:
- specifier: ^2.8.6
- version: 2.8.6
dotenv:
specifier: ^16.4.5
version: 16.6.1
@@ -48,12 +48,9 @@ importers:
'@cucumber/cucumber':
specifier: ^13.2.1
version: 13.2.1
- '@types/cookie-parser':
- specifier: ^1.4.10
- version: 1.4.10(@types/express@4.17.25)
- '@types/cors':
- specifier: ^2.8.19
- version: 2.8.19
+ '@faker-js/faker':
+ specifier: ^10.6.0
+ version: 10.6.0
'@types/express':
specifier: ^4.17.21
version: 4.17.25
@@ -93,12 +90,18 @@ importers:
'@batch-cooking/shared':
specifier: workspace:*
version: link:../../packages/shared
+ i18next:
+ specifier: ^26.3.6
+ version: 26.3.6(typescript@5.9.3)
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)
@@ -117,10 +120,13 @@ importers:
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))
+ version: 4.7.0(vite@5.4.21(@types/node@22.20.1)(sass@1.102.0))
cypress:
specifier: ^13.15.2
version: 13.17.0
+ sass:
+ specifier: ^1.102.0
+ version: 1.102.0
start-server-and-test:
specifier: ^2.0.8
version: 2.1.5
@@ -129,7 +135,57 @@ importers:
version: 5.9.3
vite:
specifier: ^5.4.11
- version: 5.4.21(@types/node@22.20.1)
+ version: 5.4.21(@types/node@22.20.1)(sass@1.102.0)
+
+ packages/error-tools:
+ dependencies:
+ '@batch-cooking/shared':
+ specifier: workspace:*
+ version: link:../shared
+ zod:
+ specifier: ^3.25.76
+ version: 3.25.76
+ devDependencies:
+ '@types/node':
+ specifier: ^22.9.0
+ version: 22.20.1
+ typescript:
+ specifier: ^5.7.2
+ version: 5.9.3
+
+ packages/express-tools:
+ dependencies:
+ '@batch-cooking/error-tools':
+ specifier: workspace:*
+ version: link:../error-tools
+ '@batch-cooking/shared':
+ specifier: workspace:*
+ version: link:../shared
+ cookie-parser:
+ specifier: ^1.4.7
+ version: 1.4.7
+ cors:
+ specifier: ^2.8.6
+ version: 2.8.6
+ express:
+ specifier: ^4.21.1
+ version: 4.22.2
+ devDependencies:
+ '@types/cookie-parser':
+ specifier: ^1.4.10
+ version: 1.4.10(@types/express@4.17.25)
+ '@types/cors':
+ specifier: ^2.8.19
+ version: 2.8.19
+ '@types/express':
+ specifier: ^4.17.21
+ version: 4.17.25
+ '@types/node':
+ specifier: ^22.9.0
+ version: 22.20.1
+ typescript:
+ specifier: ^5.7.2
+ version: 5.9.3
packages/shared:
dependencies:
@@ -214,6 +270,10 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
+ '@babel/runtime@7.29.7':
+ resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==, tarball: https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz}
+ engines: {node: '>=6.9.0'}
+
'@babel/template@7.29.7':
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==, tarball: https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz}
engines: {node: '>=6.9.0'}
@@ -655,6 +715,10 @@ packages:
cpu: [x64]
os: [win32]
+ '@faker-js/faker@10.6.0':
+ resolution: {integrity: sha512-3RQHgEtvL1Frl/d1cSreo7qhJ3Gk1OdNUai/CtZ8G+wYeRQnJih3s9xJ9/kgYekPQRdwgh0HXRPqMlzWGwivIQ==, tarball: https://registry.npmjs.org/@faker-js/faker/-/faker-10.6.0.tgz}
+ engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'}
+
'@hapi/address@5.1.1':
resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==, tarball: https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz}
engines: {node: '>=14.0.0'}
@@ -708,6 +772,82 @@ packages:
'@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==, tarball: https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz}
+ '@parcel/watcher-android-arm64@2.6.0':
+ resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==, tarball: https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ '@parcel/watcher-darwin-arm64@2.6.0':
+ resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==, tarball: https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@parcel/watcher-darwin-x64@2.6.0':
+ resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==, tarball: https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@parcel/watcher-freebsd-x64@2.6.0':
+ resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==, tarball: https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@parcel/watcher-linux-arm-glibc@2.6.0':
+ resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==, tarball: https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ '@parcel/watcher-linux-arm-musl@2.6.0':
+ resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==, tarball: https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ '@parcel/watcher-linux-arm64-glibc@2.6.0':
+ resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==, tarball: https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@parcel/watcher-linux-arm64-musl@2.6.0':
+ resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==, tarball: https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@parcel/watcher-linux-x64-glibc@2.6.0':
+ resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==, tarball: https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ '@parcel/watcher-linux-x64-musl@2.6.0':
+ resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==, tarball: https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ '@parcel/watcher-win32-arm64@2.6.0':
+ resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==, tarball: https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@parcel/watcher-win32-x64@2.6.0':
+ resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==, tarball: https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ '@parcel/watcher@2.6.0':
+ resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==, tarball: https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz}
+ engines: {node: '>= 10.0.0'}
+
'@phc/format@1.0.0':
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==, tarball: https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz}
engines: {node: '>=10'}
@@ -1184,6 +1324,10 @@ packages:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==, tarball: https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz}
engines: {node: '>= 8.10.0'}
+ chokidar@5.0.0:
+ resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==, tarball: https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz}
+ engines: {node: '>= 20.19.0'}
+
chownr@2.0.0:
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==, tarball: https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz}
engines: {node: '>=10'}
@@ -1677,6 +1821,9 @@ packages:
resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==, tarball: https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz}
engines: {node: ^20.17.0 || >=22.9.0}
+ html-parse-stringify@4.0.1:
+ resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==, tarball: https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz}
+
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==, tarball: https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz}
engines: {node: '>= 0.8'}
@@ -1697,6 +1844,14 @@ packages:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==, tarball: https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz}
engines: {node: '>=10.17.0'}
+ i18next@26.3.6:
+ resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==, tarball: https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz}
+ peerDependencies:
+ typescript: ^5 || ^6 || ^7
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==, tarball: https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz}
engines: {node: '>=0.10.0'}
@@ -1704,6 +1859,9 @@ packages:
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==, tarball: https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz}
+ immutable@5.1.9:
+ resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==, tarball: https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz}
+
indent-string@4.0.0:
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==, tarball: https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz}
engines: {node: '>=8'}
@@ -2139,6 +2297,10 @@ packages:
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==, tarball: https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz}
engines: {node: '>=8.6'}
+ picomatch@4.0.5:
+ resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==, tarball: https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz}
+ engines: {node: '>=12'}
+
pify@2.3.0:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==, tarball: https://registry.npmjs.org/pify/-/pify-2.3.0.tgz}
engines: {node: '>=0.10.0'}
@@ -2206,6 +2368,22 @@ packages:
peerDependencies:
react: ^18.3.1
+ react-i18next@17.0.11:
+ resolution: {integrity: sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==, tarball: https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz}
+ peerDependencies:
+ i18next: '>= 26.2.0'
+ react: '>= 16.8.0'
+ react-dom: '*'
+ react-native: '*'
+ typescript: ^5 || ^6 || ^7
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+ react-native:
+ optional: true
+ typescript:
+ optional: true
+
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==, tarball: https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz}
engines: {node: '>=0.10.0'}
@@ -2247,6 +2425,10 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==, tarball: https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz}
engines: {node: '>=8.10.0'}
+ readdirp@5.1.1:
+ resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==, tarball: https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz}
+ engines: {node: '>= 20.19.0'}
+
regexp-match-indices@1.0.2:
resolution: {integrity: sha512-DwZuAkt8NF5mKwGGER1EGh2PRqyvhRhhLviH+R8y8dIuaQROlUfXjt4s9ZTXstIsSkptf06BSvwcEmmfheJJWQ==, tarball: https://registry.npmjs.org/regexp-match-indices/-/regexp-match-indices-1.0.2.tgz}
@@ -2291,6 +2473,11 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==, tarball: https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz}
+ sass@1.102.0:
+ resolution: {integrity: sha512-NSOyTnaQF7rTAEOtI2fwb386vL+akyiQLBZu8Na7hXCb+umJy0GAqlcMIaqACZ6Z1VgTBS4K9PG6B3IdjHGJsw==, tarball: https://registry.npmjs.org/sass/-/sass-1.102.0.tgz}
+ engines: {node: '>=20.19.0'}
+ hasBin: true
+
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==, tarball: https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz}
@@ -2565,6 +2752,11 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
+ use-sync-external-store@1.6.0:
+ resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==, tarball: https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
util-arity@1.1.0:
resolution: {integrity: sha512-kkyIsXKwemfSy8ZEoaIz06ApApnWsk5hQO0vLjZS6UkBiGiW++Jsyb8vSBoc0WKlffGoGs5yYy/j5pp8zckrFA==, tarball: https://registry.npmjs.org/util-arity/-/util-arity-1.1.0.tgz}
@@ -2790,6 +2982,8 @@ snapshots:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.29.7
+ '@babel/runtime@7.29.7': {}
+
'@babel/template@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
@@ -3134,6 +3328,8 @@ snapshots:
'@esbuild/win32-x64@0.28.2':
optional: true
+ '@faker-js/faker@10.6.0': {}
+
'@hapi/address@5.1.1':
dependencies:
'@hapi/hoek': 11.0.7
@@ -3193,6 +3389,63 @@ snapshots:
dependencies:
'@noble/hashes': 1.8.0
+ '@parcel/watcher-android-arm64@2.6.0':
+ optional: true
+
+ '@parcel/watcher-darwin-arm64@2.6.0':
+ optional: true
+
+ '@parcel/watcher-darwin-x64@2.6.0':
+ optional: true
+
+ '@parcel/watcher-freebsd-x64@2.6.0':
+ optional: true
+
+ '@parcel/watcher-linux-arm-glibc@2.6.0':
+ optional: true
+
+ '@parcel/watcher-linux-arm-musl@2.6.0':
+ optional: true
+
+ '@parcel/watcher-linux-arm64-glibc@2.6.0':
+ optional: true
+
+ '@parcel/watcher-linux-arm64-musl@2.6.0':
+ optional: true
+
+ '@parcel/watcher-linux-x64-glibc@2.6.0':
+ optional: true
+
+ '@parcel/watcher-linux-x64-musl@2.6.0':
+ optional: true
+
+ '@parcel/watcher-win32-arm64@2.6.0':
+ optional: true
+
+ '@parcel/watcher-win32-x64@2.6.0':
+ optional: true
+
+ '@parcel/watcher@2.6.0':
+ dependencies:
+ detect-libc: 2.1.2
+ is-glob: 4.0.3
+ node-addon-api: 7.1.1
+ picomatch: 4.0.5
+ optionalDependencies:
+ '@parcel/watcher-android-arm64': 2.6.0
+ '@parcel/watcher-darwin-arm64': 2.6.0
+ '@parcel/watcher-darwin-x64': 2.6.0
+ '@parcel/watcher-freebsd-x64': 2.6.0
+ '@parcel/watcher-linux-arm-glibc': 2.6.0
+ '@parcel/watcher-linux-arm-musl': 2.6.0
+ '@parcel/watcher-linux-arm64-glibc': 2.6.0
+ '@parcel/watcher-linux-arm64-musl': 2.6.0
+ '@parcel/watcher-linux-x64-glibc': 2.6.0
+ '@parcel/watcher-linux-x64-musl': 2.6.0
+ '@parcel/watcher-win32-arm64': 2.6.0
+ '@parcel/watcher-win32-x64': 2.6.0
+ optional: true
+
'@phc/format@1.0.0': {}
'@prisma/client@5.22.0(prisma@5.22.0)':
@@ -3427,7 +3680,7 @@ snapshots:
'@types/node': 22.20.1
optional: true
- '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.1))':
+ '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.1)(sass@1.102.0))':
dependencies:
'@babel/core': 7.29.7
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
@@ -3435,7 +3688,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
- vite: 5.4.21(@types/node@22.20.1)
+ vite: 5.4.21(@types/node@22.20.1)(sass@1.102.0)
transitivePeerDependencies:
- supports-color
@@ -3654,6 +3907,10 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
+ chokidar@5.0.0:
+ dependencies:
+ readdirp: 5.1.1
+
chownr@2.0.0: {}
ci-info@4.4.0: {}
@@ -4251,6 +4508,8 @@ snapshots:
dependencies:
lru-cache: 11.5.2
+ html-parse-stringify@4.0.1: {}
+
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@@ -4276,12 +4535,18 @@ snapshots:
human-signals@2.1.0: {}
+ i18next@26.3.6(typescript@5.9.3):
+ optionalDependencies:
+ typescript: 5.9.3
+
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
ieee754@1.2.1: {}
+ immutable@5.1.9: {}
+
indent-string@4.0.0: {}
indent-string@5.0.0: {}
@@ -4656,6 +4921,9 @@ snapshots:
picomatch@2.3.2: {}
+ picomatch@4.0.5:
+ optional: true
+
pify@2.3.0: {}
postcss@8.5.26:
@@ -4722,6 +4990,17 @@ snapshots:
react: 18.3.1
scheduler: 0.23.2
+ react-i18next@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):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ html-parse-stringify: 4.0.1
+ i18next: 26.3.6(typescript@5.9.3)
+ react: 18.3.1
+ use-sync-external-store: 1.6.0(react@18.3.1)
+ optionalDependencies:
+ react-dom: 18.3.1(react@18.3.1)
+ typescript: 5.9.3
+
react-refresh@0.17.0: {}
react-router-dom@7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
@@ -4766,6 +5045,8 @@ snapshots:
dependencies:
picomatch: 2.3.2
+ readdirp@5.1.1: {}
+
regexp-match-indices@1.0.2:
dependencies:
regexp-tree: 0.1.27
@@ -4831,6 +5112,14 @@ snapshots:
safer-buffer@2.1.2: {}
+ sass@1.102.0:
+ dependencies:
+ chokidar: 5.0.0
+ immutable: 5.1.9
+ source-map-js: 1.2.1
+ optionalDependencies:
+ '@parcel/watcher': 2.6.0
+
scheduler@0.23.2:
dependencies:
loose-envify: 1.4.0
@@ -5126,6 +5415,10 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
+ use-sync-external-store@1.6.0(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+
util-arity@1.1.0: {}
util-deprecate@1.0.2: {}
@@ -5147,7 +5440,7 @@ snapshots:
core-util-is: 1.0.2
extsprintf: 1.3.0
- vite@5.4.21(@types/node@22.20.1):
+ vite@5.4.21(@types/node@22.20.1)(sass@1.102.0):
dependencies:
esbuild: 0.21.5
postcss: 8.5.26
@@ -5155,6 +5448,7 @@ snapshots:
optionalDependencies:
'@types/node': 22.20.1
fsevents: 2.3.3
+ sass: 1.102.0
wait-on@9.0.4(debug@4.4.3):
dependencies:
diff --git a/specs/backend-architecture.md b/specs/backend-architecture.md
new file mode 100644
index 0000000..a0f39be
--- /dev/null
+++ b/specs/backend-architecture.md
@@ -0,0 +1,138 @@
+# Architecture backend — Projet Batch-cooking
+
+> Documentation de l'organisation d'`apps/api` et de l'outillage partagé
+> (`packages/express-tools`, `packages/error-tools`, `packages/shared`).
+
+---
+
+## `packages/express-tools` — outillage Express générique
+
+Package séparé, réutilisable par n'importe quel service Express du monorepo (pas
+seulement `apps/api`) : pas de logique métier, juste de l'infra Express.
+
+### `ExpressServer` — init serveur, routes, middlewares
+
+Enveloppe une application Express derrière une API typée, au lieu que chaque
+service refasse le même `express()` à la main :
+
+```ts
+const server = new ExpressServer();
+server.setupCore({ corsOrigin: env.CORS_ORIGIN }); // cors + json + cookie-parser
+server.addRoute("get", "/health", (_req, res) => res.status(200).json({ status: "ok" }));
+server.mountRouter("/auth", authRouter);
+server.addMiddleware(notFoundHandler);
+server.setErrorHandler(createErrorMiddleware(errorHandlerService));
+server.listen(port, () => console.log(`Listening on ${port}`));
+```
+
+- `setupCore(options)` — middleware stack commun (CORS avec credentials, JSON,
+ cookies).
+- `addRoute(method, path, ...handlers)` — enregistre une route ; avertit et
+ ignore au lieu d'écraser silencieusement si la même route (méthode + chemin)
+ est déjà enregistrée.
+- `addMiddleware` / `mountRouter` / `setErrorHandler` — ajout de middleware
+ générique, montage d'un `Router` complet, middleware d'erreur final (4
+ arguments — doit être ajouté en dernier).
+- `.instance` — l'app Express brute, nécessaire pour les outils de test
+ (supertest) qui attendent une instance `Express`, pas le wrapper.
+- `.listen(port, onListening?)` — démarre le serveur.
+
+`apps/api/src/app.ts` expose deux fonctions : `createServer(): ExpressServer`
+(utilisée par `server.ts`, qui appelle `.listen()`) et `createApp(): Express`
+(= `createServer().instance`, utilisée par les tests).
+
+### `wrapAsyncHandler` — plus de try/catch répété dans les routes
+
+```ts
+router.post("/signup", wrapAsyncHandler(async (req, res) => {
+ const profile = await signup(req.body); // une erreur/rejet ici va automatiquement à next()
+ res.status(201).json(profile);
+}));
+```
+
+Sans ça, une exception dans un handler `async` ne remonte jamais tout seule au
+middleware d'erreur d'Express — chaque route devait faire son propre
+`try { ... } catch (err) { next(err); }`. `wrapAsyncHandler` l'automatise.
+
+### `createErrorMiddleware` — adaptateur Express pour `packages/error-tools`
+
+Voir [error-handling.md](./error-handling.md) pour le détail. `HttpError` et
+`ErrorHandlerService` vivent dans **`packages/error-tools`**, pas ici :
+`ErrorHandlerService` **n'a aucune dépendance à Express** — c'est un service
+générique `erreur → { status, body }` qui fonctionnerait à l'identique derrière
+Fastify ou n'importe quel autre framework, donc il n'a rien à faire dans un
+package *express*-tools. `ExpressServer` et `createErrorMiddleware` (ici) sont
+la vraie couche Express : elles adaptent des pièces indépendantes du framework
+(`ErrorHandlerService`, importé depuis `@batch-cooking/error-tools`) à l'API
+d'Express.
+
+---
+
+## Auth : `res.locals`, pas d'augmentation du namespace Express
+
+`requireAuth` (`apps/api/src/middlewares/require-auth.ts`) attache le profil
+authentifié à **`res.locals.userProfile`**, typé via l'interface `AuthLocals` :
+
+```ts
+export interface AuthLocals {
+ userProfile: SafeUserProfile;
+}
+
+export async function requireAuth(req: Request, res: Response, next: NextFunction) {
+ // ...
+ res.locals.userProfile = safeProfile;
+ next();
+}
+```
+
+Un handler derrière ce middleware type sa réponse `Response`
+et lit `res.locals.userProfile` sans cast :
+
+```ts
+authRouter.get("/me", requireAuth, (_req, res: Response) => {
+ res.status(200).json(res.locals.userProfile);
+});
+```
+
+**Pourquoi pas `declare global { namespace Express { interface Request {...} } }`**
+(l'approche initialement utilisée, retirée depuis) : `res.locals` est le
+mécanisme natif d'Express prévu exactement pour ça (faire passer des données
+d'un middleware au handler suivant), typé par route via un paramètre
+générique — pas une augmentation globale et permanente qui change
+silencieusement le type de **toutes** les `Request` du projet, qu'elles soient
+passées par ce middleware ou non.
+
+---
+
+## `packages/shared` — `assertIsNever`
+
+`packages/shared/src/tools/assert-is-never.ts` — vérification d'exhaustivité
+pour un `switch`/`if`-chain sur une union :
+
+```ts
+switch (shape.kind) {
+ case "circle": return Math.PI * shape.radius ** 2;
+ case "square": return shape.side ** 2;
+ default: return assertIsNever(shape); // erreur de compilation si un cas manque
+}
+```
+
+Si un membre de l'union n'est pas traité par une branche précédente, `shape`
+n'est plus de type `never` au niveau du `default` → **erreur de compilation**
+(vérifié : `tsc` rejette bien un cas manquant). Lève aussi une vraie erreur au
+runtime, en filet de sécurité si une valeur invalide échappe au système de
+types (ex. donnée externe non validée).
+
+Pas encore de point d'usage réel dans le code métier actuel (aucun
+switch/if-chain exhaustif sur une union n'existe encore) — prêt à l'emploi dès
+qu'un cas s'y prête (le module « Calcul batch-cooking » ou le pipeline d'import
+de recette, tous deux encore à construire, en auront probablement).
+
+---
+
+## Pas de fichiers `.d.ts` écrits à la main
+
+Voir [frontend-architecture.md](./frontend-architecture.md#note-sur-les-fichiers-dts)
+pour le détail côté `apps/web`. Côté `apps/api` : aucune augmentation de type
+globale (`declare global`) n'est utilisée — voir la section `res.locals`
+ci-dessus, qui est précisément ce qui aurait nécessité ce genre de fichier.
diff --git a/specs/batch-cooking-architecture.md b/specs/batch-cooking-architecture.md
index 9332a8a..1744b5f 100644
--- a/specs/batch-cooking-architecture.md
+++ b/specs/batch-cooking-architecture.md
@@ -70,3 +70,13 @@ Stockage de l'ensemble des données de l'application (voir le modèle de donnée
- Le module de calcul batch-cooking est le principal chantier restant côté serveur (TODO).
- Le websocket est utilisé pour la communication temps réel, en complément de l'API.
+
+---
+
+## Documents liés
+
+Documentation d'implémentation (ajoutée au fil des features, complète ce document
+conceptuel sans le remplacer) :
+
+- [error-handling.md](./error-handling.md) — contrat d'erreurs partagé entre l'API et le client
+- [frontend-architecture.md](./frontend-architecture.md) — organisation d'`apps/web`
diff --git a/specs/error-handling.md b/specs/error-handling.md
new file mode 100644
index 0000000..01f4800
--- /dev/null
+++ b/specs/error-handling.md
@@ -0,0 +1,176 @@
+# Gestion des erreurs — Projet Batch-cooking
+
+> Documentation du contrat d'erreurs partagé entre `apps/api` et `apps/web`.
+
+---
+
+## Vue d'ensemble
+
+Quatre pièces travaillent ensemble pour que **toute** erreur, du serveur jusqu'à
+l'affichage utilisateur, passe par un chemin unique et prévisible :
+
+- **`packages/shared`** — le contrat : `ErrorCode` (énumération **numérique** de
+ tous les codes d'erreur métier) et `ApiErrorResponse` (forme JSON de toute
+ réponse d'erreur de l'API). Ni l'API ni le web ne définissent leur propre liste
+ de codes, et aucune valeur n'est jamais codée en dur ailleurs (toujours
+ `ErrorCode.XXX`, jamais un nombre/une chaîne littérale).
+- **`packages/error-tools`** — package séparé, **indépendant de tout framework
+ HTTP** (n'importe pas `express`) : `HttpError`, `ErrorHandlerService`. Le mapping
+ « erreur → `{ status, body }` » n'a rien de spécifique à Express, donc il ne vit
+ pas dans `express-tools`.
+- **`packages/express-tools`** — package séparé pour l'outillage Express générique
+ (réutilisable par n'importe quel service Express du monorepo, pas seulement
+ `apps/api`) : `createErrorMiddleware` (adapte `ErrorHandlerService` à l'API
+ Express), `ExpressServer`, `wrapAsyncHandler`.
+- **`apps/api`** — consomme les deux : lève des `HttpError` (`error-tools`), le
+ middleware d'erreur final n'est qu'un appel à
+ `createErrorMiddleware(errorHandlerService)` (`express-tools`).
+- **`apps/web` → `ErrorMessageService`** — associe chaque `ErrorCode` à une clé de
+ traduction, résolue via **i18next** (fichiers de locale sous `src/locales/`).
+ Les composants n'écrivent jamais de texte d'erreur en dur.
+
+```mermaid
+flowchart LR
+ subgraph ERRTOOLS["packages/error-tools"]
+ HTTPERR["HttpError"]
+ EHS["ErrorHandlerService.handle()"]
+ end
+
+ subgraph TOOLS["packages/express-tools"]
+ MW["createErrorMiddleware()"]
+ end
+
+ subgraph API["apps/api"]
+ THROW["Route / service throw new HttpError(status, code, message)"]
+ THROW --> EHS
+ MW -->|"app.use(...)"| EHS
+ end
+
+ EHS -->|"JSON: { code, message, details? }"| HTTP["Réponse HTTP"]
+
+ subgraph WEB["apps/web"]
+ CLIENT["ApiClient lève ApiError(status, code, ...)"]
+ EMS["ErrorMessageService.getLabel(code)"]
+ I18N["i18next locales/fr/translation.json"]
+ UI["Composant (LoginPage, SignupPage...)"]
+ CLIENT --> EMS --> I18N --> UI
+ end
+
+ HTTP --> CLIENT
+
+ SHARED[("packages/shared ErrorCode (numérique), ApiErrorResponse")]
+ SHARED -. contrat .-> THROW
+ SHARED -. contrat .-> CLIENT
+ SHARED -. contrat .-> EMS
+
+ style SHARED fill:none,stroke:#888,stroke-width:1px
+ style ERRTOOLS fill:none,stroke:#888,stroke-width:1px
+ style TOOLS fill:none,stroke:#888,stroke-width:1px
+```
+
+---
+
+## Le contrat (`packages/shared/src/errors/error-codes.ts`)
+
+```ts
+enum ErrorCode {
+ VALIDATION_ERROR = 4000,
+ EMAIL_ALREADY_IN_USE = 4001,
+ INVALID_CREDENTIALS = 4010,
+ NOT_AUTHENTICATED = 4011,
+ NOT_FOUND = 4040,
+ INTERNAL_ERROR = 5000,
+}
+
+interface ApiErrorResponse {
+ code: ErrorCode;
+ message: string; // anglais, dev-facing — jamais affiché tel quel côté UI
+ details?: Record; // uniquement pour VALIDATION_ERROR
+}
+```
+
+**Codes numériques, groupés par famille** (comme les codes HTTP) : `4000`–`4099`
+validation, `4010`–`4019` authentification, `4040`–`4049` ressource introuvable,
+`5000`–`5099` interne. Le numéro donne une indication de la catégorie même sans
+regarder l'enum.
+
+**Règle** : `message` est destiné aux logs/au débogage (toujours en anglais, jamais
+localisé). Le texte affiché à l'utilisateur vient **toujours** de
+`ErrorMessageService.getLabel(code)` côté client, jamais de `message` directement.
+Et **aucune valeur `ErrorCode` n'est jamais écrite en dur** (ni en nombre, ni en
+chaîne) — toujours une référence `ErrorCode.XXX`, y compris dans les tests/mocks.
+
+Pour ajouter un nouveau cas d'erreur :
+1. Ajouter le membre dans `ErrorCode`, dans la bonne plage numérique.
+2. Le lever via `new HttpError(status, ErrorCode.XXX, "message dev-facing")`.
+3. Ajouter sa traduction dans **chaque** fichier `apps/web/src/locales/*/translation.json`, sous `errors.XXX`.
+
+---
+
+## `packages/error-tools` — les pièces liées aux erreurs, indépendantes du framework
+
+- **`http-error.ts`** — `HttpError` : erreur typée portant `status` (code HTTP) et
+ `code` (`ErrorCode`). C'est ce que lèvent les routes/services au lieu de
+ construire une réponse HTTP à la main.
+- **`error-handler.service.ts`** — `ErrorHandlerService` : un seul point qui sait
+ transformer n'importe quelle erreur JS (`ZodError`, `HttpError`, n'importe quoi
+ d'autre) en `{ status, body }`. Le cas générique (`INTERNAL_ERROR`, 500) logue
+ l'erreur côté serveur sans jamais exposer de détail interne au client.
+ **N'importe pas `express`** — c'est un service générique, indépendant du
+ framework HTTP, qui fonctionnerait à l'identique derrière Fastify ou autre. C'est
+ précisément pour ça qu'il vit dans son propre package plutôt que dans
+ `express-tools` : rien ici ne dépend d'Express, donc rien ici n'a sa place dans
+ un package *express*-tools.
+
+Build réel (`tsc` → `dist/`, comme `packages/shared`) : consommé en JS compilé,
+pas en TS brut — voir la note dans
+[frontend-architecture.md](./frontend-architecture.md#note-sur-les-fichiers-dts)
+sur pourquoi ça compte pour un runtime Node pur (Docker).
+
+## `packages/express-tools` — l'adaptateur Express
+
+`packages/express-tools` contient `ExpressServer` (init serveur, enregistrement
+de routes/middlewares) et `wrapAsyncHandler` — voir
+[backend-architecture.md](./backend-architecture.md) pour le détail complet du
+package. La pièce qui concerne spécifiquement les erreurs :
+
+- **`error-middleware.ts`** — `createErrorMiddleware(service: ErrorHandlerService)` :
+ construit le middleware d'erreur Express (signature à 4 arguments) à partir
+ d'un `ErrorHandlerService` importé de `@batch-cooking/error-tools` — c'est LUI
+ la vraie couche Express, `ErrorHandlerService` reste agnostique. `express-tools`
+ dépend de `error-tools`, jamais l'inverse.
+
+## Côté API (`apps/api`)
+
+- **`app.ts`** — le middleware d'erreur final est enregistré via
+ `server.setErrorHandler(createErrorMiddleware(errorHandlerService))` (voir
+ [backend-architecture.md](./backend-architecture.md) pour `ExpressServer`) ;
+ aucune logique de mapping n'y vit directement, tout est dans `error-tools`.
+- Les modules métier (`modules/auth/auth.service.ts`, `middlewares/require-auth.ts`)
+ importent `HttpError` depuis `@batch-cooking/error-tools` et `ErrorCode` depuis
+ `@batch-cooking/shared`.
+
+## Côté Web (`apps/web`)
+
+- **`api/client.ts`** — `ApiClient` : lève `ApiError` (porteur de `status`, `code`,
+ `fieldErrors`) pour toute réponse non-2xx.
+- **`services/error-message.service.ts`** — `ErrorMessageService` : convertit le
+ `ErrorCode` numérique reçu en nom de membre (`ErrorCode[code]`, ex. `4001` →
+ `"EMAIL_ALREADY_IN_USE"`), puis délègue la traduction à **i18next**
+ (`i18n.t(\`errors.${memberName}\`)`). N'a pas sa propre table de libellés — c'est
+ i18next + les fichiers de locale qui la portent.
+- **`i18n/i18n.ts`** + **`locales/fr/translation.json`** — configuration et
+ ressources i18next. Ajouter une langue = ajouter une entrée `resources.`
+ pointant vers un nouveau fichier de locale, sans toucher un seul composant.
+- Les pages (`LoginPage`, `SignupPage`) attrapent `ApiError`, récupèrent `err.code`,
+ et appellent `errorMessageService.getLabel(err.code)` pour l'afficher — jamais
+ `err.message`.
+
+## Validation côté formulaire (distincte du contrat d'erreurs API)
+
+Les schémas zod partagés (`packages/shared/src/schemas/auth.ts`) portent leurs
+propres messages en français, utilisés pour la validation **avant** l'appel réseau
+(retour instantané, aucun aller-retour serveur). C'est un mécanisme séparé du
+contrat `ErrorCode`/i18next : ces messages ne quittent jamais le navigateur, et ne
+vivent pas dans les fichiers de locale (ils sont dans `packages/shared`, consommé
+aussi par l'API qui ne dépend pas d'i18next).
diff --git a/specs/frontend-architecture.md b/specs/frontend-architecture.md
new file mode 100644
index 0000000..4348234
--- /dev/null
+++ b/specs/frontend-architecture.md
@@ -0,0 +1,145 @@
+# Architecture frontend — Projet Batch-cooking
+
+> Documentation de l'organisation d'`apps/web` : structure des dossiers, routing,
+> gestion des erreurs, et conventions de style (SCSS/theming).
+
+---
+
+## Structure des dossiers
+
+```
+apps/web/src/
+├── api/
+│ └── client.ts # ApiClient — appels fetch vers l'API (voir error-handling.md)
+├── i18n/
+│ └── i18n.ts # config i18next, importé une fois (main.tsx) pour son effet de bord
+├── locales/
+│ └── fr/translation.json # libellés français (errors.*, auth.*, home.*)
+├── services/
+│ └── error-message.service.ts # ErrorMessageService — code d'erreur → clé i18next
+├── features/
+│ └── auth/ # tout ce qui concerne l'authentification
+│ ├── AuthContext.tsx # état global (profil connecté, login/signup/logout)
+│ ├── RequireAuth.tsx # garde de route : redirige vers /login si non connecté
+│ ├── RedirectIfAuthenticated.tsx # garde de route inverse (pour /login, /signup)
+│ └── auth-form.scss # styles partagés par LoginPage et SignupPage
+├── pages/
+│ ├── LoginPage.tsx / .scss (via auth-form.scss, partagé)
+│ ├── SignupPage.tsx / .scss (via auth-form.scss, partagé)
+│ └── HomePage.tsx + HomePage.scss
+├── styles/
+│ ├── _theme.scss # tokens de design (couleurs, espacements, typographie)
+│ └── global.scss # reset minimal + import du theme — importé une seule fois (main.tsx)
+├── lib/
+│ └── zod-errors.ts # utilitaire : erreurs zod → { champ: message }
+├── App.tsx # table de routes
+└── main.tsx # point d'entrée : providers (Router, AuthProvider) + imports i18n/CSS globaux
+```
+
+**Règle de placement des styles** : un style spécifique à un seul composant/page vit
+dans un fichier `.scss` au même niveau que ce composant (`HomePage.tsx` +
+`HomePage.scss`). Un style partagé par plusieurs composants d'une même feature vit
+dans le dossier de la feature (`features/auth/auth-form.scss`, utilisé par
+`LoginPage` et `SignupPage`). Seuls le reset et les tokens globaux vivent dans
+`styles/`.
+
+---
+
+## Routing et gardes d'authentification
+
+```mermaid
+flowchart TB
+ START(("Visite de l'app"))
+ CHECK{"AuthProvider : GET /auth/me"}
+ START --> CHECK
+
+ CHECK -->|"200 (session valide)"| AUTHED["user défini"]
+ CHECK -->|"401 (pas de session)"| ANON["user = null"]
+
+ AUTHED --> ROUTE_HOME["/ → HomePage"]
+ AUTHED --> ROUTE_LOGIN_A["/login ou /signup"]
+ ROUTE_LOGIN_A -->|"RedirectIfAuthenticated"| ROUTE_HOME
+
+ ANON --> ROUTE_HOME_A["/"]
+ ROUTE_HOME_A -->|"RequireAuth"| ROUTE_LOGIN["/login"]
+ ANON --> ROUTE_LOGIN2["/login ou /signup → rendu normal"]
+```
+
+- `AuthContext` (`features/auth/AuthContext.tsx`) appelle `GET /auth/me` une seule
+ fois au montage pour restaurer la session depuis le cookie httpOnly — c'est ce qui
+ permet à un rechargement de page de garder l'utilisateur connecté.
+- `RequireAuth` et `RedirectIfAuthenticated` sont deux gardes de route
+ (`react-router-dom`) qui lisent cet état : la première protège `/`, la seconde
+ protège `/login` et `/signup` (redirige un utilisateur déjà connecté vers `/`).
+ Les deux affichent `null` tant que la vérification initiale est en cours, pour
+ éviter un flash de contenu suivi d'une redirection.
+
+---
+
+## Client API et gestion des erreurs
+
+Voir [error-handling.md](./error-handling.md) pour le détail du contrat d'erreurs
+partagé avec l'API. En résumé côté frontend :
+
+- `ApiClient` (`api/client.ts`) — classe avec instance unique exportée
+ (`apiClient`), enveloppe `fetch` avec `credentials: "include"` (requis pour que
+ le cookie de session httpOnly parte/revienne, l'API et le web étant sur des
+ origines différentes). Lève `ApiError` (porteuse du `code` d'erreur) pour toute
+ réponse non-2xx.
+- `ErrorMessageService` (`services/error-message.service.ts`) — convertit un `code`
+ d'erreur numérique en clé de traduction, résolue via i18next.
+
+---
+
+## i18n (internationalisation)
+
+**i18next** + **react-i18next** — pas de solution maison : tout le texte affiché
+(libellés de formulaire, boutons, messages d'erreur) vient de fichiers de locale
+JSON, jamais codé en dur dans un composant.
+
+- `i18n/i18n.ts` — initialise l'instance i18next (langue par défaut `fr`), importé
+ une seule fois pour son effet de bord dans `main.tsx`, avant le premier rendu.
+- `locales/fr/translation.json` — toutes les chaînes françaises, organisées par
+ namespace : `errors.*` (voir [error-handling.md](./error-handling.md)),
+ `auth.login.*` / `auth.signup.*`, `home.*`.
+- Dans un composant : `const { t } = useTranslation(); t("auth.login.title")`.
+- Ajouter une langue : créer `locales//translation.json` avec les mêmes clés,
+ ajouter `resources.` dans `i18n/i18n.ts` — aucun composant à toucher.
+
+---
+
+## Note sur les fichiers `.d.ts`
+
+Aucun fichier `.d.ts` écrit à la main dans `apps/web` : le
+`/// ` généré par défaut par Vite (habituellement
+`vite-env.d.ts`) est remplacé par `"types": ["vite/client"]` dans
+`tsconfig.app.json` — même effet (typage de `import.meta.env`, imports d'assets),
+sans fichier dédié.
+
+Côté `apps/api`, aucune augmentation de type globale n'est utilisée du tout — voir
+[backend-architecture.md](./backend-architecture.md#auth--reslocals-pas-daugmentation-du-namespace-express)
+: le profil authentifié passe par `res.locals` (mécanisme natif d'Express), pas
+par un `declare global` sur `Express.Request`.
+
+---
+
+## SCSS et theming
+
+- **`sass`** (Dart Sass) est utilisé via le support natif de Vite — aucune config
+ supplémentaire needed au-delà d'avoir le package installé (`vite.config.ts` fixe
+ juste l'API moderne de Sass pour éviter un warning de dépréciation).
+- **`styles/_theme.scss`** — tokens de design exposés en **custom properties CSS**
+ sur `:root` (`--color-primary`, `--space-md`, etc.), pas en simples variables
+ SCSS : ça les rend disponibles au runtime, pas seulement à la compilation — ce qui
+ permettrait un futur switch de thème (ex. mode sombre) en redéfinissant juste ces
+ variables, sans reconstruire les feuilles de style. Toute nouvelle règle CSS doit
+ référencer `var(--token)`, jamais une couleur/valeur en dur.
+- **`styles/global.scss`** — importé une seule fois, dans `main.tsx`. Contient
+ uniquement le reset minimal et l'import du thème (`@use "./theme"`). Rien de
+ spécifique à une page/un composant n'y va.
+- Les tokens étant des **custom properties CSS** (pas des variables Sass), ils sont
+ disponibles globalement au runtime dès que `global.scss` a été chargé une fois —
+ un fichier `.scss` de composant/page les consomme directement via `var(--token)`,
+ sans avoir besoin de `@use` le partiel theme (ce serait un import sans effet,
+ puisqu'aucun symbole Sass n'en est consommé). Chaque fichier documente en
+ commentaire à quoi correspond chaque règle un peu non-triviale.