From 3ad854a269d977ac996ecd11dbeb7fdec02bd2c9 Mon Sep 17 00:00:00 2001 From: kyuno053 <31762247+kyuno053@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:21:40 +0200 Subject: [PATCH] Login/signup UI (apps/web) (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- README.md | 36 ++++- apps/api/package.json | 1 + apps/api/src/app.ts | 6 +- apps/api/src/middlewares/require-auth.ts | 6 +- apps/api/src/modules/auth/auth.routes.ts | 2 +- apps/api/src/modules/auth/auth.schema.ts | 18 --- apps/api/src/modules/auth/auth.service.ts | 6 +- apps/web/.env.example | 2 + apps/web/cypress/e2e/auth.cy.ts | 151 ++++++++++++++++++ apps/web/cypress/e2e/smoke.cy.ts | 6 +- apps/web/package.json | 5 +- apps/web/src/App.tsx | 38 ++++- apps/web/src/api/client.ts | 51 ++++++ apps/web/src/features/auth/AuthContext.tsx | 54 +++++++ .../features/auth/RedirectIfAuthenticated.tsx | 16 ++ apps/web/src/features/auth/RequireAuth.tsx | 16 ++ apps/web/src/index.css | 64 ++++++++ apps/web/src/lib/zod-errors.ts | 13 ++ apps/web/src/main.tsx | 9 +- apps/web/src/pages/HomePage.tsx | 24 +++ apps/web/src/pages/LoginPage.tsx | 77 +++++++++ apps/web/src/pages/SignupPage.tsx | 97 +++++++++++ packages/shared/package.json | 15 +- packages/shared/src/index.ts | 7 +- packages/shared/src/schemas/auth.ts | 24 +++ packages/shared/src/types/user-profile.ts | 12 ++ packages/shared/tsconfig.json | 8 +- pnpm-lock.yaml | 58 +++++++ 28 files changed, 770 insertions(+), 52 deletions(-) delete mode 100644 apps/api/src/modules/auth/auth.schema.ts create mode 100644 apps/web/.env.example create mode 100644 apps/web/cypress/e2e/auth.cy.ts create mode 100644 apps/web/src/api/client.ts create mode 100644 apps/web/src/features/auth/AuthContext.tsx create mode 100644 apps/web/src/features/auth/RedirectIfAuthenticated.tsx create mode 100644 apps/web/src/features/auth/RequireAuth.tsx create mode 100644 apps/web/src/index.css create mode 100644 apps/web/src/lib/zod-errors.ts create mode 100644 apps/web/src/pages/HomePage.tsx create mode 100644 apps/web/src/pages/LoginPage.tsx create mode 100644 apps/web/src/pages/SignupPage.tsx create mode 100644 packages/shared/src/schemas/auth.ts create mode 100644 packages/shared/src/types/user-profile.ts diff --git a/README.md b/README.md index 4858067..c845a53 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,11 @@ Monorepo pnpm workspaces : - `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) -- `packages/shared` — code partagé entre `api` et `web` (types, schémas de validation, constantes) — vide pour l'instant - -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). +- `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. ## Prérequis @@ -22,6 +23,7 @@ Aucun module métier n'est encore implémenté : cette base ne contient que l'ou pnpm install cp .env.example .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` @@ -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 provisionne un vrai Postgres de service (`.github/workflows/ci.yml`) et exécute `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). diff --git a/apps/api/package.json b/apps/api/package.json index 94db4a2..26287d5 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,6 +14,7 @@ "postinstall": "prisma generate" }, "dependencies": { + "@batch-cooking/shared": "workspace:*", "@prisma/client": "^5.22.0", "argon2": "0.31.2", "cookie-parser": "^1.4.7", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 7c02179..4d6a230 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -22,12 +22,12 @@ export function createApp() { app.use("/auth", authRouter); 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) => { 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; } if (err instanceof HttpError) { @@ -35,7 +35,7 @@ export function createApp() { return; } console.error(err); - res.status(500).json({ error: "Internal server error" }); + res.status(500).json({ error: "Erreur interne du serveur" }); }); return app; diff --git a/apps/api/src/middlewares/require-auth.ts b/apps/api/src/middlewares/require-auth.ts index 1ed0e11..a745996 100644 --- a/apps/api/src/middlewares/require-auth.ts +++ b/apps/api/src/middlewares/require-auth.ts @@ -11,14 +11,14 @@ export async function requireAuth(req: Request, _res: Response, next: NextFuncti try { const token = req.cookies?.[env.AUTH_COOKIE_NAME]; if (typeof token !== "string") { - throw new HttpError(401, "Not authenticated"); + throw new HttpError(401, "Non authentifié"); } const payload = verifyAuthToken(token); const profile = await prisma.userProfile.findUnique({ where: { id: payload.userProfileId } }); if (!profile || profile.tokenVersion !== payload.tokenVersion) { - throw new HttpError(401, "Not authenticated"); + throw new HttpError(401, "Non authentifié"); } const { passwordHash: _passwordHash, ...safeProfile } = profile; @@ -29,7 +29,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, "Not authenticated")); + next(new HttpError(401, "Non authentifié")); } } } diff --git a/apps/api/src/modules/auth/auth.routes.ts b/apps/api/src/modules/auth/auth.routes.ts index 5eacfbe..ee6c5e6 100644 --- a/apps/api/src/modules/auth/auth.routes.ts +++ b/apps/api/src/modules/auth/auth.routes.ts @@ -1,8 +1,8 @@ +import { loginSchema, signupSchema } from "@batch-cooking/shared"; import { Router } from "express"; import type { CookieOptions } from "express"; import { env } from "../../config/env.js"; import { requireAuth } from "../../middlewares/require-auth.js"; -import { loginSchema, signupSchema } from "./auth.schema.js"; import { login, signup } from "./auth.service.js"; export const authRouter = Router(); diff --git a/apps/api/src/modules/auth/auth.schema.ts b/apps/api/src/modules/auth/auth.schema.ts deleted file mode 100644 index 3e3574b..0000000 --- a/apps/api/src/modules/auth/auth.schema.ts +++ /dev/null @@ -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; - -export const loginSchema = z.object({ - email: z.string().trim().toLowerCase().email(), - password: z.string().min(1), -}); -export type LoginInput = z.infer; diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index da25dae..e5044ce 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -1,10 +1,10 @@ +import type { LoginInput, SignupInput } from "@batch-cooking/shared"; import type { UserProfile } from "@prisma/client"; import argon2 from "argon2"; import { env } from "../../config/env.js"; import { prisma } from "../../db/prisma.js"; import { HttpError } from "../../lib/http-error.js"; import { signAuthToken } from "../../lib/jwt.js"; -import type { LoginInput, SignupInput } from "./auth.schema.js"; type SafeProfile = Omit; @@ -24,7 +24,7 @@ function toSafeProfile(profile: UserProfile): SafeProfile { export async function signup(input: SignupInput): Promise<{ profile: SafeProfile; token: string }> { const existing = await prisma.userProfile.findUnique({ where: { email: input.email } }); 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); @@ -57,7 +57,7 @@ export async function login(input: LoginInput): Promise<{ profile: SafeProfile; // 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, "Invalid email or password"); + throw new HttpError(401, "Email ou mot de passe incorrect"); } const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion }); diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..8f1a573 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,2 @@ +# Vite only exposes vars prefixed with VITE_ to client code. +VITE_API_URL=http://localhost:3000 diff --git a/apps/web/cypress/e2e/auth.cy.ts b/apps/web/cypress/e2e/auth.cy.ts new file mode 100644 index 0000000..3b486f6 --- /dev/null +++ b/apps/web/cypress/e2e/auth.cy.ts @@ -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"); + }); +}); diff --git a/apps/web/cypress/e2e/smoke.cy.ts b/apps/web/cypress/e2e/smoke.cy.ts index 27b35c5..7d4d225 100644 --- a/apps/web/cypress/e2e/smoke.cy.ts +++ b/apps/web/cypress/e2e/smoke.cy.ts @@ -1,6 +1,8 @@ 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.contains("h1", "batchCooking").should("be.visible"); + cy.url().should("include", "/login"); + cy.contains("h1", "Se connecter").should("be.visible"); }); }); diff --git a/apps/web/package.json b/apps/web/package.json index db6713f..484ffbe 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,8 +13,11 @@ "e2e": "start-server-and-test dev http://localhost:5173 cy:run" }, "dependencies": { + "@batch-cooking/shared": "workspace:*", "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": { "@types/node": "^22.9.0", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 1104f3a..b48c2af 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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() { return ( -
-

batchCooking

-

Setup initial — les features arriveront une fois les specs définies.

-
+ + + + + } + /> + + + + } + /> + + + + } + /> + } /> + ); } diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts new file mode 100644 index 0000000..5886f32 --- /dev/null +++ b/apps/web/src/api/client.ts @@ -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; + + constructor(status: number, message: string, fieldErrors?: Record) { + super(message); + this.name = "ApiError"; + this.status = status; + this.fieldErrors = fieldErrors; + } +} + +async function request(path: string, options: RequestInit = {}): Promise { + const res = await fetch(`${API_URL}${path}`, { + ...options, + // Required for the httpOnly session cookie to be sent/received — + // the API and the web app run on different origins. + credentials: "include", + headers: { "Content-Type": "application/json", ...options.headers }, + }); + + 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; +} + +export function signup(input: SignupInput): Promise { + return request("/auth/signup", { method: "POST", body: JSON.stringify(input) }); +} + +export function login(input: LoginInput): Promise { + return request("/auth/login", { method: "POST", body: JSON.stringify(input) }); +} + +export function logout(): Promise { + return request("/auth/logout", { method: "POST" }); +} + +export function me(): Promise { + return request("/auth/me"); +} diff --git a/apps/web/src/features/auth/AuthContext.tsx b/apps/web/src/features/auth/AuthContext.tsx new file mode 100644 index 0000000..3d7f50b --- /dev/null +++ b/apps/web/src/features/auth/AuthContext.tsx @@ -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; + login: (input: LoginInput) => Promise; + logout: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(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 ( + + {children} + + ); +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return ctx; +} diff --git a/apps/web/src/features/auth/RedirectIfAuthenticated.tsx b/apps/web/src/features/auth/RedirectIfAuthenticated.tsx new file mode 100644 index 0000000..e1a2f7a --- /dev/null +++ b/apps/web/src/features/auth/RedirectIfAuthenticated.tsx @@ -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 ; + } + return <>{children}; +} diff --git a/apps/web/src/features/auth/RequireAuth.tsx b/apps/web/src/features/auth/RequireAuth.tsx new file mode 100644 index 0000000..0996c0e --- /dev/null +++ b/apps/web/src/features/auth/RequireAuth.tsx @@ -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 ; + } + return <>{children}; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css new file mode 100644 index 0000000..b11ea88 --- /dev/null +++ b/apps/web/src/index.css @@ -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; +} diff --git a/apps/web/src/lib/zod-errors.ts b/apps/web/src/lib/zod-errors.ts new file mode 100644 index 0000000..146f615 --- /dev/null +++ b/apps/web/src/lib/zod-errors.ts @@ -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 { + const flat = error.flatten().fieldErrors; + const result: Record = {}; + for (const [key, messages] of Object.entries(flat)) { + if (messages?.[0]) { + result[key] = messages[0]; + } + } + return result; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index e8a805c..2838b41 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,6 +1,9 @@ import { StrictMode } from "react"; 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"; const rootElement = document.getElementById("root"); if (!rootElement) { @@ -9,6 +12,10 @@ if (!rootElement) { createRoot(rootElement).render( - + + + + + , ); diff --git a/apps/web/src/pages/HomePage.tsx b/apps/web/src/pages/HomePage.tsx new file mode 100644 index 0000000..0777d62 --- /dev/null +++ b/apps/web/src/pages/HomePage.tsx @@ -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 ( +
+

batchCooking

+

+ Bonjour {user?.firstName} {user?.lastName} 👋 +

+ +
+ ); +} diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx new file mode 100644 index 0000000..8929538 --- /dev/null +++ b/apps/web/src/pages/LoginPage.tsx @@ -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>({}); + const [formError, setFormError] = useState(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 ( +
+
+

Se connecter

+ + + setEmail(e.target.value)} + autoComplete="email" + /> + {fieldErrors.email &&

{fieldErrors.email}

} + + + setPassword(e.target.value)} + autoComplete="current-password" + /> + {fieldErrors.password &&

{fieldErrors.password}

} + + {formError &&

{formError}

} + + + +

+ Pas encore de compte ? Créer un profil +

+
+
+ ); +} diff --git a/apps/web/src/pages/SignupPage.tsx b/apps/web/src/pages/SignupPage.tsx new file mode 100644 index 0000000..9c3f448 --- /dev/null +++ b/apps/web/src/pages/SignupPage.tsx @@ -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>({}); + const [formError, setFormError] = useState(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 ( +
+
+

Créer un profil

+ + + setFirstName(e.target.value)} + autoComplete="given-name" + /> + {fieldErrors.firstName &&

{fieldErrors.firstName}

} + + + setLastName(e.target.value)} + autoComplete="family-name" + /> + {fieldErrors.lastName &&

{fieldErrors.lastName}

} + + + setEmail(e.target.value)} + autoComplete="email" + /> + {fieldErrors.email &&

{fieldErrors.email}

} + + + setPassword(e.target.value)} + autoComplete="new-password" + /> + {fieldErrors.password &&

{fieldErrors.password}

} + + {formError &&

{formError}

} + + + +

