Centralize error handling (shared codes + API/client services), code quality pass

## Error handling

Requested: a centralized error-handling service on the API, custom error
codes shared across apps, and a client-side error service for i18n labels.

- packages/shared/src/errors/error-codes.ts — ErrorCode enum + ApiErrorResponse
  contract. Single source of truth: neither side hardcodes a raw error string
  the other has to guess at.
- apps/api: HttpError now carries an ErrorCode (not just a message).
  ErrorHandlerService (new) centralizes every "how do we turn a thrown error
  into an HTTP response" decision — app.ts's error middleware is now a thin
  adapter calling into it. API messages reverted to English/dev-facing (they
  were French from an earlier pass) since user-facing text is now generated
  client-side from the code.
- apps/web: ApiClient (class, singleton instance) throws ApiError carrying
  the code. ErrorMessageService (new) maps every ErrorCode to a localized
  label, structured with a Locale type from the start (only "fr" exists, but
  adding a language later is "add a locale to the map", not "hunt down every
  hardcoded string"). LoginPage/SignupPage now display
  errorMessageService.getLabel(err.code), never err.message directly.
- Tests strengthened to assert on `code`, not just HTTP status (Mocha +
  Cucumber, new "the response error code should be" step). Cypress mocks
  updated to the new {code, message} response shape.

## Code quality pass

Per explicit feedback: heavy JSDoc on every interface/type/class/function/
method/member touched in this PR, explicit public/private visibility on
every class member (ApiClient, ErrorMessageService, ErrorHandlerService,
HttpError), no HTML/logic mixing (styling extracted out of components
entirely, never inline).

ApiClient/ErrorMessageService were initially written as static-only classes;
switched to instance-based singletons (matching ErrorHandlerService's
existing pattern) after Biome's noStaticOnlyClass rule flagged the
static-only shape as an anti-pattern — same "class with visibility
modifiers" outcome, without fighting the linter.

## SCSS + theming

- apps/web/src/styles/_theme.scss — design tokens as CSS custom properties
  on :root (colors, spacing, typography), not plain Sass variables — makes
  them available at runtime, not just compile time, so a future theme
  switch (e.g. dark mode) is "redefine these variables" rather than
  rebuilding stylesheets.
- apps/web/src/styles/global.scss replaces the old single index.css:
  reset + theme import only, loaded once from main.tsx.
- Per-page/component styles colocated (HomePage.tsx + HomePage.scss);
  styles shared by multiple pages within one feature live in that feature's
  folder (features/auth/auth-form.scss, used by both Login/SignupPage) —
  not duplicated per page, not dumped in the global stylesheet either.
- Component-level .scss files intentionally don't `@use` the theme
  partial: they only consume CSS custom properties (global at runtime via
  global.scss), not Sass-level symbols, so importing it would do nothing —
  documented inline rather than left as a silently-redundant import.
- vite.config.ts opts into Sass's modern compiler API to silence a
  legacy-js-api deprecation warning on every build.

## specs/ updates

- New specs/error-handling.md — the ErrorCode/ApiErrorResponse contract,
  both services, with a flow diagram.
- New specs/frontend-architecture.md — apps/web folder structure, routing/
  auth-guard flow, SCSS/theming conventions.
- specs/batch-cooking-architecture.md links to both (original doc content
  otherwise untouched — it's the user's own hand-authored source doc).

## Verification

Full lint/mocha/cucumber/build green. Manually re-verified the whole auth
flow in a real browser against native dev servers (not just the automated
suites): signup, the EMAIL_ALREADY_IN_USE → "Cet email est déjà utilisé"
translation end-to-end (confirmed the raw API response carries the English
dev message + code, and the UI shows the French label), wrong-password
INVALID_CREDENTIALS → its label, and confirmed the theme tokens actually
apply (computed button background-color matches --color-primary, card
max-width matches the token value) rather than trusting the build succeeding.
This commit is contained in:
Nicolas 2026-08-16 15:44:09 +02:00
parent 3ad854a269
commit e9d94ff5f9
42 changed files with 1193 additions and 182 deletions

View file

@ -8,8 +8,11 @@ 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`,
`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. A un vrai
build (`tsc` → `dist/`, voir son `package.json`) : consommé en JS compilé par
l'API (runtime Node pur, pas de transpilation à la volée) comme par le web (Vite).
## Prérequis
@ -155,20 +158,36 @@ 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`, `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 `ErrorMessageService` (`apps/web/src/services/error-message.service.ts`).
Côté API, `ErrorHandlerService` (`apps/api/src/services/error-handler.service.ts`)
centralise la transformation de toute erreur levée en réponse HTTP conforme.
Détail complet (schéma, exemples, comment ajouter un nouveau code d'erreur) :
[specs/error-handling.md](specs/error-handling.md).

View file

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

View file

@ -14,3 +14,10 @@ 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.
Then("the response error code should be {string}", function (this: CustomWorld, code: string) {
assert.equal(this.response.body.code, code);
});

View file

@ -1,16 +1,24 @@
import { ErrorCode } from "@batch-cooking/shared";
import cookieParser from "cookie-parser";
import cors from "cors";
import express, { type NextFunction, type Request, type Response } from "express";
import { ZodError } from "zod";
import { env } from "./config/env.js";
import { HttpError } from "./lib/http-error.js";
import { authRouter } from "./modules/auth/auth.routes.js";
import { errorHandlerService } from "./services/error-handler.service.js";
// Application factory. Feature modules are added under src/modules/* as
// specs land; auth is the first one (login page / profile creation).
/**
* Builds a fresh Express application instance (no shared mutable state
* between calls used both by the real server entrypoint and by tests,
* which each get their own app via supertest).
*
* Feature modules are mounted under `/`-prefixed routers as specs land;
* `auth` is the first one (login page / profile creation).
*/
export function createApp() {
const app = express();
// Frontend and API run on different origins — `credentials: true` is
// required for the httpOnly session cookie to be sent/received.
app.use(cors({ origin: env.CORS_ORIGIN, credentials: true }));
app.use(express.json());
app.use(cookieParser());
@ -21,21 +29,18 @@ export function createApp() {
app.use("/auth", authRouter);
// No route matched — same shape as every other error response, via the
// shared ErrorCode contract, so clients never special-case 404s.
app.use((_req: Request, res: Response) => {
res.status(404).json({ error: "Ressource introuvable" });
res.status(404).json({ code: ErrorCode.NOT_FOUND, message: "Not found" });
});
// 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 — this stays a thin adapter.
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" });
const { status, body } = errorHandlerService.handle(err);
res.status(status).json(body);
});
return app;

View file

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

View file

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

View file

@ -1,11 +1,30 @@
/** 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;
import type { ErrorCode } from "@batch-cooking/shared";
constructor(status: number, message: string) {
/**
* 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 `ErrorHandlerService` is the single
* place that turns it into an actual HTTP response, so every error path in
* the API is shaped consistently. See `services/error-handler.service.ts`.
*/
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;
}
}

