API: le foyer n'est plus créé automatiquement à l'inscription + suppression de compte (step 3/8)

- 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)
This commit is contained in:
Nicolas 2026-08-17 10:38:47 +02:00
parent 7d5a6c05bb
commit 7af98756dc
2 changed files with 62 additions and 23 deletions

View file

@ -1,10 +1,10 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools"; 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 { Router } from "express";
import type { CookieOptions, Response } from "express"; import type { CookieOptions, Response } from "express";
import { env } from "../../config/env.js"; import { env } from "../../config/env.js";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.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. */ /** Router mounted at `/auth` in app.ts — signup, login, logout, current-profile. */
export const authRouter = Router(); export const authRouter = Router();
@ -15,7 +15,7 @@ export const authRouter = Router();
// bounds how long the browser keeps *sending* the cookie at all. // bounds how long the browser keeps *sending* the cookie at all.
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; 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 = { const cookieOptions: CookieOptions = {
httpOnly: true, httpOnly: true,
// Only require HTTPS in production — local dev/CI serve over plain HTTP. // Only require HTTPS in production — local dev/CI serve over plain HTTP.
@ -24,6 +24,12 @@ const cookieOptions: CookieOptions = {
maxAge: SEVEN_DAYS_MS, 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 * Creates a profile (+ its household) and logs the new user in
* immediately. `wrapAsyncHandler` forwards a thrown/rejected error to * 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). */ /** 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) => { authRouter.post("/logout", (_req, res) => {
res.clearCookie(env.AUTH_COOKIE_NAME, cookieOptions); res.clearCookie(env.AUTH_COOKIE_NAME, clearCookieOptions);
res.status(204).end(); res.status(204).end();
}); });
@ -60,3 +66,15 @@ authRouter.post("/logout", (_req, res) => {
authRouter.get("/me", requireAuth, (_req, res: Response<unknown, AuthLocals>) => { authRouter.get("/me", requireAuth, (_req, res: Response<unknown, AuthLocals>) => {
res.status(200).json(res.locals.userProfile); 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<unknown, AuthLocals>(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();
}),
);

View file

@ -10,6 +10,7 @@ import { env } from "../../config/env.js";
import { prisma } from "../../db/prisma.js"; import { prisma } from "../../db/prisma.js";
import { signAuthToken } from "../../lib/jwt.js"; import { signAuthToken } from "../../lib/jwt.js";
import { toSafeProfile } from "../../lib/safe-profile.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. */ /** Result of a successful signup/login: the safe profile plus the signed session JWT to set as a cookie. */
interface AuthResult { interface AuthResult {
@ -28,8 +29,8 @@ const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 };
const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined; const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
/** /**
* Creates a new household (`house`) and profile (`user_profiles`) together * Creates a profile (`user_profiles`), hashes the password, and issues a
* in one transaction, hashes the password, and issues a session token. * session token.
* *
* @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken. * @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken.
*/ */
@ -41,29 +42,49 @@ export async function signup(input: SignupInput): Promise<AuthResult> {
const passwordHash = await argon2.hash(input.password, hashOptions); const passwordHash = await argon2.hash(input.password, hashOptions);
// A profile always belongs to a house; signup creates one, named after // No household is created here — it's now an optional step of the
// the new user for now — renamed via `PATCH /house/current` (the // onboarding wizard (create or join one, or skip — see
// household step of the profile journey). Joining an existing house is a // `house.service.ts`'s `createHouse`/`joinHouse`), not an implicit side
// separate, not-yet-built feature. // effect of signing up. `houseId` starts out `null`, same as `dietId`.
const profile = await prisma.$transaction(async (tx) => { const profile = await prisma.userProfile.create({
const house = await tx.house.create({
data: { name: `Foyer de ${input.firstName}` },
});
return tx.userProfile.create({
data: { data: {
firstName: input.firstName, firstName: input.firstName,
lastName: input.lastName, lastName: input.lastName,
email: input.email, email: input.email,
passwordHash, passwordHash,
houseId: house.id,
}, },
}); });
});
const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion }); const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion });
return { profile: toSafeProfile(profile), token }; 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<void> {
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. * Verifies credentials and issues a fresh session token.
* *