From 1248461125d3c21d8a4a6bbf52ba981d1b13f542 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 17 Aug 2026 10:37:50 +0200 Subject: [PATCH 1/8] =?UTF-8?q?Shared:=20types/sch=C3=A9mas/codes=20d'erre?= =?UTF-8?q?ur=20pour=20foyer=20admin/invitation=20+=20suppression=20de=20c?= =?UTF-8?q?ompte=20(step=201/8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HouseView gagne adminId/inviteCode/members (HouseMemberView) - createHouseSchema, joinHouseSchema - deleteAccountSchema (nouveau packages/shared/src/schemas/account.ts) - ErrorCode: ALREADY_HAS_HOUSE, NOT_HOUSE_ADMIN, INVITE_CODE_NOT_FOUND --- packages/shared/src/errors/error-codes.ts | 8 ++++++++ packages/shared/src/index.ts | 1 + packages/shared/src/schemas/account.ts | 15 ++++++++++++++ packages/shared/src/schemas/household.ts | 14 +++++++++++++ packages/shared/src/types/household.ts | 24 ++++++++++++++++++++--- 5 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 packages/shared/src/schemas/account.ts diff --git a/packages/shared/src/errors/error-codes.ts b/packages/shared/src/errors/error-codes.ts index b245726..d70609d 100644 --- a/packages/shared/src/errors/error-codes.ts +++ b/packages/shared/src/errors/error-codes.ts @@ -14,6 +14,8 @@ * code families — * - `4000`–`4099`: request validation * - `4010`–`4019`: authentication + * - `4020`–`4029`: conflicting/invalid state transition + * - `4030`–`4039`: authorization (caller authenticated, but not allowed to) * - `4040`–`4049`: not found * - `5000`–`5099`: internal/unexpected * @@ -32,6 +34,10 @@ export enum ErrorCode { INVALID_CREDENTIALS = 4010, /** Request required a session cookie/JWT that is missing, invalid, or stale. */ NOT_AUTHENTICATED = 4011, + /** `POST /house` or `POST /house/join` attempted while the profile already belongs to a household. */ + ALREADY_HAS_HOUSE = 4020, + /** A household action reserved to its admin (delete the household, remove a member) attempted by a non-admin member. */ + NOT_HOUSE_ADMIN = 4030, /** No route/resource matches the request. */ NOT_FOUND = 4040, /** The profile making the request has no household yet (`houseId` is `null`). */ @@ -40,6 +46,8 @@ export enum ErrorCode { DIET_NOT_FOUND = 4042, /** One or more `allergyIds` don't match any reference `Allergy` row. */ ALLERGY_NOT_FOUND = 4043, + /** `POST /house/join`'s `inviteCode` doesn't match any household. */ + INVITE_CODE_NOT_FOUND = 4044, /** Unexpected/unhandled failure — the catch-all, always logged server-side. */ INTERNAL_ERROR = 5000, } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8f20709..95aa7b9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,6 +4,7 @@ // detail specific to one side. export * from "./errors/error-codes.js"; +export * from "./schemas/account.js"; export * from "./schemas/auth.js"; export * from "./schemas/household.js"; export * from "./schemas/profile.js"; diff --git a/packages/shared/src/schemas/account.ts b/packages/shared/src/schemas/account.ts new file mode 100644 index 0000000..a8a9f77 --- /dev/null +++ b/packages/shared/src/schemas/account.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +// See schemas/auth.ts for the shared client/server validation rationale. + +/** + * Payload accepted by `DELETE /auth/me`. Deleting an account is + * irreversible, so it's gated behind re-entering the current password — + * same idea as `loginSchema`'s password field (presence only, the API does + * the real `argon2.verify`), not a fresh set of complexity rules. + */ +export const deleteAccountSchema = z.object({ + password: z.string().min(1, "Le mot de passe est requis"), +}); +/** Inferred TS type for {@link deleteAccountSchema}'s validated output. */ +export type DeleteAccountInput = z.infer; diff --git a/packages/shared/src/schemas/household.ts b/packages/shared/src/schemas/household.ts index bd138aa..df95e94 100644 --- a/packages/shared/src/schemas/household.ts +++ b/packages/shared/src/schemas/household.ts @@ -10,3 +10,17 @@ export const renameHouseSchema = z.object({ }); /** Inferred TS type for {@link renameHouseSchema}'s validated output. */ export type RenameHouseInput = z.infer; + +/** Payload accepted by `POST /house` — same naming rule as renaming one. */ +export const createHouseSchema = z.object({ + name: z.string().trim().min(1, "Le nom du foyer est requis").max(100), +}); +/** Inferred TS type for {@link createHouseSchema}'s validated output. */ +export type CreateHouseInput = z.infer; + +/** Payload accepted by `POST /house/join`. Invite codes are always 8 characters — see `house.service.ts`'s generator. */ +export const joinHouseSchema = z.object({ + inviteCode: z.string().trim().length(8, "Le code d'invitation doit contenir 8 caractères"), +}); +/** Inferred TS type for {@link joinHouseSchema}'s validated output. */ +export type JoinHouseInput = z.infer; diff --git a/packages/shared/src/types/household.ts b/packages/shared/src/types/household.ts index 32b7c93..d519ede 100644 --- a/packages/shared/src/types/household.ts +++ b/packages/shared/src/types/household.ts @@ -1,9 +1,27 @@ /** - * A household, as returned by `GET /house/current` / `PATCH /house/current`. - * Unlike `DietView`/`AllergyView` this isn't reference data — it's the - * current user's own household. + * One member of a household, as embedded in {@link HouseView}. Deliberately + * a small subset of `SafeUserProfile` — just enough for the household + * settings page's member list (name + who's the admin), not the member's + * own email/diet/etc. + */ +export interface HouseMemberView { + id: number; + firstName: string; + lastName: string; +} + +/** + * A household, as returned by `GET /house/current` / `PATCH /house/current` + * / `POST /house` / `POST /house/join`. Unlike `DietView`/`AllergyView` + * this isn't reference data — it's the current user's own household. */ export interface HouseView { id: number; name: string; + /** FK to the member who administers this household (created it, or inherited adminship — see `house.service.ts`'s `leaveCurrentHouse`). */ + adminId: number; + /** Shareable code another user enters via `POST /house/join` to become a member. */ + inviteCode: string; + /** Every member of this household, including the caller. */ + members: HouseMemberView[]; } From 7d5a6c05bbed625d21ff6ee40054e07be635c1be Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 17 Aug 2026 10:38:16 +0200 Subject: [PATCH 2/8] =?UTF-8?q?API:=20administrateur=20de=20foyer,=20code?= =?UTF-8?q?=20d'invitation,=20cr=C3=A9er/rejoindre/quitter/supprimer=20(st?= =?UTF-8?q?ep=202/8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - House.adminId / House.inviteCode (migration Prisma) - house.service: createHouse, joinHouse, leaveCurrentHouse (transfert d'admin ou suppression du foyer si dernier membre), deleteHouse (admin-only), removeMember (admin-only) - house.routes: POST /house, POST /house/join, POST /house/leave, DELETE /house/current, DELETE /house/members/:memberId - GET/PATCH /house/current renvoient désormais membres + admin + code --- .../migration.sql | 9 + apps/api/prisma/schema.prisma | 26 +- apps/api/src/modules/house/house.routes.ts | 90 ++++++- apps/api/src/modules/house/house.service.ts | 248 +++++++++++++++++- 4 files changed, 357 insertions(+), 16 deletions(-) create mode 100644 apps/api/prisma/migrations/20260817093000_add_house_admin_invite_code/migration.sql diff --git a/apps/api/prisma/migrations/20260817093000_add_house_admin_invite_code/migration.sql b/apps/api/prisma/migrations/20260817093000_add_house_admin_invite_code/migration.sql new file mode 100644 index 0000000..e355307 --- /dev/null +++ b/apps/api/prisma/migrations/20260817093000_add_house_admin_invite_code/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "house" ADD COLUMN "admin_id" INTEGER NOT NULL, +ADD COLUMN "invite_code" TEXT NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "house_invite_code_key" ON "house"("invite_code"); + +-- AddForeignKey +ALTER TABLE "house" ADD CONSTRAINT "house_admin_id_fkey" FOREIGN KEY ("admin_id") REFERENCES "user_profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index ebe8716..769943d 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -13,10 +13,19 @@ datasource db { // ----------------------------------------------------------------------------- model House { - id Int @id @default(autoincrement()) - name String + id Int @id @default(autoincrement()) + name String + /// The member who administers this household — created it, or inherited + /// adminship when the previous admin left/deleted their account (see + /// `house.service.ts`'s `leaveCurrentHouse`). Always set: a house is + /// deleted outright once it would otherwise have no admin left. + adminId Int @map("admin_id") + /// Shareable code another user enters via `POST /house/join` to become a + /// member — see `house.service.ts`'s generator for the charset/length. + inviteCode String @unique @map("invite_code") - members UserProfile[] + admin UserProfile @relation("HouseAdmin", fields: [adminId], references: [id]) + members UserProfile[] @relation("HouseMember") plannings Planning[] @@map("house") @@ -81,9 +90,14 @@ model UserProfile { houseId Int? @map("house_id") dietId Int? @map("diet_id") - house House? @relation(fields: [houseId], references: [id], onDelete: SetNull) - diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull) - allergies UserProfileAllergy[] + house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull) + diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull) + allergies UserProfileAllergy[] + /// Households this profile administers. In practice at most one — a + /// profile can only ever belong to (and thus admin) a single household at + /// a time — but Prisma models the admin side of a one-to-many FK as a + /// list regardless of that real-world cardinality. + administeredHouses House[] @relation("HouseAdmin") @@map("user_profiles") } diff --git a/apps/api/src/modules/house/house.routes.ts b/apps/api/src/modules/house/house.routes.ts index ec9c67d..ec9fde9 100644 --- a/apps/api/src/modules/house/house.routes.ts +++ b/apps/api/src/modules/house/house.routes.ts @@ -1,10 +1,24 @@ +import { HttpError } from "@batch-cooking/error-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools"; -import { renameHouseSchema } from "@batch-cooking/shared"; +import { + ErrorCode, + createHouseSchema, + joinHouseSchema, + renameHouseSchema, +} from "@batch-cooking/shared"; import { Router } from "express"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; -import { getCurrentHouse, renameHouse } from "./house.service.js"; +import { + createHouse, + deleteHouse, + getCurrentHouse, + joinHouse, + leaveCurrentHouse, + removeMember, + renameHouse, +} from "./house.service.js"; -/** Router mounted at `/house` in app.ts. Both routes require a session — a household is per-user (via their profile), never public. */ +/** Router mounted at `/house` in app.ts. Every route requires a session — a household is per-user (via their profile), never public. */ export const houseRouter = Router(); houseRouter.get( @@ -16,7 +30,7 @@ houseRouter.get( }), ); -/** The household step of the profile journey (signup wizard and the `/foyer` settings page both call this). */ +/** The household step of the profile journey (onboarding wizard and the `/parametres/foyer` settings page both call this) — renaming, open to any member. */ houseRouter.patch( "/current", requireAuth, @@ -26,3 +40,71 @@ houseRouter.patch( res.status(200).json(house); }), ); + +/** Creates a new household for a profile that doesn't have one yet — the "create" half of the optional household step. */ +houseRouter.post( + "/", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const input = createHouseSchema.parse(req.body); + const house = await createHouse( + res.locals.userProfile.id, + res.locals.userProfile.houseId, + input.name, + ); + res.status(201).json(house); + }), +); + +/** Joins an existing household by invite code — the "join" half of the optional household step. */ +houseRouter.post( + "/join", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const input = joinHouseSchema.parse(req.body); + const house = await joinHouse( + res.locals.userProfile.id, + res.locals.userProfile.houseId, + input.inviteCode, + ); + res.status(200).json(house); + }), +); + +/** Removes the caller from their own household — see `deleteHouse` below for removing the household itself. */ +houseRouter.post( + "/leave", + requireAuth, + wrapAsyncHandler(async (_req, res) => { + await leaveCurrentHouse(res.locals.userProfile.id, res.locals.userProfile.houseId); + res.status(204).end(); + }), +); + +/** Deletes the household entirely — every member loses it. Admin-only, see `house.service.ts`. */ +houseRouter.delete( + "/current", + requireAuth, + wrapAsyncHandler(async (_req, res) => { + await deleteHouse(res.locals.userProfile.id, res.locals.userProfile.houseId); + res.status(204).end(); + }), +); + +/** Removes one specific member from the caller's household. Admin-only, see `house.service.ts`. */ +houseRouter.delete( + "/members/:memberId", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const memberId = Number(req.params.memberId); + if (!Number.isInteger(memberId)) { + throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "memberId must be an integer"); + } + const house = await removeMember( + res.locals.userProfile.id, + res.locals.userProfile.houseId, + memberId, + ); + res.status(200).json(house); + }), +); diff --git a/apps/api/src/modules/house/house.service.ts b/apps/api/src/modules/house/house.service.ts index 6a34438..05c30a2 100644 --- a/apps/api/src/modules/house/house.service.ts +++ b/apps/api/src/modules/house/house.service.ts @@ -1,17 +1,59 @@ +import { randomInt } from "node:crypto"; import { HttpError } from "@batch-cooking/error-tools"; import { ErrorCode, type HouseView } from "@batch-cooking/shared"; import { prisma } from "../../db/prisma.js"; -/** Returns the profile's household, or `null` if the profile has none yet (`houseId` is `null` — see `SafeUserProfile`). */ +/** + * Charset for {@link generateInviteCode} — uppercase letters/digits only, + * minus the visually-ambiguous `0`/`O`/`1`/`I` (this code is meant to be + * read off one screen and typed into another). + */ +const INVITE_CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; +const INVITE_CODE_LENGTH = 8; + +/** Generates one candidate invite code. Collisions are handled by the caller (retry on the DB's unique-constraint failure), not here. */ +function generateInviteCode(): string { + let code = ""; + for (let i = 0; i < INVITE_CODE_LENGTH; i++) { + code += INVITE_CODE_CHARS[randomInt(INVITE_CODE_CHARS.length)]; + } + return code; +} + +/** Shapes a Prisma `House` (with its `members` relation included) into the public {@link HouseView}. */ +function toHouseView(house: { + id: number; + name: string; + adminId: number; + inviteCode: string; + members: { id: number; firstName: string; lastName: string }[]; +}): HouseView { + return { + id: house.id, + name: house.name, + adminId: house.adminId, + inviteCode: house.inviteCode, + members: house.members, + }; +} + +/** Shared `include` for every query that needs to return a full {@link HouseView}. */ +const houseWithMembers = { + members: { select: { id: true, firstName: true, lastName: true } }, +} as const; + +/** Returns the profile's household (with its member list), or `null` if the profile has none yet (`houseId` is `null` — see `SafeUserProfile`). */ export async function getCurrentHouse(houseId: number | null): Promise { if (houseId === null) { return null; } - return findHouseOrThrow(houseId); + return toHouseView(await findHouseOrThrow(houseId)); } /** - * Renames the profile's household. + * Renames the profile's household. Open to any member, not just the admin — + * unlike deleting the household or removing a member, renaming isn't + * destructive enough to gate behind adminship. * * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet. */ @@ -20,7 +62,198 @@ export async function renameHouse(houseId: number | null, name: string): Promise throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); } await findHouseOrThrow(houseId); - return prisma.house.update({ where: { id: houseId }, data: { name } }); + const house = await prisma.house.update({ + where: { id: houseId }, + data: { name }, + include: houseWithMembers, + }); + return toHouseView(house); +} + +/** + * Creates a new household for a profile that doesn't have one yet, with the + * creating profile as its admin. + * + * @throws {HttpError} `409 ALREADY_HAS_HOUSE` if the profile already belongs to a household. + */ +export async function createHouse( + profileId: number, + houseId: number | null, + name: string, +): Promise { + if (houseId !== null) { + throw new HttpError(409, ErrorCode.ALREADY_HAS_HOUSE, "Profile already belongs to a household"); + } + + // Astronomically unlikely to collide (33^8 possibilities), but retried + // rather than assumed — a `@unique` constraint failure is the only fully + // reliable way to detect it. + const maxAttempts = 5; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const house = await prisma.$transaction(async (tx) => { + const created = await tx.house.create({ + data: { name, adminId: profileId, inviteCode: generateInviteCode() }, + }); + await tx.userProfile.update({ where: { id: profileId }, data: { houseId: created.id } }); + return created; + }); + return getCurrentHouseOrThrow(house.id); + } catch (err) { + if (isUniqueInviteCodeViolation(err) && attempt < maxAttempts) continue; + throw err; + } + } + throw new Error("Failed to generate a unique invite code after several attempts"); +} + +/** + * Joins an existing household by invite code. + * + * @throws {HttpError} `409 ALREADY_HAS_HOUSE` if the profile already belongs to a household. + * @throws {HttpError} `404 INVITE_CODE_NOT_FOUND` if no household matches the code. + */ +export async function joinHouse( + profileId: number, + houseId: number | null, + inviteCode: string, +): Promise { + if (houseId !== null) { + throw new HttpError(409, ErrorCode.ALREADY_HAS_HOUSE, "Profile already belongs to a household"); + } + + const house = await prisma.house.findUnique({ where: { inviteCode } }); + if (!house) { + throw new HttpError( + 404, + ErrorCode.INVITE_CODE_NOT_FOUND, + "No household matches this invite code", + ); + } + + await prisma.userProfile.update({ where: { id: profileId }, data: { houseId: house.id } }); + return getCurrentHouseOrThrow(house.id); +} + +/** + * Removes a profile from its current household — used both by + * `POST /house/leave` (a member removing themselves) and by account + * deletion (`auth.service.ts`'s `deleteAccount`, before the profile row + * itself is deleted). + * + * If the leaving profile was the household's admin: adminship transfers to + * the longest-standing remaining member (lowest id) if there is one, + * otherwise the household itself is deleted (cascading its plannings) since + * a household can never be left without an admin. + * + * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household. + */ +export async function leaveCurrentHouse(profileId: number, houseId: number | null): Promise { + if (houseId === null) { + throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); + } + + const house = await findHouseOrThrow(houseId); + const remainingMembers = house.members.filter((member) => member.id !== profileId); + + await prisma.$transaction(async (tx) => { + await tx.userProfile.update({ where: { id: profileId }, data: { houseId: null } }); + + if (house.adminId !== profileId) { + return; + } + if (remainingMembers.length === 0) { + await tx.house.delete({ where: { id: house.id } }); + return; + } + const nextAdmin = remainingMembers.reduce((oldest, member) => + member.id < oldest.id ? member : oldest, + ); + await tx.house.update({ where: { id: house.id }, data: { adminId: nextAdmin.id } }); + }); +} + +/** + * Deletes a household outright — every member (not just the caller) loses + * it, and its plannings are cascaded away. Only the household's admin may + * do this; a non-admin member wanting out should call + * {@link leaveCurrentHouse} (`POST /house/leave`) instead. + * + * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household. + * @throws {HttpError} `403 NOT_HOUSE_ADMIN` if the profile isn't this household's admin. + */ +export async function deleteHouse(profileId: number, houseId: number | null): Promise { + if (houseId === null) { + throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); + } + const house = await findHouseOrThrow(houseId); + if (house.adminId !== profileId) { + throw new HttpError(403, ErrorCode.NOT_HOUSE_ADMIN, "Only the household's admin can delete it"); + } + + // Members' houseId also cascades to null via the FK's onDelete: SetNull, + // but clearing it explicitly first keeps the outcome obvious without + // relying on that FK behavior being read alongside this function. + await prisma.$transaction([ + prisma.userProfile.updateMany({ where: { houseId: house.id }, data: { houseId: null } }), + prisma.house.delete({ where: { id: house.id } }), + ]); +} + +/** + * Removes one specific member from the caller's household — the admin + * acting on someone else. The admin can't remove themselves this way (no + * adminship to hand off here); they use {@link leaveCurrentHouse} instead, + * same as any other member leaving voluntarily. + * + * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household. + * @throws {HttpError} `403 NOT_HOUSE_ADMIN` if the profile isn't this household's admin. + * @throws {HttpError} `400 VALIDATION_ERROR` if `targetMemberId` is the caller, or isn't a member of this household. + */ +export async function removeMember( + profileId: number, + houseId: number | null, + targetMemberId: number, +): Promise { + if (houseId === null) { + throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); + } + const house = await findHouseOrThrow(houseId); + if (house.adminId !== profileId) { + throw new HttpError( + 403, + ErrorCode.NOT_HOUSE_ADMIN, + "Only the household's admin can remove a member", + ); + } + if (targetMemberId === profileId) { + throw new HttpError( + 400, + ErrorCode.VALIDATION_ERROR, + "Use POST /house/leave to remove yourself", + ); + } + if (!house.members.some((member) => member.id === targetMemberId)) { + throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Not a member of this household"); + } + + await prisma.userProfile.update({ where: { id: targetMemberId }, data: { houseId: null } }); + return getCurrentHouseOrThrow(house.id); +} + +/** Re-fetches a household by id (as a {@link HouseView}) once its id is already known to be valid — the common "reload after a mutation" step shared by several functions above. */ +async function getCurrentHouseOrThrow(houseId: number): Promise { + return toHouseView(await findHouseOrThrow(houseId)); +} + +/** True if `err` is Prisma's unique-constraint violation (`P2002`) on `invite_code` — the only expected cause of a collision retry in {@link createHouse}. */ +function isUniqueInviteCodeViolation(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code: unknown }).code === "P2002" + ); } /** @@ -31,8 +264,11 @@ export async function renameHouse(houseId: number | null, name: string): Promise * through the API, hence a plain `Error` (500) rather than a * `HOUSE_NOT_FOUND` HttpError. */ -async function findHouseOrThrow(houseId: number): Promise { - const house = await prisma.house.findUnique({ where: { id: houseId } }); +async function findHouseOrThrow(houseId: number) { + const house = await prisma.house.findUnique({ + where: { id: houseId }, + include: houseWithMembers, + }); if (!house) { throw new Error(`House ${houseId} referenced by a profile but not found`); } From 7af98756dcf7508075a7234236bfd5df6619124d Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 17 Aug 2026 10:38:47 +0200 Subject: [PATCH 3/8] =?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. * From 3363cfad7562c72e534b90d1a58757661b97bacc Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 17 Aug 2026 10:40:23 +0200 Subject: [PATCH 4/8] Tests API: couverture Mocha + Cucumber pour le foyer et la suppression de compte (step 4/8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - house.test.ts réécrit (le foyer n'est plus auto-créé) + POST /house, POST /house/join, POST /house/leave, DELETE /house/current, DELETE /house/members/:id - auth.test.ts: signup renvoie houseId=null, DELETE /auth/me (mauvais mot de passe, suppression, transfert d'admin) - planning.test.ts/steps.ts: création explicite du foyer (POST /house) - household.feature: scénarios créer/rejoindre/quitter/supprimer/ retirer un membre, via un second agent (CustomWorld.secondAgent) - auth.feature: scénarios de suppression de compte --- apps/api/features/auth.feature | 15 + apps/api/features/household.feature | 57 +++- .../features/step-definitions/auth.steps.ts | 12 + .../step-definitions/household.steps.ts | 80 ++++- .../step-definitions/planning.steps.ts | 10 +- apps/api/features/support/world.ts | 4 + apps/api/test/auth.test.ts | 77 ++++- apps/api/test/house.test.ts | 274 +++++++++++++++++- apps/api/test/planning.test.ts | 10 +- 9 files changed, 516 insertions(+), 23 deletions(-) diff --git a/apps/api/features/auth.feature b/apps/api/features/auth.feature index e08fe1a..c31a7b2 100644 --- a/apps/api/features/auth.feature +++ b/apps/api/features/auth.feature @@ -33,3 +33,18 @@ Feature: Account creation and login When I log in with email "alice@example.com" and password "wrong-password" Then the response status should be 401 And the response error code should be "INVALID_CREDENTIALS" + + Scenario: A signed-in user cannot delete their account with the wrong password + Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" + And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + When I delete my account with password "wrong-password" + Then the response status should be 401 + And the response error code should be "INVALID_CREDENTIALS" + And I am authenticated as "alice@example.com" + + Scenario: A signed-in user deletes their account + Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" + And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + When I delete my account with password "correct-horse-battery-staple" + Then the response status should be 204 + And I am no longer authenticated diff --git a/apps/api/features/household.feature b/apps/api/features/household.feature index 94a56e2..bb1e874 100644 --- a/apps/api/features/household.feature +++ b/apps/api/features/household.feature @@ -1,16 +1,67 @@ -Feature: Household name +Feature: Household As a signed-in user - I want to name my household - So that it's recognizable as ours, not the auto-generated default + I want to name my household, invite others to it, and manage its members + So that my whole household can share the same planning Scenario: A visitor without a session cannot read the household When I send a GET request to "/house/current" Then the response status should be 401 And the response error code should be "NOT_AUTHENTICATED" + Scenario: A signed-in user creates a household + Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" + And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + When I create a household named "Chez Alice" + Then the response status should be 201 + And my household should be named "Chez Alice" + Scenario: A signed-in user renames their household Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + And I have a household named "Foyer de test" When I rename my household to "Chez les Martin" Then the response status should be 200 And my household should be named "Chez les Martin" + + Scenario: A second user joins a household using its invite code + Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" + And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + And I have a household named "Chez Alice" + And a profile already exists with email "bob@example.com" and password "correct-horse-battery-staple" + When the second user logs in with email "bob@example.com" and password "correct-horse-battery-staple" + And the second user joins my household using its invite code + Then the second user's response status should be 200 + And the second user should be a member of my household + + Scenario: A non-admin member cannot delete the household + Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" + And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + And I have a household named "Chez Alice" + And a profile already exists with email "bob@example.com" and password "correct-horse-battery-staple" + And the second user logs in with email "bob@example.com" and password "correct-horse-battery-staple" + And the second user joins my household using its invite code + When the second user tries to delete the household + Then the second user's response status should be 403 + And the second user's response error code should be "NOT_HOUSE_ADMIN" + + Scenario: Adminship transfers to the remaining member when the admin leaves + Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" + And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + And I have a household named "Chez Alice" + And a profile already exists with email "bob@example.com" and password "correct-horse-battery-staple" + And the second user logs in with email "bob@example.com" and password "correct-horse-battery-staple" + And the second user joins my household using its invite code + When I leave the household + Then the response status should be 204 + And the second user should be the household's admin + + Scenario: The admin removes a member + Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" + And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + And I have a household named "Chez Alice" + And a profile already exists with email "bob@example.com" and password "correct-horse-battery-staple" + And the second user logs in with email "bob@example.com" and password "correct-horse-battery-staple" + And the second user joins my household using its invite code + When I remove the second user from my household + Then the response status should be 200 + And the second user should have no household diff --git a/apps/api/features/step-definitions/auth.steps.ts b/apps/api/features/step-definitions/auth.steps.ts index 64149e5..9f5a737 100644 --- a/apps/api/features/step-definitions/auth.steps.ts +++ b/apps/api/features/step-definitions/auth.steps.ts @@ -48,8 +48,20 @@ When( }, ); +When( + "I delete my account with password {string}", + async function (this: CustomWorld, password: string) { + this.response = await this.agent.delete("/auth/me").send({ password }); + }, +); + Then("I am authenticated as {string}", async function (this: CustomWorld, email: string) { const res = await this.agent.get("/auth/me"); assert.equal(res.status, 200); assert.equal(res.body.email, email); }); + +Then("I am no longer authenticated", async function (this: CustomWorld) { + const res = await this.agent.get("/auth/me"); + assert.equal(res.status, 401); +}); diff --git a/apps/api/features/step-definitions/household.steps.ts b/apps/api/features/step-definitions/household.steps.ts index 4a8dabb..9e48e0d 100644 --- a/apps/api/features/step-definitions/household.steps.ts +++ b/apps/api/features/step-definitions/household.steps.ts @@ -1,12 +1,90 @@ import assert from "node:assert/strict"; -import { Then, When } from "@cucumber/cucumber"; +import { ErrorCode } from "@batch-cooking/shared"; +import { Given, Then, When } from "@cucumber/cucumber"; import type { CustomWorld } from "../support/world.js"; +Given("I have a household named {string}", async function (this: CustomWorld, name: string) { + const res = await this.agent.post("/house").send({ name }); + assert.equal(res.status, 201, JSON.stringify(res.body)); +}); + +When("I create a household named {string}", async function (this: CustomWorld, name: string) { + this.response = await this.agent.post("/house").send({ name }); +}); + When("I rename my household to {string}", async function (this: CustomWorld, name: string) { this.response = await this.agent.patch("/house/current").send({ name }); }); +When("I leave the household", async function (this: CustomWorld) { + this.response = await this.agent.post("/house/leave"); +}); + +When("I remove the second user from my household", async function (this: CustomWorld) { + const secondMe = await this.secondAgent.get("/auth/me"); + this.response = await this.agent.delete(`/house/members/${secondMe.body.id}`); +}); + Then("my household should be named {string}", async function (this: CustomWorld, name: string) { const res = await this.agent.get("/house/current"); assert.equal(res.body.name, name); }); + +// --- Steps involving a second, independently signed-in user --------------- +// The first ("a profile already exists with email ...") step is reused +// as-is for the second user too — it just inserts a row, agent-agnostic. + +When( + "the second user logs in with email {string} and password {string}", + async function (this: CustomWorld, email: string, password: string) { + this.secondResponse = await this.secondAgent.post("/auth/login").send({ email, password }); + }, +); + +When( + "the second user joins my household using its invite code", + async function (this: CustomWorld) { + const house = await this.agent.get("/house/current"); + this.secondResponse = await this.secondAgent + .post("/house/join") + .send({ inviteCode: house.body.inviteCode }); + }, +); + +When("the second user tries to delete the household", async function (this: CustomWorld) { + this.secondResponse = await this.secondAgent.delete("/house/current"); +}); + +Then( + "the second user's response status should be {int}", + function (this: CustomWorld, status: number) { + assert.equal(this.secondResponse.status, status); + }, +); + +Then( + "the second user's response error code should be {string}", + function (this: CustomWorld, code: string) { + const expected = ErrorCode[code as keyof typeof ErrorCode]; + assert.notEqual(expected, undefined, `Unknown ErrorCode member: "${code}"`); + assert.equal(this.secondResponse.body.code, expected); + }, +); + +Then("the second user should be a member of my household", async function (this: CustomWorld) { + const house = await this.agent.get("/house/current"); + const secondMe = await this.secondAgent.get("/auth/me"); + const memberIds = (house.body.members as Array<{ id: number }>).map((member) => member.id); + assert.ok(memberIds.includes(secondMe.body.id)); +}); + +Then("the second user should be the household's admin", async function (this: CustomWorld) { + const secondMe = await this.secondAgent.get("/auth/me"); + const house = await this.secondAgent.get("/house/current"); + assert.equal(house.body.adminId, secondMe.body.id); +}); + +Then("the second user should have no household", async function (this: CustomWorld) { + const secondMe = await this.secondAgent.get("/auth/me"); + assert.equal(secondMe.body.houseId, null); +}); diff --git a/apps/api/features/step-definitions/planning.steps.ts b/apps/api/features/step-definitions/planning.steps.ts index 67a25b1..12c36eb 100644 --- a/apps/api/features/step-definitions/planning.steps.ts +++ b/apps/api/features/step-definitions/planning.steps.ts @@ -15,14 +15,14 @@ Then("the current planning response should be empty", function (this: CustomWorl // the API — there's no "create a planning" endpoint yet (see // specs/batch-cooking-architecture.md, "Calcul batch-cooking" is still // TODO), so this is the only way to get a household into a state where it -// has one. Reads the household off the already-authenticated agent (via -// `GET /auth/me`) rather than taking it as a step argument, since the -// scenario never names it explicitly. +// has one. A household is no longer created implicitly at signup, so this +// step creates one via `POST /house` first — the scenario never names it +// explicitly, its name doesn't matter here. Given( "my household has a planning covering today with recipe {string} on {string} for {string}", async function (this: CustomWorld, recipeName: string, weekDay: string, meal: string) { - const me = await this.agent.get("/auth/me"); - const houseId: number = me.body.houseId; + const houseRes = await this.agent.post("/house").send({ name: "Foyer de test" }); + const houseId: number = houseRes.body.id; const recipe = await prisma.recipe.create({ data: { name: recipeName } }); const today = new Date(); diff --git a/apps/api/features/support/world.ts b/apps/api/features/support/world.ts index d8460b5..69b84e6 100644 --- a/apps/api/features/support/world.ts +++ b/apps/api/features/support/world.ts @@ -11,11 +11,15 @@ export class CustomWorld extends World { app: Express; agent: ReturnType; response!: request.Response; + /** A second, independent session (own cookie jar) — only used by scenarios needing two distinct signed-in users, e.g. household invites/admin transfer/member removal. */ + secondAgent: ReturnType; + secondResponse!: request.Response; constructor(options: IWorldOptions) { super(options); this.app = createApp(); this.agent = request.agent(this.app); + this.secondAgent = request.agent(this.app); } } diff --git a/apps/api/test/auth.test.ts b/apps/api/test/auth.test.ts index c071e25..8383a6b 100644 --- a/apps/api/test/auth.test.ts +++ b/apps/api/test/auth.test.ts @@ -38,7 +38,7 @@ describe("Auth", () => { }); describe("POST /auth/signup", () => { - it("creates a profile and its house, and sets a session cookie", async () => { + it("creates a profile without a household yet, and sets a session cookie", async () => { const payload = buildSignupPayload(); const res = await request(app).post("/auth/signup").send(payload); @@ -49,7 +49,9 @@ describe("Auth", () => { email: payload.email, }); expect(res.body).to.not.have.property("passwordHash"); - expect(res.body.houseId).to.be.a("number"); + // No household is created at signup anymore — it's an optional + // onboarding step (create/join/skip), see house.test.ts. + expect(res.body.houseId).to.equal(null); expect(res.headers["set-cookie"]?.[0]).to.include("session="); }); @@ -131,4 +133,75 @@ describe("Auth", () => { expect(res.body.email).to.equal(payload.email); }); }); + + describe("DELETE /auth/me", () => { + it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { + const res = await request(app).delete("/auth/me").send({ password: "whatever" }); + + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + + it("rejects a wrong password with 401 INVALID_CREDENTIALS, without deleting the profile", async () => { + const payload = buildSignupPayload(); + const agent = request.agent(app); + const signupRes = await agent.post("/auth/signup").send(payload); + + const res = await agent.delete("/auth/me").send({ password: "wrong-password" }); + + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS); + expect( + await prisma.userProfile.findUnique({ where: { id: signupRes.body.id } }), + ).to.not.equal(null); + }); + + it("deletes the profile and clears the session cookie", async () => { + const payload = buildSignupPayload(); + const agent = request.agent(app); + const signupRes = await agent.post("/auth/signup").send(payload); + + const res = await agent.delete("/auth/me").send({ password: payload.password }); + + expect(res.status).to.equal(204); + expect(await prisma.userProfile.findUnique({ where: { id: signupRes.body.id } })).to.equal( + null, + ); + + const meRes = await agent.get("/auth/me"); + expect(meRes.status).to.equal(401); + }); + + it("deletes the household along with the account when it's the sole member", async () => { + const payload = buildSignupPayload(); + const agent = request.agent(app); + await agent.post("/auth/signup").send(payload); + const houseRes = await agent.post("/house").send({ name: "Chez moi" }); + + const res = await agent.delete("/auth/me").send({ password: payload.password }); + + expect(res.status).to.equal(204); + expect(await prisma.house.findUnique({ where: { id: houseRes.body.id } })).to.equal(null); + }); + + it("transfers adminship to another member before deleting an admin's account", async () => { + const adminPayload = buildSignupPayload(); + const adminAgent = request.agent(app); + const houseRes = await adminAgent + .post("/auth/signup") + .send(adminPayload) + .then(() => adminAgent.post("/house").send({ name: "Chez nous" })); + + const memberPayload = buildSignupPayload(); + const memberAgent = request.agent(app); + const memberSignupRes = await memberAgent.post("/auth/signup").send(memberPayload); + await memberAgent.post("/house/join").send({ inviteCode: houseRes.body.inviteCode }); + + const res = await adminAgent.delete("/auth/me").send({ password: adminPayload.password }); + + expect(res.status).to.equal(204); + const house = await prisma.house.findUnique({ where: { id: houseRes.body.id } }); + expect(house?.adminId).to.equal(memberSignupRes.body.id); + }); + }); }); diff --git a/apps/api/test/house.test.ts b/apps/api/test/house.test.ts index 87cd0cf..29e6206 100644 --- a/apps/api/test/house.test.ts +++ b/apps/api/test/house.test.ts @@ -1,6 +1,7 @@ import { ErrorCode, type SignupInput } from "@batch-cooking/shared"; import { faker } from "@faker-js/faker"; import { expect } from "chai"; +import type { Express } from "express"; import request from "supertest"; import { createApp } from "../src/app.js"; import { prisma } from "../src/db/prisma.js"; @@ -17,6 +18,13 @@ function buildSignupPayload(): SignupInput { }; } +/** Signs up a fresh profile on a brand new agent (its own cookie jar) and returns both. */ +async function signupAgent(app: Express) { + const agent = request.agent(app); + const res = await agent.post("/auth/signup").send(buildSignupPayload()); + return { agent, profile: res.body }; +} + describe("Household", () => { const app = createApp(); @@ -36,14 +44,28 @@ describe("Household", () => { expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); }); - it("returns the household created at signup", async () => { - const agent = request.agent(app); - const signupRes = await agent.post("/auth/signup").send(buildSignupPayload()); + it("returns null when the profile has no household yet", async () => { + const { agent } = await signupAgent(app); const res = await agent.get("/house/current"); expect(res.status).to.equal(200); - expect(res.body).to.deep.equal({ id: signupRes.body.houseId, name: res.body.name }); + expect(res.body).to.equal(null); + }); + + it("returns the household with its admin and member list, once created", async () => { + const { agent, profile } = await signupAgent(app); + await agent.post("/house").send({ name: "Chez Alice" }); + + const res = await agent.get("/house/current"); + + expect(res.status).to.equal(200); + expect(res.body.name).to.equal("Chez Alice"); + expect(res.body.adminId).to.equal(profile.id); + expect(res.body.inviteCode).to.match(/^[A-Z2-9]{8}$/); + expect(res.body.members).to.deep.equal([ + { id: profile.id, firstName: profile.firstName, lastName: profile.lastName }, + ]); }); }); @@ -55,9 +77,18 @@ describe("Household", () => { expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); }); + it("rejects renaming when the profile has no household yet with 404 HOUSE_NOT_FOUND", async () => { + const { agent } = await signupAgent(app); + + const res = await agent.patch("/house/current").send({ name: "Chez nous" }); + + expect(res.status).to.equal(404); + expect(res.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND); + }); + it("renames the household", async () => { - const agent = request.agent(app); - await agent.post("/auth/signup").send(buildSignupPayload()); + const { agent } = await signupAgent(app); + await agent.post("/house").send({ name: "Chez Alice" }); const res = await agent.patch("/house/current").send({ name: "Chez les Dupont" }); @@ -69,8 +100,8 @@ describe("Household", () => { }); it("rejects an empty name with 400 VALIDATION_ERROR", async () => { - const agent = request.agent(app); - await agent.post("/auth/signup").send(buildSignupPayload()); + const { agent } = await signupAgent(app); + await agent.post("/house").send({ name: "Chez Alice" }); const res = await agent.patch("/house/current").send({ name: "" }); @@ -78,4 +109,231 @@ describe("Household", () => { expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); }); }); + + describe("POST /house", () => { + it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { + const res = await request(app).post("/house").send({ name: "Chez nous" }); + + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + + it("creates a household with the caller as its admin", async () => { + const { agent, profile } = await signupAgent(app); + + const res = await agent.post("/house").send({ name: "Chez Alice" }); + + expect(res.status).to.equal(201); + expect(res.body.name).to.equal("Chez Alice"); + expect(res.body.adminId).to.equal(profile.id); + + const me = await agent.get("/auth/me"); + expect(me.body.houseId).to.equal(res.body.id); + }); + + it("rejects an empty name with 400 VALIDATION_ERROR", async () => { + const { agent } = await signupAgent(app); + + const res = await agent.post("/house").send({ name: "" }); + + expect(res.status).to.equal(400); + expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); + }); + + it("rejects creating a second household with 409 ALREADY_HAS_HOUSE", async () => { + const { agent } = await signupAgent(app); + await agent.post("/house").send({ name: "Chez Alice" }); + + const res = await agent.post("/house").send({ name: "Chez Alice bis" }); + + expect(res.status).to.equal(409); + expect(res.body.code).to.equal(ErrorCode.ALREADY_HAS_HOUSE); + }); + }); + + describe("POST /house/join", () => { + it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { + const res = await request(app).post("/house/join").send({ inviteCode: "ABCDEFGH" }); + + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + + it("joins an existing household by invite code", async () => { + const { agent: adminAgent } = await signupAgent(app); + const created = await adminAgent.post("/house").send({ name: "Chez Alice" }); + const { agent: joinerAgent, profile: joiner } = await signupAgent(app); + + const res = await joinerAgent + .post("/house/join") + .send({ inviteCode: created.body.inviteCode }); + + expect(res.status).to.equal(200); + expect(res.body.id).to.equal(created.body.id); + expect(res.body.members.map((m: { id: number }) => m.id)).to.include(joiner.id); + }); + + it("rejects an unknown invite code with 404 INVITE_CODE_NOT_FOUND", async () => { + const { agent } = await signupAgent(app); + + const res = await agent.post("/house/join").send({ inviteCode: "ZZZZZZZZ" }); + + expect(res.status).to.equal(404); + expect(res.body.code).to.equal(ErrorCode.INVITE_CODE_NOT_FOUND); + }); + + it("rejects joining when the profile already belongs to a household with 409 ALREADY_HAS_HOUSE", async () => { + const { agent: adminAgent } = await signupAgent(app); + const created = await adminAgent.post("/house").send({ name: "Chez Alice" }); + const { agent } = await signupAgent(app); + await agent.post("/house").send({ name: "Chez Bob" }); + + const res = await agent.post("/house/join").send({ inviteCode: created.body.inviteCode }); + + expect(res.status).to.equal(409); + expect(res.body.code).to.equal(ErrorCode.ALREADY_HAS_HOUSE); + }); + }); + + describe("POST /house/leave", () => { + it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { + const res = await request(app).post("/house/leave"); + + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + + it("rejects leaving when the profile has no household with 404 HOUSE_NOT_FOUND", async () => { + const { agent } = await signupAgent(app); + + const res = await agent.post("/house/leave"); + + expect(res.status).to.equal(404); + expect(res.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND); + }); + + it("deletes the household when its sole member leaves", async () => { + const { agent } = await signupAgent(app); + const created = await agent.post("/house").send({ name: "Chez Alice" }); + + const res = await agent.post("/house/leave"); + + expect(res.status).to.equal(204); + expect(await prisma.house.findUnique({ where: { id: created.body.id } })).to.equal(null); + }); + + it("transfers adminship to the remaining member when the admin leaves", async () => { + const { agent: adminAgent } = await signupAgent(app); + const created = await adminAgent.post("/house").send({ name: "Chez Alice" }); + const { agent: memberAgent, profile: member } = await signupAgent(app); + await memberAgent.post("/house/join").send({ inviteCode: created.body.inviteCode }); + + const res = await adminAgent.post("/house/leave"); + + expect(res.status).to.equal(204); + const house = await prisma.house.findUnique({ where: { id: created.body.id } }); + expect(house?.adminId).to.equal(member.id); + }); + }); + + describe("DELETE /house/current", () => { + it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { + const res = await request(app).delete("/house/current"); + + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + + it("rejects deleting when the profile has no household with 404 HOUSE_NOT_FOUND", async () => { + const { agent } = await signupAgent(app); + + const res = await agent.delete("/house/current"); + + expect(res.status).to.equal(404); + expect(res.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND); + }); + + it("rejects a non-admin member with 403 NOT_HOUSE_ADMIN", async () => { + const { agent: adminAgent } = await signupAgent(app); + const created = await adminAgent.post("/house").send({ name: "Chez Alice" }); + const { agent: memberAgent } = await signupAgent(app); + await memberAgent.post("/house/join").send({ inviteCode: created.body.inviteCode }); + + const res = await memberAgent.delete("/house/current"); + + expect(res.status).to.equal(403); + expect(res.body.code).to.equal(ErrorCode.NOT_HOUSE_ADMIN); + }); + + it("deletes the household for every member, cascading its plannings", async () => { + const { agent: adminAgent } = await signupAgent(app); + const created = await adminAgent.post("/house").send({ name: "Chez Alice" }); + const { agent: memberAgent, profile: member } = await signupAgent(app); + await memberAgent.post("/house/join").send({ inviteCode: created.body.inviteCode }); + const planning = await prisma.planning.create({ + data: { + houseId: created.body.id, + startDate: new Date(Date.UTC(2000, 0, 1)), + finishDate: new Date(Date.UTC(2000, 0, 7)), + }, + }); + + const res = await adminAgent.delete("/house/current"); + + expect(res.status).to.equal(204); + expect(await prisma.house.findUnique({ where: { id: created.body.id } })).to.equal(null); + expect(await prisma.planning.findUnique({ where: { id: planning.id } })).to.equal(null); + const memberProfile = await prisma.userProfile.findUniqueOrThrow({ + where: { id: member.id }, + }); + expect(memberProfile.houseId).to.equal(null); + }); + }); + + describe("DELETE /house/members/:memberId", () => { + it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { + const res = await request(app).delete("/house/members/1"); + + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + + it("rejects a non-admin member with 403 NOT_HOUSE_ADMIN", async () => { + const { agent: adminAgent, profile: admin } = await signupAgent(app); + const created = await adminAgent.post("/house").send({ name: "Chez Alice" }); + const { agent: memberAgent } = await signupAgent(app); + await memberAgent.post("/house/join").send({ inviteCode: created.body.inviteCode }); + + const res = await memberAgent.delete(`/house/members/${admin.id}`); + + expect(res.status).to.equal(403); + expect(res.body.code).to.equal(ErrorCode.NOT_HOUSE_ADMIN); + }); + + it("rejects the admin trying to remove themselves with 400 VALIDATION_ERROR", async () => { + const { agent: adminAgent, profile: admin } = await signupAgent(app); + await adminAgent.post("/house").send({ name: "Chez Alice" }); + + const res = await adminAgent.delete(`/house/members/${admin.id}`); + + expect(res.status).to.equal(400); + expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); + }); + + it("removes the targeted member from the household", async () => { + const { agent: adminAgent } = await signupAgent(app); + const created = await adminAgent.post("/house").send({ name: "Chez Alice" }); + const { agent: memberAgent, profile: member } = await signupAgent(app); + await memberAgent.post("/house/join").send({ inviteCode: created.body.inviteCode }); + + const res = await adminAgent.delete(`/house/members/${member.id}`); + + expect(res.status).to.equal(200); + expect(res.body.members.map((m: { id: number }) => m.id)).to.not.include(member.id); + const memberProfile = await prisma.userProfile.findUniqueOrThrow({ + where: { id: member.id }, + }); + expect(memberProfile.houseId).to.equal(null); + }); + }); }); diff --git a/apps/api/test/planning.test.ts b/apps/api/test/planning.test.ts index f83395e..71fffe0 100644 --- a/apps/api/test/planning.test.ts +++ b/apps/api/test/planning.test.ts @@ -49,8 +49,9 @@ describe("Planning", () => { it("returns the household's planning covering today, with recipes resolved", async () => { const agent = request.agent(app); - const signupRes = await agent.post("/auth/signup").send(buildSignupPayload()); - const houseId: number = signupRes.body.houseId; + await agent.post("/auth/signup").send(buildSignupPayload()); + const houseRes = await agent.post("/house").send({ name: "Chez moi" }); + const houseId: number = houseRes.body.id; const recipe = await prisma.recipe.create({ data: { name: "Ratatouille" } }); const today = new Date(); @@ -80,8 +81,9 @@ describe("Planning", () => { it("returns null when the household's planning does not cover today", async () => { const agent = request.agent(app); - const signupRes = await agent.post("/auth/signup").send(buildSignupPayload()); - const houseId: number = signupRes.body.houseId; + await agent.post("/auth/signup").send(buildSignupPayload()); + const houseRes = await agent.post("/house").send({ name: "Chez moi" }); + const houseId: number = houseRes.body.id; // A planning entirely in the past — shouldn't be picked up as "current". await prisma.planning.create({ From 8e9457297d5ee6f7d19bfbff04771a8e78454d9a Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 17 Aug 2026 10:42:36 +0200 Subject: [PATCH 5/8] =?UTF-8?q?Web:=20menu=20Param=C3=A8tres=20en=20bas=20?= =?UTF-8?q?de=20la=20sidebar=20+=20menu=20compte=20utilisateur=20(step=205?= =?UTF-8?q?/8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppLayout: retire "Foyer & profil" de la nav principale - SettingsMenu: bloc repliable (Compte/Préférences/Foyer), ouvert par défaut si la route courante est sous /parametres - AccountMenu: remplace le footer statique (salutation + déconnexion) par un petit menu déroulant (Mon compte, Se déconnecter) --- apps/web/src/layouts/AppLayout.scss | 78 +++++++++++++++-- apps/web/src/layouts/AppLayout.tsx | 127 +++++++++++++++++++++++----- 2 files changed, 174 insertions(+), 31 deletions(-) diff --git a/apps/web/src/layouts/AppLayout.scss b/apps/web/src/layouts/AppLayout.scss index 1cc7dfe..f8ffa0c 100644 --- a/apps/web/src/layouts/AppLayout.scss +++ b/apps/web/src/layouts/AppLayout.scss @@ -60,25 +60,51 @@ } } - &__footer { - display: flex; - flex-direction: column; - gap: var(--space-sm); + // --- "Paramètres" collapsible menu ------------------------------------- + &__settings { padding-top: var(--space-md); border-top: 1px solid var(--color-border); } - &__user { - padding: 0 var(--space-sm); - color: var(--color-text-muted); + &__settings-toggle { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: var(--space-sm); + font-family: var(--font-body); font-size: var(--font-size-sm); + font-weight: 600; + text-align: left; + color: var(--color-text); + background: none; + border: none; + border-radius: var(--radius-base); + cursor: pointer; + + &:hover { + background: var(--color-surface-alt); + } } - &__footer button { + &__settings-nav { + margin-top: var(--space-xs); + } + + // --- Account menu (bottom of the sidebar) ------------------------------- + &__footer { + position: relative; + padding-top: var(--space-md); + border-top: 1px solid var(--color-border); + } + + &__account-toggle { + width: 100%; padding: 0.5rem var(--space-sm); font-family: var(--font-body); font-size: var(--font-size-sm); font-weight: 600; + text-align: left; cursor: pointer; border-radius: var(--radius-base); border: 1px solid var(--color-border); @@ -89,6 +115,40 @@ background: var(--color-surface-alt); } } + + // Anchored just above the toggle rather than inline in the flow — it's a + // transient overlay, not part of the sidebar's permanent layout. + &__account-menu { + position: absolute; + bottom: calc(100% + var(--space-xs)); + left: 0; + right: 0; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-base); + box-shadow: var(--shadow-md); + + a, + button { + padding: var(--space-sm); + font-family: var(--font-body); + font-size: var(--font-size-sm); + font-weight: 600; + text-align: left; + text-decoration: none; + color: var(--color-text); + background: none; + border: none; + cursor: pointer; + + &:hover { + background: var(--color-surface-alt); + } + } + } } .app-content { @@ -125,8 +185,8 @@ overflow-x: auto; } + &__settings, &__footer { - flex-direction: row; padding-top: 0; border-top: none; } diff --git a/apps/web/src/layouts/AppLayout.tsx b/apps/web/src/layouts/AppLayout.tsx index 4497971..eb58f9a 100644 --- a/apps/web/src/layouts/AppLayout.tsx +++ b/apps/web/src/layouts/AppLayout.tsx @@ -1,10 +1,11 @@ +import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { NavLink, Outlet, useNavigate } from "react-router-dom"; +import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import { useAuth } from "../features/auth/AuthContext"; import "./AppLayout.scss"; /** - * One entry in the sidebar nav. `key` maps to `layout.nav.` in + * One entry in the sidebar's main nav. `key` maps to `layout.nav.` in * `locales/fr/translation.json` — adding a section is one array entry plus * one locale key, no other file to touch. */ @@ -12,29 +13,30 @@ const NAV_ITEMS = [ { to: "/", key: "planning" }, { to: "/recettes", key: "recipes" }, { to: "/liste-de-courses", key: "shoppingList" }, - { to: "/foyer", key: "household" }, ] as const; /** - * Shell for every authenticated page: a sidebar (brand, section nav, and - * the signed-in user + logout at the bottom) plus a main content area - * rendering the matched child route via ``. + * One entry in the "Paramètres" nav, revealed by {@link SettingsMenu}. `key` + * maps to `layout.settings.nav.`. + */ +const SETTINGS_ITEMS = [ + { to: "/parametres/compte", key: "account" }, + { to: "/parametres/preferences", key: "preferences" }, + { to: "/parametres/foyer", key: "household" }, +] as const; + +/** + * Shell for every authenticated page: a sidebar (brand, section nav, a + * collapsible "Paramètres" nav, and the account menu at the bottom) plus a + * main content area rendering the matched child route via ``. * * Mounted once as the parent element of the whole authenticated route * group, itself wrapped in {@link RequireAuth} (see `App.tsx`) — `user` is * therefore guaranteed non-null by the time this renders. */ export function AppLayout() { - const { user, logout } = useAuth(); - const navigate = useNavigate(); const { t } = useTranslation(); - /** Ends the session and returns to the login page. */ - async function handleLogout() { - await logout(); - navigate("/login"); - } - return (
@@ -73,3 +69,90 @@ export function AppLayout() {
); } + +/** + * Collapsible "Paramètres" section revealing the three settings pages + * (Compte/Préférences/Foyer — see `pages/settings/`). Starts open whenever + * the current route is already under `/parametres`, so following a link + * there (e.g. from {@link AccountMenu}) doesn't land on a collapsed menu; + * otherwise starts closed to keep the sidebar's main focus on the primary + * nav above it. + */ +function SettingsMenu() { + const { t } = useTranslation(); + const location = useLocation(); + const [isOpen, setIsOpen] = useState(location.pathname.startsWith("/parametres")); + + return ( +
+ + + {isOpen && ( + + )} +
+ ); +} + +/** + * Account menu — a small dropdown opened from the signed-in user's name at + * the very bottom of the sidebar, replacing what used to be a plain + * greeting + logout button. Offers a shortcut straight to `/parametres/compte` + * plus the logout action; closes itself after either action so it never + * lingers open across a navigation. + */ +function AccountMenu() { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + const { t } = useTranslation(); + const [isOpen, setIsOpen] = useState(false); + + /** Ends the session and returns to the login page. */ + async function handleLogout() { + setIsOpen(false); + await logout(); + navigate("/login"); + } + + return ( +
+ + + {isOpen && ( +
+ setIsOpen(false)}> + {t("layout.accountMenu.myAccount")} + + +
+ )} +
+ ); +} From 7abe030bb97de6a3abd25558f402653ca2ec2f6c Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 17 Aug 2026 10:45:58 +0200 Subject: [PATCH 6/8] =?UTF-8?q?Web:=20pages=20Compte/Pr=C3=A9f=C3=A9rences?= =?UTF-8?q?/Foyer,=20client=20API=20et=20d=C3=A9placement=20des=20menus=20?= =?UTF-8?q?(step=206/8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pages/settings/AccountSettingsPage: identité + suppression de compte (confirmation en deux temps, mot de passe requis) - pages/settings/PreferencesPage: régime + allergies/intolérances, sorti de HouseholdPage (attribut du profil, pas du foyer) - pages/settings/HouseholdSettingsPage: sans foyer → créer/rejoindre ; avec foyer → renommer (hot-save), code d'invitation, membres, retirer un membre / supprimer le foyer (admin) ou le quitter - HouseholdPage.tsx/.scss supprimés (contenu réparti ci-dessus) - apiClient: createHouse/joinHouse/leaveHouse/deleteHouse/ removeHouseMember/deleteAccount - AuthContext: deleteAccount() - i18n: namespaces account/preferences réorganisés, household réduit au foyer, common.saving/saved factorisées --- apps/web/src/App.tsx | 42 +- apps/web/src/api/client.ts | 32 +- apps/web/src/features/auth/AuthContext.tsx | 13 +- .../src/features/profile/AllergySelect.tsx | 4 +- apps/web/src/features/profile/DietSelect.tsx | 12 +- .../src/features/profile/HouseNameField.tsx | 2 +- .../src/features/profile/profile-forms.scss | 9 +- apps/web/src/locales/fr/translation.json | 84 +++- apps/web/src/pages/HouseholdPage.scss | 46 --- apps/web/src/pages/HouseholdPage.tsx | 239 ----------- .../pages/settings/AccountSettingsPage.tsx | 106 +++++ .../pages/settings/HouseholdSettingsPage.tsx | 386 ++++++++++++++++++ .../src/pages/settings/PreferencesPage.tsx | 185 +++++++++ .../src/pages/settings/settings-pages.scss | 151 +++++++ 14 files changed, 984 insertions(+), 327 deletions(-) delete mode 100644 apps/web/src/pages/HouseholdPage.scss delete mode 100644 apps/web/src/pages/HouseholdPage.tsx create mode 100644 apps/web/src/pages/settings/AccountSettingsPage.tsx create mode 100644 apps/web/src/pages/settings/HouseholdSettingsPage.tsx create mode 100644 apps/web/src/pages/settings/PreferencesPage.tsx create mode 100644 apps/web/src/pages/settings/settings-pages.scss diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 94cc69c..ec6d75f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,7 +3,6 @@ import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated import { RequireAuth } from "./features/auth/RequireAuth"; import { AppLayout } from "./layouts/AppLayout"; import { HomePage } from "./pages/HomePage"; -import { HouseholdPage } from "./pages/HouseholdPage"; import { LoginPage } from "./pages/LoginPage"; import { RecipesPage } from "./pages/RecipesPage"; import { ShoppingListPage } from "./pages/ShoppingListPage"; @@ -11,6 +10,9 @@ import { SignupPage } from "./pages/SignupPage"; import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage"; import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage"; import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage"; +import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage"; +import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage"; +import { PreferencesPage } from "./pages/settings/PreferencesPage"; /** * Top-level route table. Every authenticated section is nested under one @@ -20,11 +22,18 @@ import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdP * {@link RedirectIfAuthenticated}). Anything else falls back to `/`, which * itself redirects to `/login` if needed. * - * `/onboarding/*` (household/regime/allergens) is also `RequireAuth`-gated - * — reached right after signup, once a session already exists — but - * deliberately its own top-level route group, *not* nested under - * `AppLayout`: a focused, distraction-free wizard with no sidebar, same - * full-page-card language as `/login`/`/signup` (see `onboarding.scss`). + * `/parametres/*` (compte/préférences/foyer) are the settings pages, + * reachable from the sidebar's bottom "Paramètres" menu and the account + * menu (see `AppLayout`) — nested under `AppLayout` like every other + * authenticated section. `/foyer` is the old, pre-split combined page's + * path; it now just redirects to `/parametres/foyer` so an existing + * bookmark/link keeps working. + * + * `/onboarding/*` (regime/foyer/allergens, in that order) is also + * `RequireAuth`-gated — reached right after signup, once a session already + * exists — but deliberately its own top-level route group, *not* nested + * under `AppLayout`: a focused, distraction-free wizard with no sidebar, + * same full-page-card language as `/login`/`/signup` (see `onboarding.scss`). */ export function App() { return ( @@ -39,16 +48,11 @@ export function App() { } /> } /> } /> - } /> + } /> + } /> + } /> + } /> - - - - } - /> } /> + + + + } + /> { + return this.request("/auth/me", { method: "DELETE", body: JSON.stringify({ password }) }); + } + /** Fetches the current user's household's planning for today, or `null` if there isn't one yet. */ public getCurrentPlanning(): Promise { return this.request("/planning/current"); @@ -113,7 +118,7 @@ export class ApiClient { return this.request("/reference/allergies"); } - /** Fetches the current user's household. */ + /** Fetches the current user's household (with its member list), or `null` if they don't have one yet. */ public getCurrentHouse(): Promise { return this.request("/house/current"); } @@ -123,6 +128,31 @@ export class ApiClient { return this.request("/house/current", { method: "PATCH", body: JSON.stringify({ name }) }); } + /** Creates a new household, with the caller as its admin — rejects with `ALREADY_HAS_HOUSE` if they already belong to one. */ + public createHouse(name: string): Promise { + return this.request("/house", { method: "POST", body: JSON.stringify({ name }) }); + } + + /** Joins an existing household by invite code — rejects with `ALREADY_HAS_HOUSE`/`INVITE_CODE_NOT_FOUND`. */ + public joinHouse(inviteCode: string): Promise { + return this.request("/house/join", { method: "POST", body: JSON.stringify({ inviteCode }) }); + } + + /** Removes the current user from their household — hands off adminship or deletes the household if they were its last member (see the API's `house.service.ts`). */ + public leaveHouse(): Promise { + return this.request("/house/leave", { method: "POST" }); + } + + /** Deletes the current user's household outright — every member loses it. Admin-only. */ + public deleteHouse(): Promise { + return this.request("/house/current", { method: "DELETE" }); + } + + /** Removes one specific member from the current user's household. Admin-only. */ + public removeHouseMember(memberId: number): Promise { + return this.request(`/house/members/${memberId}`, { method: "DELETE" }); + } + /** Sets (or clears, with `null`) the current user's dietary regime. */ public updateDiet(dietId: number | null): Promise { return this.request("/profile/diet", { method: "PATCH", body: JSON.stringify({ dietId }) }); diff --git a/apps/web/src/features/auth/AuthContext.tsx b/apps/web/src/features/auth/AuthContext.tsx index cca04ac..1a8b58a 100644 --- a/apps/web/src/features/auth/AuthContext.tsx +++ b/apps/web/src/features/auth/AuthContext.tsx @@ -14,11 +14,13 @@ interface AuthContextValue { login: (input: LoginInput) => Promise; /** Ends the session and clears `user`. */ logout: () => Promise; + /** Permanently deletes the current account and clears `user`. Throws `ApiError` (e.g. wrong password) on failure. */ + deleteAccount: (password: string) => Promise; /** * Re-fetches the current profile and updates `user`. Needed after * anything that changes profile fields `user` carries (e.g. `dietId`) * outside of `signup`/`login` — `PATCH /profile/diet` (see - * `HouseholdPage.tsx`) updates the database directly via `apiClient`, + * `PreferencesPage.tsx`) updates the database directly via `apiClient`, * which doesn't touch this context on its own. */ refreshUser: () => Promise; @@ -59,12 +61,19 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUser(null); }, []); + const deleteAccount = useCallback(async (password: string) => { + await apiClient.deleteAccount(password); + setUser(null); + }, []); + const refreshUser = useCallback(async () => { setUser(await apiClient.me()); }, []); return ( - + {children} ); diff --git a/apps/web/src/features/profile/AllergySelect.tsx b/apps/web/src/features/profile/AllergySelect.tsx index d271ac3..0141dae 100644 --- a/apps/web/src/features/profile/AllergySelect.tsx +++ b/apps/web/src/features/profile/AllergySelect.tsx @@ -12,8 +12,8 @@ interface AllergySelectProps { * Multi-select (checkbox grid, not a native ` onChange(e.target.value === "" ? null : Number(e.target.value))} > - + {diets.map((diet) => (