View file

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

View file

@ -1,24 +1,36 @@
import { ErrorCode } 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). */
/**
* 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 `req.userProfile` (see
* `src/types/express.d.ts`) for downstream handlers to use.
*
* @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;
@ -29,7 +41,7 @@ export async function requireAuth(req: Request, _res: Response, next: NextFuncti
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"));
}
}
}

View file

@ -5,20 +5,25 @@ import { env } from "../../config/env.js";
import { 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,
};
/** Creates a profile (+ its household) and logs the new user in immediately. */
authRouter.post("/signup", async (req, res, next) => {
try {
const input = signupSchema.parse(req.body);
@ -30,6 +35,7 @@ authRouter.post("/signup", async (req, res, next) => {
}
});
/** Verifies credentials and starts a new session. */
authRouter.post("/login", async (req, res, next) => {
try {
const input = loginSchema.parse(req.body);
@ -41,11 +47,13 @@ authRouter.post("/login", async (req, res, next) => {
}
});
/** 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();
});
/** Returns the currently authenticated profile. Behind requireAuth — 401s if there's no valid session. */
authRouter.get("/me", requireAuth, (req, res) => {
res.status(200).json(req.userProfile);
});

View file

@ -1,4 +1,4 @@
import type { LoginInput, SignupInput } from "@batch-cooking/shared";
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";
@ -6,8 +6,15 @@ 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<UserProfile, "passwordHash">;
/** Result of a successful signup/login: the safe profile plus the signed session JWT to set as a cookie. */
interface AuthResult {
profile: SafeProfile;
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 +23,22 @@ type SafeProfile = Omit<UserProfile, "passwordHash">;
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<AuthResult> {
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 +65,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<AuthResult> {
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 });

View file

@ -0,0 +1,73 @@
import { type ApiErrorResponse, ErrorCode } from "@batch-cooking/shared";
import { ZodError } from "zod";
import { HttpError } from "../lib/http-error.js";
/** Return value of {@link ErrorHandlerService.handle}: everything an Express error middleware needs to send a 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 for the API in one place, so route handlers and the Express
* error middleware in `app.ts` never duplicate this logic.
*
* 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();

View file

@ -1,5 +1,8 @@
import type { UserProfile } from "@prisma/client";
// Module augmentation: adds a `userProfile` field to Express's Request type
// so `requireAuth` can attach the authenticated profile and downstream
// handlers get it fully typed, without an `as` cast at every call site.
declare global {
namespace Express {
interface Request {

View file

@ -1,9 +1,11 @@
import { ErrorCode } from "@batch-cooking/shared";
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";
/** Valid signup payload reused across tests. */
const validSignup = {
firstName: "Nicolas",
lastName: "Lefevre",
@ -37,19 +39,22 @@ describe("Auth", () => {
expect(res.headers["set-cookie"]?.[0]).to.include("session=");
});
it("rejects a duplicate email with 409", async () => {
it("rejects a duplicate email with 409 EMAIL_ALREADY_IN_USE", async () => {
await request(app).post("/auth/signup").send(validSignup);
const res = await request(app).post("/auth/signup").send(validSignup);
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 () => {
it("rejects an invalid payload with 400 VALIDATION_ERROR", async () => {
const res = await request(app)
.post("/auth/signup")
.send({ firstName: "X", lastName: "Y", 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"]);
});
});
@ -67,27 +72,31 @@ describe("Auth", () => {
expect(res.body.email).to.equal(validSignup.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" });
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 });
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 () => {

View file

@ -50,7 +50,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: "EMAIL_ALREADY_IN_USE", message: "Email already in use" },
}).as("signup");
cy.visit("/signup");
@ -94,7 +94,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: "INVALID_CREDENTIALS", message: "Invalid email or password" },
}).as("login");
cy.visit("/login");

View file

@ -1,6 +1,9 @@
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: "NOT_AUTHENTICATED", message: "Not authenticated" },
});
cy.visit("/");
cy.url().should("include", "/login");
cy.contains("h1", "Se connecter").should("be.visible");

View file

@ -25,6 +25,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"

View file

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

View file

@ -1,51 +1,97 @@
import type { LoginInput, SafeUserProfile, SignupInput } from "@batch-cooking/shared";
import type {
ApiErrorResponse,
ErrorCode,
LoginInput,
SafeUserProfile,
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<string, string[] | undefined>;
/** 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<string, string[] | undefined>;
constructor(status: number, message: string, fieldErrors?: Record<string, string[] | undefined>) {
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<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_URL}${path}`, {
/**
* 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<TResponseBody>(
path: string,
options: RequestInit = {},
): Promise<TResponseBody> {
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.
// 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;
throw new ApiError(
response.status,
body ?? { code: "INTERNAL_ERROR" as ErrorCode, message: "Something went wrong" },
);
}
if (res.status === 204) {
return undefined as T;
// 204 No Content (e.g. logout) has no body to parse.
if (response.status === 204) {
return undefined as TResponseBody;
}
return response.json() as Promise<TResponseBody>;
}
/** Creates a profile (+ household) and starts a session. */
public signup(input: SignupInput): Promise<SafeUserProfile> {
return this.request("/auth/signup", { method: "POST", body: JSON.stringify(input) });
}
/** Verifies credentials and starts a session. */
public login(input: LoginInput): Promise<SafeUserProfile> {
return this.request("/auth/login", { method: "POST", body: JSON.stringify(input) });
}
/** Ends the current session. */
public logout(): Promise<void> {
return this.request("/auth/logout", { method: "POST" });
}
/** Fetches the currently authenticated profile — rejects with `NOT_AUTHENTICATED` if there's no session. */
public me(): Promise<SafeUserProfile> {
return this.request("/auth/me");
}
return res.json() as Promise<T>;
}
export function signup(input: SignupInput): Promise<SafeUserProfile> {
return request("/auth/signup", { method: "POST", body: JSON.stringify(input) });
}
export function login(input: LoginInput): Promise<SafeUserProfile> {
return request("/auth/login", { method: "POST", body: JSON.stringify(input) });
}
export function logout(): Promise<void> {
return request("/auth/logout", { method: "POST" });
}
export function me(): Promise<SafeUserProfile> {
return request("/auth/me");
}
/** Single shared instance — this client is stateless, no need for one per caller. */
export const apiClient = new ApiClient();

View file

@ -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<void>;
/** Verifies credentials and updates `user` on success. Throws `ApiError` on failure. */
login: (input: LoginInput) => Promise<void>;
/** Ends the session and clears `user`. */
logout: () => Promise<void>;
}
/** React context carrying {@link AuthContextValue} — always accessed through {@link useAuth}, never directly. */
const AuthContext = createContext<AuthContextValue | null>(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<SafeUserProfile | null>(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) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,7 +3,9 @@ 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";
// 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) {

View file

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

View file

@ -1,10 +1,16 @@
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.
*/
export function HomePage() {
const { user, logout } = useAuth();
const navigate = useNavigate();
/** Ends the session and returns to the login page. */
async function handleLogout() {
await logout();
navigate("/login");

View file

@ -1,20 +1,37 @@
import { loginSchema } from "@batch-cooking/shared";
import { ErrorCode, loginSchema } from "@batch-cooking/shared";
import { type FormEvent, useState } from "react";
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}.
*/
export function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
// 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<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(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 +48,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);
}

View file

@ -1,22 +1,39 @@
import { signupSchema } from "@batch-cooking/shared";
import { ErrorCode, signupSchema } from "@batch-cooking/shared";
import { type FormEvent, useState } from "react";
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}.
*/
export function SignupPage() {
const { signup } = useAuth();
const navigate = useNavigate();
// 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<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(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 +50,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);
}

View file

@ -0,0 +1,53 @@
import { ErrorCode } from "@batch-cooking/shared";
/** Locales this service knows how to label errors in. Extend here when adding a new language. */
export type Locale = "fr";
/** Per-locale map of every {@link ErrorCode} to its user-facing label. */
type LabelsByLocale = Record<Locale, Record<ErrorCode, string>>;
/**
* Centralizes every user-facing error label in the app, keyed by the
* {@link ErrorCode} the API returns. Components never hardcode error text
* they call `errorMessageService.getLabel(...)` and get back the right
* string for the current locale.
*
* Only French exists today (the whole UI is French), but the `Locale`
* type and the per-locale label map exist so adding a second language
* later is "add a locale to the map", not "hunt down every hardcoded
* string in every component". Used as a single shared instance
* (`errorMessageService`, exported below).
*/
export class ErrorMessageService {
/** Locale used when none is explicitly requested — the only one that exists today. */
private readonly defaultLocale: Locale = "fr";
/** Every known error code's label, per locale. */
private readonly labels: LabelsByLocale = {
fr: {
[ErrorCode.VALIDATION_ERROR]: "Erreur de validation",
[ErrorCode.EMAIL_ALREADY_IN_USE]: "Cet email est déjà utilisé",
[ErrorCode.INVALID_CREDENTIALS]: "Email ou mot de passe incorrect",
[ErrorCode.NOT_AUTHENTICATED]: "Vous devez être connecté",
[ErrorCode.NOT_FOUND]: "Ressource introuvable",
[ErrorCode.INTERNAL_ERROR]: "Une erreur est survenue, réessayez plus tard",
},
};
/**
* Returns the localized, user-facing label for a given error code.
*
* @param code - Error code as returned by the API. Typed as `string` (not
* strictly `ErrorCode`) because it's coming off the network an
* unrecognized value falls back to the generic "internal error" label
* instead of throwing.
* @param locale - Defaults to {@link defaultLocale}.
*/
public getLabel(code: string, locale: Locale = this.defaultLocale): string {
const labelsForLocale = this.labels[locale];
return labelsForLocale[code as ErrorCode] ?? labelsForLocale[ErrorCode.INTERNAL_ERROR];
}
}
/** Single shared instance — this service is stateless, no need for one per caller. */
export const errorMessageService = new ErrorMessageService();

View file

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

View file

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

View file

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

View file

@ -0,0 +1,45 @@
/**
* 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`). Neither side should
* ever hardcode a raw error string that the other side has to guess at
* the code is the contract.
*
* When adding a new failure case in the API:
* 1. Add a new member here.
* 2. Throw it via `HttpError` (apps/api/src/lib/http-error.ts).
* 3. Add its translation in `ErrorMessageService` (apps/web).
*/
export enum ErrorCode {
/** Request body/query failed zod schema validation. */
VALIDATION_ERROR = "VALIDATION_ERROR",
/** Signup attempted with an email that already has a profile. */
EMAIL_ALREADY_IN_USE = "EMAIL_ALREADY_IN_USE",
/** Login failed — wrong email or wrong password (never say which). */
INVALID_CREDENTIALS = "INVALID_CREDENTIALS",
/** Request required a session cookie/JWT that is missing, invalid, or stale. */
NOT_AUTHENTICATED = "NOT_AUTHENTICATED",
/** No route/resource matches the request. */
NOT_FOUND = "NOT_FOUND",
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
INTERNAL_ERROR = "INTERNAL_ERROR",
}
/**
* 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<string, string[] | undefined>;
}

View file

@ -1,2 +1,8 @@
// 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 "./types/user-profile.js";

View file

@ -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<typeof signupSchema>;
/** 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<typeof loginSchema>;

View file

@ -1,12 +1,19 @@
// Mirrors apps/api's Omit<PrismaUserProfile, "passwordHash"> — 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<PrismaUserProfile,
* "passwordHash">` — 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;
firstName: string;
lastName: string;
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;
}

View file

@ -117,10 +117,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 +132,7 @@ 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/shared:
dependencies:
@ -708,6 +711,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 +1263,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'}
@ -1704,6 +1787,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 +2225,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'}
@ -2247,6 +2337,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 +2385,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}
@ -3193,6 +3292,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 +3583,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 +3591,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 +3810,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: {}
@ -4282,6 +4442,8 @@ snapshots:
ieee754@1.2.1: {}
immutable@5.1.9: {}
indent-string@4.0.0: {}
indent-string@5.0.0: {}
@ -4656,6 +4818,9 @@ snapshots:
picomatch@2.3.2: {}
picomatch@4.0.5:
optional: true
pify@2.3.0: {}
postcss@8.5.26:
@ -4766,6 +4931,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 +4998,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
@ -5147,7 +5322,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 +5330,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:

View file

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

112
specs/error-handling.md Normal file
View file

@ -0,0 +1,112 @@
# Gestion des erreurs — Projet Batch-cooking
> Documentation du contrat d'erreurs partagé entre `apps/api` et `apps/web`.
---
## Vue d'ensemble
Trois 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 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.
- **`apps/api``ErrorHandlerService`** — centralise la traduction de n'importe
quelle erreur levée (validation zod, `HttpError` métier, erreur inattendue) en
`{ status, body }` conforme au contrat. Le middleware d'erreur d'Express
(`app.ts`) ne fait qu'appeler ce service.
- **`apps/web``ErrorMessageService`** — centralise la traduction de chaque
`ErrorCode` en libellé affichable, avec un système de locale (`fr` aujourd'hui,
extensible). Les composants n'écrivent jamais de texte d'erreur en dur.
```mermaid
flowchart LR
subgraph API["apps/api"]
THROW["Route / service<br/>throw new HttpError(status, code, message)"]
EHS["ErrorHandlerService.handle()"]
THROW --> EHS
end
EHS -->|"JSON: { code, message, details? }"| HTTP["Réponse HTTP"]
subgraph WEB["apps/web"]
CLIENT["ApiClient<br/>lève ApiError(status, code, ...)"]
EMS["ErrorMessageService.getLabel(code)"]
UI["Composant (LoginPage, SignupPage...)"]
CLIENT --> EMS --> UI
end
HTTP --> CLIENT
SHARED[("packages/shared<br/>ErrorCode, ApiErrorResponse")]
SHARED -. contrat .-> THROW
SHARED -. contrat .-> CLIENT
SHARED -. contrat .-> EMS
style SHARED fill:none,stroke:#888,stroke-width:1px
```
---
## Le contrat (`packages/shared/src/errors/error-codes.ts`)
```ts
enum ErrorCode {
VALIDATION_ERROR,
EMAIL_ALREADY_IN_USE,
INVALID_CREDENTIALS,
NOT_AUTHENTICATED,
NOT_FOUND,
INTERNAL_ERROR,
}
interface ApiErrorResponse {
code: ErrorCode;
message: string; // anglais, dev-facing — jamais affiché tel quel côté UI
details?: Record<string, string[] | undefined>; // uniquement pour VALIDATION_ERROR
}
```
**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.
Pour ajouter un nouveau cas d'erreur :
1. Ajouter le membre dans `ErrorCode`.
2. Le lever via `new HttpError(status, ErrorCode.XXX, "message dev-facing")`.
3. Ajouter sa traduction dans `ErrorMessageService.LABELS.fr`.
---
## Côté API (`apps/api`)
- **`lib/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.
- **`services/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.
- **`app.ts`** — le middleware d'erreur final d'Express ne fait qu'appeler
`errorHandlerService.handle(err)` et renvoyer le résultat ; aucune logique de
mapping n'y vit directement.
## 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` : associe chaque
`ErrorCode` à un libellé, par locale (`Record<Locale, Record<ErrorCode, string>>`).
Une seule langue existe aujourd'hui (`fr`), mais la structure est prête pour en
ajouter une deuxième sans toucher aux composants.
- 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 déjà des
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` : ces messages ne quittent jamais le navigateur.

View file

@ -0,0 +1,110 @@
# 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)
├── services/
│ └── error-message.service.ts # ErrorMessageService — libellés d'erreur i18n
├── 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) + import du CSS global
```
**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 :<br/>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`) — traduit un `code`
d'erreur en libellé affichable, avec support de locale (`fr` uniquement pour
l'instant).
---
## 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.