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 (

batchCooking

-

- Bonjour {user?.firstName} {user?.lastName} 👋 -

+

{t("home.greeting", { firstName: user?.firstName, lastName: user?.lastName })}

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

Se connecter

+

{t("auth.login.title")}

- + {fieldErrors.email &&

{fieldErrors.email}

} - + {formError}

}

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

Créer un profil

+

{t("auth.signup.title")}

- + {fieldErrors.firstName &&

{fieldErrors.firstName}

} - + {fieldErrors.lastName &&

{fieldErrors.lastName}

} - + {fieldErrors.email &&

{fieldErrors.email}

} - + {formError}

}

- Déjà un compte ? Se connecter + {t("auth.signup.hasAccount")} {t("auth.signup.loginLink")}

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.