+ Déjà un compte ? Se connecter +

+
+
+ ); +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 90b30e4..e75491c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,16 +3,23 @@ "version": "0.0.0", "private": true, "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { - ".": "./src/index.ts" + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } }, "scripts": { "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": { "typescript": "^5.7.2" + }, + "dependencies": { + "zod": "^3.25.76" } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 998ca2a..57162f1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,5 +1,2 @@ -// Point d'entrée du code partagé entre apps/api et apps/web. -// Types, schémas de validation (zod) et constantes communes seront ajoutés -// ici une fois le modèle de données défini. - -export {}; +export * from "./schemas/auth.js"; +export * from "./types/user-profile.js"; diff --git a/packages/shared/src/schemas/auth.ts b/packages/shared/src/schemas/auth.ts new file mode 100644 index 0000000..41f4674 --- /dev/null +++ b/packages/shared/src/schemas/auth.ts @@ -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; + +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; diff --git a/packages/shared/src/types/user-profile.ts b/packages/shared/src/types/user-profile.ts new file mode 100644 index 0000000..e002cae --- /dev/null +++ b/packages/shared/src/types/user-profile.ts @@ -0,0 +1,12 @@ +// Mirrors apps/api's Omit — declared by +// hand rather than derived from the Prisma type, since apps/web must not +// depend on @prisma/client. +export interface SafeUserProfile { + id: number; + firstName: string; + lastName: string; + email: string; + tokenVersion: number; + houseId: number | null; + dietId: number | null; +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index b278f63..fb47b20 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -1,9 +1,11 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "module": "ESNext", - "moduleResolution": "Bundler", - "noEmit": true + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "outDir": "dist", + "rootDir": "src" }, "include": ["src"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85fa005..119c394 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: apps/api: dependencies: + '@batch-cooking/shared': + specifier: workspace:* + version: link:../../packages/shared '@prisma/client': specifier: ^5.22.0 version: 5.22.0(prisma@5.22.0) @@ -87,12 +90,21 @@ importers: apps/web: dependencies: + '@batch-cooking/shared': + specifier: workspace:* + version: link:../../packages/shared react: specifier: ^18.3.1 version: 18.3.1 react-dom: specifier: ^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: '@types/node': specifier: ^22.9.0 @@ -120,6 +132,10 @@ importers: version: 5.4.21(@types/node@22.20.1) packages/shared: + dependencies: + zod: + specifier: ^3.25.76 + version: 3.25.76 devDependencies: typescript: 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} 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: 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} 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: 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'} @@ -2283,6 +2320,9 @@ packages: 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} + 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: 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@1.1.1: {} + cookiejar@2.1.4: {} core-util-is@1.0.2: {} @@ -4682,6 +4724,20 @@ snapshots: 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: dependencies: loose-envify: 1.4.0 @@ -4818,6 +4874,8 @@ snapshots: set-blocking@2.0.0: {} + set-cookie-parser@2.7.2: {} + setprototypeof@1.2.0: {} shebang-command@2.0.0: