Login/signup UI (apps/web) (#6)
* 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.
This commit is contained in:
parent
bc582b75d6
commit
3ad854a269
28 changed files with 770 additions and 52 deletions
36
README.md
36
README.md
|
|
@ -5,10 +5,11 @@
|
||||||
Monorepo pnpm workspaces :
|
Monorepo pnpm workspaces :
|
||||||
|
|
||||||
- `apps/api` — backend Express/TypeScript (squelette générique : healthcheck, config env, Prisma non modélisé, tests Mocha + Cucumber/BDD)
|
- `apps/api` — backend Express/TypeScript (squelette générique : healthcheck, config env, Prisma non modélisé, tests Mocha + Cucumber/BDD)
|
||||||
- `apps/web` — frontend React/Vite/TypeScript (squelette générique, prêt à être embarqué par Capacitor plus tard)
|
- `apps/web` — frontend React/Vite/TypeScript, prêt à être embarqué par Capacitor plus tard.
|
||||||
- `packages/shared` — code partagé entre `api` et `web` (types, schémas de validation, constantes) — vide pour l'instant
|
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`,
|
||||||
Aucun module métier n'est encore implémenté : cette base ne contient que l'outillage générique (lint/format, tests, CI, DB locale).
|
`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.
|
||||||
|
|
||||||
## Prérequis
|
## Prérequis
|
||||||
|
|
||||||
|
|
@ -22,6 +23,7 @@ Aucun module métier n'est encore implémenté : cette base ne contient que l'ou
|
||||||
pnpm install
|
pnpm install
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
cp apps/api/.env.example apps/api/.env
|
cp apps/api/.env.example apps/api/.env
|
||||||
|
cp apps/web/.env.example apps/web/.env
|
||||||
```
|
```
|
||||||
|
|
||||||
Puis **édite ces deux `.env`** pour renseigner de vrais `POSTGRES_USER`/`POSTGRES_PASSWORD`
|
Puis **édite ces deux `.env`** pour renseigner de vrais `POSTGRES_USER`/`POSTGRES_PASSWORD`
|
||||||
|
|
@ -144,3 +146,29 @@ Les tests (Mocha + Cucumber) tournent avec un coût argon2 réduit
|
||||||
élevé (sécurité), ce qui rendrait la suite de tests lente/instable sinon. La CI
|
élevé (sécurité), ce qui rendrait la suite de tests lente/instable sinon. La CI
|
||||||
provisionne un vrai Postgres de service (`.github/workflows/ci.yml`) et exécute
|
provisionne un vrai Postgres de service (`.github/workflows/ci.yml`) et exécute
|
||||||
`prisma migrate deploy` avant les tests.
|
`prisma migrate deploy` avant les tests.
|
||||||
|
|
||||||
|
> **Les tests automatisés et `pnpm dev:api` partagent la même base Postgres locale.**
|
||||||
|
> Lancer `pnpm test`/`test:bdd` **vide `user_profiles`/`house`** (`TRUNCATE ... CASCADE`,
|
||||||
|
> voir `test-support/reset-db.ts`) — si tu es en train de tester manuellement à la main
|
||||||
|
> (via le navigateur ou curl) contre le serveur de dev, un run de tests en parallèle
|
||||||
|
> efface tes données de test sans prévenir. Pas un bug, juste à savoir.
|
||||||
|
|
||||||
|
## 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/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).
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"postinstall": "prisma generate"
|
"postinstall": "prisma generate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@batch-cooking/shared": "workspace:*",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"argon2": "0.31.2",
|
"argon2": "0.31.2",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,12 @@ export function createApp() {
|
||||||
app.use("/auth", authRouter);
|
app.use("/auth", authRouter);
|
||||||
|
|
||||||
app.use((_req: Request, res: Response) => {
|
app.use((_req: Request, res: Response) => {
|
||||||
res.status(404).json({ error: "Not found" });
|
res.status(404).json({ error: "Ressource introuvable" });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
if (err instanceof ZodError) {
|
if (err instanceof ZodError) {
|
||||||
res.status(400).json({ error: "Validation error", details: err.flatten() });
|
res.status(400).json({ error: "Erreur de validation", details: err.flatten() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (err instanceof HttpError) {
|
if (err instanceof HttpError) {
|
||||||
|
|
@ -35,7 +35,7 @@ export function createApp() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.status(500).json({ error: "Internal server error" });
|
res.status(500).json({ error: "Erreur interne du serveur" });
|
||||||
});
|
});
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,14 @@ export async function requireAuth(req: Request, _res: Response, next: NextFuncti
|
||||||
try {
|
try {
|
||||||
const token = req.cookies?.[env.AUTH_COOKIE_NAME];
|
const token = req.cookies?.[env.AUTH_COOKIE_NAME];
|
||||||
if (typeof token !== "string") {
|
if (typeof token !== "string") {
|
||||||
throw new HttpError(401, "Not authenticated");
|
throw new HttpError(401, "Non authentifié");
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = verifyAuthToken(token);
|
const payload = verifyAuthToken(token);
|
||||||
const profile = await prisma.userProfile.findUnique({ where: { id: payload.userProfileId } });
|
const profile = await prisma.userProfile.findUnique({ where: { id: payload.userProfileId } });
|
||||||
|
|
||||||
if (!profile || profile.tokenVersion !== payload.tokenVersion) {
|
if (!profile || profile.tokenVersion !== payload.tokenVersion) {
|
||||||
throw new HttpError(401, "Not authenticated");
|
throw new HttpError(401, "Non authentifié");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { passwordHash: _passwordHash, ...safeProfile } = profile;
|
const { passwordHash: _passwordHash, ...safeProfile } = profile;
|
||||||
|
|
@ -29,7 +29,7 @@ export async function requireAuth(req: Request, _res: Response, next: NextFuncti
|
||||||
next(err);
|
next(err);
|
||||||
} else {
|
} else {
|
||||||
// Covers jwt.verify failures (expired/invalid/malformed token).
|
// Covers jwt.verify failures (expired/invalid/malformed token).
|
||||||
next(new HttpError(401, "Not authenticated"));
|
next(new HttpError(401, "Non authentifié"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
|
import { loginSchema, signupSchema } from "@batch-cooking/shared";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import type { CookieOptions } from "express";
|
import type { CookieOptions } from "express";
|
||||||
import { env } from "../../config/env.js";
|
import { env } from "../../config/env.js";
|
||||||
import { requireAuth } from "../../middlewares/require-auth.js";
|
import { requireAuth } from "../../middlewares/require-auth.js";
|
||||||
import { loginSchema, signupSchema } from "./auth.schema.js";
|
|
||||||
import { login, signup } from "./auth.service.js";
|
import { login, signup } from "./auth.service.js";
|
||||||
|
|
||||||
export const authRouter = Router();
|
export const authRouter = Router();
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export const signupSchema = z.object({
|
|
||||||
firstName: z.string().trim().min(1).max(100),
|
|
||||||
lastName: z.string().trim().min(1).max(100),
|
|
||||||
email: z.string().trim().toLowerCase().email(),
|
|
||||||
// Length only — not the place to enforce complexity rules; argon2 already
|
|
||||||
// makes brute-forcing short-but-random passwords impractical, and
|
|
||||||
// complexity rules mostly push users toward predictable patterns.
|
|
||||||
password: z.string().min(8).max(200),
|
|
||||||
});
|
|
||||||
export type SignupInput = z.infer<typeof signupSchema>;
|
|
||||||
|
|
||||||
export const loginSchema = z.object({
|
|
||||||
email: z.string().trim().toLowerCase().email(),
|
|
||||||
password: z.string().min(1),
|
|
||||||
});
|
|
||||||
export type LoginInput = z.infer<typeof loginSchema>;
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
|
import type { LoginInput, SignupInput } from "@batch-cooking/shared";
|
||||||
import type { UserProfile } from "@prisma/client";
|
import type { UserProfile } from "@prisma/client";
|
||||||
import argon2 from "argon2";
|
import argon2 from "argon2";
|
||||||
import { env } from "../../config/env.js";
|
import { env } from "../../config/env.js";
|
||||||
import { prisma } from "../../db/prisma.js";
|
import { prisma } from "../../db/prisma.js";
|
||||||
import { HttpError } from "../../lib/http-error.js";
|
import { HttpError } from "../../lib/http-error.js";
|
||||||
import { signAuthToken } from "../../lib/jwt.js";
|
import { signAuthToken } from "../../lib/jwt.js";
|
||||||
import type { LoginInput, SignupInput } from "./auth.schema.js";
|
|
||||||
|
|
||||||
type SafeProfile = Omit<UserProfile, "passwordHash">;
|
type SafeProfile = Omit<UserProfile, "passwordHash">;
|
||||||
|
|
||||||
|
|
@ -24,7 +24,7 @@ function toSafeProfile(profile: UserProfile): SafeProfile {
|
||||||
export async function signup(input: SignupInput): Promise<{ profile: SafeProfile; token: string }> {
|
export async function signup(input: SignupInput): Promise<{ profile: SafeProfile; token: string }> {
|
||||||
const existing = await prisma.userProfile.findUnique({ where: { email: input.email } });
|
const existing = await prisma.userProfile.findUnique({ where: { email: input.email } });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new HttpError(409, "Email already in use");
|
throw new HttpError(409, "Cet email est déjà utilisé");
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await argon2.hash(input.password, hashOptions);
|
const passwordHash = await argon2.hash(input.password, hashOptions);
|
||||||
|
|
@ -57,7 +57,7 @@ export async function login(input: LoginInput): Promise<{ profile: SafeProfile;
|
||||||
// Deliberately generic error/message for both "no such email" and "wrong
|
// Deliberately generic error/message for both "no such email" and "wrong
|
||||||
// password" — don't leak which one it was.
|
// password" — don't leak which one it was.
|
||||||
if (!profile || !(await argon2.verify(profile.passwordHash, input.password))) {
|
if (!profile || !(await argon2.verify(profile.passwordHash, input.password))) {
|
||||||
throw new HttpError(401, "Invalid email or password");
|
throw new HttpError(401, "Email ou mot de passe incorrect");
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion });
|
const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion });
|
||||||
|
|
|
||||||
2
apps/web/.env.example
Normal file
2
apps/web/.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Vite only exposes vars prefixed with VITE_ to client code.
|
||||||
|
VITE_API_URL=http://localhost:3000
|
||||||
151
apps/web/cypress/e2e/auth.cy.ts
Normal file
151
apps/web/cypress/e2e/auth.cy.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
// 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
describe("smoke test", () => {
|
describe("smoke test", () => {
|
||||||
it("loads the app shell", () => {
|
it("redirects an unauthenticated visitor to the login page", () => {
|
||||||
|
cy.intercept("GET", "**/auth/me", { statusCode: 401, body: { error: "Non authentifié" } });
|
||||||
cy.visit("/");
|
cy.visit("/");
|
||||||
cy.contains("h1", "batchCooking").should("be.visible");
|
cy.url().should("include", "/login");
|
||||||
|
cy.contains("h1", "Se connecter").should("be.visible");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,11 @@
|
||||||
"e2e": "start-server-and-test dev http://localhost:5173 cy:run"
|
"e2e": "start-server-and-test dev http://localhost:5173 cy:run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@batch-cooking/shared": "workspace:*",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^7.18.2",
|
||||||
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,38 @@
|
||||||
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated";
|
||||||
|
import { RequireAuth } from "./features/auth/RequireAuth";
|
||||||
|
import { HomePage } from "./pages/HomePage";
|
||||||
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
|
import { SignupPage } from "./pages/SignupPage";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<main>
|
<Routes>
|
||||||
<h1>batchCooking</h1>
|
<Route
|
||||||
<p>Setup initial — les features arriveront une fois les specs définies.</p>
|
path="/"
|
||||||
</main>
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<HomePage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/login"
|
||||||
|
element={
|
||||||
|
<RedirectIfAuthenticated>
|
||||||
|
<LoginPage />
|
||||||
|
</RedirectIfAuthenticated>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/signup"
|
||||||
|
element={
|
||||||
|
<RedirectIfAuthenticated>
|
||||||
|
<SignupPage />
|
||||||
|
</RedirectIfAuthenticated>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
51
apps/web/src/api/client.ts
Normal file
51
apps/web/src/api/client.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import type { LoginInput, SafeUserProfile, SignupInput } from "@batch-cooking/shared";
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3000";
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
fieldErrors?: Record<string, string[] | undefined>;
|
||||||
|
|
||||||
|
constructor(status: number, message: string, fieldErrors?: Record<string, string[] | undefined>) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
this.status = status;
|
||||||
|
this.fieldErrors = fieldErrors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new ApiError(res.status, body.error ?? "Something went wrong", body.details?.fieldErrors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
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");
|
||||||
|
}
|
||||||
54
apps/web/src/features/auth/AuthContext.tsx
Normal file
54
apps/web/src/features/auth/AuthContext.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
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";
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
user: SafeUserProfile | null;
|
||||||
|
/** True only while the initial /auth/me check (on app load) is pending. */
|
||||||
|
isLoading: boolean;
|
||||||
|
signup: (input: SignupInput) => Promise<void>;
|
||||||
|
login: (input: LoginInput) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<SafeUserProfile | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.me()
|
||||||
|
.then(setUser)
|
||||||
|
.catch(() => setUser(null))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const signup = useCallback(async (input: SignupInput) => {
|
||||||
|
setUser(await api.signup(input));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = useCallback(async (input: LoginInput) => {
|
||||||
|
setUser(await api.login(input));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
await api.logout();
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, isLoading, signup, login, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useAuth must be used within an AuthProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
16
apps/web/src/features/auth/RedirectIfAuthenticated.tsx
Normal file
16
apps/web/src/features/auth/RedirectIfAuthenticated.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
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. */
|
||||||
|
export function RedirectIfAuthenticated({ children }: { children: ReactNode }) {
|
||||||
|
const { user, isLoading } = useAuth();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (user) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
16
apps/web/src/features/auth/RequireAuth.tsx
Normal file
16
apps/web/src/features/auth/RequireAuth.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "./AuthContext";
|
||||||
|
|
||||||
|
/** Redirects to /login if there's no authenticated session. */
|
||||||
|
export function RequireAuth({ children }: { children: ReactNode }) {
|
||||||
|
const { user, isLoading } = useAuth();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
64
apps/web/src/index.css
Normal file
64
apps/web/src/index.css
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
: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;
|
||||||
|
}
|
||||||
13
apps/web/src/lib/zod-errors.ts
Normal file
13
apps/web/src/lib/zod-errors.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import type { ZodError } from "zod";
|
||||||
|
|
||||||
|
/** First error message per field, for simple inline form display. */
|
||||||
|
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)) {
|
||||||
|
if (messages?.[0]) {
|
||||||
|
result[key] = messages[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
|
import { AuthProvider } from "./features/auth/AuthContext";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
|
|
@ -9,6 +12,10 @@ if (!rootElement) {
|
||||||
|
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
24
apps/web/src/pages/HomePage.tsx
Normal file
24
apps/web/src/pages/HomePage.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../features/auth/AuthContext";
|
||||||
|
|
||||||
|
export function HomePage() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await logout();
|
||||||
|
navigate("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="home-page">
|
||||||
|
<h1>batchCooking</h1>
|
||||||
|
<p>
|
||||||
|
Bonjour {user?.firstName} {user?.lastName} 👋
|
||||||
|
</p>
|
||||||
|
<button type="button" onClick={handleLogout}>
|
||||||
|
Se déconnecter
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
apps/web/src/pages/LoginPage.tsx
Normal file
77
apps/web/src/pages/LoginPage.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { 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";
|
||||||
|
import { fieldErrorsFrom } from "../lib/zod-errors";
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setFormError(null);
|
||||||
|
|
||||||
|
const result = loginSchema.safeParse({ email, password });
|
||||||
|
if (!result.success) {
|
||||||
|
setFieldErrors(fieldErrorsFrom(result.error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFieldErrors({});
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await login(result.data);
|
||||||
|
navigate("/");
|
||||||
|
} catch (err) {
|
||||||
|
setFormError(err instanceof ApiError ? err.message : "Something went wrong");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="auth-page">
|
||||||
|
<form className="auth-card" onSubmit={handleSubmit} noValidate>
|
||||||
|
<h1>Se connecter</h1>
|
||||||
|
|
||||||
|
<label htmlFor="email">Email</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
|
||||||
|
|
||||||
|
<label htmlFor="password">Mot de passe</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
{fieldErrors.password && <p className="field-error">{fieldErrors.password}</p>}
|
||||||
|
|
||||||
|
{formError && <p className="form-error">{formError}</p>}
|
||||||
|
|
||||||
|
<button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Connexion…" : "Se connecter"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="auth-switch">
|
||||||
|
Pas encore de compte ? <Link to="/signup">Créer un profil</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
apps/web/src/pages/SignupPage.tsx
Normal file
97
apps/web/src/pages/SignupPage.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { 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";
|
||||||
|
import { fieldErrorsFrom } from "../lib/zod-errors";
|
||||||
|
|
||||||
|
export function SignupPage() {
|
||||||
|
const { signup } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
const [lastName, setLastName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setFormError(null);
|
||||||
|
|
||||||
|
const result = signupSchema.safeParse({ firstName, lastName, email, password });
|
||||||
|
if (!result.success) {
|
||||||
|
setFieldErrors(fieldErrorsFrom(result.error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFieldErrors({});
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await signup(result.data);
|
||||||
|
navigate("/");
|
||||||
|
} catch (err) {
|
||||||
|
setFormError(err instanceof ApiError ? err.message : "Something went wrong");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="auth-page">
|
||||||
|
<form className="auth-card" onSubmit={handleSubmit} noValidate>
|
||||||
|
<h1>Créer un profil</h1>
|
||||||
|
|
||||||
|
<label htmlFor="firstName">Prénom</label>
|
||||||
|
<input
|
||||||
|
id="firstName"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
autoComplete="given-name"
|
||||||
|
/>
|
||||||
|
{fieldErrors.firstName && <p className="field-error">{fieldErrors.firstName}</p>}
|
||||||
|
|
||||||
|
<label htmlFor="lastName">Nom</label>
|
||||||
|
<input
|
||||||
|
id="lastName"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
autoComplete="family-name"
|
||||||
|
/>
|
||||||
|
{fieldErrors.lastName && <p className="field-error">{fieldErrors.lastName}</p>}
|
||||||
|
|
||||||
|
<label htmlFor="email">Email</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
|
||||||
|
|
||||||
|
<label htmlFor="password">Mot de passe</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
{fieldErrors.password && <p className="field-error">{fieldErrors.password}</p>}
|
||||||
|
|
||||||
|
{formError && <p className="form-error">{formError}</p>}
|
||||||
|
|
||||||
|
<button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Création…" : "Créer mon profil"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="auth-switch">
|
||||||
|
Déjà un compte ? <Link to="/login">Se connecter</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,16 +3,23 @@
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./dist/index.js",
|
||||||
"types": "./src/index.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"no tests yet\" && exit 0",
|
"test": "echo \"no tests yet\" && exit 0",
|
||||||
"build": "echo \"no build step — consumed as TS source within the workspace\" && exit 0"
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"postinstall": "tsc -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^3.25.76"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,2 @@
|
||||||
// Point d'entrée du code partagé entre apps/api et apps/web.
|
export * from "./schemas/auth.js";
|
||||||
// Types, schémas de validation (zod) et constantes communes seront ajoutés
|
export * from "./types/user-profile.js";
|
||||||
// ici une fois le modèle de données défini.
|
|
||||||
|
|
||||||
export {};
|
|
||||||
|
|
|
||||||
24
packages/shared/src/schemas/auth.ts
Normal file
24
packages/shared/src/schemas/auth.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
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.
|
||||||
|
|
||||||
|
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),
|
||||||
|
email: z.string().trim().toLowerCase().email("Email invalide"),
|
||||||
|
// Length only — not the place to enforce complexity rules; argon2 already
|
||||||
|
// makes brute-forcing short-but-random passwords impractical, and
|
||||||
|
// complexity rules mostly push users toward predictable patterns.
|
||||||
|
password: z.string().min(8, "8 caractères minimum").max(200),
|
||||||
|
});
|
||||||
|
export type SignupInput = z.infer<typeof signupSchema>;
|
||||||
|
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
email: z.string().trim().toLowerCase().email("Email invalide"),
|
||||||
|
password: z.string().min(1, "Le mot de passe est requis"),
|
||||||
|
});
|
||||||
|
export type LoginInput = z.infer<typeof loginSchema>;
|
||||||
12
packages/shared/src/types/user-profile.ts
Normal file
12
packages/shared/src/types/user-profile.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
// 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 {
|
||||||
|
id: number;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
tokenVersion: number;
|
||||||
|
houseId: number | null;
|
||||||
|
dietId: number | null;
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "ESNext",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "NodeNext",
|
||||||
"noEmit": true
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ importers:
|
||||||
|
|
||||||
apps/api:
|
apps/api:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@batch-cooking/shared':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/shared
|
||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^5.22.0
|
specifier: ^5.22.0
|
||||||
version: 5.22.0(prisma@5.22.0)
|
version: 5.22.0(prisma@5.22.0)
|
||||||
|
|
@ -87,12 +90,21 @@ importers:
|
||||||
|
|
||||||
apps/web:
|
apps/web:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@batch-cooking/shared':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/shared
|
||||||
react:
|
react:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1(react@18.3.1)
|
version: 18.3.1(react@18.3.1)
|
||||||
|
react-router-dom:
|
||||||
|
specifier: ^7.18.2
|
||||||
|
version: 7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
zod:
|
||||||
|
specifier: ^3.25.76
|
||||||
|
version: 3.25.76
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.9.0
|
specifier: ^22.9.0
|
||||||
|
|
@ -120,6 +132,10 @@ importers:
|
||||||
version: 5.4.21(@types/node@22.20.1)
|
version: 5.4.21(@types/node@22.20.1)
|
||||||
|
|
||||||
packages/shared:
|
packages/shared:
|
||||||
|
dependencies:
|
||||||
|
zod:
|
||||||
|
specifier: ^3.25.76
|
||||||
|
version: 3.25.76
|
||||||
devDependencies:
|
devDependencies:
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.7.2
|
specifier: ^5.7.2
|
||||||
|
|
@ -1263,6 +1279,10 @@ packages:
|
||||||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==, tarball: https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz}
|
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==, tarball: https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
cookie@1.1.1:
|
||||||
|
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==, tarball: https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
cookiejar@2.1.4:
|
cookiejar@2.1.4:
|
||||||
resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==, tarball: https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz}
|
resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==, tarball: https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz}
|
||||||
|
|
||||||
|
|
@ -2190,6 +2210,23 @@ packages:
|
||||||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==, tarball: https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz}
|
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==, tarball: https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
react-router-dom@7.18.2:
|
||||||
|
resolution: {integrity: sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==, tarball: https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=18'
|
||||||
|
react-dom: '>=18'
|
||||||
|
|
||||||
|
react-router@7.18.2:
|
||||||
|
resolution: {integrity: sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==, tarball: https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=18'
|
||||||
|
react-dom: '>=18'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
react@18.3.1:
|
react@18.3.1:
|
||||||
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==, tarball: https://registry.npmjs.org/react/-/react-18.3.1.tgz}
|
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==, tarball: https://registry.npmjs.org/react/-/react-18.3.1.tgz}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -2283,6 +2320,9 @@ packages:
|
||||||
set-blocking@2.0.0:
|
set-blocking@2.0.0:
|
||||||
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==, tarball: https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz}
|
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==, tarball: https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz}
|
||||||
|
|
||||||
|
set-cookie-parser@2.7.2:
|
||||||
|
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==, tarball: https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz}
|
||||||
|
|
||||||
setprototypeof@1.2.0:
|
setprototypeof@1.2.0:
|
||||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==, tarball: https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz}
|
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==, tarball: https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz}
|
||||||
|
|
||||||
|
|
@ -3688,6 +3728,8 @@ snapshots:
|
||||||
|
|
||||||
cookie@0.7.2: {}
|
cookie@0.7.2: {}
|
||||||
|
|
||||||
|
cookie@1.1.1: {}
|
||||||
|
|
||||||
cookiejar@2.1.4: {}
|
cookiejar@2.1.4: {}
|
||||||
|
|
||||||
core-util-is@1.0.2: {}
|
core-util-is@1.0.2: {}
|
||||||
|
|
@ -4682,6 +4724,20 @@ snapshots:
|
||||||
|
|
||||||
react-refresh@0.17.0: {}
|
react-refresh@0.17.0: {}
|
||||||
|
|
||||||
|
react-router-dom@7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
react-router: 7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
|
||||||
|
react-router@7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
cookie: 1.1.1
|
||||||
|
react: 18.3.1
|
||||||
|
set-cookie-parser: 2.7.2
|
||||||
|
optionalDependencies:
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
|
||||||
react@18.3.1:
|
react@18.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
|
|
@ -4818,6 +4874,8 @@ snapshots:
|
||||||
|
|
||||||
set-blocking@2.0.0: {}
|
set-blocking@2.0.0: {}
|
||||||
|
|
||||||
|
set-cookie-parser@2.7.2: {}
|
||||||
|
|
||||||
setprototypeof@1.2.0: {}
|
setprototypeof@1.2.0: {}
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue