* Add login/signup UI (apps/web)
Wires the frontend to the existing auth API: signup, login, logout,
session restore on load.
- src/api/client.ts — fetch wrapper, credentials: "include" (required
for the httpOnly session cookie — api and web run on different
origins)
- src/features/auth/AuthContext.tsx — global auth state; calls
GET /auth/me on mount to restore the session from the cookie
- src/features/auth/RequireAuth.tsx / RedirectIfAuthenticated.tsx —
react-router-dom route guards (/ requires auth, /login and /signup
redirect away if already authenticated)
- src/pages/{Login,Signup,Home}Page.tsx — forms with client-side
validation via the shared zod schemas, API errors displayed as-is
Moves signupSchema/loginSchema from apps/api into packages/shared
(new SafeUserProfile type too) so frontend and backend validate with
the exact same rules — this is what that package was scaffolded for.
apps/api's auth.schema.ts is gone, auth.routes.ts/auth.service.ts now
import from @batch-cooking/shared directly.
Translated all user-facing API error messages and zod validation
messages to French (were English, inconsistent with the rest of the
UI) — found by actually clicking through the flow in a browser, not
just reading the code.
zod pinned to the same v3 range across api/web/shared on purpose:
apps/web's `pnpm add zod` initially resolved v4, which would have let
a major-version mismatch slip in silently (zod v3 and v4 aren't drop-in
compatible) since shared's schemas are built with v3.
Cypress specs updated for the new routing (unauthenticated visitors
now land on /login, not the old placeholder) and a new auth.cy.ts
mocking the API via cy.intercept — the e2e CI job has no live
backend, so these test frontend behavior only. Real API behavior is
covered by apps/api's Mocha/Cucumber suites against a real database.
Verified end-to-end in a real browser (not just curl): signup, session
persistence across reload, logout (confirmed the cookie was actually
cleared server-side, not just client state), wrong-password error
display, client-side validation blocking short passwords without a
network round trip, duplicate-email conflict. Full lint/mocha/
cucumber/build suite green.
* Fix packages/shared: build to dist/ instead of shipping raw TS
Found by Docker-packaging apps/api and actually running the container:
it crash-looped with "Cannot find module
'/repo/packages/shared/src/schemas/auth.js'" — Node's plain ESM loader
(node dist/server.js, no tsx/ts-node registered) can't execute .ts
source files.
This was invisible everywhere else: tsx (dev, mocha, cucumber) and
Vite both transpile TS on the fly regardless of what package.json
points to, so every dev/test/build path masked the problem. The
Docker container is the first place this code path actually runs
through a plain Node runtime — exactly the kind of thing "package
every change in Docker and run it" is supposed to catch.
Fix: give packages/shared a real build (tsc emitting to dist/, with
.d.ts), and point package.json's main/types/exports at dist/ instead
of src/index.ts. Added as a postinstall (same pattern as apps/api's
`prisma generate`) so dist/ regenerates automatically after any
`pnpm install`; after editing packages/shared's source directly,
`pnpm --filter shared build` (or `pnpm build`) is needed before the
change is visible to consumers pointing at the compiled dist/.
Verified: `node dist/server.js` (plain node, no tsx — mirrors exactly
what the Docker container runs) starts and responds on /health.
Rebuilt the Docker images and re-ran a full signup through the
containerized stack end-to-end. Full lint/mocha/cucumber/build suite
still green.
151 lines
4.5 KiB
TypeScript
151 lines
4.5 KiB
TypeScript
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
|
|
// .github/workflows/ci.yml), and it keeps these specs focused on frontend
|
|
// behavior. Backend behavior itself is covered by apps/api's Mocha/Cucumber
|
|
// suites against a real database.
|
|
|
|
describe("Signup", () => {
|
|
it("creates a profile and lands on the home page", () => {
|
|
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
|
cy.intercept("POST", "**/auth/signup", {
|
|
statusCode: 201,
|
|
body: {
|
|
id: 1,
|
|
firstName: "Alice",
|
|
lastName: "Martin",
|
|
email: "alice@example.com",
|
|
tokenVersion: 0,
|
|
houseId: 1,
|
|
dietId: null,
|
|
},
|
|
}).as("signup");
|
|
|
|
cy.visit("/signup");
|
|
cy.get("#firstName").type("Alice");
|
|
cy.get("#lastName").type("Martin");
|
|
cy.get("#email").type("alice@example.com");
|
|
cy.get("#password").type("correct-horse-battery-staple");
|
|
cy.contains("button", "Créer mon profil").click();
|
|
|
|
cy.wait("@signup");
|
|
cy.url().should("not.include", "/signup");
|
|
cy.contains("Bonjour Alice Martin").should("be.visible");
|
|
});
|
|
|
|
it("shows a client-side validation error without calling the API", () => {
|
|
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
|
cy.intercept("POST", "**/auth/signup").as("signup");
|
|
|
|
cy.visit("/signup");
|
|
cy.get("#firstName").type("A");
|
|
cy.get("#lastName").type("B");
|
|
cy.get("#email").type("a@example.com");
|
|
cy.get("#password").type("short");
|
|
cy.contains("button", "Créer mon profil").click();
|
|
|
|
cy.contains("8 caractères minimum").should("be.visible");
|
|
cy.get("@signup.all").should("have.length", 0);
|
|
});
|
|
|
|
it("shows the API's error when the email is already taken", () => {
|
|
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
|
cy.intercept("POST", "**/auth/signup", {
|
|
statusCode: 409,
|
|
body: { error: "Cet email est déjà utilisé" },
|
|
}).as("signup");
|
|
|
|
cy.visit("/signup");
|
|
cy.get("#firstName").type("Alice");
|
|
cy.get("#lastName").type("Martin");
|
|
cy.get("#email").type("alice@example.com");
|
|
cy.get("#password").type("correct-horse-battery-staple");
|
|
cy.contains("button", "Créer mon profil").click();
|
|
|
|
cy.wait("@signup");
|
|
cy.contains("Cet email est déjà utilisé").should("be.visible");
|
|
});
|
|
});
|
|
|
|
describe("Login", () => {
|
|
it("logs in and lands on the home page", () => {
|
|
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
|
cy.intercept("POST", "**/auth/login", {
|
|
statusCode: 200,
|
|
body: {
|
|
id: 1,
|
|
firstName: "Alice",
|
|
lastName: "Martin",
|
|
email: "alice@example.com",
|
|
tokenVersion: 0,
|
|
houseId: 1,
|
|
dietId: null,
|
|
},
|
|
}).as("login");
|
|
|
|
cy.visit("/login");
|
|
cy.get("#email").type("alice@example.com");
|
|
cy.get("#password").type("correct-horse-battery-staple");
|
|
cy.contains("button", "Se connecter").click();
|
|
|
|
cy.wait("@login");
|
|
cy.contains("Bonjour Alice Martin").should("be.visible");
|
|
});
|
|
|
|
it("shows an error on invalid credentials", () => {
|
|
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
|
cy.intercept("POST", "**/auth/login", {
|
|
statusCode: 401,
|
|
body: { error: "Email ou mot de passe incorrect" },
|
|
}).as("login");
|
|
|
|
cy.visit("/login");
|
|
cy.get("#email").type("alice@example.com");
|
|
cy.get("#password").type("wrong-password");
|
|
cy.contains("button", "Se connecter").click();
|
|
|
|
cy.wait("@login");
|
|
cy.contains("Email ou mot de passe incorrect").should("be.visible");
|
|
});
|
|
});
|
|
|
|
describe("Already authenticated", () => {
|
|
it("redirects away from /login to the home page", () => {
|
|
cy.intercept("GET", "**/auth/me", {
|
|
statusCode: 200,
|
|
body: {
|
|
id: 1,
|
|
firstName: "Alice",
|
|
lastName: "Martin",
|
|
email: "alice@example.com",
|
|
tokenVersion: 0,
|
|
houseId: 1,
|
|
dietId: null,
|
|
},
|
|
});
|
|
|
|
cy.visit("/login");
|
|
cy.url().should("not.include", "/login");
|
|
cy.contains("Bonjour Alice Martin").should("be.visible");
|
|
});
|
|
|
|
it("logs out and returns to the login page", () => {
|
|
cy.intercept("GET", "**/auth/me", {
|
|
statusCode: 200,
|
|
body: {
|
|
id: 1,
|
|
firstName: "Alice",
|
|
lastName: "Martin",
|
|
email: "alice@example.com",
|
|
tokenVersion: 0,
|
|
houseId: 1,
|
|
dietId: null,
|
|
},
|
|
});
|
|
cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout");
|
|
|
|
cy.visit("/");
|
|
cy.contains("button", "Se déconnecter").click();
|
|
|
|
cy.wait("@logout");
|
|
cy.url().should("include", "/login");
|
|
});
|
|
});
|