diff --git a/README.md b/README.md index c845a53..1e268c2 100644 --- a/README.md +++ b/README.md @@ -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). 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/health.steps.ts b/apps/api/features/step-definitions/health.steps.ts index 296733f..5094898 100644 --- a/apps/api/features/step-definitions/health.steps.ts +++ b/apps/api/features/step-definitions/health.steps.ts @@ -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); +}); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 4d6a230..3ac5348 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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; 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 index b1931ad..bfc5bd5 100644 --- a/apps/api/src/lib/http-error.ts +++ b/apps/api/src/lib/http-error.ts @@ -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; } } 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..f10bf81 100644 --- a/apps/api/src/middlewares/require-auth.ts +++ b/apps/api/src/middlewares/require-auth.ts @@ -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")); } } } diff --git a/apps/api/src/modules/auth/auth.routes.ts b/apps/api/src/modules/auth/auth.routes.ts index ee6c5e6..83760a2 100644 --- a/apps/api/src/modules/auth/auth.routes.ts +++ b/apps/api/src/modules/auth/auth.routes.ts @@ -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); }); diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index e5044ce..1fc9482 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -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; +/** 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; 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 +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 { 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/services/error-handler.service.ts b/apps/api/src/services/error-handler.service.ts new file mode 100644 index 0000000..ddf8df9 --- /dev/null +++ b/apps/api/src/services/error-handler.service.ts @@ -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(); diff --git a/apps/api/src/types/express.d.ts b/apps/api/src/types/express.d.ts index a3db624..61290ec 100644 --- a/apps/api/src/types/express.d.ts +++ b/apps/api/src/types/express.d.ts @@ -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 { diff --git a/apps/api/test/auth.test.ts b/apps/api/test/auth.test.ts index a3523bc..aab4118 100644 --- a/apps/api/test/auth.test.ts +++ b/apps/api/test/auth.test.ts @@ -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 () => { diff --git a/apps/web/cypress/e2e/auth.cy.ts b/apps/web/cypress/e2e/auth.cy.ts index 3b486f6..b2fa37a 100644 --- a/apps/web/cypress/e2e/auth.cy.ts +++ b/apps/web/cypress/e2e/auth.cy.ts @@ -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"); diff --git a/apps/web/cypress/e2e/smoke.cy.ts b/apps/web/cypress/e2e/smoke.cy.ts index 7d4d225..b26544b 100644 --- a/apps/web/cypress/e2e/smoke.cy.ts +++ b/apps/web/cypress/e2e/smoke.cy.ts @@ -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"); diff --git a/apps/web/package.json b/apps/web/package.json index 484ffbe..bb10d80 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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" 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..32a476c 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -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; + /** 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; + throw new ApiError( + response.status, + body ?? { code: "INTERNAL_ERROR" as ErrorCode, 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/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/main.tsx b/apps/web/src/main.tsx index 2838b41..40db779 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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) { 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..ea5e329 100644 --- a/apps/web/src/pages/HomePage.tsx +++ b/apps/web/src/pages/HomePage.tsx @@ -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"); diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx index 8929538..e49fce3 100644 --- a/apps/web/src/pages/LoginPage.tsx +++ b/apps/web/src/pages/LoginPage.tsx @@ -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>({}); 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 +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); } diff --git a/apps/web/src/pages/SignupPage.tsx b/apps/web/src/pages/SignupPage.tsx index 9c3f448..a6b0a5d 100644 --- a/apps/web/src/pages/SignupPage.tsx +++ b/apps/web/src/pages/SignupPage.tsx @@ -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>({}); 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 +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); } 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..caf3d67 --- /dev/null +++ b/apps/web/src/services/error-message.service.ts @@ -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>; + +/** + * 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(); 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/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/shared/src/errors/error-codes.ts b/packages/shared/src/errors/error-codes.ts new file mode 100644 index 0000000..a59082a --- /dev/null +++ b/packages/shared/src/errors/error-codes.ts @@ -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; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 57162f1..b0017b4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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"; 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/types/user-profile.ts b/packages/shared/src/types/user-profile.ts index e002cae..f4c0056 100644 --- a/packages/shared/src/types/user-profile.ts +++ b/packages/shared/src/types/user-profile.ts @@ -1,12 +1,19 @@ -// 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; 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; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 119c394..b54cba6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: 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..581947e --- /dev/null +++ b/specs/error-handling.md @@ -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
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
lève ApiError(status, code, ...)"] + EMS["ErrorMessageService.getLabel(code)"] + UI["Composant (LoginPage, SignupPage...)"] + CLIENT --> EMS --> UI + end + + HTTP --> CLIENT + + SHARED[("packages/shared
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; // 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>`). + 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. diff --git a/specs/frontend-architecture.md b/specs/frontend-architecture.md new file mode 100644 index 0000000..46f5913 --- /dev/null +++ b/specs/frontend-architecture.md @@ -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 :
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.