Address review: no .d.ts, express-tools package, faker fixtures, numeric codes, real i18n lib
Five explicit review points, addressed on this same PR branch (not a new PR) per updated preference. ## No .d.ts files in the codebase - apps/web: vite-env.d.ts removed — its /// <reference types="vite/client" /> is replaced by "types": ["vite/client"] in tsconfig.app.json, same effect. - apps/api: src/types/express.d.ts renamed to express-request.augment.ts — `declare global` module augmentation works identically in a plain .ts file as long as it has a top-level import (making it a module); the .d.ts extension wasn't doing anything for us here. ## packages/express-tools — separate package for Express tooling Moved HttpError and ErrorHandlerService out of apps/api into a new workspace package, plus a new createErrorMiddleware() factory (the actual Express 4-arg error-handling middleware, previously inlined in app.ts). apps/api now just consumes @batch-cooking/express-tools. Has a real build (tsc -> dist/, same pattern as packages/shared) — required for the same reason shared needed one: apps/api's Docker image runs plain `node dist/server.js`, no tsx. apps/api/Dockerfile updated to COPY the new package's dist alongside shared's. ## faker.js for test fixtures apps/api/test/auth.test.ts: replaced the hardcoded "Nicolas Lefevre"/nicolas@example.com fixture (looked like real user data) with @faker-js/faker, generated fresh per test via buildSignupPayload(). features/step-definitions/auth.steps.ts: fakerized the filler firstName/lastName/password used for background state the scenarios don't actually read. Deliberately did NOT fakerize the literal example values inside auth.feature itself (alice@example.com etc.) — those are the readable, illustrative Gherkin examples that are the whole point of BDD scenarios, not real PII, and randomizing them would make the scenarios harder to read for no real gain. Flagged this reasoning in the README in case that call should go the other way. Caught a real bug while wiring this up: faker.internet.email() sometimes capitalizes parts of the address, but signupSchema/loginSchema normalize emails to lowercase — the test fixture needs to match what's actually stored, so buildSignupPayload() lowercases the generated email too. Found by actually running the suite repeatedly, not just once. ## ErrorCode: numeric enum, zero hardcoded values packages/shared/src/errors/error-codes.ts: ErrorCode is now a numeric enum (4000 VALIDATION_ERROR, 4001 EMAIL_ALREADY_IN_USE, 4010 INVALID_CREDENTIALS, 4011 NOT_AUTHENTICATED, 4040 NOT_FOUND, 5000 INTERNAL_ERROR — grouped by family like HTTP status codes). Audited and fixed every place that hardcoded a raw code value instead of referencing the enum: ApiClient's fallback (`"INTERNAL_ERROR" as ErrorCode` — would no longer even type-check once the enum went numeric, which is exactly the point), and the Cypress mock bodies (now import ErrorCode from @batch-cooking/shared instead of typing the string). Cucumber's "the response error code should be {string}" step still takes the *name* in the .feature file (readable: "EMAIL_ALREADY_IN_USE") and resolves it to the real numeric value via ErrorCode[name] — TypeScript's reverse enum mapping — before comparing, so the Gherkin stays readable without the step hardcoding a number either. ## Real i18n library (i18next), not a hand-rolled label map apps/web: added i18next + react-i18next. New locales/fr/translation.json holds every user-facing string — not just error labels (errors.*), but the login/signup/home pages' labels, buttons and headings too (auth.login.*, auth.signup.*, home.*) — via useTranslation()/t() in each page. ErrorMessageService no longer owns its own label map; it converts the numeric ErrorCode to its enum member name and delegates the actual lookup to i18next (errors.<MEMBER_NAME>). Adding a language is now "add a locale file", not a code change anywhere. ## specs/ and README updated specs/error-handling.md and specs/frontend-architecture.md rewritten for the new package, numeric codes, and i18next. New "i18n" and "no .d.ts" sections. README covers the same, plus a note on the faker.js scope decision (feature-file literals excluded, on purpose). ## Verification Full lint/mocha (x3 runs)/cucumber/build green. Re-verified express-tools' extraction against a real risk (not just tsc passing): ran `node dist/server.js` standalone (mirrors the Docker runtime, no tsx) and hit /health, a 404 (confirmed numeric code 4040 over the wire), and a real signup + duplicate-email 409 (confirmed numeric 4001). Then re-verified the full pipeline in a real browser against native dev servers: signup, EMAIL_ALREADY_IN_USE -> i18next -> "Cet email est déjà utilisé" end-to-end, home page i18next interpolation ({{firstName}}/{{lastName}}) rendering correctly.
This commit is contained in:
parent
e9d94ff5f9
commit
1cedb25d74
33 changed files with 578 additions and 179 deletions
51
README.md
51
README.md
|
|
@ -8,11 +8,17 @@ Monorepo pnpm workspaces :
|
||||||
- `apps/web` — frontend React/Vite/TypeScript, prêt à être embarqué par Capacitor plus tard.
|
- `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.
|
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`,
|
- `packages/shared` — code partagé entre `api` et `web` : schémas zod (`signupSchema`,
|
||||||
`loginSchema`), types (`SafeUserProfile`), et le contrat d'erreurs (`ErrorCode`,
|
`loginSchema`), types (`SafeUserProfile`), et le contrat d'erreurs (`ErrorCode`
|
||||||
`ApiErrorResponse`, voir [specs/error-handling.md](specs/error-handling.md)) —
|
numérique, `ApiErrorResponse`, voir [specs/error-handling.md](specs/error-handling.md)) —
|
||||||
même règles des deux côtés, pas de risque de dérive entre front et back. A un vrai
|
même règles des deux côtés, pas de risque de dérive entre front et back.
|
||||||
build (`tsc` → `dist/`, voir son `package.json`) : consommé en JS compilé par
|
- `packages/express-tools` — outillage Express générique et réutilisable (`HttpError`,
|
||||||
l'API (runtime Node pur, pas de transpilation à la volée) comme par le web (Vite).
|
`ErrorHandlerService`, `createErrorMiddleware`), séparé d'`apps/api` : pas de
|
||||||
|
logique métier, juste de l'infra Express.
|
||||||
|
|
||||||
|
`packages/shared` et `packages/express-tools` ont un vrai build (`tsc` → `dist/`,
|
||||||
|
voir leur `package.json`) : consommés en JS compilé, pas en TS brut — nécessaire
|
||||||
|
pour un runtime Node pur (Docker, pas de transpilation à la volée), voir la note
|
||||||
|
dans [specs/frontend-architecture.md](specs/frontend-architecture.md#note-sur-les-fichiers-dts).
|
||||||
|
|
||||||
## Prérequis
|
## Prérequis
|
||||||
|
|
||||||
|
|
@ -182,12 +188,35 @@ base).
|
||||||
|
|
||||||
## Gestion des erreurs (API ↔ web)
|
## Gestion des erreurs (API ↔ web)
|
||||||
|
|
||||||
Contrat d'erreurs partagé via `packages/shared` (`ErrorCode`, `ApiErrorResponse`) :
|
Contrat d'erreurs partagé via `packages/shared` (`ErrorCode`, énumération
|
||||||
l'API renvoie toujours `{ code, message, details? }` (message en anglais,
|
**numérique** groupée par famille — `4000` validation, `401x` auth, `404x` not
|
||||||
dev-facing — jamais affiché tel quel), et le client traduit `code` en libellé
|
found, `500x` interne — et `ApiErrorResponse`) : l'API renvoie toujours
|
||||||
français via `ErrorMessageService` (`apps/web/src/services/error-message.service.ts`).
|
`{ code, message, details? }` (message en anglais, dev-facing — jamais affiché tel
|
||||||
Côté API, `ErrorHandlerService` (`apps/api/src/services/error-handler.service.ts`)
|
quel), et le client traduit `code` en libellé français via **i18next**
|
||||||
centralise la transformation de toute erreur levée en réponse HTTP conforme.
|
(`ErrorMessageService`, `apps/web/src/services/error-message.service.ts` →
|
||||||
|
`apps/web/src/locales/fr/translation.json`). Côté API, `ErrorHandlerService` et
|
||||||
|
`createErrorMiddleware` (`packages/express-tools`) centralisent la transformation
|
||||||
|
de toute erreur levée en réponse HTTP conforme — aucune valeur `ErrorCode` codée
|
||||||
|
en dur nulle part (toujours `ErrorCode.XXX`, y compris dans les mocks Cypress).
|
||||||
|
|
||||||
Détail complet (schéma, exemples, comment ajouter un nouveau code d'erreur) :
|
Détail complet (schéma, exemples, comment ajouter un nouveau code d'erreur) :
|
||||||
[specs/error-handling.md](specs/error-handling.md).
|
[specs/error-handling.md](specs/error-handling.md).
|
||||||
|
|
||||||
|
## i18n
|
||||||
|
|
||||||
|
**i18next** + **react-i18next** — tout le texte affiché (formulaires, boutons,
|
||||||
|
erreurs) vient de fichiers de locale JSON (`apps/web/src/locales/<lng>/translation.json`),
|
||||||
|
jamais codé en dur dans un composant. Une seule langue existe aujourd'hui (`fr`) ;
|
||||||
|
en ajouter une est une question de fichier de locale, pas de code. Détail :
|
||||||
|
[specs/frontend-architecture.md](specs/frontend-architecture.md#i18n-internationalisation).
|
||||||
|
|
||||||
|
## Données de test (faker.js)
|
||||||
|
|
||||||
|
`apps/api` utilise [`@faker-js/faker`](https://fakerjs.dev/) pour toutes les données
|
||||||
|
de test dans `test/auth.test.ts` (Mocha) et le "bruit" (prénom/nom de remplissage)
|
||||||
|
des steps Cucumber — jamais de nom/email qui ressemble à une vraie personne en dur
|
||||||
|
dans un fixture. Les valeurs *littérales* des scénarios `.feature` eux-mêmes
|
||||||
|
(ex. `alice@example.com`) restent volontairement statiques : c'est le point des
|
||||||
|
scénarios Gherkin lisibles (exemples illustratifs conventionnels en BDD, pas des
|
||||||
|
données réelles) — seules les données de remplissage hors du texte lisible du
|
||||||
|
scénario sont générées.
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ COPY --from=build /repo/node_modules ./node_modules
|
||||||
COPY --from=build /repo/package.json ./package.json
|
COPY --from=build /repo/package.json ./package.json
|
||||||
COPY --from=build /repo/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
COPY --from=build /repo/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||||
COPY --from=build /repo/packages/shared ./packages/shared
|
COPY --from=build /repo/packages/shared ./packages/shared
|
||||||
|
COPY --from=build /repo/packages/express-tools ./packages/express-tools
|
||||||
COPY --from=build /repo/apps/api/node_modules ./apps/api/node_modules
|
COPY --from=build /repo/apps/api/node_modules ./apps/api/node_modules
|
||||||
COPY --from=build /repo/apps/api/dist ./apps/api/dist
|
COPY --from=build /repo/apps/api/dist ./apps/api/dist
|
||||||
COPY --from=build /repo/apps/api/prisma ./apps/api/prisma
|
COPY --from=build /repo/apps/api/prisma ./apps/api/prisma
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,33 @@
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import type { DataTable } from "@cucumber/cucumber";
|
import type { DataTable } from "@cucumber/cucumber";
|
||||||
import { Given, Then, When } from "@cucumber/cucumber";
|
import { Given, Then, When } from "@cucumber/cucumber";
|
||||||
|
import { faker } from "@faker-js/faker";
|
||||||
import { signup } from "../../src/modules/auth/auth.service.js";
|
import { signup } from "../../src/modules/auth/auth.service.js";
|
||||||
import type { CustomWorld } from "../support/world.js";
|
import type { CustomWorld } from "../support/world.js";
|
||||||
|
|
||||||
|
// firstName/lastName/password below are filler for background state the
|
||||||
|
// scenario doesn't actually read (only the emails in the .feature file are
|
||||||
|
// part of what's being tested) — faker-generated rather than hardcoded so
|
||||||
|
// no test fixture ever looks like a real person's data.
|
||||||
|
|
||||||
Given("a profile already exists with email {string}", async (email: string) => {
|
Given("a profile already exists with email {string}", async (email: string) => {
|
||||||
await signup({
|
await signup({
|
||||||
firstName: "Existing",
|
firstName: faker.person.firstName(),
|
||||||
lastName: "User",
|
lastName: faker.person.lastName(),
|
||||||
email,
|
email,
|
||||||
password: "some-existing-password",
|
password: faker.internet.password({ length: 16 }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Given(
|
Given(
|
||||||
"a profile already exists with email {string} and password {string}",
|
"a profile already exists with email {string} and password {string}",
|
||||||
async (email: string, password: string) => {
|
async (email: string, password: string) => {
|
||||||
await signup({ firstName: "Existing", lastName: "User", email, password });
|
await signup({
|
||||||
|
firstName: faker.person.firstName(),
|
||||||
|
lastName: faker.person.lastName(),
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { ErrorCode } from "@batch-cooking/shared";
|
||||||
import { Then, When } from "@cucumber/cucumber";
|
import { Then, When } from "@cucumber/cucumber";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import type { CustomWorld } from "../support/world.js";
|
import type { CustomWorld } from "../support/world.js";
|
||||||
|
|
@ -18,6 +19,13 @@ Then("the response body should be:", function (this: CustomWorld, expectedJson:
|
||||||
// Generic enough to be reused by any feature asserting on the shared
|
// Generic enough to be reused by any feature asserting on the shared
|
||||||
// ApiErrorResponse contract's `code` field — not health-specific, but this
|
// ApiErrorResponse contract's `code` field — not health-specific, but this
|
||||||
// file is where the other generic response-assertion steps already live.
|
// file is where the other generic response-assertion steps already live.
|
||||||
|
//
|
||||||
|
// `code` here is the enum *member name* (readable in the .feature file,
|
||||||
|
// e.g. "EMAIL_ALREADY_IN_USE") — ErrorCode[name] resolves it to the real
|
||||||
|
// numeric value via TypeScript's reverse enum lookup, so this never
|
||||||
|
// compares against a hardcoded number.
|
||||||
Then("the response error code should be {string}", function (this: CustomWorld, code: string) {
|
Then("the response error code should be {string}", function (this: CustomWorld, code: string) {
|
||||||
assert.equal(this.response.body.code, code);
|
const expected = ErrorCode[code as keyof typeof ErrorCode];
|
||||||
|
assert.notEqual(expected, undefined, `Unknown ErrorCode member: "${code}"`);
|
||||||
|
assert.equal(this.response.body.code, expected);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"postinstall": "prisma generate"
|
"postinstall": "prisma generate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@batch-cooking/express-tools": "workspace:*",
|
||||||
"@batch-cooking/shared": "workspace:*",
|
"@batch-cooking/shared": "workspace:*",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"argon2": "0.31.2",
|
"argon2": "0.31.2",
|
||||||
|
|
@ -26,6 +27,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cucumber/cucumber": "^13.2.1",
|
"@cucumber/cucumber": "^13.2.1",
|
||||||
|
"@faker-js/faker": "^10.6.0",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
|
import { createErrorMiddleware, errorHandlerService } from "@batch-cooking/express-tools";
|
||||||
import { ErrorCode } from "@batch-cooking/shared";
|
import { ErrorCode } from "@batch-cooking/shared";
|
||||||
import cookieParser from "cookie-parser";
|
import cookieParser from "cookie-parser";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import express, { type NextFunction, type Request, type Response } from "express";
|
import express, { type Request, type Response } from "express";
|
||||||
import { env } from "./config/env.js";
|
import { env } from "./config/env.js";
|
||||||
import { authRouter } from "./modules/auth/auth.routes.js";
|
import { authRouter } from "./modules/auth/auth.routes.js";
|
||||||
import { errorHandlerService } from "./services/error-handler.service.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a fresh Express application instance (no shared mutable state
|
* Builds a fresh Express application instance (no shared mutable state
|
||||||
|
|
@ -37,11 +37,9 @@ export function createApp() {
|
||||||
|
|
||||||
// Final error-handling middleware: every thrown/`next(err)`-ed error in
|
// 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
|
// the app ends up here. All the "what status/body does this error map
|
||||||
// to" logic lives in ErrorHandlerService — this stays a thin adapter.
|
// to" logic lives in ErrorHandlerService, from @batch-cooking/express-tools
|
||||||
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
// — this stays a thin adapter.
|
||||||
const { status, body } = errorHandlerService.handle(err);
|
app.use(createErrorMiddleware(errorHandlerService));
|
||||||
res.status(status).json(body);
|
|
||||||
});
|
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
|
import { HttpError } from "@batch-cooking/express-tools";
|
||||||
import { ErrorCode } from "@batch-cooking/shared";
|
import { ErrorCode } from "@batch-cooking/shared";
|
||||||
import type { NextFunction, Request, Response } from "express";
|
import type { NextFunction, Request, Response } from "express";
|
||||||
import { env } from "../config/env.js";
|
import { env } from "../config/env.js";
|
||||||
import { prisma } from "../db/prisma.js";
|
import { prisma } from "../db/prisma.js";
|
||||||
import { HttpError } from "../lib/http-error.js";
|
|
||||||
import { verifyAuthToken } from "../lib/jwt.js";
|
import { verifyAuthToken } from "../lib/jwt.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -13,7 +13,7 @@ import { verifyAuthToken } from "../lib/jwt.js";
|
||||||
* once that feature exists) despite carrying no server-side session.
|
* once that feature exists) despite carrying no server-side session.
|
||||||
*
|
*
|
||||||
* On success, attaches the resolved profile to `req.userProfile` (see
|
* On success, attaches the resolved profile to `req.userProfile` (see
|
||||||
* `src/types/express.d.ts`) for downstream handlers to use.
|
* `src/types/express-request.augment.ts`) for downstream handlers to use.
|
||||||
*
|
*
|
||||||
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing
|
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing
|
||||||
* cookie, malformed/expired JWT, unknown profile, or stale tokenVersion.
|
* cookie, malformed/expired JWT, unknown profile, or stale tokenVersion.
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
|
import { HttpError } from "@batch-cooking/express-tools";
|
||||||
import { ErrorCode, type LoginInput, type SignupInput } from "@batch-cooking/shared";
|
import { ErrorCode, type LoginInput, type SignupInput } from "@batch-cooking/shared";
|
||||||
import type { UserProfile } from "@prisma/client";
|
import type { UserProfile } from "@prisma/client";
|
||||||
import argon2 from "argon2";
|
import argon2 from "argon2";
|
||||||
import { env } from "../../config/env.js";
|
import { env } from "../../config/env.js";
|
||||||
import { prisma } from "../../db/prisma.js";
|
import { prisma } from "../../db/prisma.js";
|
||||||
import { HttpError } from "../../lib/http-error.js";
|
|
||||||
import { signAuthToken } from "../../lib/jwt.js";
|
import { signAuthToken } from "../../lib/jwt.js";
|
||||||
|
|
||||||
/** A UserProfile as it's safe to hand back to a client — never the password hash. */
|
/** A UserProfile as it's safe to hand back to a client — never the password hash. */
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,11 @@ import type { UserProfile } from "@prisma/client";
|
||||||
// Module augmentation: adds a `userProfile` field to Express's Request type
|
// Module augmentation: adds a `userProfile` field to Express's Request type
|
||||||
// so `requireAuth` can attach the authenticated profile and downstream
|
// so `requireAuth` can attach the authenticated profile and downstream
|
||||||
// handlers get it fully typed, without an `as` cast at every call site.
|
// handlers get it fully typed, without an `as` cast at every call site.
|
||||||
|
//
|
||||||
|
// Deliberately a plain .ts file, not a .d.ts: `declare global` augmentation
|
||||||
|
// works identically either way as long as the file has a top-level import
|
||||||
|
// (which makes it a module TS will include in the program) — no need for
|
||||||
|
// the .d.ts extension just for this.
|
||||||
declare global {
|
declare global {
|
||||||
namespace Express {
|
namespace Express {
|
||||||
interface Request {
|
interface Request {
|
||||||
|
|
@ -1,17 +1,30 @@
|
||||||
import { ErrorCode } from "@batch-cooking/shared";
|
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
|
||||||
|
import { faker } from "@faker-js/faker";
|
||||||
import { expect } from "chai";
|
import { expect } from "chai";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { createApp } from "../src/app.js";
|
import { createApp } from "../src/app.js";
|
||||||
import { prisma } from "../src/db/prisma.js";
|
import { prisma } from "../src/db/prisma.js";
|
||||||
import { resetDatabase } from "../test-support/reset-db.js";
|
import { resetDatabase } from "../test-support/reset-db.js";
|
||||||
|
|
||||||
/** Valid signup payload reused across tests. */
|
/**
|
||||||
const validSignup = {
|
* Builds a fresh, fake (never real-looking) signup payload. Called anew per
|
||||||
firstName: "Nicolas",
|
* test rather than sharing one module-level constant, so tests never
|
||||||
lastName: "Lefevre",
|
* accidentally depend on a specific fixture value and each run exercises
|
||||||
email: "nicolas@example.com",
|
* different data — closer to how the app actually gets used.
|
||||||
password: "correct-horse-battery-staple",
|
*/
|
||||||
};
|
function buildSignupPayload(): SignupInput {
|
||||||
|
const firstName = faker.person.firstName();
|
||||||
|
const lastName = faker.person.lastName();
|
||||||
|
return {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
// Lowercased to match what signupSchema/loginSchema normalize the email
|
||||||
|
// to (`.toLowerCase()`) — faker sometimes capitalizes parts of it, and
|
||||||
|
// without this the fixture value stops matching what's actually stored.
|
||||||
|
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
|
||||||
|
password: faker.internet.password({ length: 16 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("Auth", () => {
|
describe("Auth", () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|
@ -26,13 +39,14 @@ describe("Auth", () => {
|
||||||
|
|
||||||
describe("POST /auth/signup", () => {
|
describe("POST /auth/signup", () => {
|
||||||
it("creates a profile and its house, and sets a session cookie", async () => {
|
it("creates a profile and its house, and sets a session cookie", async () => {
|
||||||
const res = await request(app).post("/auth/signup").send(validSignup);
|
const payload = buildSignupPayload();
|
||||||
|
const res = await request(app).post("/auth/signup").send(payload);
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
expect(res.status).to.equal(201);
|
||||||
expect(res.body).to.include({
|
expect(res.body).to.include({
|
||||||
firstName: "Nicolas",
|
firstName: payload.firstName,
|
||||||
lastName: "Lefevre",
|
lastName: payload.lastName,
|
||||||
email: "nicolas@example.com",
|
email: payload.email,
|
||||||
});
|
});
|
||||||
expect(res.body).to.not.have.property("passwordHash");
|
expect(res.body).to.not.have.property("passwordHash");
|
||||||
expect(res.body.houseId).to.be.a("number");
|
expect(res.body.houseId).to.be.a("number");
|
||||||
|
|
@ -40,17 +54,21 @@ describe("Auth", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a duplicate email with 409 EMAIL_ALREADY_IN_USE", async () => {
|
it("rejects a duplicate email with 409 EMAIL_ALREADY_IN_USE", async () => {
|
||||||
await request(app).post("/auth/signup").send(validSignup);
|
const payload = buildSignupPayload();
|
||||||
const res = await request(app).post("/auth/signup").send(validSignup);
|
await request(app).post("/auth/signup").send(payload);
|
||||||
|
const res = await request(app).post("/auth/signup").send(payload);
|
||||||
|
|
||||||
expect(res.status).to.equal(409);
|
expect(res.status).to.equal(409);
|
||||||
expect(res.body.code).to.equal(ErrorCode.EMAIL_ALREADY_IN_USE);
|
expect(res.body.code).to.equal(ErrorCode.EMAIL_ALREADY_IN_USE);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an invalid payload with 400 VALIDATION_ERROR", async () => {
|
it("rejects an invalid payload with 400 VALIDATION_ERROR", async () => {
|
||||||
const res = await request(app)
|
const res = await request(app).post("/auth/signup").send({
|
||||||
.post("/auth/signup")
|
firstName: faker.person.firstName(),
|
||||||
.send({ firstName: "X", lastName: "Y", email: "not-an-email", password: "short" });
|
lastName: faker.person.lastName(),
|
||||||
|
email: "not-an-email",
|
||||||
|
password: "short",
|
||||||
|
});
|
||||||
|
|
||||||
expect(res.status).to.equal(400);
|
expect(res.status).to.equal(400);
|
||||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||||
|
|
@ -59,23 +77,26 @@ describe("Auth", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /auth/login", () => {
|
describe("POST /auth/login", () => {
|
||||||
|
let payload: SignupInput;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await request(app).post("/auth/signup").send(validSignup);
|
payload = buildSignupPayload();
|
||||||
|
await request(app).post("/auth/signup").send(payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs in with correct credentials", async () => {
|
it("logs in with correct credentials", async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post("/auth/login")
|
.post("/auth/login")
|
||||||
.send({ email: validSignup.email, password: validSignup.password });
|
.send({ email: payload.email, password: payload.password });
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
expect(res.status).to.equal(200);
|
||||||
expect(res.body.email).to.equal(validSignup.email);
|
expect(res.body.email).to.equal(payload.email);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => {
|
it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post("/auth/login")
|
.post("/auth/login")
|
||||||
.send({ email: validSignup.email, password: "wrong-password" });
|
.send({ email: payload.email, password: faker.internet.password({ length: 16 }) });
|
||||||
|
|
||||||
expect(res.status).to.equal(401);
|
expect(res.status).to.equal(401);
|
||||||
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
|
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
|
||||||
|
|
@ -84,7 +105,7 @@ describe("Auth", () => {
|
||||||
it("rejects an unknown email with 401 INVALID_CREDENTIALS", async () => {
|
it("rejects an unknown email with 401 INVALID_CREDENTIALS", async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post("/auth/login")
|
.post("/auth/login")
|
||||||
.send({ email: "nobody@example.com", password: validSignup.password });
|
.send({ email: faker.internet.email(), password: payload.password });
|
||||||
|
|
||||||
expect(res.status).to.equal(401);
|
expect(res.status).to.equal(401);
|
||||||
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
|
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
|
||||||
|
|
@ -100,13 +121,14 @@ describe("Auth", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns the current profile when authenticated", async () => {
|
it("returns the current profile when authenticated", async () => {
|
||||||
|
const payload = buildSignupPayload();
|
||||||
const agent = request.agent(app);
|
const agent = request.agent(app);
|
||||||
await agent.post("/auth/signup").send(validSignup);
|
await agent.post("/auth/signup").send(payload);
|
||||||
|
|
||||||
const res = await agent.get("/auth/me");
|
const res = await agent.get("/auth/me");
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
expect(res.status).to.equal(200);
|
||||||
expect(res.body.email).to.equal(validSignup.email);
|
expect(res.body.email).to.equal(payload.email);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { ErrorCode } from "@batch-cooking/shared";
|
||||||
|
|
||||||
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
|
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
|
||||||
// .github/workflows/ci.yml), and it keeps these specs focused on frontend
|
// .github/workflows/ci.yml), and it keeps these specs focused on frontend
|
||||||
// behavior. Backend behavior itself is covered by apps/api's Mocha/Cucumber
|
// behavior. Backend behavior itself is covered by apps/api's Mocha/Cucumber
|
||||||
|
|
@ -50,7 +52,7 @@ describe("Signup", () => {
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
||||||
cy.intercept("POST", "**/auth/signup", {
|
cy.intercept("POST", "**/auth/signup", {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
body: { code: "EMAIL_ALREADY_IN_USE", message: "Email already in use" },
|
body: { code: ErrorCode.EMAIL_ALREADY_IN_USE, message: "Email already in use" },
|
||||||
}).as("signup");
|
}).as("signup");
|
||||||
|
|
||||||
cy.visit("/signup");
|
cy.visit("/signup");
|
||||||
|
|
@ -94,7 +96,7 @@ describe("Login", () => {
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
||||||
cy.intercept("POST", "**/auth/login", {
|
cy.intercept("POST", "**/auth/login", {
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
body: { code: "INVALID_CREDENTIALS", message: "Invalid email or password" },
|
body: { code: ErrorCode.INVALID_CREDENTIALS, message: "Invalid email or password" },
|
||||||
}).as("login");
|
}).as("login");
|
||||||
|
|
||||||
cy.visit("/login");
|
cy.visit("/login");
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
import { ErrorCode } from "@batch-cooking/shared";
|
||||||
|
|
||||||
describe("smoke test", () => {
|
describe("smoke test", () => {
|
||||||
it("redirects an unauthenticated visitor to the login page", () => {
|
it("redirects an unauthenticated visitor to the login page", () => {
|
||||||
cy.intercept("GET", "**/auth/me", {
|
cy.intercept("GET", "**/auth/me", {
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
body: { code: "NOT_AUTHENTICATED", message: "Not authenticated" },
|
body: { code: ErrorCode.NOT_AUTHENTICATED, message: "Not authenticated" },
|
||||||
});
|
});
|
||||||
cy.visit("/");
|
cy.visit("/");
|
||||||
cy.url().should("include", "/login");
|
cy.url().should("include", "/login");
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,10 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@batch-cooking/shared": "workspace:*",
|
"@batch-cooking/shared": "workspace:*",
|
||||||
|
"i18next": "^26.3.6",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-i18next": "^17.0.11",
|
||||||
"react-router-dom": "^7.18.2",
|
"react-router-dom": "^7.18.2",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import type {
|
import {
|
||||||
ApiErrorResponse,
|
type ApiErrorResponse,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
LoginInput,
|
type LoginInput,
|
||||||
SafeUserProfile,
|
type SafeUserProfile,
|
||||||
SignupInput,
|
type SignupInput,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
|
|
||||||
/** Base URL of the API, configurable via `VITE_API_URL` (see `.env.example`). */
|
/** Base URL of the API, configurable via `VITE_API_URL` (see `.env.example`). */
|
||||||
|
|
@ -59,9 +59,11 @@ export class ApiClient {
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = (await response.json().catch(() => null)) as ApiErrorResponse | null;
|
const body = (await response.json().catch(() => null)) as ApiErrorResponse | null;
|
||||||
|
// Fallback for a response that couldn't even be parsed as JSON — no
|
||||||
|
// hardcoded string, always the real enum member.
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
response.status,
|
response.status,
|
||||||
body ?? { code: "INTERNAL_ERROR" as ErrorCode, message: "Something went wrong" },
|
body ?? { code: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
26
apps/web/src/i18n/i18n.ts
Normal file
26
apps/web/src/i18n/i18n.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import i18next from "i18next";
|
||||||
|
import { initReactI18next } from "react-i18next";
|
||||||
|
import fr from "../locales/fr/translation.json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* i18next instance for the whole app, imported once for its side effect
|
||||||
|
* (`main.tsx`) before anything renders. Only French exists today —
|
||||||
|
* `packages/shared`'s `ErrorCode` enum members double as translation keys
|
||||||
|
* under the `errors` namespace (see `services/error-message.service.ts`).
|
||||||
|
*
|
||||||
|
* Adding a language later is "add a `resources.<lng>` entry pointing at a
|
||||||
|
* new locale file", not touching a single component.
|
||||||
|
*/
|
||||||
|
void i18next.use(initReactI18next).init({
|
||||||
|
resources: {
|
||||||
|
fr: { translation: fr },
|
||||||
|
},
|
||||||
|
lng: "fr",
|
||||||
|
fallbackLng: "fr",
|
||||||
|
// React already escapes interpolated values when rendering JSX — letting
|
||||||
|
// i18next also HTML-escape them would double-escape (e.g. turn "é" text
|
||||||
|
// into visible "é" in some setups).
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default i18next;
|
||||||
36
apps/web/src/locales/fr/translation.json
Normal file
36
apps/web/src/locales/fr/translation.json
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"errors": {
|
||||||
|
"VALIDATION_ERROR": "Erreur de validation",
|
||||||
|
"EMAIL_ALREADY_IN_USE": "Cet email est déjà utilisé",
|
||||||
|
"INVALID_CREDENTIALS": "Email ou mot de passe incorrect",
|
||||||
|
"NOT_AUTHENTICATED": "Vous devez être connecté",
|
||||||
|
"NOT_FOUND": "Ressource introuvable",
|
||||||
|
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"login": {
|
||||||
|
"title": "Se connecter",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"passwordLabel": "Mot de passe",
|
||||||
|
"submit": "Se connecter",
|
||||||
|
"submitting": "Connexion…",
|
||||||
|
"noAccount": "Pas encore de compte ?",
|
||||||
|
"createProfileLink": "Créer un profil"
|
||||||
|
},
|
||||||
|
"signup": {
|
||||||
|
"title": "Créer un profil",
|
||||||
|
"firstNameLabel": "Prénom",
|
||||||
|
"lastNameLabel": "Nom",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"passwordLabel": "Mot de passe",
|
||||||
|
"submit": "Créer mon profil",
|
||||||
|
"submitting": "Création…",
|
||||||
|
"hasAccount": "Déjà un compte ?",
|
||||||
|
"loginLink": "Se connecter"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"greeting": "Bonjour {{firstName}} {{lastName}} 👋",
|
||||||
|
"logout": "Se déconnecter"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,9 @@ import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import { AuthProvider } from "./features/auth/AuthContext";
|
import { AuthProvider } from "./features/auth/AuthContext";
|
||||||
|
// Side-effect import: initializes the i18next instance before anything
|
||||||
|
// renders (react-i18next reads it via context under the hood). See i18n/i18n.ts.
|
||||||
|
import "./i18n/i18n";
|
||||||
// Global stylesheet (theme tokens + minimal reset) — the only .scss import
|
// Global stylesheet (theme tokens + minimal reset) — the only .scss import
|
||||||
// that isn't colocated with a specific component/page. See styles/global.scss.
|
// that isn't colocated with a specific component/page. See styles/global.scss.
|
||||||
import "./styles/global.scss";
|
import "./styles/global.scss";
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../features/auth/AuthContext";
|
import { useAuth } from "../features/auth/AuthContext";
|
||||||
import "./HomePage.scss";
|
import "./HomePage.scss";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Landing page for an authenticated visitor. Behind {@link RequireAuth} —
|
* Landing page for an authenticated visitor. Behind {@link RequireAuth} —
|
||||||
* `user` is guaranteed non-null by the time this renders.
|
* `user` is guaranteed non-null by the time this renders. Static copy
|
||||||
|
* comes from i18next (`locales/fr/translation.json`, `home` namespace).
|
||||||
*/
|
*/
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
/** Ends the session and returns to the login page. */
|
/** Ends the session and returns to the login page. */
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
|
|
@ -19,11 +22,9 @@ export function HomePage() {
|
||||||
return (
|
return (
|
||||||
<main className="home-page">
|
<main className="home-page">
|
||||||
<h1>batchCooking</h1>
|
<h1>batchCooking</h1>
|
||||||
<p>
|
<p>{t("home.greeting", { firstName: user?.firstName, lastName: user?.lastName })}</p>
|
||||||
Bonjour {user?.firstName} {user?.lastName} 👋
|
|
||||||
</p>
|
|
||||||
<button type="button" onClick={handleLogout}>
|
<button type="button" onClick={handleLogout}>
|
||||||
Se déconnecter
|
{t("home.logout")}
|
||||||
</button>
|
</button>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { ErrorCode, loginSchema } from "@batch-cooking/shared";
|
import { ErrorCode, loginSchema } from "@batch-cooking/shared";
|
||||||
import { type FormEvent, useState } from "react";
|
import { type FormEvent, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
import { useAuth } from "../features/auth/AuthContext";
|
import { useAuth } from "../features/auth/AuthContext";
|
||||||
|
|
@ -14,11 +15,14 @@ import { errorMessageService } from "../services/error-message.service";
|
||||||
* same rules the API enforces) for instant feedback with no network round
|
* same rules the API enforces) for instant feedback with no network round
|
||||||
* trip; only calls the API once the payload is locally valid, and
|
* trip; only calls the API once the payload is locally valid, and
|
||||||
* translates any API failure into a localized label via
|
* translates any API failure into a localized label via
|
||||||
* {@link ErrorMessageService}.
|
* {@link ErrorMessageService}. All static copy comes from i18next
|
||||||
|
* (`locales/fr/translation.json`, `auth.login` namespace) via
|
||||||
|
* {@link useTranslation}, not hardcoded JSX text.
|
||||||
*/
|
*/
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Controlled form fields.
|
// Controlled form fields.
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
|
|
@ -61,9 +65,9 @@ export function LoginPage() {
|
||||||
return (
|
return (
|
||||||
<main className="auth-page">
|
<main className="auth-page">
|
||||||
<form className="auth-card" onSubmit={handleSubmit} noValidate>
|
<form className="auth-card" onSubmit={handleSubmit} noValidate>
|
||||||
<h1>Se connecter</h1>
|
<h1>{t("auth.login.title")}</h1>
|
||||||
|
|
||||||
<label htmlFor="email">Email</label>
|
<label htmlFor="email">{t("auth.login.emailLabel")}</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
|
|
@ -73,7 +77,7 @@ export function LoginPage() {
|
||||||
/>
|
/>
|
||||||
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
|
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
|
||||||
|
|
||||||
<label htmlFor="password">Mot de passe</label>
|
<label htmlFor="password">{t("auth.login.passwordLabel")}</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
|
|
@ -86,11 +90,11 @@ export function LoginPage() {
|
||||||
{formError && <p className="form-error">{formError}</p>}
|
{formError && <p className="form-error">{formError}</p>}
|
||||||
|
|
||||||
<button type="submit" disabled={isSubmitting}>
|
<button type="submit" disabled={isSubmitting}>
|
||||||
{isSubmitting ? "Connexion…" : "Se connecter"}
|
{isSubmitting ? t("auth.login.submitting") : t("auth.login.submit")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="auth-switch">
|
<p className="auth-switch">
|
||||||
Pas encore de compte ? <Link to="/signup">Créer un profil</Link>
|
{t("auth.login.noAccount")} <Link to="/signup">{t("auth.login.createProfileLink")}</Link>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { ErrorCode, signupSchema } from "@batch-cooking/shared";
|
import { ErrorCode, signupSchema } from "@batch-cooking/shared";
|
||||||
import { type FormEvent, useState } from "react";
|
import { type FormEvent, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
import { useAuth } from "../features/auth/AuthContext";
|
import { useAuth } from "../features/auth/AuthContext";
|
||||||
|
|
@ -15,10 +16,13 @@ import { errorMessageService } from "../services/error-message.service";
|
||||||
* feedback with no network round trip; only calls the API once the
|
* feedback with no network round trip; only calls the API once the
|
||||||
* payload is locally valid, and translates any API failure (e.g. email
|
* payload is locally valid, and translates any API failure (e.g. email
|
||||||
* already taken) into a localized label via {@link ErrorMessageService}.
|
* already taken) into a localized label via {@link ErrorMessageService}.
|
||||||
|
* All static copy comes from i18next (`locales/fr/translation.json`,
|
||||||
|
* `auth.signup` namespace) via {@link useTranslation}, not hardcoded JSX text.
|
||||||
*/
|
*/
|
||||||
export function SignupPage() {
|
export function SignupPage() {
|
||||||
const { signup } = useAuth();
|
const { signup } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Controlled form fields.
|
// Controlled form fields.
|
||||||
const [firstName, setFirstName] = useState("");
|
const [firstName, setFirstName] = useState("");
|
||||||
|
|
@ -63,9 +67,9 @@ export function SignupPage() {
|
||||||
return (
|
return (
|
||||||
<main className="auth-page">
|
<main className="auth-page">
|
||||||
<form className="auth-card" onSubmit={handleSubmit} noValidate>
|
<form className="auth-card" onSubmit={handleSubmit} noValidate>
|
||||||
<h1>Créer un profil</h1>
|
<h1>{t("auth.signup.title")}</h1>
|
||||||
|
|
||||||
<label htmlFor="firstName">Prénom</label>
|
<label htmlFor="firstName">{t("auth.signup.firstNameLabel")}</label>
|
||||||
<input
|
<input
|
||||||
id="firstName"
|
id="firstName"
|
||||||
value={firstName}
|
value={firstName}
|
||||||
|
|
@ -74,7 +78,7 @@ export function SignupPage() {
|
||||||
/>
|
/>
|
||||||
{fieldErrors.firstName && <p className="field-error">{fieldErrors.firstName}</p>}
|
{fieldErrors.firstName && <p className="field-error">{fieldErrors.firstName}</p>}
|
||||||
|
|
||||||
<label htmlFor="lastName">Nom</label>
|
<label htmlFor="lastName">{t("auth.signup.lastNameLabel")}</label>
|
||||||
<input
|
<input
|
||||||
id="lastName"
|
id="lastName"
|
||||||
value={lastName}
|
value={lastName}
|
||||||
|
|
@ -83,7 +87,7 @@ export function SignupPage() {
|
||||||
/>
|
/>
|
||||||
{fieldErrors.lastName && <p className="field-error">{fieldErrors.lastName}</p>}
|
{fieldErrors.lastName && <p className="field-error">{fieldErrors.lastName}</p>}
|
||||||
|
|
||||||
<label htmlFor="email">Email</label>
|
<label htmlFor="email">{t("auth.signup.emailLabel")}</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
|
|
@ -93,7 +97,7 @@ export function SignupPage() {
|
||||||
/>
|
/>
|
||||||
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
|
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
|
||||||
|
|
||||||
<label htmlFor="password">Mot de passe</label>
|
<label htmlFor="password">{t("auth.signup.passwordLabel")}</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
|
|
@ -106,11 +110,11 @@ export function SignupPage() {
|
||||||
{formError && <p className="form-error">{formError}</p>}
|
{formError && <p className="form-error">{formError}</p>}
|
||||||
|
|
||||||
<button type="submit" disabled={isSubmitting}>
|
<button type="submit" disabled={isSubmitting}>
|
||||||
{isSubmitting ? "Création…" : "Créer mon profil"}
|
{isSubmitting ? t("auth.signup.submitting") : t("auth.signup.submit")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="auth-switch">
|
<p className="auth-switch">
|
||||||
Déjà un compte ? <Link to="/login">Se connecter</Link>
|
{t("auth.signup.hasAccount")} <Link to="/login">{t("auth.signup.loginLink")}</Link>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,30 @@
|
||||||
import { ErrorCode } from "@batch-cooking/shared";
|
import { ErrorCode } from "@batch-cooking/shared";
|
||||||
|
import i18n from "../i18n/i18n";
|
||||||
/** 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
|
* Centralizes lookup of the user-facing label for a given {@link ErrorCode},
|
||||||
* {@link ErrorCode} the API returns. Components never hardcode error text —
|
* delegating the actual translation storage/lookup to i18next (see
|
||||||
* they call `errorMessageService.getLabel(...)` and get back the right
|
* `i18n/i18n.ts` and `locales/fr/translation.json`) — components never
|
||||||
* string for the current locale.
|
* hardcode error text, and adding a language is a locale file, not a
|
||||||
|
* code change.
|
||||||
*
|
*
|
||||||
* Only French exists today (the whole UI is French), but the `Locale`
|
* A numeric `ErrorCode` value isn't a valid i18next key by itself (and
|
||||||
* type and the per-locale label map exist so adding a second language
|
* numeric JSON keys would be far less readable in the locale file than
|
||||||
* later is "add a locale to the map", not "hunt down every hardcoded
|
* names), so this reverse-maps the enum value to its member name (e.g.
|
||||||
* string in every component". Used as a single shared instance
|
* `4001` → `"EMAIL_ALREADY_IN_USE"`) via TypeScript's numeric-enum reverse
|
||||||
* (`errorMessageService`, exported below).
|
* mapping, then looks that name up under the `errors` namespace.
|
||||||
*/
|
*/
|
||||||
export class ErrorMessageService {
|
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.
|
* Returns the localized, user-facing label for a given error code.
|
||||||
*
|
*
|
||||||
* @param code - Error code as returned by the API. Typed as `string` (not
|
* @param code - Error code as returned by the API. An unrecognized value
|
||||||
* strictly `ErrorCode`) because it's coming off the network — an
|
* (e.g. the client is older than the API and doesn't know a newer code)
|
||||||
* unrecognized value falls back to the generic "internal error" label
|
* falls back to the generic `INTERNAL_ERROR` label instead of throwing.
|
||||||
* instead of throwing.
|
|
||||||
* @param locale - Defaults to {@link defaultLocale}.
|
|
||||||
*/
|
*/
|
||||||
public getLabel(code: string, locale: Locale = this.defaultLocale): string {
|
public getLabel(code: ErrorCode): string {
|
||||||
const labelsForLocale = this.labels[locale];
|
const memberName = ErrorCode[code] ?? ErrorCode[ErrorCode.INTERNAL_ERROR];
|
||||||
return labelsForLocale[code as ErrorCode] ?? labelsForLocale[ErrorCode.INTERNAL_ERROR];
|
return i18n.t(`errors.${memberName}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
1
apps/web/src/vite-env.d.ts
vendored
1
apps/web/src/vite-env.d.ts
vendored
|
|
@ -1 +0,0 @@
|
||||||
/// <reference types="vite/client" />
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
"types": ["vite/client"],
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||||
|
|
|
||||||
29
packages/express-tools/package.json
Normal file
29
packages/express-tools/package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"name": "@batch-cooking/express-tools",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"no tests yet\" && exit 0",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"postinstall": "tsc -p tsconfig.json"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^22.9.0",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@batch-cooking/shared": "workspace:*",
|
||||||
|
"express": "^4.21.1",
|
||||||
|
"zod": "^3.25.76"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { type ApiErrorResponse, ErrorCode } from "@batch-cooking/shared";
|
import { type ApiErrorResponse, ErrorCode } from "@batch-cooking/shared";
|
||||||
import { ZodError } from "zod";
|
import { ZodError } from "zod";
|
||||||
import { HttpError } from "../lib/http-error.js";
|
import { HttpError } from "./http-error.js";
|
||||||
|
|
||||||
/** Return value of {@link ErrorHandlerService.handle}: everything an Express error middleware needs to send a response. */
|
/** Return value of {@link ErrorHandlerService.handle}: everything an Express error middleware needs to send a response. */
|
||||||
export interface ErrorHandlingResult {
|
export interface ErrorHandlingResult {
|
||||||
|
|
@ -12,8 +12,9 @@ export interface ErrorHandlingResult {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Centralizes every "how do we turn a thrown error into an HTTP response"
|
* 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
|
* decision for an Express app in one place, so route handlers and the
|
||||||
* error middleware in `app.ts` never duplicate this logic.
|
* error middleware never duplicate this logic. Wire it into Express via
|
||||||
|
* {@link createErrorMiddleware} (see error-middleware.ts).
|
||||||
*
|
*
|
||||||
* Recognizes three error shapes today (zod validation failures, our own
|
* Recognizes three error shapes today (zod validation failures, our own
|
||||||
* `HttpError`, and anything else) and always falls back to a safe, generic
|
* `HttpError`, and anything else) and always falls back to a safe, generic
|
||||||
27
packages/express-tools/src/error-middleware.ts
Normal file
27
packages/express-tools/src/error-middleware.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import type { ErrorHandlerService } from "./error-handler.service.js";
|
||||||
|
|
||||||
|
/** Express error-handling middleware signature (the 4-arg form Express detects as an error handler). */
|
||||||
|
type ExpressErrorMiddleware = (
|
||||||
|
err: unknown,
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the final Express error-handling middleware for an app: every
|
||||||
|
* thrown/`next(err)`-ed error ends up here, gets mapped by the given
|
||||||
|
* {@link ErrorHandlerService}, and sent as the response. Keeps the actual
|
||||||
|
* "what does this error mean" logic in the service, testable on its own —
|
||||||
|
* this factory is just the thin Express adapter.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* app.use(createErrorMiddleware(errorHandlerService));
|
||||||
|
*/
|
||||||
|
export function createErrorMiddleware(errorHandler: ErrorHandlerService): ExpressErrorMiddleware {
|
||||||
|
return (err, _req, res, _next) => {
|
||||||
|
const { status, body } = errorHandler.handle(err);
|
||||||
|
res.status(status).json(body);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -5,9 +5,9 @@ import type { ErrorCode } from "@batch-cooking/shared";
|
||||||
* business {@link ErrorCode} that identifies *why* it happened.
|
* business {@link ErrorCode} that identifies *why* it happened.
|
||||||
*
|
*
|
||||||
* Route handlers throw this (or let it bubble from a service call) instead
|
* Route handlers throw this (or let it bubble from a service call) instead
|
||||||
* of manually setting a status/body — `ErrorHandlerService` is the single
|
* of manually setting a status/body — {@link ErrorHandlerService} is the
|
||||||
* place that turns it into an actual HTTP response, so every error path in
|
* single place that turns it into an actual HTTP response, so every error
|
||||||
* the API is shaped consistently. See `services/error-handler.service.ts`.
|
* path in an Express app built with these tools is shaped consistently.
|
||||||
*/
|
*/
|
||||||
export class HttpError extends Error {
|
export class HttpError extends Error {
|
||||||
/** HTTP status code to respond with (e.g. 401, 404, 409). */
|
/** HTTP status code to respond with (e.g. 401, 404, 409). */
|
||||||
8
packages/express-tools/src/index.ts
Normal file
8
packages/express-tools/src/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
// Public entry point of the Express-specific tooling shared across any
|
||||||
|
// Express app in this monorepo (currently apps/api). Generic HTTP/Express
|
||||||
|
// infrastructure lives here — domain-specific code (auth, business logic)
|
||||||
|
// stays in the consuming app.
|
||||||
|
|
||||||
|
export * from "./error-handler.service.js";
|
||||||
|
export * from "./error-middleware.js";
|
||||||
|
export * from "./http-error.js";
|
||||||
12
packages/express-tools/tsconfig.json
Normal file
12
packages/express-tools/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
@ -4,28 +4,38 @@
|
||||||
* This is the single source of truth for error identification across the
|
* This is the single source of truth for error identification across the
|
||||||
* whole monorepo: `apps/api` throws errors carrying one of these codes,
|
* whole monorepo: `apps/api` throws errors carrying one of these codes,
|
||||||
* and `apps/web` maps each code to a localized, user-facing label (see
|
* and `apps/web` maps each code to a localized, user-facing label (see
|
||||||
* `apps/web/src/services/error-message.service.ts`). Neither side should
|
* `apps/web/src/services/error-message.service.ts`, backed by i18next
|
||||||
* ever hardcode a raw error string that the other side has to guess at —
|
* locale files under `apps/web/src/locales/`). Neither side should ever
|
||||||
* the code is the contract.
|
* hardcode a raw error value that the other side has to guess at — always
|
||||||
|
* reference `ErrorCode.XXX`, never a bare number/string.
|
||||||
|
*
|
||||||
|
* Numeric values (not string codes): grouped by category so the number
|
||||||
|
* itself hints at the kind of failure, similar in spirit to HTTP status
|
||||||
|
* code families —
|
||||||
|
* - `4000`–`4099`: request validation
|
||||||
|
* - `4010`–`4019`: authentication
|
||||||
|
* - `4040`–`4049`: not found
|
||||||
|
* - `5000`–`5099`: internal/unexpected
|
||||||
*
|
*
|
||||||
* When adding a new failure case in the API:
|
* When adding a new failure case in the API:
|
||||||
* 1. Add a new member here.
|
* 1. Add a new member here, in the right range, with the next free number.
|
||||||
* 2. Throw it via `HttpError` (apps/api/src/lib/http-error.ts).
|
* 2. Throw it via `HttpError` (`@batch-cooking/express-tools`).
|
||||||
* 3. Add its translation in `ErrorMessageService` (apps/web).
|
* 3. Add its translation key to every locale file under
|
||||||
|
* `apps/web/src/locales` (one `translation.json` per language).
|
||||||
*/
|
*/
|
||||||
export enum ErrorCode {
|
export enum ErrorCode {
|
||||||
/** Request body/query failed zod schema validation. */
|
/** Request body/query failed zod schema validation. */
|
||||||
VALIDATION_ERROR = "VALIDATION_ERROR",
|
VALIDATION_ERROR = 4000,
|
||||||
/** Signup attempted with an email that already has a profile. */
|
/** Signup attempted with an email that already has a profile. */
|
||||||
EMAIL_ALREADY_IN_USE = "EMAIL_ALREADY_IN_USE",
|
EMAIL_ALREADY_IN_USE = 4001,
|
||||||
/** Login failed — wrong email or wrong password (never say which). */
|
/** Login failed — wrong email or wrong password (never say which). */
|
||||||
INVALID_CREDENTIALS = "INVALID_CREDENTIALS",
|
INVALID_CREDENTIALS = 4010,
|
||||||
/** Request required a session cookie/JWT that is missing, invalid, or stale. */
|
/** Request required a session cookie/JWT that is missing, invalid, or stale. */
|
||||||
NOT_AUTHENTICATED = "NOT_AUTHENTICATED",
|
NOT_AUTHENTICATED = 4011,
|
||||||
/** No route/resource matches the request. */
|
/** No route/resource matches the request. */
|
||||||
NOT_FOUND = "NOT_FOUND",
|
NOT_FOUND = 4040,
|
||||||
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
|
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
|
||||||
INTERNAL_ERROR = "INTERNAL_ERROR",
|
INTERNAL_ERROR = 5000,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ importers:
|
||||||
|
|
||||||
apps/api:
|
apps/api:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@batch-cooking/express-tools':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/express-tools
|
||||||
'@batch-cooking/shared':
|
'@batch-cooking/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared
|
version: link:../../packages/shared
|
||||||
|
|
@ -48,6 +51,9 @@ importers:
|
||||||
'@cucumber/cucumber':
|
'@cucumber/cucumber':
|
||||||
specifier: ^13.2.1
|
specifier: ^13.2.1
|
||||||
version: 13.2.1
|
version: 13.2.1
|
||||||
|
'@faker-js/faker':
|
||||||
|
specifier: ^10.6.0
|
||||||
|
version: 10.6.0
|
||||||
'@types/cookie-parser':
|
'@types/cookie-parser':
|
||||||
specifier: ^1.4.10
|
specifier: ^1.4.10
|
||||||
version: 1.4.10(@types/express@4.17.25)
|
version: 1.4.10(@types/express@4.17.25)
|
||||||
|
|
@ -93,12 +99,18 @@ importers:
|
||||||
'@batch-cooking/shared':
|
'@batch-cooking/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared
|
version: link:../../packages/shared
|
||||||
|
i18next:
|
||||||
|
specifier: ^26.3.6
|
||||||
|
version: 26.3.6(typescript@5.9.3)
|
||||||
react:
|
react:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1(react@18.3.1)
|
version: 18.3.1(react@18.3.1)
|
||||||
|
react-i18next:
|
||||||
|
specifier: ^17.0.11
|
||||||
|
version: 17.0.11(i18next@26.3.6(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)
|
||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^7.18.2
|
specifier: ^7.18.2
|
||||||
version: 7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
|
@ -134,6 +146,28 @@ importers:
|
||||||
specifier: ^5.4.11
|
specifier: ^5.4.11
|
||||||
version: 5.4.21(@types/node@22.20.1)(sass@1.102.0)
|
version: 5.4.21(@types/node@22.20.1)(sass@1.102.0)
|
||||||
|
|
||||||
|
packages/express-tools:
|
||||||
|
dependencies:
|
||||||
|
'@batch-cooking/shared':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../shared
|
||||||
|
express:
|
||||||
|
specifier: ^4.21.1
|
||||||
|
version: 4.22.2
|
||||||
|
zod:
|
||||||
|
specifier: ^3.25.76
|
||||||
|
version: 3.25.76
|
||||||
|
devDependencies:
|
||||||
|
'@types/express':
|
||||||
|
specifier: ^4.17.21
|
||||||
|
version: 4.17.25
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.9.0
|
||||||
|
version: 22.20.1
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.7.2
|
||||||
|
version: 5.9.3
|
||||||
|
|
||||||
packages/shared:
|
packages/shared:
|
||||||
dependencies:
|
dependencies:
|
||||||
zod:
|
zod:
|
||||||
|
|
@ -217,6 +251,10 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@babel/core': ^7.0.0-0
|
'@babel/core': ^7.0.0-0
|
||||||
|
|
||||||
|
'@babel/runtime@7.29.7':
|
||||||
|
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==, tarball: https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/template@7.29.7':
|
'@babel/template@7.29.7':
|
||||||
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==, tarball: https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz}
|
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==, tarball: https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
@ -658,6 +696,10 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@faker-js/faker@10.6.0':
|
||||||
|
resolution: {integrity: sha512-3RQHgEtvL1Frl/d1cSreo7qhJ3Gk1OdNUai/CtZ8G+wYeRQnJih3s9xJ9/kgYekPQRdwgh0HXRPqMlzWGwivIQ==, tarball: https://registry.npmjs.org/@faker-js/faker/-/faker-10.6.0.tgz}
|
||||||
|
engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'}
|
||||||
|
|
||||||
'@hapi/address@5.1.1':
|
'@hapi/address@5.1.1':
|
||||||
resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==, tarball: https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz}
|
resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==, tarball: https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
@ -1760,6 +1802,9 @@ packages:
|
||||||
resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==, tarball: https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz}
|
resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==, tarball: https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz}
|
||||||
engines: {node: ^20.17.0 || >=22.9.0}
|
engines: {node: ^20.17.0 || >=22.9.0}
|
||||||
|
|
||||||
|
html-parse-stringify@4.0.1:
|
||||||
|
resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==, tarball: https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz}
|
||||||
|
|
||||||
http-errors@2.0.1:
|
http-errors@2.0.1:
|
||||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==, tarball: https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz}
|
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==, tarball: https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
@ -1780,6 +1825,14 @@ packages:
|
||||||
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==, tarball: https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz}
|
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==, tarball: https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz}
|
||||||
engines: {node: '>=10.17.0'}
|
engines: {node: '>=10.17.0'}
|
||||||
|
|
||||||
|
i18next@26.3.6:
|
||||||
|
resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==, tarball: https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz}
|
||||||
|
peerDependencies:
|
||||||
|
typescript: ^5 || ^6 || ^7
|
||||||
|
peerDependenciesMeta:
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
|
||||||
iconv-lite@0.4.24:
|
iconv-lite@0.4.24:
|
||||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==, tarball: https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz}
|
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==, tarball: https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -2296,6 +2349,22 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.3.1
|
react: ^18.3.1
|
||||||
|
|
||||||
|
react-i18next@17.0.11:
|
||||||
|
resolution: {integrity: sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==, tarball: https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz}
|
||||||
|
peerDependencies:
|
||||||
|
i18next: '>= 26.2.0'
|
||||||
|
react: '>= 16.8.0'
|
||||||
|
react-dom: '*'
|
||||||
|
react-native: '*'
|
||||||
|
typescript: ^5 || ^6 || ^7
|
||||||
|
peerDependenciesMeta:
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
react-native:
|
||||||
|
optional: true
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
|
||||||
react-refresh@0.17.0:
|
react-refresh@0.17.0:
|
||||||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==, tarball: https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz}
|
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==, tarball: https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -2664,6 +2733,11 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
browserslist: '>= 4.21.0'
|
browserslist: '>= 4.21.0'
|
||||||
|
|
||||||
|
use-sync-external-store@1.6.0:
|
||||||
|
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==, tarball: https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
util-arity@1.1.0:
|
util-arity@1.1.0:
|
||||||
resolution: {integrity: sha512-kkyIsXKwemfSy8ZEoaIz06ApApnWsk5hQO0vLjZS6UkBiGiW++Jsyb8vSBoc0WKlffGoGs5yYy/j5pp8zckrFA==, tarball: https://registry.npmjs.org/util-arity/-/util-arity-1.1.0.tgz}
|
resolution: {integrity: sha512-kkyIsXKwemfSy8ZEoaIz06ApApnWsk5hQO0vLjZS6UkBiGiW++Jsyb8vSBoc0WKlffGoGs5yYy/j5pp8zckrFA==, tarball: https://registry.npmjs.org/util-arity/-/util-arity-1.1.0.tgz}
|
||||||
|
|
||||||
|
|
@ -2889,6 +2963,8 @@ snapshots:
|
||||||
'@babel/core': 7.29.7
|
'@babel/core': 7.29.7
|
||||||
'@babel/helper-plugin-utils': 7.29.7
|
'@babel/helper-plugin-utils': 7.29.7
|
||||||
|
|
||||||
|
'@babel/runtime@7.29.7': {}
|
||||||
|
|
||||||
'@babel/template@7.29.7':
|
'@babel/template@7.29.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.7
|
'@babel/code-frame': 7.29.7
|
||||||
|
|
@ -3233,6 +3309,8 @@ snapshots:
|
||||||
'@esbuild/win32-x64@0.28.2':
|
'@esbuild/win32-x64@0.28.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@faker-js/faker@10.6.0': {}
|
||||||
|
|
||||||
'@hapi/address@5.1.1':
|
'@hapi/address@5.1.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@hapi/hoek': 11.0.7
|
'@hapi/hoek': 11.0.7
|
||||||
|
|
@ -4411,6 +4489,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
lru-cache: 11.5.2
|
lru-cache: 11.5.2
|
||||||
|
|
||||||
|
html-parse-stringify@4.0.1: {}
|
||||||
|
|
||||||
http-errors@2.0.1:
|
http-errors@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
depd: 2.0.0
|
depd: 2.0.0
|
||||||
|
|
@ -4436,6 +4516,10 @@ snapshots:
|
||||||
|
|
||||||
human-signals@2.1.0: {}
|
human-signals@2.1.0: {}
|
||||||
|
|
||||||
|
i18next@26.3.6(typescript@5.9.3):
|
||||||
|
optionalDependencies:
|
||||||
|
typescript: 5.9.3
|
||||||
|
|
||||||
iconv-lite@0.4.24:
|
iconv-lite@0.4.24:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
|
|
@ -4887,6 +4971,17 @@ snapshots:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
scheduler: 0.23.2
|
scheduler: 0.23.2
|
||||||
|
|
||||||
|
react-i18next@17.0.11(i18next@26.3.6(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3):
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.29.7
|
||||||
|
html-parse-stringify: 4.0.1
|
||||||
|
i18next: 26.3.6(typescript@5.9.3)
|
||||||
|
react: 18.3.1
|
||||||
|
use-sync-external-store: 1.6.0(react@18.3.1)
|
||||||
|
optionalDependencies:
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
typescript: 5.9.3
|
||||||
|
|
||||||
react-refresh@0.17.0: {}
|
react-refresh@0.17.0: {}
|
||||||
|
|
||||||
react-router-dom@7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
react-router-dom@7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
|
|
@ -5301,6 +5396,10 @@ snapshots:
|
||||||
escalade: 3.2.0
|
escalade: 3.2.0
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
|
|
||||||
|
use-sync-external-store@1.6.0(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
|
||||||
util-arity@1.1.0: {}
|
util-arity@1.1.0: {}
|
||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
|
||||||
|
|
@ -6,26 +6,35 @@
|
||||||
|
|
||||||
## Vue d'ensemble
|
## Vue d'ensemble
|
||||||
|
|
||||||
Trois pièces travaillent ensemble pour que **toute** erreur, du serveur jusqu'à
|
Quatre pièces travaillent ensemble pour que **toute** erreur, du serveur jusqu'à
|
||||||
l'affichage utilisateur, passe par un chemin unique et prévisible :
|
l'affichage utilisateur, passe par un chemin unique et prévisible :
|
||||||
|
|
||||||
- **`packages/shared`** — le contrat : `ErrorCode` (énumération de tous les codes
|
- **`packages/shared`** — le contrat : `ErrorCode` (énumération **numérique** de
|
||||||
d'erreur métier) et `ApiErrorResponse` (forme JSON de toute réponse d'erreur de
|
tous les codes d'erreur métier) et `ApiErrorResponse` (forme JSON de toute
|
||||||
l'API). Ni l'API ni le web ne définissent leur propre liste de codes.
|
réponse d'erreur de l'API). Ni l'API ni le web ne définissent leur propre liste
|
||||||
- **`apps/api` → `ErrorHandlerService`** — centralise la traduction de n'importe
|
de codes, et aucune valeur n'est jamais codée en dur ailleurs (toujours
|
||||||
quelle erreur levée (validation zod, `HttpError` métier, erreur inattendue) en
|
`ErrorCode.XXX`, jamais un nombre/une chaîne littérale).
|
||||||
`{ status, body }` conforme au contrat. Le middleware d'erreur d'Express
|
- **`packages/express-tools`** — package séparé pour l'outillage Express générique
|
||||||
(`app.ts`) ne fait qu'appeler ce service.
|
(réutilisable par n'importe quel service Express du monorepo, pas seulement
|
||||||
- **`apps/web` → `ErrorMessageService`** — centralise la traduction de chaque
|
`apps/api`) : `HttpError`, `ErrorHandlerService`, `createErrorMiddleware`.
|
||||||
`ErrorCode` en libellé affichable, avec un système de locale (`fr` aujourd'hui,
|
- **`apps/api`** — consomme `express-tools` : lève des `HttpError`, le middleware
|
||||||
extensible). Les composants n'écrivent jamais de texte d'erreur en dur.
|
d'erreur final n'est qu'un appel à `createErrorMiddleware(errorHandlerService)`.
|
||||||
|
- **`apps/web` → `ErrorMessageService`** — associe chaque `ErrorCode` à une clé de
|
||||||
|
traduction, résolue via **i18next** (fichiers de locale sous `src/locales/`).
|
||||||
|
Les composants n'écrivent jamais de texte d'erreur en dur.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
|
subgraph TOOLS["packages/express-tools"]
|
||||||
|
HTTPERR["HttpError"]
|
||||||
|
EHS["ErrorHandlerService.handle()"]
|
||||||
|
MW["createErrorMiddleware()"]
|
||||||
|
end
|
||||||
|
|
||||||
subgraph API["apps/api"]
|
subgraph API["apps/api"]
|
||||||
THROW["Route / service<br/>throw new HttpError(status, code, message)"]
|
THROW["Route / service<br/>throw new HttpError(status, code, message)"]
|
||||||
EHS["ErrorHandlerService.handle()"]
|
|
||||||
THROW --> EHS
|
THROW --> EHS
|
||||||
|
MW -->|"app.use(...)"| EHS
|
||||||
end
|
end
|
||||||
|
|
||||||
EHS -->|"JSON: { code, message, details? }"| HTTP["Réponse HTTP"]
|
EHS -->|"JSON: { code, message, details? }"| HTTP["Réponse HTTP"]
|
||||||
|
|
@ -33,18 +42,20 @@ flowchart LR
|
||||||
subgraph WEB["apps/web"]
|
subgraph WEB["apps/web"]
|
||||||
CLIENT["ApiClient<br/>lève ApiError(status, code, ...)"]
|
CLIENT["ApiClient<br/>lève ApiError(status, code, ...)"]
|
||||||
EMS["ErrorMessageService.getLabel(code)"]
|
EMS["ErrorMessageService.getLabel(code)"]
|
||||||
|
I18N["i18next<br/>locales/fr/translation.json"]
|
||||||
UI["Composant (LoginPage, SignupPage...)"]
|
UI["Composant (LoginPage, SignupPage...)"]
|
||||||
CLIENT --> EMS --> UI
|
CLIENT --> EMS --> I18N --> UI
|
||||||
end
|
end
|
||||||
|
|
||||||
HTTP --> CLIENT
|
HTTP --> CLIENT
|
||||||
|
|
||||||
SHARED[("packages/shared<br/>ErrorCode, ApiErrorResponse")]
|
SHARED[("packages/shared<br/>ErrorCode (numérique), ApiErrorResponse")]
|
||||||
SHARED -. contrat .-> THROW
|
SHARED -. contrat .-> THROW
|
||||||
SHARED -. contrat .-> CLIENT
|
SHARED -. contrat .-> CLIENT
|
||||||
SHARED -. contrat .-> EMS
|
SHARED -. contrat .-> EMS
|
||||||
|
|
||||||
style SHARED fill:none,stroke:#888,stroke-width:1px
|
style SHARED fill:none,stroke:#888,stroke-width:1px
|
||||||
|
style TOOLS fill:none,stroke:#888,stroke-width:1px
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -53,12 +64,12 @@ flowchart LR
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
enum ErrorCode {
|
enum ErrorCode {
|
||||||
VALIDATION_ERROR,
|
VALIDATION_ERROR = 4000,
|
||||||
EMAIL_ALREADY_IN_USE,
|
EMAIL_ALREADY_IN_USE = 4001,
|
||||||
INVALID_CREDENTIALS,
|
INVALID_CREDENTIALS = 4010,
|
||||||
NOT_AUTHENTICATED,
|
NOT_AUTHENTICATED = 4011,
|
||||||
NOT_FOUND,
|
NOT_FOUND = 4040,
|
||||||
INTERNAL_ERROR,
|
INTERNAL_ERROR = 5000,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiErrorResponse {
|
interface ApiErrorResponse {
|
||||||
|
|
@ -68,45 +79,75 @@ interface ApiErrorResponse {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Codes numériques, groupés par famille** (comme les codes HTTP) : `4000`–`4099`
|
||||||
|
validation, `4010`–`4019` authentification, `4040`–`4049` ressource introuvable,
|
||||||
|
`5000`–`5099` interne. Le numéro donne une indication de la catégorie même sans
|
||||||
|
regarder l'enum.
|
||||||
|
|
||||||
**Règle** : `message` est destiné aux logs/au débogage (toujours en anglais, jamais
|
**Règle** : `message` est destiné aux logs/au débogage (toujours en anglais, jamais
|
||||||
localisé). Le texte affiché à l'utilisateur vient **toujours** de
|
localisé). Le texte affiché à l'utilisateur vient **toujours** de
|
||||||
`ErrorMessageService.getLabel(code)` côté client, jamais de `message` directement.
|
`ErrorMessageService.getLabel(code)` côté client, jamais de `message` directement.
|
||||||
|
Et **aucune valeur `ErrorCode` n'est jamais écrite en dur** (ni en nombre, ni en
|
||||||
|
chaîne) — toujours une référence `ErrorCode.XXX`, y compris dans les tests/mocks.
|
||||||
|
|
||||||
Pour ajouter un nouveau cas d'erreur :
|
Pour ajouter un nouveau cas d'erreur :
|
||||||
1. Ajouter le membre dans `ErrorCode`.
|
1. Ajouter le membre dans `ErrorCode`, dans la bonne plage numérique.
|
||||||
2. Le lever via `new HttpError(status, ErrorCode.XXX, "message dev-facing")`.
|
2. Le lever via `new HttpError(status, ErrorCode.XXX, "message dev-facing")`.
|
||||||
3. Ajouter sa traduction dans `ErrorMessageService.LABELS.fr`.
|
3. Ajouter sa traduction dans **chaque** fichier `apps/web/src/locales/*/translation.json`, sous `errors.XXX`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## `packages/express-tools` — outillage Express générique
|
||||||
|
|
||||||
|
Séparé d'`apps/api` volontairement : ce sont des briques génériques (n'importe quel
|
||||||
|
service Express du monorepo pourrait les utiliser), pas de logique métier.
|
||||||
|
|
||||||
|
- **`http-error.ts`** — `HttpError` : erreur typée portant `status` (code HTTP) et
|
||||||
|
`code` (`ErrorCode`). C'est ce que lèvent les routes/services au lieu de
|
||||||
|
construire une réponse HTTP à la main.
|
||||||
|
- **`error-handler.service.ts`** — `ErrorHandlerService` : un seul point qui sait
|
||||||
|
transformer n'importe quelle erreur JS (`ZodError`, `HttpError`, n'importe quoi
|
||||||
|
d'autre) en `{ status, body }`. Le cas générique (`INTERNAL_ERROR`, 500) logue
|
||||||
|
l'erreur côté serveur sans jamais exposer de détail interne au client.
|
||||||
|
- **`error-middleware.ts`** — `createErrorMiddleware(service)` : construit le
|
||||||
|
middleware d'erreur Express (signature à 4 arguments) à partir du service —
|
||||||
|
adaptateur fin, aucune logique de mapping n'y vit.
|
||||||
|
|
||||||
|
Build réel (`tsc` → `dist/`, comme `packages/shared`) : consommé en JS compilé,
|
||||||
|
pas en TS brut — voir la note dans
|
||||||
|
[frontend-architecture.md](./frontend-architecture.md#note-sur-les-fichiers-dts)
|
||||||
|
sur pourquoi ça compte pour un runtime Node pur (Docker).
|
||||||
|
|
||||||
## Côté API (`apps/api`)
|
## Côté API (`apps/api`)
|
||||||
|
|
||||||
- **`lib/http-error.ts`** — `HttpError` : erreur typée portant `status` (code HTTP)
|
- **`app.ts`** — le middleware d'erreur final est
|
||||||
et `code` (`ErrorCode`). C'est ce que lèvent les routes/services au lieu de
|
`app.use(createErrorMiddleware(errorHandlerService))` ; aucune logique de
|
||||||
construire une réponse HTTP à la main.
|
mapping n'y vit directement, tout est dans `express-tools`.
|
||||||
- **`services/error-handler.service.ts`** — `ErrorHandlerService` : un seul point
|
- Les modules métier (`modules/auth/auth.service.ts`, `middlewares/require-auth.ts`)
|
||||||
qui sait transformer n'importe quelle erreur JS (`ZodError`, `HttpError`,
|
importent `HttpError` depuis `@batch-cooking/express-tools` et `ErrorCode` depuis
|
||||||
n'importe quoi d'autre) en `{ status, body }`. Le cas générique (`INTERNAL_ERROR`,
|
`@batch-cooking/shared`.
|
||||||
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`)
|
## Côté Web (`apps/web`)
|
||||||
|
|
||||||
- **`api/client.ts`** — `ApiClient` : lève `ApiError` (porteur de `status`, `code`,
|
- **`api/client.ts`** — `ApiClient` : lève `ApiError` (porteur de `status`, `code`,
|
||||||
`fieldErrors`) pour toute réponse non-2xx.
|
`fieldErrors`) pour toute réponse non-2xx.
|
||||||
- **`services/error-message.service.ts`** — `ErrorMessageService` : associe chaque
|
- **`services/error-message.service.ts`** — `ErrorMessageService` : convertit le
|
||||||
`ErrorCode` à un libellé, par locale (`Record<Locale, Record<ErrorCode, string>>`).
|
`ErrorCode` numérique reçu en nom de membre (`ErrorCode[code]`, ex. `4001` →
|
||||||
Une seule langue existe aujourd'hui (`fr`), mais la structure est prête pour en
|
`"EMAIL_ALREADY_IN_USE"`), puis délègue la traduction à **i18next**
|
||||||
ajouter une deuxième sans toucher aux composants.
|
(`i18n.t(\`errors.${memberName}\`)`). N'a pas sa propre table de libellés — c'est
|
||||||
|
i18next + les fichiers de locale qui la portent.
|
||||||
|
- **`i18n/i18n.ts`** + **`locales/fr/translation.json`** — configuration et
|
||||||
|
ressources i18next. Ajouter une langue = ajouter une entrée `resources.<lng>`
|
||||||
|
pointant vers un nouveau fichier de locale, sans toucher un seul composant.
|
||||||
- Les pages (`LoginPage`, `SignupPage`) attrapent `ApiError`, récupèrent `err.code`,
|
- Les pages (`LoginPage`, `SignupPage`) attrapent `ApiError`, récupèrent `err.code`,
|
||||||
et appellent `errorMessageService.getLabel(err.code)` pour l'afficher — jamais
|
et appellent `errorMessageService.getLabel(err.code)` pour l'afficher — jamais
|
||||||
`err.message`.
|
`err.message`.
|
||||||
|
|
||||||
## Validation côté formulaire (distincte du contrat d'erreurs API)
|
## 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
|
Les schémas zod partagés (`packages/shared/src/schemas/auth.ts`) portent leurs
|
||||||
messages en français, utilisés pour la validation **avant** l'appel réseau (retour
|
propres messages en français, utilisés pour la validation **avant** l'appel réseau
|
||||||
instantané, aucun aller-retour serveur). C'est un mécanisme séparé du contrat
|
(retour instantané, aucun aller-retour serveur). C'est un mécanisme séparé du
|
||||||
`ErrorCode` : ces messages ne quittent jamais le navigateur.
|
contrat `ErrorCode`/i18next : ces messages ne quittent jamais le navigateur, et ne
|
||||||
|
vivent pas dans les fichiers de locale (ils sont dans `packages/shared`, consommé
|
||||||
|
aussi par l'API qui ne dépend pas d'i18next).
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,12 @@
|
||||||
apps/web/src/
|
apps/web/src/
|
||||||
├── api/
|
├── api/
|
||||||
│ └── client.ts # ApiClient — appels fetch vers l'API (voir error-handling.md)
|
│ └── client.ts # ApiClient — appels fetch vers l'API (voir error-handling.md)
|
||||||
|
├── i18n/
|
||||||
|
│ └── i18n.ts # config i18next, importé une fois (main.tsx) pour son effet de bord
|
||||||
|
├── locales/
|
||||||
|
│ └── fr/translation.json # libellés français (errors.*, auth.*, home.*)
|
||||||
├── services/
|
├── services/
|
||||||
│ └── error-message.service.ts # ErrorMessageService — libellés d'erreur i18n
|
│ └── error-message.service.ts # ErrorMessageService — code d'erreur → clé i18next
|
||||||
├── features/
|
├── features/
|
||||||
│ └── auth/ # tout ce qui concerne l'authentification
|
│ └── auth/ # tout ce qui concerne l'authentification
|
||||||
│ ├── AuthContext.tsx # état global (profil connecté, login/signup/logout)
|
│ ├── AuthContext.tsx # état global (profil connecté, login/signup/logout)
|
||||||
|
|
@ -29,7 +33,7 @@ apps/web/src/
|
||||||
├── lib/
|
├── lib/
|
||||||
│ └── zod-errors.ts # utilitaire : erreurs zod → { champ: message }
|
│ └── zod-errors.ts # utilitaire : erreurs zod → { champ: message }
|
||||||
├── App.tsx # table de routes
|
├── App.tsx # table de routes
|
||||||
└── main.tsx # point d'entrée : providers (Router, AuthProvider) + import du CSS global
|
└── main.tsx # point d'entrée : providers (Router, AuthProvider) + imports i18n/CSS globaux
|
||||||
```
|
```
|
||||||
|
|
||||||
**Règle de placement des styles** : un style spécifique à un seul composant/page vit
|
**Règle de placement des styles** : un style spécifique à un seul composant/page vit
|
||||||
|
|
@ -82,9 +86,40 @@ partagé avec l'API. En résumé côté frontend :
|
||||||
le cookie de session httpOnly parte/revienne, l'API et le web étant sur des
|
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
|
origines différentes). Lève `ApiError` (porteuse du `code` d'erreur) pour toute
|
||||||
réponse non-2xx.
|
réponse non-2xx.
|
||||||
- `ErrorMessageService` (`services/error-message.service.ts`) — traduit un `code`
|
- `ErrorMessageService` (`services/error-message.service.ts`) — convertit un `code`
|
||||||
d'erreur en libellé affichable, avec support de locale (`fr` uniquement pour
|
d'erreur numérique en clé de traduction, résolue via i18next.
|
||||||
l'instant).
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## i18n (internationalisation)
|
||||||
|
|
||||||
|
**i18next** + **react-i18next** — pas de solution maison : tout le texte affiché
|
||||||
|
(libellés de formulaire, boutons, messages d'erreur) vient de fichiers de locale
|
||||||
|
JSON, jamais codé en dur dans un composant.
|
||||||
|
|
||||||
|
- `i18n/i18n.ts` — initialise l'instance i18next (langue par défaut `fr`), importé
|
||||||
|
une seule fois pour son effet de bord dans `main.tsx`, avant le premier rendu.
|
||||||
|
- `locales/fr/translation.json` — toutes les chaînes françaises, organisées par
|
||||||
|
namespace : `errors.*` (voir [error-handling.md](./error-handling.md)),
|
||||||
|
`auth.login.*` / `auth.signup.*`, `home.*`.
|
||||||
|
- Dans un composant : `const { t } = useTranslation(); t("auth.login.title")`.
|
||||||
|
- Ajouter une langue : créer `locales/<lng>/translation.json` avec les mêmes clés,
|
||||||
|
ajouter `resources.<lng>` dans `i18n/i18n.ts` — aucun composant à toucher.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Note sur les fichiers `.d.ts`
|
||||||
|
|
||||||
|
Aucun fichier `.d.ts` écrit à la main dans `apps/web` : le
|
||||||
|
`/// <reference types="vite/client" />` généré par défaut par Vite (habituellement
|
||||||
|
`vite-env.d.ts`) est remplacé par `"types": ["vite/client"]` dans
|
||||||
|
`tsconfig.app.json` — même effet (typage de `import.meta.env`, imports d'assets),
|
||||||
|
sans fichier dédié.
|
||||||
|
|
||||||
|
Même logique côté `apps/api` : l'augmentation du type `Express.Request` (pour
|
||||||
|
`req.userProfile`) vit dans `src/types/express-request.augment.ts`, un fichier
|
||||||
|
`.ts` classique (pas `.d.ts`) — une augmentation `declare global` fonctionne
|
||||||
|
identiquement dans les deux, tant que le fichier a un import qui en fait un module.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue