Centralize error handling (shared codes + API/client services), code quality pass
## Error handling
Requested: a centralized error-handling service on the API, custom error
codes shared across apps, and a client-side error service for i18n labels.
- packages/shared/src/errors/error-codes.ts — ErrorCode enum + ApiErrorResponse
contract. Single source of truth: neither side hardcodes a raw error string
the other has to guess at.
- apps/api: HttpError now carries an ErrorCode (not just a message).
ErrorHandlerService (new) centralizes every "how do we turn a thrown error
into an HTTP response" decision — app.ts's error middleware is now a thin
adapter calling into it. API messages reverted to English/dev-facing (they
were French from an earlier pass) since user-facing text is now generated
client-side from the code.
- apps/web: ApiClient (class, singleton instance) throws ApiError carrying
the code. ErrorMessageService (new) maps every ErrorCode to a localized
label, structured with a Locale type from the start (only "fr" exists, but
adding a language later is "add a locale to the map", not "hunt down every
hardcoded string"). LoginPage/SignupPage now display
errorMessageService.getLabel(err.code), never err.message directly.
- Tests strengthened to assert on `code`, not just HTTP status (Mocha +
Cucumber, new "the response error code should be" step). Cypress mocks
updated to the new {code, message} response shape.
## Code quality pass
Per explicit feedback: heavy JSDoc on every interface/type/class/function/
method/member touched in this PR, explicit public/private visibility on
every class member (ApiClient, ErrorMessageService, ErrorHandlerService,
HttpError), no HTML/logic mixing (styling extracted out of components
entirely, never inline).
ApiClient/ErrorMessageService were initially written as static-only classes;
switched to instance-based singletons (matching ErrorHandlerService's
existing pattern) after Biome's noStaticOnlyClass rule flagged the
static-only shape as an anti-pattern — same "class with visibility
modifiers" outcome, without fighting the linter.
## SCSS + theming
- apps/web/src/styles/_theme.scss — design tokens as CSS custom properties
on :root (colors, spacing, typography), not plain Sass variables — makes
them available at runtime, not just compile time, so a future theme
switch (e.g. dark mode) is "redefine these variables" rather than
rebuilding stylesheets.
- apps/web/src/styles/global.scss replaces the old single index.css:
reset + theme import only, loaded once from main.tsx.
- Per-page/component styles colocated (HomePage.tsx + HomePage.scss);
styles shared by multiple pages within one feature live in that feature's
folder (features/auth/auth-form.scss, used by both Login/SignupPage) —
not duplicated per page, not dumped in the global stylesheet either.
- Component-level .scss files intentionally don't `@use` the theme
partial: they only consume CSS custom properties (global at runtime via
global.scss), not Sass-level symbols, so importing it would do nothing —
documented inline rather than left as a silently-redundant import.
- vite.config.ts opts into Sass's modern compiler API to silence a
legacy-js-api deprecation warning on every build.
## specs/ updates
- New specs/error-handling.md — the ErrorCode/ApiErrorResponse contract,
both services, with a flow diagram.
- New specs/frontend-architecture.md — apps/web folder structure, routing/
auth-guard flow, SCSS/theming conventions.
- specs/batch-cooking-architecture.md links to both (original doc content
otherwise untouched — it's the user's own hand-authored source doc).
## Verification
Full lint/mocha/cucumber/build green. Manually re-verified the whole auth
flow in a real browser against native dev servers (not just the automated
suites): signup, the EMAIL_ALREADY_IN_USE → "Cet email est déjà utilisé"
translation end-to-end (confirmed the raw API response carries the English
dev message + code, and the UI shows the French label), wrong-password
INVALID_CREDENTIALS → its label, and confirmed the theme tokens actually
apply (computed button background-color matches --color-primary, card
max-width matches the token value) rather than trusting the build succeeding.
This commit is contained in:
parent
3ad854a269
commit
e9d94ff5f9
42 changed files with 1193 additions and 182 deletions
33
README.md
33
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).
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { LoginInput, SignupInput } from "@batch-cooking/shared";
|
||||
import { ErrorCode, type LoginInput, type SignupInput } from "@batch-cooking/shared";
|
||||
import type { UserProfile } from "@prisma/client";
|
||||
import argon2 from "argon2";
|
||||
import { env } from "../../config/env.js";
|
||||
|
|
@ -6,8 +6,15 @@ import { prisma } from "../../db/prisma.js";
|
|||
import { HttpError } from "../../lib/http-error.js";
|
||||
import { signAuthToken } from "../../lib/jwt.js";
|
||||
|
||||
/** A UserProfile as it's safe to hand back to a client — never the password hash. */
|
||||
type SafeProfile = Omit<UserProfile, "passwordHash">;
|
||||
|
||||
/** Result of a successful signup/login: the safe profile plus the signed session JWT to set as a cookie. */
|
||||
interface AuthResult {
|
||||
profile: SafeProfile;
|
||||
token: string;
|
||||
}
|
||||
|
||||
// argon2's defaults (64 MB memory, 3 passes) are deliberately expensive —
|
||||
// that's the point, for real passwords. In tests we hash/verify dozens of
|
||||
// times per run against throwaway data, so a much cheaper cost keeps the
|
||||
|
|
@ -16,15 +23,22 @@ type SafeProfile = Omit<UserProfile, "passwordHash">;
|
|||
const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 };
|
||||
const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
|
||||
|
||||
/** Strips `passwordHash` off a Prisma UserProfile before it's ever sent to a client. */
|
||||
function toSafeProfile(profile: UserProfile): SafeProfile {
|
||||
const { passwordHash: _passwordHash, ...safeProfile } = profile;
|
||||
return safeProfile;
|
||||
}
|
||||
|
||||
export async function signup(input: SignupInput): Promise<{ profile: SafeProfile; token: string }> {
|
||||
/**
|
||||
* Creates a new household (`house`) and profile (`user_profiles`) together
|
||||
* in one transaction, hashes the password, and issues a session token.
|
||||
*
|
||||
* @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken.
|
||||
*/
|
||||
export async function signup(input: SignupInput): Promise<AuthResult> {
|
||||
const existing = await prisma.userProfile.findUnique({ where: { email: input.email } });
|
||||
if (existing) {
|
||||
throw new HttpError(409, "Cet email est déjà utilisé");
|
||||
throw new HttpError(409, ErrorCode.EMAIL_ALREADY_IN_USE, "Email already in use");
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(input.password, hashOptions);
|
||||
|
|
@ -51,13 +65,18 @@ export async function signup(input: SignupInput): Promise<{ profile: SafeProfile
|
|||
return { profile: toSafeProfile(profile), token };
|
||||
}
|
||||
|
||||
export async function login(input: LoginInput): Promise<{ profile: SafeProfile; token: string }> {
|
||||
/**
|
||||
* Verifies credentials and issues a fresh session token.
|
||||
*
|
||||
* @throws {HttpError} `401 INVALID_CREDENTIALS` for either an unknown email
|
||||
* or a wrong password — deliberately the same error either way, so a
|
||||
* caller can never learn whether a given email has an account.
|
||||
*/
|
||||
export async function login(input: LoginInput): Promise<AuthResult> {
|
||||
const profile = await prisma.userProfile.findUnique({ where: { email: input.email } });
|
||||
|
||||
// Deliberately generic error/message for both "no such email" and "wrong
|
||||
// password" — don't leak which one it was.
|
||||
if (!profile || !(await argon2.verify(profile.passwordHash, input.password))) {
|
||||
throw new HttpError(401, "Email ou mot de passe incorrect");
|
||||
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password");
|
||||
}
|
||||
|
||||
const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion });
|
||||
|
|
|
|||
73
apps/api/src/services/error-handler.service.ts
Normal file
73
apps/api/src/services/error-handler.service.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { type ApiErrorResponse, ErrorCode } from "@batch-cooking/shared";
|
||||
import { ZodError } from "zod";
|
||||
import { HttpError } from "../lib/http-error.js";
|
||||
|
||||
/** Return value of {@link ErrorHandlerService.handle}: everything an Express error middleware needs to send a response. */
|
||||
export interface ErrorHandlingResult {
|
||||
/** HTTP status code to respond with. */
|
||||
status: number;
|
||||
/** JSON body to respond with — matches the shared {@link ApiErrorResponse} contract. */
|
||||
body: ApiErrorResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralizes every "how do we turn a thrown error into an HTTP response"
|
||||
* decision for the API in one place, so route handlers and the Express
|
||||
* error middleware in `app.ts` never duplicate this logic.
|
||||
*
|
||||
* Recognizes three error shapes today (zod validation failures, our own
|
||||
* `HttpError`, and anything else) and always falls back to a safe, generic
|
||||
* 500 for the unknown case — a caller of `handle()` never needs its own
|
||||
* fallback branch.
|
||||
*/
|
||||
export class ErrorHandlerService {
|
||||
/**
|
||||
* Maps any thrown value into a status + body pair ready to send to the
|
||||
* client. Always succeeds — an error that doesn't match a known shape
|
||||
* becomes a generic {@link ErrorCode.INTERNAL_ERROR} and is logged.
|
||||
*/
|
||||
public handle(error: unknown): ErrorHandlingResult {
|
||||
if (error instanceof ZodError) {
|
||||
return this.fromZodError(error);
|
||||
}
|
||||
if (error instanceof HttpError) {
|
||||
return this.fromHttpError(error);
|
||||
}
|
||||
return this.fromUnknownError(error);
|
||||
}
|
||||
|
||||
/** Request body/query failed schema validation — always a 400. */
|
||||
private fromZodError(error: ZodError): ErrorHandlingResult {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
code: ErrorCode.VALIDATION_ERROR,
|
||||
message: "Validation error",
|
||||
details: error.flatten().fieldErrors,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Our own typed error — status/code were decided by whoever threw it. */
|
||||
private fromHttpError(error: HttpError): ErrorHandlingResult {
|
||||
return {
|
||||
status: error.status,
|
||||
body: { code: error.code, message: error.message },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Anything unrecognized: logged server-side (so it's still diagnosable)
|
||||
* but never leaks internal details to the client — always a generic 500.
|
||||
*/
|
||||
private fromUnknownError(error: unknown): ErrorHandlingResult {
|
||||
console.error(error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { code: ErrorCode.INTERNAL_ERROR, message: "Internal server error" },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Single shared instance — this service is stateless, no need for one per request. */
|
||||
export const errorHandlerService = new ErrorHandlerService();
|
||||
3
apps/api/src/types/express.d.ts
vendored
3
apps/api/src/types/express.d.ts
vendored
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ import { HomePage } from "./pages/HomePage";
|
|||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { SignupPage } from "./pages/SignupPage";
|
||||
|
||||
/**
|
||||
* Top-level route table. `/` requires an authenticated session (see
|
||||
* {@link RequireAuth}); `/login` and `/signup` redirect an already-logged-in
|
||||
* visitor to `/` instead (see {@link RedirectIfAuthenticated}). Anything
|
||||
* else falls back to `/`, which itself redirects to `/login` if needed.
|
||||
*/
|
||||
export function App() {
|
||||
return (
|
||||
<Routes>
|
||||
|
|
|
|||
|
|
@ -1,51 +1,97 @@
|
|||
import type { LoginInput, SafeUserProfile, SignupInput } from "@batch-cooking/shared";
|
||||
import type {
|
||||
ApiErrorResponse,
|
||||
ErrorCode,
|
||||
LoginInput,
|
||||
SafeUserProfile,
|
||||
SignupInput,
|
||||
} from "@batch-cooking/shared";
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3000";
|
||||
/** Base URL of the API, configurable via `VITE_API_URL` (see `.env.example`). */
|
||||
const API_BASE_URL: string = import.meta.env.VITE_API_URL ?? "http://localhost:3000";
|
||||
|
||||
/**
|
||||
* Thrown by {@link ApiClient} whenever the API responds with a non-2xx
|
||||
* status. Carries the same {@link ErrorCode} the API returned, so callers
|
||||
* can branch on `error.code` (and UI code can look up its label via
|
||||
* `ErrorMessageService.getLabel(error.code)`) instead of parsing text.
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
fieldErrors?: Record<string, string[] | undefined>;
|
||||
/** HTTP status code of the failed response. */
|
||||
public readonly status: number;
|
||||
/** Machine-readable error code — see {@link ErrorCode}. */
|
||||
public readonly code: ErrorCode;
|
||||
/** Per-field validation messages, present only when `code` is `VALIDATION_ERROR`. */
|
||||
public readonly fieldErrors?: Record<string, string[] | undefined>;
|
||||
|
||||
constructor(status: number, message: string, fieldErrors?: Record<string, string[] | undefined>) {
|
||||
super(message);
|
||||
public constructor(status: number, body: ApiErrorResponse) {
|
||||
super(body.message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.fieldErrors = fieldErrors;
|
||||
this.code = body.code;
|
||||
this.fieldErrors = body.details;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`${API_URL}${path}`, {
|
||||
...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<TResponseBody>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<TResponseBody> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...options,
|
||||
// Required for the httpOnly session cookie to be sent/received — the
|
||||
// API and the web app run on different origins.
|
||||
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<TResponseBody>;
|
||||
}
|
||||
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
/** Creates a profile (+ household) and starts a session. */
|
||||
public signup(input: SignupInput): Promise<SafeUserProfile> {
|
||||
return this.request("/auth/signup", { method: "POST", body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
/** Verifies credentials and starts a session. */
|
||||
public login(input: LoginInput): Promise<SafeUserProfile> {
|
||||
return this.request("/auth/login", { method: "POST", body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
/** Ends the current session. */
|
||||
public logout(): Promise<void> {
|
||||
return this.request("/auth/logout", { method: "POST" });
|
||||
}
|
||||
|
||||
/** Fetches the currently authenticated profile — rejects with `NOT_AUTHENTICATED` if there's no session. */
|
||||
public me(): Promise<SafeUserProfile> {
|
||||
return this.request("/auth/me");
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function signup(input: SignupInput): Promise<SafeUserProfile> {
|
||||
return request("/auth/signup", { method: "POST", body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
export function login(input: LoginInput): Promise<SafeUserProfile> {
|
||||
return request("/auth/login", { method: "POST", body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
export function logout(): Promise<void> {
|
||||
return request("/auth/logout", { method: "POST" });
|
||||
}
|
||||
|
||||
export function me(): Promise<SafeUserProfile> {
|
||||
return request("/auth/me");
|
||||
}
|
||||
/** Single shared instance — this client is stateless, no need for one per caller. */
|
||||
export const apiClient = new ApiClient();
|
||||
|
|
|
|||
|
|
@ -1,40 +1,53 @@
|
|||
import type { LoginInput, SafeUserProfile, SignupInput } from "@batch-cooking/shared";
|
||||
import { type ReactNode, createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import * as api from "../../api/client";
|
||||
import { apiClient } from "../../api/client";
|
||||
|
||||
/** Shape of the auth state/actions exposed via {@link useAuth}. */
|
||||
interface AuthContextValue {
|
||||
/** Currently authenticated profile, or `null` if no active session. */
|
||||
user: SafeUserProfile | null;
|
||||
/** True only while the initial /auth/me check (on app load) is pending. */
|
||||
/** True only while the initial `/auth/me` check (on app load) is pending — lets route guards avoid a premature redirect. */
|
||||
isLoading: boolean;
|
||||
/** Creates a profile (+ household) and updates `user` on success. Throws `ApiError` on failure. */
|
||||
signup: (input: SignupInput) => Promise<void>;
|
||||
/** Verifies credentials and updates `user` on success. Throws `ApiError` on failure. */
|
||||
login: (input: LoginInput) => Promise<void>;
|
||||
/** Ends the session and clears `user`. */
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** React context carrying {@link AuthContextValue} — always accessed through {@link useAuth}, never directly. */
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Provides authentication state to the whole app. On mount, calls
|
||||
* `GET /auth/me` once to restore the session from the httpOnly cookie (if
|
||||
* any) — this is what lets a page reload keep the user logged in.
|
||||
*/
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<SafeUserProfile | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
apiClient
|
||||
.me()
|
||||
.then(setUser)
|
||||
// No session cookie (or it's invalid/expired) — that's the normal
|
||||
// state for a first-time visitor, not an error to surface.
|
||||
.catch(() => setUser(null))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
const signup = useCallback(async (input: SignupInput) => {
|
||||
setUser(await api.signup(input));
|
||||
setUser(await apiClient.signup(input));
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (input: LoginInput) => {
|
||||
setUser(await api.login(input));
|
||||
setUser(await apiClient.login(input));
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await api.logout();
|
||||
await apiClient.logout();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
|
|
@ -45,6 +58,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
);
|
||||
}
|
||||
|
||||
/** Reads the current auth state/actions. Must be called from within an {@link AuthProvider}. */
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
88
apps/web/src/features/auth/auth-form.scss
Normal file
88
apps/web/src/features/auth/auth-form.scss
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// =============================================================================
|
||||
// Styles shared by LoginPage and SignupPage — both render the same card/form
|
||||
// layout, so this lives in features/auth/ (the concern both pages share)
|
||||
// rather than being duplicated in each page's own stylesheet. Imported by
|
||||
// both LoginPage.tsx and SignupPage.tsx.
|
||||
// =============================================================================
|
||||
|
||||
// No `@use` of the theme partial needed here: every design token below is a
|
||||
// CSS custom property (--color-*, --space-*...) declared once on :root in
|
||||
// styles/global.scss and available globally at runtime — not a Sass-level
|
||||
// variable/mixin that would require an explicit compile-time import.
|
||||
|
||||
// Full-viewport centering wrapper for the auth card.
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
// The form itself: a vertically-stacked card, capped width so it stays
|
||||
// readable on wide screens.
|
||||
.auth-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
width: 100%;
|
||||
max-width: var(--max-width-form);
|
||||
|
||||
// Field labels sit directly above their input, with a little breathing
|
||||
// room from the previous field.
|
||||
label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
input {
|
||||
padding: var(--space-sm);
|
||||
font-size: var(--font-size-base);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
}
|
||||
|
||||
// Submit button: full-width, visually separated from the fields above it.
|
||||
button {
|
||||
margin-top: var(--space-md);
|
||||
padding: 0.6rem;
|
||||
font-size: var(--font-size-base);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-base);
|
||||
border: none;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-field validation message (client-side, from zod) — sits directly
|
||||
// under its input.
|
||||
.field-error {
|
||||
color: var(--color-error);
|
||||
font-size: var(--font-size-xs);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Whole-form error message (from the API, e.g. wrong credentials) — sits
|
||||
// above the submit button.
|
||||
.form-error {
|
||||
color: var(--color-error);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
// "Already have an account? / No account yet?" link row under the form.
|
||||
.auth-switch {
|
||||
font-size: var(--font-size-sm);
|
||||
margin-top: var(--space-md);
|
||||
text-align: center;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,13 +1,18 @@
|
|||
import type { ZodError } from "zod";
|
||||
|
||||
/** First error message per field, for simple inline form display. */
|
||||
/**
|
||||
* Flattens a zod validation error into `{ fieldName: firstMessage }`, for
|
||||
* simple inline display under each form field (only the first message per
|
||||
* field is shown — good enough for the single-rule-per-field schemas this
|
||||
* app uses today).
|
||||
*/
|
||||
export function fieldErrorsFrom(error: ZodError): Record<string, string> {
|
||||
const flat = error.flatten().fieldErrors;
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, messages] of Object.entries(flat)) {
|
||||
const fieldErrors = error.flatten().fieldErrors;
|
||||
const firstMessagePerField: Record<string, string> = {};
|
||||
for (const [field, messages] of Object.entries(fieldErrors)) {
|
||||
if (messages?.[0]) {
|
||||
result[key] = messages[0];
|
||||
firstMessagePerField[field] = messages[0];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
return firstMessagePerField;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
35
apps/web/src/pages/HomePage.scss
Normal file
35
apps/web/src/pages/HomePage.scss
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// =============================================================================
|
||||
// Styles specific to HomePage — colocated next to HomePage.tsx since nothing
|
||||
// else uses these classes.
|
||||
// =============================================================================
|
||||
|
||||
// No `@use` of the theme partial needed here: every design token below is a
|
||||
// CSS custom property (--color-*, --space-*...) declared once on :root in
|
||||
// styles/global.scss and available globally at runtime — not a Sass-level
|
||||
// variable/mixin that would require an explicit compile-time import.
|
||||
|
||||
// Full-viewport centering wrapper, mirroring .auth-page's layout so the app
|
||||
// doesn't visually jump between the login/signup screens and the home page.
|
||||
.home-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
|
||||
button {
|
||||
padding: 0.6rem var(--space-md);
|
||||
font-size: var(--font-size-base);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-base);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -1,20 +1,37 @@
|
|||
import { loginSchema } from "@batch-cooking/shared";
|
||||
import { ErrorCode, loginSchema } from "@batch-cooking/shared";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { ApiError } from "../api/client";
|
||||
import { useAuth } from "../features/auth/AuthContext";
|
||||
// Shared with SignupPage — see the file for why it's colocated in
|
||||
// features/auth/ rather than duplicated per page.
|
||||
import "../features/auth/auth-form.scss";
|
||||
import { fieldErrorsFrom } from "../lib/zod-errors";
|
||||
import { errorMessageService } from "../services/error-message.service";
|
||||
|
||||
/**
|
||||
* Login form. Validates client-side first (via the shared `loginSchema`,
|
||||
* same rules the API enforces) for instant feedback with no network round
|
||||
* trip; only calls the API once the payload is locally valid, and
|
||||
* translates any API failure into a localized label via
|
||||
* {@link ErrorMessageService}.
|
||||
*/
|
||||
export function LoginPage() {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Controlled form fields.
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
// Per-field validation messages (client-side, from zod) and a whole-form
|
||||
// error message (from the API), kept separate since they're displayed
|
||||
// in different places and cleared at different times.
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
/** Validates, then submits the form; navigates home on success. */
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
|
@ -31,7 +48,11 @@ export function LoginPage() {
|
|||
await login(result.data);
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : "Something went wrong");
|
||||
// ApiError.code is looked up through ErrorMessageService so the
|
||||
// label is centralized and localized — never display err.message
|
||||
// directly, it's the API's developer-facing (English) text.
|
||||
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||
setFormError(errorMessageService.getLabel(code));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,39 @@
|
|||
import { signupSchema } from "@batch-cooking/shared";
|
||||
import { ErrorCode, signupSchema } from "@batch-cooking/shared";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { ApiError } from "../api/client";
|
||||
import { useAuth } from "../features/auth/AuthContext";
|
||||
// Shared with LoginPage — see the file for why it's colocated in
|
||||
// features/auth/ rather than duplicated per page.
|
||||
import "../features/auth/auth-form.scss";
|
||||
import { fieldErrorsFrom } from "../lib/zod-errors";
|
||||
import { errorMessageService } from "../services/error-message.service";
|
||||
|
||||
/**
|
||||
* Signup form (profile creation). Validates client-side first (via the
|
||||
* shared `signupSchema`, same rules the API enforces) for instant
|
||||
* feedback with no network round trip; only calls the API once the
|
||||
* payload is locally valid, and translates any API failure (e.g. email
|
||||
* already taken) into a localized label via {@link ErrorMessageService}.
|
||||
*/
|
||||
export function SignupPage() {
|
||||
const { signup } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Controlled form fields.
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
// Per-field validation messages (client-side, from zod) and a whole-form
|
||||
// error message (from the API), kept separate since they're displayed
|
||||
// in different places and cleared at different times.
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
/** Validates, then submits the form; navigates home on success. */
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
|
@ -33,7 +50,11 @@ export function SignupPage() {
|
|||
await signup(result.data);
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : "Something went wrong");
|
||||
// ApiError.code is looked up through ErrorMessageService so the
|
||||
// label is centralized and localized — never display err.message
|
||||
// directly, it's the API's developer-facing (English) text.
|
||||
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||
setFormError(errorMessageService.getLabel(code));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
|
|
|||
53
apps/web/src/services/error-message.service.ts
Normal file
53
apps/web/src/services/error-message.service.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { ErrorCode } from "@batch-cooking/shared";
|
||||
|
||||
/** Locales this service knows how to label errors in. Extend here when adding a new language. */
|
||||
export type Locale = "fr";
|
||||
|
||||
/** Per-locale map of every {@link ErrorCode} to its user-facing label. */
|
||||
type LabelsByLocale = Record<Locale, Record<ErrorCode, string>>;
|
||||
|
||||
/**
|
||||
* Centralizes every user-facing error label in the app, keyed by the
|
||||
* {@link ErrorCode} the API returns. Components never hardcode error text —
|
||||
* they call `errorMessageService.getLabel(...)` and get back the right
|
||||
* string for the current locale.
|
||||
*
|
||||
* Only French exists today (the whole UI is French), but the `Locale`
|
||||
* type and the per-locale label map exist so adding a second language
|
||||
* later is "add a locale to the map", not "hunt down every hardcoded
|
||||
* string in every component". Used as a single shared instance
|
||||
* (`errorMessageService`, exported below).
|
||||
*/
|
||||
export class ErrorMessageService {
|
||||
/** Locale used when none is explicitly requested — the only one that exists today. */
|
||||
private readonly defaultLocale: Locale = "fr";
|
||||
|
||||
/** Every known error code's label, per locale. */
|
||||
private readonly labels: LabelsByLocale = {
|
||||
fr: {
|
||||
[ErrorCode.VALIDATION_ERROR]: "Erreur de validation",
|
||||
[ErrorCode.EMAIL_ALREADY_IN_USE]: "Cet email est déjà utilisé",
|
||||
[ErrorCode.INVALID_CREDENTIALS]: "Email ou mot de passe incorrect",
|
||||
[ErrorCode.NOT_AUTHENTICATED]: "Vous devez être connecté",
|
||||
[ErrorCode.NOT_FOUND]: "Ressource introuvable",
|
||||
[ErrorCode.INTERNAL_ERROR]: "Une erreur est survenue, réessayez plus tard",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the localized, user-facing label for a given error code.
|
||||
*
|
||||
* @param code - Error code as returned by the API. Typed as `string` (not
|
||||
* strictly `ErrorCode`) because it's coming off the network — an
|
||||
* unrecognized value falls back to the generic "internal error" label
|
||||
* instead of throwing.
|
||||
* @param locale - Defaults to {@link defaultLocale}.
|
||||
*/
|
||||
public getLabel(code: string, locale: Locale = this.defaultLocale): string {
|
||||
const labelsForLocale = this.labels[locale];
|
||||
return labelsForLocale[code as ErrorCode] ?? labelsForLocale[ErrorCode.INTERNAL_ERROR];
|
||||
}
|
||||
}
|
||||
|
||||
/** Single shared instance — this service is stateless, no need for one per caller. */
|
||||
export const errorMessageService = new ErrorMessageService();
|
||||
55
apps/web/src/styles/_theme.scss
Normal file
55
apps/web/src/styles/_theme.scss
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// =============================================================================
|
||||
// Design tokens — the single source of truth for colors, spacing, typography
|
||||
// and other reusable values across the whole app.
|
||||
//
|
||||
// Exposed as CSS custom properties on :root (not plain SCSS variables) so
|
||||
// they're available at *runtime*, not just compile time — this is what
|
||||
// would let a future dark-mode toggle (or any theme switch) just redefine
|
||||
// these variables instead of rebuilding the stylesheet. Every other .scss
|
||||
// file should reference `var(--token-name)`, never a hardcoded color/size.
|
||||
//
|
||||
// Import this partial once, globally (see global.scss) — never re-import it
|
||||
// from a component-level .scss file, `:root` only needs to be declared once.
|
||||
// =============================================================================
|
||||
|
||||
:root {
|
||||
// --- Color palette --------------------------------------------------------
|
||||
// Neutral surface: page background vs. the "card" surface content sits on.
|
||||
--color-background: #ffffff;
|
||||
--color-surface: #ffffff;
|
||||
// Text.
|
||||
--color-text: #1a1a1a;
|
||||
--color-text-muted: #555555;
|
||||
// Brand/accent — used for primary buttons and links.
|
||||
--color-primary: #2f6f4f;
|
||||
--color-primary-hover: #24573e;
|
||||
// Feedback.
|
||||
--color-error: #c0392b;
|
||||
--color-border: #888888;
|
||||
|
||||
// --- Spacing scale ---------------------------------------------------------
|
||||
// Multiples of a 4px base unit — use these instead of ad hoc px values so
|
||||
// spacing stays visually consistent as the app grows.
|
||||
--space-xs: 0.25rem; // 4px
|
||||
--space-sm: 0.5rem; // 8px
|
||||
--space-md: 1rem; // 16px
|
||||
--space-lg: 1.5rem; // 24px
|
||||
--space-xl: 2rem; // 32px
|
||||
|
||||
// --- Typography --------------------------------------------------------
|
||||
--font-family-base: system-ui, sans-serif;
|
||||
--font-size-base: 1rem;
|
||||
--font-size-sm: 0.875rem;
|
||||
--font-size-xs: 0.8rem;
|
||||
|
||||
// --- Shape / misc --------------------------------------------------------
|
||||
--radius-base: 4px;
|
||||
--max-width-form: 22rem;
|
||||
}
|
||||
|
||||
// Lets the browser pick sensible default colors (form controls, scrollbars)
|
||||
// for whichever mode (light/dark) the user's OS is in, until this app has
|
||||
// its own explicit dark theme wired to the tokens above.
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
17
apps/web/src/styles/global.scss
Normal file
17
apps/web/src/styles/global.scss
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// =============================================================================
|
||||
// Global stylesheet — imported exactly once, in main.tsx. Contains only
|
||||
// truly app-wide rules: the theme tokens and a minimal reset/base styling
|
||||
// that every page inherits. Anything specific to one component or page
|
||||
// belongs in a .scss file colocated next to that component/page instead.
|
||||
// =============================================================================
|
||||
|
||||
@use "./theme";
|
||||
|
||||
// Minimal reset: remove the default body margin so pages can control their
|
||||
// own layout without fighting the browser's default 8px margin.
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-family-base);
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
45
packages/shared/src/errors/error-codes.ts
Normal file
45
packages/shared/src/errors/error-codes.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Enumeration of every business/domain error code the API can return.
|
||||
*
|
||||
* This is the single source of truth for error identification across the
|
||||
* whole monorepo: `apps/api` throws errors carrying one of these codes,
|
||||
* and `apps/web` maps each code to a localized, user-facing label (see
|
||||
* `apps/web/src/services/error-message.service.ts`). Neither side should
|
||||
* ever hardcode a raw error string that the other side has to guess at —
|
||||
* the code is the contract.
|
||||
*
|
||||
* When adding a new failure case in the API:
|
||||
* 1. Add a new member here.
|
||||
* 2. Throw it via `HttpError` (apps/api/src/lib/http-error.ts).
|
||||
* 3. Add its translation in `ErrorMessageService` (apps/web).
|
||||
*/
|
||||
export enum ErrorCode {
|
||||
/** Request body/query failed zod schema validation. */
|
||||
VALIDATION_ERROR = "VALIDATION_ERROR",
|
||||
/** Signup attempted with an email that already has a profile. */
|
||||
EMAIL_ALREADY_IN_USE = "EMAIL_ALREADY_IN_USE",
|
||||
/** Login failed — wrong email or wrong password (never say which). */
|
||||
INVALID_CREDENTIALS = "INVALID_CREDENTIALS",
|
||||
/** Request required a session cookie/JWT that is missing, invalid, or stale. */
|
||||
NOT_AUTHENTICATED = "NOT_AUTHENTICATED",
|
||||
/** No route/resource matches the request. */
|
||||
NOT_FOUND = "NOT_FOUND",
|
||||
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
|
||||
INTERNAL_ERROR = "INTERNAL_ERROR",
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of every JSON error body the API returns, whatever the failure.
|
||||
* Kept intentionally small and stable: `code` is what clients should
|
||||
* branch on, `message` is a human-readable (English, developer-facing)
|
||||
* description useful for logs/debugging — never shown to end users as-is,
|
||||
* since end-user-facing text is localized client-side from `code`.
|
||||
*/
|
||||
export interface ApiErrorResponse {
|
||||
/** Machine-readable error identifier — see {@link ErrorCode}. */
|
||||
code: ErrorCode;
|
||||
/** Developer-facing description (English). Not localized, not for UI display. */
|
||||
message: string;
|
||||
/** Present only for VALIDATION_ERROR: per-field error messages from zod. */
|
||||
details?: Record<string, string[] | undefined>;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -3,9 +3,13 @@ import { z } from "zod";
|
|||
// Shared between apps/api (server-side validation, source of truth) and
|
||||
// apps/web (client-side validation for instant feedback before the round
|
||||
// trip) — one set of rules, no risk of the two drifting apart.
|
||||
// Messages are in French: this is the only place end users ever see zod's
|
||||
// text (surfaced as-is in apps/web's forms), and the whole UI is French.
|
||||
// Zod's own `.min()`/`.email()` messages are in French: this is the only
|
||||
// place end users ever see them (surfaced as-is in apps/web's forms), and
|
||||
// the whole UI is French. This is distinct from the ErrorCode-based i18n
|
||||
// used for *API* errors (see error-codes.ts) — these are purely
|
||||
// client-side, pre-submit validation messages that never leave the browser.
|
||||
|
||||
/** Payload accepted by `POST /auth/signup`. */
|
||||
export const signupSchema = z.object({
|
||||
firstName: z.string().trim().min(1, "Le prénom est requis").max(100),
|
||||
lastName: z.string().trim().min(1, "Le nom est requis").max(100),
|
||||
|
|
@ -15,10 +19,13 @@ export const signupSchema = z.object({
|
|||
// complexity rules mostly push users toward predictable patterns.
|
||||
password: z.string().min(8, "8 caractères minimum").max(200),
|
||||
});
|
||||
/** Inferred TS type for {@link signupSchema}'s validated output. */
|
||||
export type SignupInput = z.infer<typeof signupSchema>;
|
||||
|
||||
/** Payload accepted by `POST /auth/login`. */
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().trim().toLowerCase().email("Email invalide"),
|
||||
password: z.string().min(1, "Le mot de passe est requis"),
|
||||
});
|
||||
/** Inferred TS type for {@link loginSchema}'s validated output. */
|
||||
export type LoginInput = z.infer<typeof loginSchema>;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
// Mirrors apps/api's Omit<PrismaUserProfile, "passwordHash"> — declared by
|
||||
// hand rather than derived from the Prisma type, since apps/web must not
|
||||
// depend on @prisma/client.
|
||||
/**
|
||||
* Public shape of a user profile, as returned by the API (never includes
|
||||
* the password hash). Mirrors apps/api's `Omit<PrismaUserProfile,
|
||||
* "passwordHash">` — declared by hand rather than derived from the Prisma
|
||||
* type, since apps/web must not depend on `@prisma/client`.
|
||||
*/
|
||||
export interface SafeUserProfile {
|
||||
/** Primary key. */
|
||||
id: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
/** Incremented server-side to invalidate previously-issued JWTs (e.g. on password change). Not used directly by the client. */
|
||||
tokenVersion: number;
|
||||
/** FK to the household this profile belongs to, or `null` if not yet assigned to one. */
|
||||
houseId: number | null;
|
||||
/** FK to this profile's diet preference, or `null` if unset. */
|
||||
dietId: number | null;
|
||||
}
|
||||
|
|
|
|||
186
pnpm-lock.yaml
186
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:
|
||||
|
|
|
|||
|
|
@ -70,3 +70,13 @@ Stockage de l'ensemble des données de l'application (voir le modèle de donnée
|
|||
|
||||
- Le module de calcul batch-cooking est le principal chantier restant côté serveur (TODO).
|
||||
- Le websocket est utilisé pour la communication temps réel, en complément de l'API.
|
||||
|
||||
---
|
||||
|
||||
## Documents liés
|
||||
|
||||
Documentation d'implémentation (ajoutée au fil des features, complète ce document
|
||||
conceptuel sans le remplacer) :
|
||||
|
||||
- [error-handling.md](./error-handling.md) — contrat d'erreurs partagé entre l'API et le client
|
||||
- [frontend-architecture.md](./frontend-architecture.md) — organisation d'`apps/web`
|
||||
|
|
|
|||
112
specs/error-handling.md
Normal file
112
specs/error-handling.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# Gestion des erreurs — Projet Batch-cooking
|
||||
|
||||
> Documentation du contrat d'erreurs partagé entre `apps/api` et `apps/web`.
|
||||
|
||||
---
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Trois pièces travaillent ensemble pour que **toute** erreur, du serveur jusqu'à
|
||||
l'affichage utilisateur, passe par un chemin unique et prévisible :
|
||||
|
||||
- **`packages/shared`** — le contrat : `ErrorCode` (énumération de tous les codes
|
||||
d'erreur métier) et `ApiErrorResponse` (forme JSON de toute réponse d'erreur de
|
||||
l'API). Ni l'API ni le web ne définissent leur propre liste de codes.
|
||||
- **`apps/api` → `ErrorHandlerService`** — centralise la traduction de n'importe
|
||||
quelle erreur levée (validation zod, `HttpError` métier, erreur inattendue) en
|
||||
`{ status, body }` conforme au contrat. Le middleware d'erreur d'Express
|
||||
(`app.ts`) ne fait qu'appeler ce service.
|
||||
- **`apps/web` → `ErrorMessageService`** — centralise la traduction de chaque
|
||||
`ErrorCode` en libellé affichable, avec un système de locale (`fr` aujourd'hui,
|
||||
extensible). Les composants n'écrivent jamais de texte d'erreur en dur.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph API["apps/api"]
|
||||
THROW["Route / service<br/>throw new HttpError(status, code, message)"]
|
||||
EHS["ErrorHandlerService.handle()"]
|
||||
THROW --> EHS
|
||||
end
|
||||
|
||||
EHS -->|"JSON: { code, message, details? }"| HTTP["Réponse HTTP"]
|
||||
|
||||
subgraph WEB["apps/web"]
|
||||
CLIENT["ApiClient<br/>lève ApiError(status, code, ...)"]
|
||||
EMS["ErrorMessageService.getLabel(code)"]
|
||||
UI["Composant (LoginPage, SignupPage...)"]
|
||||
CLIENT --> EMS --> UI
|
||||
end
|
||||
|
||||
HTTP --> CLIENT
|
||||
|
||||
SHARED[("packages/shared<br/>ErrorCode, ApiErrorResponse")]
|
||||
SHARED -. contrat .-> THROW
|
||||
SHARED -. contrat .-> CLIENT
|
||||
SHARED -. contrat .-> EMS
|
||||
|
||||
style SHARED fill:none,stroke:#888,stroke-width:1px
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Le contrat (`packages/shared/src/errors/error-codes.ts`)
|
||||
|
||||
```ts
|
||||
enum ErrorCode {
|
||||
VALIDATION_ERROR,
|
||||
EMAIL_ALREADY_IN_USE,
|
||||
INVALID_CREDENTIALS,
|
||||
NOT_AUTHENTICATED,
|
||||
NOT_FOUND,
|
||||
INTERNAL_ERROR,
|
||||
}
|
||||
|
||||
interface ApiErrorResponse {
|
||||
code: ErrorCode;
|
||||
message: string; // anglais, dev-facing — jamais affiché tel quel côté UI
|
||||
details?: Record<string, string[] | undefined>; // uniquement pour VALIDATION_ERROR
|
||||
}
|
||||
```
|
||||
|
||||
**Règle** : `message` est destiné aux logs/au débogage (toujours en anglais, jamais
|
||||
localisé). Le texte affiché à l'utilisateur vient **toujours** de
|
||||
`ErrorMessageService.getLabel(code)` côté client, jamais de `message` directement.
|
||||
|
||||
Pour ajouter un nouveau cas d'erreur :
|
||||
1. Ajouter le membre dans `ErrorCode`.
|
||||
2. Le lever via `new HttpError(status, ErrorCode.XXX, "message dev-facing")`.
|
||||
3. Ajouter sa traduction dans `ErrorMessageService.LABELS.fr`.
|
||||
|
||||
---
|
||||
|
||||
## Côté API (`apps/api`)
|
||||
|
||||
- **`lib/http-error.ts`** — `HttpError` : erreur typée portant `status` (code HTTP)
|
||||
et `code` (`ErrorCode`). C'est ce que lèvent les routes/services au lieu de
|
||||
construire une réponse HTTP à la main.
|
||||
- **`services/error-handler.service.ts`** — `ErrorHandlerService` : un seul point
|
||||
qui sait transformer n'importe quelle erreur JS (`ZodError`, `HttpError`,
|
||||
n'importe quoi d'autre) en `{ status, body }`. Le cas générique (`INTERNAL_ERROR`,
|
||||
500) logue l'erreur côté serveur sans jamais exposer de détail interne au client.
|
||||
- **`app.ts`** — le middleware d'erreur final d'Express ne fait qu'appeler
|
||||
`errorHandlerService.handle(err)` et renvoyer le résultat ; aucune logique de
|
||||
mapping n'y vit directement.
|
||||
|
||||
## Côté Web (`apps/web`)
|
||||
|
||||
- **`api/client.ts`** — `ApiClient` : lève `ApiError` (porteur de `status`, `code`,
|
||||
`fieldErrors`) pour toute réponse non-2xx.
|
||||
- **`services/error-message.service.ts`** — `ErrorMessageService` : associe chaque
|
||||
`ErrorCode` à un libellé, par locale (`Record<Locale, Record<ErrorCode, string>>`).
|
||||
Une seule langue existe aujourd'hui (`fr`), mais la structure est prête pour en
|
||||
ajouter une deuxième sans toucher aux composants.
|
||||
- Les pages (`LoginPage`, `SignupPage`) attrapent `ApiError`, récupèrent `err.code`,
|
||||
et appellent `errorMessageService.getLabel(err.code)` pour l'afficher — jamais
|
||||
`err.message`.
|
||||
|
||||
## Validation côté formulaire (distincte du contrat d'erreurs API)
|
||||
|
||||
Les schémas zod partagés (`packages/shared/src/schemas/auth.ts`) portent déjà des
|
||||
messages en français, utilisés pour la validation **avant** l'appel réseau (retour
|
||||
instantané, aucun aller-retour serveur). C'est un mécanisme séparé du contrat
|
||||
`ErrorCode` : ces messages ne quittent jamais le navigateur.
|
||||
110
specs/frontend-architecture.md
Normal file
110
specs/frontend-architecture.md
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# Architecture frontend — Projet Batch-cooking
|
||||
|
||||
> Documentation de l'organisation d'`apps/web` : structure des dossiers, routing,
|
||||
> gestion des erreurs, et conventions de style (SCSS/theming).
|
||||
|
||||
---
|
||||
|
||||
## Structure des dossiers
|
||||
|
||||
```
|
||||
apps/web/src/
|
||||
├── api/
|
||||
│ └── client.ts # ApiClient — appels fetch vers l'API (voir error-handling.md)
|
||||
├── services/
|
||||
│ └── error-message.service.ts # ErrorMessageService — libellés d'erreur i18n
|
||||
├── features/
|
||||
│ └── auth/ # tout ce qui concerne l'authentification
|
||||
│ ├── AuthContext.tsx # état global (profil connecté, login/signup/logout)
|
||||
│ ├── RequireAuth.tsx # garde de route : redirige vers /login si non connecté
|
||||
│ ├── RedirectIfAuthenticated.tsx # garde de route inverse (pour /login, /signup)
|
||||
│ └── auth-form.scss # styles partagés par LoginPage et SignupPage
|
||||
├── pages/
|
||||
│ ├── LoginPage.tsx / .scss (via auth-form.scss, partagé)
|
||||
│ ├── SignupPage.tsx / .scss (via auth-form.scss, partagé)
|
||||
│ └── HomePage.tsx + HomePage.scss
|
||||
├── styles/
|
||||
│ ├── _theme.scss # tokens de design (couleurs, espacements, typographie)
|
||||
│ └── global.scss # reset minimal + import du theme — importé une seule fois (main.tsx)
|
||||
├── lib/
|
||||
│ └── zod-errors.ts # utilitaire : erreurs zod → { champ: message }
|
||||
├── App.tsx # table de routes
|
||||
└── main.tsx # point d'entrée : providers (Router, AuthProvider) + import du CSS global
|
||||
```
|
||||
|
||||
**Règle de placement des styles** : un style spécifique à un seul composant/page vit
|
||||
dans un fichier `.scss` au même niveau que ce composant (`HomePage.tsx` +
|
||||
`HomePage.scss`). Un style partagé par plusieurs composants d'une même feature vit
|
||||
dans le dossier de la feature (`features/auth/auth-form.scss`, utilisé par
|
||||
`LoginPage` et `SignupPage`). Seuls le reset et les tokens globaux vivent dans
|
||||
`styles/`.
|
||||
|
||||
---
|
||||
|
||||
## Routing et gardes d'authentification
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
START(("Visite de l'app"))
|
||||
CHECK{"AuthProvider :<br/>GET /auth/me"}
|
||||
START --> CHECK
|
||||
|
||||
CHECK -->|"200 (session valide)"| AUTHED["user défini"]
|
||||
CHECK -->|"401 (pas de session)"| ANON["user = null"]
|
||||
|
||||
AUTHED --> ROUTE_HOME["/ → HomePage"]
|
||||
AUTHED --> ROUTE_LOGIN_A["/login ou /signup"]
|
||||
ROUTE_LOGIN_A -->|"RedirectIfAuthenticated"| ROUTE_HOME
|
||||
|
||||
ANON --> ROUTE_HOME_A["/"]
|
||||
ROUTE_HOME_A -->|"RequireAuth"| ROUTE_LOGIN["/login"]
|
||||
ANON --> ROUTE_LOGIN2["/login ou /signup → rendu normal"]
|
||||
```
|
||||
|
||||
- `AuthContext` (`features/auth/AuthContext.tsx`) appelle `GET /auth/me` une seule
|
||||
fois au montage pour restaurer la session depuis le cookie httpOnly — c'est ce qui
|
||||
permet à un rechargement de page de garder l'utilisateur connecté.
|
||||
- `RequireAuth` et `RedirectIfAuthenticated` sont deux gardes de route
|
||||
(`react-router-dom`) qui lisent cet état : la première protège `/`, la seconde
|
||||
protège `/login` et `/signup` (redirige un utilisateur déjà connecté vers `/`).
|
||||
Les deux affichent `null` tant que la vérification initiale est en cours, pour
|
||||
éviter un flash de contenu suivi d'une redirection.
|
||||
|
||||
---
|
||||
|
||||
## Client API et gestion des erreurs
|
||||
|
||||
Voir [error-handling.md](./error-handling.md) pour le détail du contrat d'erreurs
|
||||
partagé avec l'API. En résumé côté frontend :
|
||||
|
||||
- `ApiClient` (`api/client.ts`) — classe avec instance unique exportée
|
||||
(`apiClient`), enveloppe `fetch` avec `credentials: "include"` (requis pour que
|
||||
le cookie de session httpOnly parte/revienne, l'API et le web étant sur des
|
||||
origines différentes). Lève `ApiError` (porteuse du `code` d'erreur) pour toute
|
||||
réponse non-2xx.
|
||||
- `ErrorMessageService` (`services/error-message.service.ts`) — traduit un `code`
|
||||
d'erreur en libellé affichable, avec support de locale (`fr` uniquement pour
|
||||
l'instant).
|
||||
|
||||
---
|
||||
|
||||
## SCSS et theming
|
||||
|
||||
- **`sass`** (Dart Sass) est utilisé via le support natif de Vite — aucune config
|
||||
supplémentaire needed au-delà d'avoir le package installé (`vite.config.ts` fixe
|
||||
juste l'API moderne de Sass pour éviter un warning de dépréciation).
|
||||
- **`styles/_theme.scss`** — tokens de design exposés en **custom properties CSS**
|
||||
sur `:root` (`--color-primary`, `--space-md`, etc.), pas en simples variables
|
||||
SCSS : ça les rend disponibles au runtime, pas seulement à la compilation — ce qui
|
||||
permettrait un futur switch de thème (ex. mode sombre) en redéfinissant juste ces
|
||||
variables, sans reconstruire les feuilles de style. Toute nouvelle règle CSS doit
|
||||
référencer `var(--token)`, jamais une couleur/valeur en dur.
|
||||
- **`styles/global.scss`** — importé une seule fois, dans `main.tsx`. Contient
|
||||
uniquement le reset minimal et l'import du thème (`@use "./theme"`). Rien de
|
||||
spécifique à une page/un composant n'y va.
|
||||
- Les tokens étant des **custom properties CSS** (pas des variables Sass), ils sont
|
||||
disponibles globalement au runtime dès que `global.scss` a été chargé une fois —
|
||||
un fichier `.scss` de composant/page les consomme directement via `var(--token)`,
|
||||
sans avoir besoin de `@use` le partiel theme (ce serait un import sans effet,
|
||||
puisqu'aucun symbole Sass n'en est consommé). Chaque fichier documente en
|
||||
commentaire à quoi correspond chaque règle un peu non-triviale.
|
||||
Loading…
Reference in a new issue