From 7af98756dcf7508075a7234236bfd5df6619124d Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 17 Aug 2026 10:38:47 +0200 Subject: [PATCH] =?UTF-8?q?API:=20le=20foyer=20n'est=20plus=20cr=C3=A9?= =?UTF-8?q?=C3=A9=20automatiquement=20=C3=A0=20l'inscription=20+=20suppres?= =?UTF-8?q?sion=20de=20compte=20(step=203/8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - signup() ne crée plus de House — houseId démarre à null, le foyer devient une étape optionnelle de l'onboarding (créer/rejoindre/passer) - deleteAccount(): revérifie le mot de passe, transfère l'adminship ou supprime le foyer si nécessaire (leaveCurrentHouse), puis supprime le profil (cascade sur les allergies) - DELETE /auth/me — nouvelle route, gated par mot de passe - clearCookie n'envoie plus maxAge (corrige un warning de dépréciation Express, déjà latent sur /auth/logout) --- apps/api/src/modules/auth/auth.routes.ts | 26 ++++++++-- apps/api/src/modules/auth/auth.service.ts | 59 +++++++++++++++-------- 2 files changed, 62 insertions(+), 23 deletions(-) diff --git a/apps/api/src/modules/auth/auth.routes.ts b/apps/api/src/modules/auth/auth.routes.ts index 044a8ab..5a97e2e 100644 --- a/apps/api/src/modules/auth/auth.routes.ts +++ b/apps/api/src/modules/auth/auth.routes.ts @@ -1,10 +1,10 @@ import { wrapAsyncHandler } from "@batch-cooking/express-tools"; -import { loginSchema, signupSchema } from "@batch-cooking/shared"; +import { deleteAccountSchema, loginSchema, signupSchema } from "@batch-cooking/shared"; import { Router } from "express"; import type { CookieOptions, Response } from "express"; import { env } from "../../config/env.js"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; -import { login, signup } from "./auth.service.js"; +import { deleteAccount, login, signup } from "./auth.service.js"; /** Router mounted at `/auth` in app.ts — signup, login, logout, current-profile. */ export const authRouter = Router(); @@ -15,7 +15,7 @@ export const authRouter = Router(); // bounds how long the browser keeps *sending* the cookie at all. const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; -/** Cookie options shared by every route that sets/clears the session cookie. */ +/** Cookie options shared by every route that sets the session cookie. */ const cookieOptions: CookieOptions = { httpOnly: true, // Only require HTTPS in production — local dev/CI serve over plain HTTP. @@ -24,6 +24,12 @@ const cookieOptions: CookieOptions = { maxAge: SEVEN_DAYS_MS, }; +// `res.clearCookie` sets its own expiry to clear the cookie — passing +// `maxAge` alongside is deprecated (and pointless) as of Express 4.20, so +// every clearing route reuses `cookieOptions` minus that one field rather +// than duplicating the rest by hand. +const { maxAge: _maxAge, ...clearCookieOptions } = cookieOptions; + /** * Creates a profile (+ its household) and logs the new user in * immediately. `wrapAsyncHandler` forwards a thrown/rejected error to @@ -52,7 +58,7 @@ authRouter.post( /** Ends the current session by clearing the cookie. Stateless JWT, so there's nothing to revoke server-side (yet — see tokenVersion). */ authRouter.post("/logout", (_req, res) => { - res.clearCookie(env.AUTH_COOKIE_NAME, cookieOptions); + res.clearCookie(env.AUTH_COOKIE_NAME, clearCookieOptions); res.status(204).end(); }); @@ -60,3 +66,15 @@ authRouter.post("/logout", (_req, res) => { authRouter.get("/me", requireAuth, (_req, res: Response) => { res.status(200).json(res.locals.userProfile); }); + +/** Permanently deletes the current profile (re-verifying its password first) and clears the session cookie. */ +authRouter.delete( + "/me", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const input = deleteAccountSchema.parse(req.body); + await deleteAccount(res.locals.userProfile.id, input.password); + res.clearCookie(env.AUTH_COOKIE_NAME, clearCookieOptions); + res.status(204).end(); + }), +); diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index 24a6508..e0bc542 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -10,6 +10,7 @@ import { env } from "../../config/env.js"; import { prisma } from "../../db/prisma.js"; import { signAuthToken } from "../../lib/jwt.js"; import { toSafeProfile } from "../../lib/safe-profile.js"; +import { leaveCurrentHouse } from "../house/house.service.js"; /** Result of a successful signup/login: the safe profile plus the signed session JWT to set as a cookie. */ interface AuthResult { @@ -28,8 +29,8 @@ const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 }; const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined; /** - * Creates a new household (`house`) and profile (`user_profiles`) together - * in one transaction, hashes the password, and issues a session token. + * Creates a profile (`user_profiles`), hashes the password, and issues a + * session token. * * @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken. */ @@ -41,29 +42,49 @@ export async function signup(input: SignupInput): Promise { const passwordHash = await argon2.hash(input.password, hashOptions); - // A profile always belongs to a house; signup creates one, named after - // the new user for now — renamed via `PATCH /house/current` (the - // household step of the profile journey). Joining an existing house is a - // separate, not-yet-built feature. - const profile = await prisma.$transaction(async (tx) => { - const house = await tx.house.create({ - data: { name: `Foyer de ${input.firstName}` }, - }); - return tx.userProfile.create({ - data: { - firstName: input.firstName, - lastName: input.lastName, - email: input.email, - passwordHash, - houseId: house.id, - }, - }); + // No household is created here — it's now an optional step of the + // onboarding wizard (create or join one, or skip — see + // `house.service.ts`'s `createHouse`/`joinHouse`), not an implicit side + // effect of signing up. `houseId` starts out `null`, same as `dietId`. + const profile = await prisma.userProfile.create({ + data: { + firstName: input.firstName, + lastName: input.lastName, + email: input.email, + passwordHash, + }, }); const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion }); return { profile: toSafeProfile(profile), token }; } +/** + * Permanently deletes a profile, after re-verifying its password — + * deleting an account is irreversible, so it's gated behind proving the + * caller still is who the session says they are, same spirit as the + * password check in {@link login}. + * + * If the profile administers a household with other members, adminship is + * handed off before the profile is deleted (see `house.service.ts`'s + * `leaveCurrentHouse`); if it's the household's last member, the household + * itself is deleted along with it. `UserProfileAllergy` rows cascade via + * the schema's `onDelete: Cascade`. + * + * @throws {HttpError} `401 INVALID_CREDENTIALS` if the password is wrong. + */ +export async function deleteAccount(profileId: number, password: string): Promise { + const profile = await prisma.userProfile.findUnique({ where: { id: profileId } }); + if (!profile || !(await argon2.verify(profile.passwordHash, password))) { + throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid password"); + } + + if (profile.houseId !== null) { + await leaveCurrentHouse(profile.id, profile.houseId); + } + await prisma.userProfile.delete({ where: { id: profile.id } }); +} + /** * Verifies credentials and issues a fresh session token. *