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:
kyuno053 2026-08-16 15:21:40 +02:00 committed by GitHub
parent bc582b75d6
commit 3ad854a269
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 770 additions and 52 deletions

View file

@ -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).

View file

@ -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",

View file

@ -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;

View file

@ -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é"));
}
}
}

View file

@ -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();

View file

@ -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>;

View file

@ -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<UserProfile, "passwordHash">;
@ -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 });

2
apps/web/.env.example Normal file
View file

@ -0,0 +1,2 @@
# Vite only exposes vars prefixed with VITE_ to client code.
VITE_API_URL=http://localhost:3000

View 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");
});
});

View file

@ -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");
});
});

View file

@ -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",

View file

@ -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 (
<main>
<h1>batchCooking</h1>
<p>Setup initial les features arriveront une fois les specs définies.</p>
</main>
<Routes>
<Route
path="/"
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>
);
}

View 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");
}

View 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;
}

View 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}</>;
}

View 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
View 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;
}

View 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;
}

View file

@ -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(
<StrictMode>
<App />
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</StrictMode>,
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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"
}
}

View file

@ -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";

View 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>;

View 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;
}

View file

@ -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"]
}

View file

@ -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: