Merge pull request #11 from kyuno053/feat/settings-household-account

Refonte des paramètres compte/foyer + parcours d'inscription
This commit is contained in:
kyuno053 2026-08-17 18:53:41 +02:00 committed by GitHub
commit c112eaa43e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 2701 additions and 572 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,11 +11,15 @@ export class CustomWorld extends World {
app: Express;
agent: ReturnType<typeof request.agent>;
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<typeof request.agent>;
secondResponse!: request.Response;
constructor(options: IWorldOptions) {
super(options);
this.app = createApp();
this.agent = request.agent(this.app);
this.secondAgent = request.agent(this.app);
}
}

View file

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

View file

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

View file

@ -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<unknown, AuthLocals>) => {
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 { 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<AuthResult> {
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<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.
*

View file

@ -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<unknown, AuthLocals>(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<unknown, AuthLocals>(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<unknown, AuthLocals>(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<unknown, AuthLocals>(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<unknown, AuthLocals>(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);
}),
);

View file

@ -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<HouseView | null> {
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<HouseView> {
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<HouseView> {
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<void> {
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<void> {
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<HouseView> {
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<HouseView> {
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<HouseView> {
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`);
}

View file

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

View file

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

View file

@ -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({

View file

@ -0,0 +1,68 @@
import { ErrorCode } from "@batch-cooking/shared";
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale.
const authenticatedProfile = {
id: 1,
firstName: "Alice",
lastName: "Martin",
email: "alice@example.com",
tokenVersion: 0,
houseId: null,
dietId: null,
};
describe("Account settings (/parametres/compte)", () => {
beforeEach(() => {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
});
it("shows the signed-in profile's identity", () => {
cy.visit("/parametres/compte");
cy.contains("Alice").should("be.visible");
cy.contains("Martin").should("be.visible");
cy.contains("alice@example.com").should("be.visible");
});
it("shows an error and keeps the session when the password is wrong", () => {
cy.intercept("DELETE", "**/auth/me", {
statusCode: 401,
body: { code: ErrorCode.INVALID_CREDENTIALS, message: "Invalid password" },
}).as("deleteAccount");
cy.visit("/parametres/compte");
cy.contains("button", "Supprimer mon compte").click();
cy.get("#deleteAccountPassword").type("wrong-password");
cy.contains("button", "Confirmer la suppression").click();
cy.wait("@deleteAccount");
cy.contains("Email ou mot de passe incorrect").should("be.visible");
cy.url().should("include", "/parametres/compte");
});
it("deletes the account and returns to the login page", () => {
cy.intercept("DELETE", "**/auth/me", { statusCode: 204 }).as("deleteAccount");
cy.visit("/parametres/compte");
cy.contains("button", "Supprimer mon compte").click();
cy.get("#deleteAccountPassword").type("correct-horse-battery-staple");
cy.contains("button", "Confirmer la suppression").click();
cy.wait("@deleteAccount")
.its("request.body")
.should("deep.equal", { password: "correct-horse-battery-staple" });
cy.url().should("include", "/login");
});
it("cancels the deletion without calling the API", () => {
cy.intercept("DELETE", "**/auth/me").as("deleteAccount");
cy.visit("/parametres/compte");
cy.contains("button", "Supprimer mon compte").click();
cy.contains("button", "Annuler").click();
cy.contains("button", "Confirmer la suppression").should("not.exist");
cy.get("@deleteAccount.all").should("have.length", 0);
});
});

View file

@ -6,14 +6,11 @@ import { ErrorCode } from "@batch-cooking/shared";
// suites against a real database.
describe("Signup", () => {
it("creates a profile and starts the onboarding wizard (household/regime/allergens)", () => {
it("creates a profile and starts the onboarding wizard (regime/household/allergens)", () => {
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
// The onboarding wizard's first step (see onboarding.cy.ts for the full
// walkthrough) reads the household right away to prefill its name field.
cy.intercept("GET", "**/house/current", {
statusCode: 200,
body: { id: 1, name: "Foyer de Alice" },
});
// walkthrough) is the regime step, which fetches the reference list.
cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] });
cy.intercept("POST", "**/auth/signup", {
statusCode: 201,
body: {
@ -22,7 +19,7 @@ describe("Signup", () => {
lastName: "Martin",
email: "alice@example.com",
tokenVersion: 0,
houseId: 1,
houseId: null,
dietId: null,
},
}).as("signup");
@ -38,8 +35,8 @@ describe("Signup", () => {
// Not the home page directly — signup hands off to the onboarding
// wizard first (RedirectIfAuthenticated no longer applies here, it's a
// RequireAuth-gated route of its own, see App.tsx).
cy.url().should("include", "/onboarding/foyer");
cy.get("#houseName").should("have.value", "Foyer de Alice");
cy.url().should("include", "/onboarding/regime");
cy.contains("Étape 1 sur 3").should("be.visible");
});
it("shows a client-side validation error without calling the API", () => {
@ -157,6 +154,9 @@ describe("Already authenticated", () => {
cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout");
cy.visit("/");
// "Se déconnecter" lives inside the account menu, opened by clicking
// the greeting button — see AppLayout.tsx's AccountMenu.
cy.contains("button", "Bonjour Alice").click();
cy.contains("button", "Se déconnecter").click();
cy.wait("@logout");

View file

@ -19,6 +19,8 @@ describe("Sidebar navigation", () => {
cy.visit("/");
});
// The Foyer/Compte/Préférences links — behind the sidebar's "Paramètres"
// toggle, not the main nav tested here — are covered by sidebar.cy.ts.
it("highlights the current section and navigates between stub pages", () => {
cy.contains("nav a", "Planning").should("have.class", "active");
@ -32,19 +34,15 @@ describe("Sidebar navigation", () => {
cy.url().should("include", "/liste-de-courses");
cy.contains("h1", "Liste de courses").should("be.visible");
cy.contains("nav a", "Foyer & profil").click();
cy.url().should("include", "/foyer");
cy.contains("h1", "Foyer & profil").should("be.visible");
cy.contains("nav a", "Planning").click();
cy.url().should("eq", `${Cypress.config().baseUrl}/`);
cy.contains("h1", "Planning de la semaine").should("be.visible");
});
it("shows the signed-in user's name and lets them log out from the sidebar", () => {
it("shows the signed-in user's name and lets them log out from the account menu", () => {
cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout");
cy.contains("Bonjour Alice").should("be.visible");
cy.contains("button", "Bonjour Alice").should("be.visible").click();
cy.contains("button", "Se déconnecter").click();
cy.wait("@logout");

View file

@ -0,0 +1,185 @@
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale.
const adminProfile = {
id: 1,
firstName: "Alice",
lastName: "Martin",
email: "alice@example.com",
tokenVersion: 0,
houseId: null as number | null,
dietId: null,
};
const houseWithTwoMembers = {
id: 1,
name: "Chez Alice",
adminId: 1,
inviteCode: "ABCD2345",
members: [
{ id: 1, firstName: "Alice", lastName: "Martin" },
{ id: 2, firstName: "Bob", lastName: "Dupont" },
],
};
describe("Household settings (/parametres/foyer) — no household yet", () => {
beforeEach(() => {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: adminProfile });
cy.intercept("GET", "**/house/current", { statusCode: 200, body: null });
});
it("offers to create or join a household", () => {
cy.visit("/parametres/foyer");
cy.contains("Créer un foyer").should("be.visible");
cy.contains("Rejoindre un foyer").should("be.visible");
});
it("creates a household", () => {
const createdHouse = {
id: 1,
name: "Chez Alice",
adminId: 1,
inviteCode: "ABCD2345",
members: [{ id: 1, firstName: "Alice", lastName: "Martin" }],
};
// The page reloads `GET /house/current` right after creating succeeds —
// see the "deletes the household" test above for the same pattern.
let created = false;
cy.intercept("GET", "**/house/current", (req) => {
req.reply({ statusCode: 200, body: created ? createdHouse : null });
});
cy.intercept("POST", "**/house", (req) => {
created = true;
req.reply({ statusCode: 201, body: createdHouse });
}).as("createHouse");
cy.visit("/parametres/foyer");
cy.get("#houseName").type("Chez Alice");
cy.contains("button", "Créer").click();
cy.wait("@createHouse").its("request.body").should("deep.equal", { name: "Chez Alice" });
cy.contains("ABCD2345").should("be.visible");
});
it("joins a household by invite code", () => {
let joined = false;
cy.intercept("GET", "**/house/current", (req) => {
req.reply({ statusCode: 200, body: joined ? houseWithTwoMembers : null });
});
cy.intercept("POST", "**/house/join", (req) => {
joined = true;
req.reply({ statusCode: 200, body: houseWithTwoMembers });
}).as("joinHouse");
cy.visit("/parametres/foyer");
cy.get("#inviteCode").type("abcd2345");
cy.contains("button", "Rejoindre").click();
cy.wait("@joinHouse").its("request.body").should("deep.equal", { inviteCode: "ABCD2345" });
cy.contains("Bob Dupont").should("be.visible");
});
});
describe("Household settings (/parametres/foyer) — as the admin", () => {
beforeEach(() => {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: { ...adminProfile, houseId: 1 } });
cy.intercept("GET", "**/house/current", { statusCode: 200, body: houseWithTwoMembers });
});
it("shows the household's name, invite code, and members with an admin badge", () => {
cy.visit("/parametres/foyer");
cy.get("#houseName").should("have.value", "Chez Alice");
cy.contains("ABCD2345").should("be.visible");
cy.contains("Bob Dupont").should("be.visible");
cy.contains("Alice Martin").parent().contains("Admin");
});
it("autosaves the household name", () => {
cy.intercept("PATCH", "**/house/current", {
statusCode: 200,
body: { ...houseWithTwoMembers, name: "Chez les Martin" },
}).as("renameHouse");
cy.visit("/parametres/foyer");
cy.get("#houseName").clear();
cy.get("#houseName").type("Chez les Martin");
cy.wait("@renameHouse").its("request.body").should("deep.equal", { name: "Chez les Martin" });
cy.contains("Enregistré ✓").should("be.visible");
});
it("removes a member", () => {
// Same reasoning as the "deletes the household" test below — the page
// reloads `GET /house/current` right after the removal succeeds.
let memberRemoved = false;
cy.intercept("GET", "**/house/current", (req) => {
const body = memberRemoved
? { ...houseWithTwoMembers, members: [houseWithTwoMembers.members[0]] }
: houseWithTwoMembers;
req.reply({ statusCode: 200, body });
});
cy.intercept("DELETE", "**/house/members/2", (req) => {
memberRemoved = true;
req.reply({
statusCode: 200,
body: { ...houseWithTwoMembers, members: [houseWithTwoMembers.members[0]] },
});
}).as("removeMember");
cy.visit("/parametres/foyer");
cy.contains("li", "Bob Dupont").contains("button", "Retirer").click();
cy.wait("@removeMember");
cy.contains("Bob Dupont").should("not.exist");
});
it("deletes the household after confirming", () => {
// The page reloads `GET /house/current` right after the delete
// succeeds — this intercept needs to answer differently before/after
// that DELETE, hence the shared mutable flag rather than two static
// `cy.intercept` calls (the later one would just win for every request,
// including the initial page load).
let houseDeleted = false;
cy.intercept("GET", "**/house/current", (req) => {
req.reply({ statusCode: 200, body: houseDeleted ? null : houseWithTwoMembers });
});
cy.intercept("DELETE", "**/house/current", (req) => {
houseDeleted = true;
req.reply({ statusCode: 204 });
}).as("deleteHouse");
cy.visit("/parametres/foyer");
cy.contains("button", "Supprimer le foyer").click();
cy.contains("button", "Confirmer la suppression").click();
cy.wait("@deleteHouse");
cy.contains("Créer un foyer").should("be.visible");
});
});
describe("Household settings (/parametres/foyer) — as a non-admin member", () => {
beforeEach(() => {
cy.intercept("GET", "**/auth/me", {
statusCode: 200,
body: { ...adminProfile, id: 2, firstName: "Bob", lastName: "Dupont", houseId: 1 },
});
cy.intercept("GET", "**/house/current", { statusCode: 200, body: houseWithTwoMembers });
});
it("offers to leave the household instead of deleting it", () => {
cy.visit("/parametres/foyer");
cy.contains("button", "Quitter le foyer").should("be.visible");
cy.contains("button", "Supprimer le foyer").should("not.exist");
});
it("leaves the household", () => {
cy.intercept("POST", "**/house/leave", { statusCode: 204 }).as("leaveHouse");
cy.visit("/parametres/foyer");
cy.contains("button", "Quitter le foyer").click();
cy.wait("@leaveHouse");
});
});

View file

@ -8,22 +8,27 @@ const signupResponse = {
lastName: "Martin",
email: "alice@example.com",
tokenVersion: 0,
houseId: 1,
houseId: null as number | null,
dietId: null,
};
describe("Onboarding wizard (household → regime → allergens)", () => {
it("walks through all three steps after signup and lands on the home", () => {
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: signupResponse }).as("signup");
cy.intercept("GET", "**/house/current", {
statusCode: 200,
body: { id: 1, name: "Foyer de Alice" },
});
cy.intercept("PATCH", "**/house/current", {
statusCode: 200,
body: { id: 1, name: "Chez Alice" },
}).as("renameHouse");
/** Signs up and lands on the wizard's first step (regime) — shared setup for every scenario below. */
function signupAndReachOnboarding() {
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: signupResponse }).as("signup");
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
cy.visit("/signup");
cy.get("#firstName").type("Alice");
cy.get("#lastName").type("Martin");
cy.get("#email").type("alice@example.com");
cy.get("#password").type("correct-horse-battery-staple");
cy.contains("button", "Créer mon profil").click();
cy.wait("@signup");
}
describe("Onboarding wizard (regime → foyer → allergens)", () => {
it("walks through all three steps, creating a household on the way, and lands on the home", () => {
cy.intercept("GET", "**/reference/diets", {
statusCode: 200,
body: [
@ -35,6 +40,11 @@ describe("Onboarding wizard (household → regime → allergens)", () => {
statusCode: 200,
body: { ...signupResponse, dietId: 2 },
}).as("updateDiet");
cy.intercept("GET", "**/house/current", { statusCode: 200, body: null });
cy.intercept("POST", "**/house", {
statusCode: 201,
body: { id: 1, name: "Chez Alice", adminId: 1, inviteCode: "ABCD2345", members: [] },
}).as("createHouse");
cy.intercept("GET", "**/reference/allergies", {
statusCode: 200,
body: [
@ -45,32 +55,23 @@ describe("Onboarding wizard (household → regime → allergens)", () => {
cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [1] }).as(
"updateAllergies",
);
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
cy.visit("/signup");
cy.get("#firstName").type("Alice");
cy.get("#lastName").type("Martin");
cy.get("#email").type("alice@example.com");
cy.get("#password").type("correct-horse-battery-staple");
cy.contains("button", "Créer mon profil").click();
cy.wait("@signup");
signupAndReachOnboarding();
// Step 1/3 — household name, prefilled with the auto-generated default.
cy.url().should("include", "/onboarding/foyer");
cy.contains("Étape 1 sur 3").should("be.visible");
cy.get("#houseName").should("have.value", "Foyer de Alice");
cy.get("#houseName").clear();
cy.get("#houseName").type("Chez Alice");
cy.contains("button", "Continuer").click();
cy.wait("@renameHouse").its("request.body").should("deep.equal", { name: "Chez Alice" });
// Step 2/3 — dietary regime.
// Step 1/3 — dietary regime.
cy.url().should("include", "/onboarding/regime");
cy.contains("Étape 2 sur 3").should("be.visible");
cy.contains("Étape 1 sur 3").should("be.visible");
cy.get("#diet").select("Végétarien");
cy.contains("button", "Continuer").click();
cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: 2 });
// Step 2/3 — household, optional: creating one here.
cy.url().should("include", "/onboarding/foyer");
cy.contains("Étape 2 sur 3").should("be.visible");
cy.get("#houseName").type("Chez Alice");
cy.contains("button", "Créer").click();
cy.wait("@createHouse").its("request.body").should("deep.equal", { name: "Chez Alice" });
// Step 3/3 — allergens (grouped into two lists) and intolerances, then finish.
cy.url().should("include", "/onboarding/allergenes");
cy.contains("Étape 3 sur 3").should("be.visible");
@ -86,46 +87,62 @@ describe("Onboarding wizard (household → regime → allergens)", () => {
cy.contains("h1", "Planning de la semaine").should("be.visible");
});
it("lets every step be skipped without changing anything", () => {
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: signupResponse });
cy.intercept("GET", "**/house/current", {
statusCode: 200,
body: { id: 1, name: "Foyer de Alice" },
});
cy.intercept("PATCH", "**/house/current", {
statusCode: 200,
body: { id: 1, name: "Foyer de Alice" },
}).as("renameHouse");
it("lets the regime and allergens steps be skipped without changing anything", () => {
cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] });
cy.intercept("PATCH", "**/profile/diet", { statusCode: 200, body: signupResponse }).as(
"updateDiet",
);
cy.intercept("GET", "**/house/current", { statusCode: 200, body: null });
cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] });
cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [] }).as(
"updateAllergies",
);
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
cy.visit("/signup");
cy.get("#firstName").type("Alice");
cy.get("#lastName").type("Martin");
cy.get("#email").type("alice@example.com");
cy.get("#password").type("correct-horse-battery-staple");
cy.contains("button", "Créer mon profil").click();
cy.url().should("include", "/onboarding/foyer");
cy.contains("button", "Continuer").click();
cy.wait("@renameHouse").its("request.body").should("deep.equal", { name: "Foyer de Alice" });
signupAndReachOnboarding();
cy.url().should("include", "/onboarding/regime");
cy.contains("button", "Continuer").click();
cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: null });
cy.url().should("include", "/onboarding/foyer");
cy.contains("button", "Passer cette étape").click();
cy.url().should("include", "/onboarding/allergenes");
cy.contains("button", "Terminer").click();
cy.wait("@updateAllergies").its("request.body").should("deep.equal", { allergyIds: [] });
cy.url().should("eq", `${Cypress.config().baseUrl}/`);
});
it("lets the household step be completed by joining an existing household instead of creating one", () => {
cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] });
cy.intercept("PATCH", "**/profile/diet", { statusCode: 200, body: signupResponse });
cy.intercept("GET", "**/house/current", { statusCode: 200, body: null });
cy.intercept("POST", "**/house/join", {
statusCode: 200,
body: {
id: 1,
name: "Chez Bob",
adminId: 2,
inviteCode: "ABCD2345",
members: [
{ id: 1, firstName: "Alice", lastName: "Martin" },
{ id: 2, firstName: "Bob", lastName: "Dupont" },
],
},
}).as("joinHouse");
cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] });
cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [] });
signupAndReachOnboarding();
cy.contains("button", "Continuer").click();
cy.url().should("include", "/onboarding/foyer");
cy.get("#inviteCode").type("abcd2345");
cy.contains("button", "Rejoindre").click();
cy.wait("@joinHouse").its("request.body").should("deep.equal", { inviteCode: "ABCD2345" });
cy.url().should("include", "/onboarding/allergenes");
});
});

View file

@ -10,13 +10,9 @@ const authenticatedProfile = {
dietId: 2,
};
describe("Household & profile settings (/foyer) — hot saving", () => {
describe("Dietary preferences (/parametres/preferences) — hot saving", () => {
beforeEach(() => {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
cy.intercept("GET", "**/house/current", {
statusCode: 200,
body: { id: 1, name: "Chez Alice" },
});
cy.intercept("GET", "**/reference/diets", {
statusCode: 200,
body: [
@ -34,10 +30,9 @@ describe("Household & profile settings (/foyer) — hot saving", () => {
cy.intercept("GET", "**/profile/allergies", { statusCode: 200, body: [2] });
});
it("loads the current household name, regime, and shows allergies/intolerances as two groups", () => {
cy.visit("/foyer");
it("loads the current regime, and shows allergies/intolerances as two groups", () => {
cy.visit("/parametres/preferences");
cy.get("#houseName").should("have.value", "Chez Alice");
cy.get("#diet").should("have.value", "2");
cy.contains("legend", "Allergies").should("be.visible");
cy.contains("legend", "Intolérances").should("be.visible");
@ -46,41 +41,17 @@ describe("Household & profile settings (/foyer) — hot saving", () => {
});
it("has no explicit save button anywhere on the page", () => {
cy.visit("/foyer");
cy.visit("/parametres/preferences");
cy.contains("button", "Enregistrer").should("not.exist");
});
it("autosaves the household name a short pause after typing, no button click", () => {
cy.intercept("PATCH", "**/house/current", {
statusCode: 200,
body: { id: 1, name: "Chez les Martin" },
}).as("renameHouse");
cy.visit("/foyer");
cy.get("#houseName").clear();
cy.get("#houseName").type("Chez les Martin");
cy.wait("@renameHouse").its("request.body").should("deep.equal", { name: "Chez les Martin" });
cy.contains("Enregistré ✓").should("be.visible");
});
it("does not autosave an empty household name — shows a validation message instead", () => {
cy.intercept("PATCH", "**/house/current").as("renameHouse");
cy.visit("/foyer");
cy.get("#houseName").clear();
cy.contains("Le nom du foyer est requis").should("be.visible");
cy.get("@renameHouse.all").should("have.length", 0);
});
it("autosaves the regime as soon as it's selected", () => {
cy.intercept("PATCH", "**/profile/diet", {
statusCode: 200,
body: { ...authenticatedProfile, dietId: 1 },
}).as("updateDiet");
cy.visit("/foyer");
cy.visit("/parametres/preferences");
cy.get("#diet").select("Omnivore");
cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: 1 });
@ -91,11 +62,12 @@ describe("Household & profile settings (/foyer) — hot saving", () => {
"updateAllergies",
);
cy.visit("/foyer");
cy.visit("/parametres/preferences");
cy.contains("label", "Arachides").find("input[type=checkbox]").check();
cy.wait("@updateAllergies")
.its("request.body")
.should("deep.equal", { allergyIds: [2, 1] });
cy.contains("Enregistré ✓").should("be.visible");
});
});

View file

@ -0,0 +1,50 @@
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale.
const authenticatedProfile = {
id: 1,
firstName: "Alice",
lastName: "Martin",
email: "alice@example.com",
tokenVersion: 0,
houseId: null,
dietId: null,
};
describe("Sidebar — settings menu and account menu", () => {
beforeEach(() => {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
});
it("no longer lists Foyer in the main nav", () => {
cy.visit("/");
cy.get(".app-sidebar__nav a").should("not.contain", "Foyer");
});
it("reveals the three settings pages behind the Paramètres toggle", () => {
cy.visit("/");
cy.contains("a", "Compte").should("not.exist");
cy.contains("button", "Paramètres").click();
cy.contains("a", "Compte").should("have.attr", "href", "/parametres/compte");
cy.contains("a", "Préférences").should("have.attr", "href", "/parametres/preferences");
cy.contains("a", "Foyer").should("have.attr", "href", "/parametres/foyer");
});
it("opens the account menu from the greeting and links to Mon compte", () => {
cy.visit("/");
cy.contains("a", "Mon compte").should("not.exist");
cy.contains("button", "Bonjour Alice").click();
cy.contains("a", "Mon compte").click();
cy.url().should("include", "/parametres/compte");
});
it("redirects the old /foyer path to /parametres/foyer", () => {
cy.intercept("GET", "**/house/current", { statusCode: 200, body: null });
cy.visit("/foyer");
cy.url().should("include", "/parametres/foyer");
});
});

View file

@ -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() {
<Route path="/" element={<HomePage />} />
<Route path="/recettes" element={<RecipesPage />} />
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
<Route path="/foyer" element={<HouseholdPage />} />
<Route path="/parametres/compte" element={<AccountSettingsPage />} />
<Route path="/parametres/preferences" element={<PreferencesPage />} />
<Route path="/parametres/foyer" element={<HouseholdSettingsPage />} />
<Route path="/foyer" element={<Navigate to="/parametres/foyer" replace />} />
</Route>
<Route
path="/onboarding/foyer"
element={
<RequireAuth>
<OnboardingHouseholdPage />
</RequireAuth>
}
/>
<Route
path="/onboarding/regime"
element={
@ -57,6 +61,14 @@ export function App() {
</RequireAuth>
}
/>
<Route
path="/onboarding/foyer"
element={
<RequireAuth>
<OnboardingHouseholdPage />
</RequireAuth>
}
/>
<Route
path="/onboarding/allergenes"
element={

View file

@ -98,6 +98,11 @@ export class ApiClient {
return this.request("/auth/me");
}
/** Permanently deletes the current profile, after re-verifying its password — rejects with `INVALID_CREDENTIALS` if it's wrong. */
public deleteAccount(password: string): Promise<void> {
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<PlanningView | null> {
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<HouseView | null> {
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<HouseView> {
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<HouseView> {
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<void> {
return this.request("/house/leave", { method: "POST" });
}
/** Deletes the current user's household outright — every member loses it. Admin-only. */
public deleteHouse(): Promise<void> {
return this.request("/house/current", { method: "DELETE" });
}
/** Removes one specific member from the current user's household. Admin-only. */
public removeHouseMember(memberId: number): Promise<HouseView> {
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<SafeUserProfile> {
return this.request("/profile/diet", { method: "PATCH", body: JSON.stringify({ dietId }) });

View file

@ -14,11 +14,13 @@ interface AuthContextValue {
login: (input: LoginInput) => Promise<void>;
/** Ends the session and clears `user`. */
logout: () => Promise<void>;
/** Permanently deletes the current account and clears `user`. Throws `ApiError` (e.g. wrong password) on failure. */
deleteAccount: (password: string) => Promise<void>;
/**
* 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<void>;
@ -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 (
<AuthContext.Provider value={{ user, isLoading, signup, login, logout, refreshUser }}>
<AuthContext.Provider
value={{ user, isLoading, signup, login, logout, deleteAccount, refreshUser }}
>
{children}
</AuthContext.Provider>
);

View file

@ -12,8 +12,8 @@ interface AllergySelectProps {
* Multi-select (checkbox grid, not a native `<select multiple>` far more
* discoverable/tappable, especially on the mobile viewport this app is
* eventually embedded into via Capacitor) for a group of allergens. Used
* both by the signup wizard's allergens step and the `/foyer` settings
* page, and rendered *twice* by each once for allergies, once for
* both by the signup wizard's allergens step and the `/parametres/preferences`
* settings page, and rendered *twice* by each once for allergies, once for
* intolerances (`AllergyView.kind` groups them; callers filter and pass
* two separate lists rather than this component knowing about the split).
* An empty `value` is a normal, valid state (no declared allergies, or

View file

@ -10,10 +10,10 @@ interface DietSelectProps {
/**
* Dropdown picker for a dietary regime used both by the signup wizard's
* regime step and the `/foyer` settings page. Always includes a "none"
* option (mapped to `null`, not just an empty label) since this step of the
* profile journey is skippable a profile with no regime is a normal,
* valid state, not an incomplete one.
* regime step and the `/parametres/preferences` settings page. Always
* includes a "none" option (mapped to `null`, not just an empty label)
* since this step of the profile journey is skippable a profile with no
* regime is a normal, valid state, not an incomplete one.
*
* Receives `diets` as a prop rather than fetching them itself: the caller
* (a page) owns loading state/errors for the reference list, this stays a
@ -24,13 +24,13 @@ export function DietSelect({ diets, value, onChange }: DietSelectProps) {
return (
<>
<label htmlFor="diet">{t("household.form.dietLabel")}</label>
<label htmlFor="diet">{t("preferences.form.dietLabel")}</label>
<select
id="diet"
value={value ?? ""}
onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))}
>
<option value="">{t("household.form.dietNone")}</option>
<option value="">{t("preferences.form.dietNone")}</option>
{diets.map((diet) => (
<option key={diet.id} value={diet.id}>
{diet.name}

View file

@ -9,7 +9,7 @@ interface HouseNameFieldProps {
/**
* Labeled text input for the household's name used both by the signup
* wizard's household step and the `/foyer` settings page (see
* wizard's household step and the `/parametres/foyer` settings page (see
* `profile-forms.scss` for the shared styling both consume). Controlled
* component: the caller owns the value and persists it (`ApiClient.
* renameHouse`) on submit, not this component.

View file

@ -1,10 +1,11 @@
// =============================================================================
// Styles shared by the profile-journey field components (HouseNameField,
// DietSelect, AllergySelect) used both by the signup wizard's steps
// (pages/onboarding/) and the `/foyer` settings page (HouseholdPage). Field
// styling only (label/input/select/checkbox) the surrounding page/card
// layout belongs to each consuming page's own .scss, same split as
// features/auth/auth-form.scss vs. LoginPage/SignupPage.
// (pages/onboarding/) and the settings pages (PreferencesPage,
// HouseholdSettingsPage, under pages/settings/). Field styling only
// (label/input/select/checkbox) the surrounding page/card layout belongs
// to each consuming page's own .scss, same split as features/auth/
// auth-form.scss vs. LoginPage/SignupPage.
// =============================================================================
// No `@use` of the theme partial needed every token below is a runtime

View file

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

View file

@ -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.<key>` in
* One entry in the sidebar's main nav. `key` maps to `layout.nav.<key>` 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 `<Outlet />`.
* One entry in the "Paramètres" nav, revealed by {@link SettingsMenu}. `key`
* maps to `layout.settings.nav.<key>`.
*/
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 `<Outlet />`.
*
* 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 (
<div className="app-layout">
<aside className="app-sidebar">
@ -57,14 +59,8 @@ export function AppLayout() {
))}
</nav>
<div className="app-sidebar__footer">
<span className="app-sidebar__user">
{t("layout.greeting", { firstName: user?.firstName })}
</span>
<button type="button" onClick={handleLogout}>
{t("layout.logout")}
</button>
</div>
<SettingsMenu />
<AccountMenu />
</aside>
<main className="app-content">
@ -73,3 +69,90 @@ export function AppLayout() {
</div>
);
}
/**
* 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 (
<div className="app-sidebar__settings">
<button
type="button"
className="app-sidebar__settings-toggle"
aria-expanded={isOpen}
onClick={() => setIsOpen((open) => !open)}
>
{t("layout.settings.toggle")}
<span aria-hidden="true">{isOpen ? "▾" : "▸"}</span>
</button>
{isOpen && (
<nav className="app-sidebar__nav app-sidebar__settings-nav">
{SETTINGS_ITEMS.map(({ to, key }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) => (isActive ? "active" : undefined)}
>
{t(`layout.settings.nav.${key}`)}
</NavLink>
))}
</nav>
)}
</div>
);
}
/**
* 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 (
<div className="app-sidebar__footer">
<button
type="button"
className="app-sidebar__account-toggle"
aria-expanded={isOpen}
onClick={() => setIsOpen((open) => !open)}
>
{t("layout.greeting", { firstName: user?.firstName })}
</button>
{isOpen && (
<div className="app-sidebar__account-menu">
<NavLink to="/parametres/compte" onClick={() => setIsOpen(false)}>
{t("layout.accountMenu.myAccount")}
</NavLink>
<button type="button" onClick={handleLogout}>
{t("layout.logout")}
</button>
</div>
)}
</div>
);
}

View file

@ -1,13 +1,20 @@
{
"common": {
"saving": "Enregistrement…",
"saved": "Enregistré ✓"
},
"errors": {
"VALIDATION_ERROR": "Erreur de validation",
"EMAIL_ALREADY_IN_USE": "Cet email est déjà utilisé",
"INVALID_CREDENTIALS": "Email ou mot de passe incorrect",
"NOT_AUTHENTICATED": "Vous devez être connecté",
"ALREADY_HAS_HOUSE": "Vous appartenez déjà à un foyer",
"NOT_HOUSE_ADMIN": "Seul l'administrateur du foyer peut faire ça",
"NOT_FOUND": "Ressource introuvable",
"HOUSE_NOT_FOUND": "Votre profil n'a pas de foyer",
"DIET_NOT_FOUND": "Ce régime alimentaire n'existe pas",
"ALLERGY_NOT_FOUND": "Un des allergènes sélectionnés n'existe pas",
"INVITE_CODE_NOT_FOUND": "Ce code d'invitation ne correspond à aucun foyer",
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
},
"auth": {
@ -37,12 +44,15 @@
"continue": "Continuer",
"finish": "Terminer",
"loading": "Chargement…",
"household": {
"title": "Comment s'appelle votre foyer ?"
},
"diet": {
"title": "Un régime alimentaire particulier ?"
},
"household": {
"title": "Rejoignez ou créez un foyer",
"subtitle": "Optionnel — vous pourrez le faire plus tard depuis les paramètres.",
"skip": "Passer cette étape",
"alreadyHasHouse": "Vous faites déjà partie du foyer « {{name}} »."
},
"allergens": {
"title": "Des allergies ou intolérances ?"
}
@ -51,8 +61,18 @@
"nav": {
"planning": "Planning",
"recipes": "Recettes",
"shoppingList": "Liste de courses",
"household": "Foyer & profil"
"shoppingList": "Liste de courses"
},
"settings": {
"toggle": "Paramètres",
"nav": {
"account": "Compte",
"preferences": "Préférences",
"household": "Foyer"
}
},
"accountMenu": {
"myAccount": "Mon compte"
},
"greeting": "Bonjour {{firstName}} 👋",
"logout": "Se déconnecter"
@ -76,16 +96,58 @@
"title": "Liste de courses",
"comingSoon": "Cette section arrive bientôt."
},
"household": {
"title": "Foyer & profil",
"account": {
"title": "Compte",
"identity": {
"firstNameLabel": "Prénom",
"lastNameLabel": "Nom",
"emailLabel": "Email"
},
"dangerZone": {
"title": "Zone dangereuse",
"description": "Supprimer votre compte est définitif et irréversible.",
"deleteButton": "Supprimer mon compte",
"passwordLabel": "Confirmez avec votre mot de passe",
"confirmButton": "Confirmer la suppression",
"cancelButton": "Annuler"
}
},
"preferences": {
"title": "Préférences alimentaires",
"form": {
"nameLabel": "Nom du foyer",
"dietLabel": "Régime alimentaire",
"dietNone": "Aucun régime particulier",
"allergiesLabel": "Allergies",
"intolerancesLabel": "Intolérances",
"saving": "Enregistrement…",
"saved": "Enregistré ✓"
"intolerancesLabel": "Intolérances"
}
},
"household": {
"title": "Foyer",
"form": {
"nameLabel": "Nom du foyer"
},
"noHouse": {
"intro": "Vous n'appartenez à aucun foyer pour le moment.",
"createTitle": "Créer un foyer",
"createButton": "Créer",
"joinTitle": "Rejoindre un foyer",
"joinLabel": "Code d'invitation",
"joinButton": "Rejoindre"
},
"inviteCodeLabel": "Code d'invitation",
"copyButton": "Copier",
"copied": "Copié ✓",
"membersTitle": "Membres",
"adminBadge": "Admin",
"youSuffix": " (vous)",
"removeButton": "Retirer",
"leaveButton": "Quitter le foyer",
"dangerZone": {
"title": "Zone dangereuse",
"description": "Supprimer le foyer le supprime pour tous ses membres, ainsi que son planning.",
"deleteButton": "Supprimer le foyer",
"confirmButton": "Confirmer la suppression",
"cancelButton": "Annuler"
}
}
}

View file

@ -1,46 +0,0 @@
// =============================================================================
// Styles specific to HouseholdPage colocated next to HouseholdPage.tsx
// since nothing else uses these classes. Field/label/input styling itself
// comes from features/profile/profile-forms.scss (shared with the
// onboarding wizard); this file only covers this page's own layout.
// =============================================================================
.household-page {
&__status {
color: var(--color-text-muted);
font-size: var(--font-size-md);
}
&__status--error {
color: var(--color-error);
}
}
// Each of the three settings (household name, regime, allergies +
// intolerances) is its own independently-autosaved section a card per
// section, same surface treatment used elsewhere (see .planning-table in
// HomePage.scss), so each reads as a distinct, self-contained unit rather
// than one long form. No buttons here (hot saving see HouseholdPage.tsx).
.household-page__section {
max-width: 32rem;
margin-top: var(--space-lg);
padding: var(--space-lg);
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.household-page__saving,
.household-page__saved {
margin: var(--space-sm) 0 0;
font-size: var(--font-size-sm);
font-weight: 600;
}
.household-page__saving {
color: var(--color-text-muted);
}
.household-page__saved {
color: var(--color-success);
}

View file

@ -1,239 +0,0 @@
import {
type AllergyView,
type DietView,
ErrorCode,
renameHouseSchema,
} from "@batch-cooking/shared";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ApiError, apiClient } from "../api/client";
import { useAuth } from "../features/auth/AuthContext";
import { AllergySelect } from "../features/profile/AllergySelect";
import { DietSelect } from "../features/profile/DietSelect";
import { HouseNameField } from "../features/profile/HouseNameField";
import { fieldErrorsFrom } from "../lib/zod-errors";
import { errorMessageService } from "../services/error-message.service";
import "./HouseholdPage.scss";
/** Status of one section's own autosave — sections save independently, each with its own feedback. */
type SaveState = "idle" | "saving" | "saved" | "error";
/** Debounce for the household name field (typing) — long enough that saves don't fire on every keystroke. */
const HOUSE_NAME_DEBOUNCE_MS = 600;
/** Debounce for the allergen checkboxes — coalesces a quick burst of several toggles into one request. */
const ALLERGIES_DEBOUNCE_MS = 500;
/**
* Household & profile settings routed at `/foyer`. The always-available
* counterpart to the signup wizard (`pages/onboarding/`): same concerns
* (household name, dietary regime, allergies, intolerances), same shared
* field components, but editable at any time rather than run once.
*
* Hot saving (retour fonctionnel) no "Enregistrer" buttons; each section
* autosaves shortly after the user stops changing it. Saves are triggered
* from the field's own `onChange` handler, *not* a generic `useEffect`
* watching the value: an effect keyed on the value would also fire the
* moment the initial `GET` calls populate that same state, with no clean
* way to tell "just loaded" apart from "user edited" routing every save
* through an explicit handler sidesteps that entirely, since the initial
* load never goes through these handlers.
*/
export function HouseholdPage() {
const { t } = useTranslation();
const { refreshUser } = useAuth();
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [houseName, setHouseName] = useState("");
const [houseNameErrors, setHouseNameErrors] = useState<Record<string, string>>({});
const [houseSaveState, setHouseSaveState] = useState<SaveState>("idle");
const [houseSaveError, setHouseSaveError] = useState<string | null>(null);
const houseNameTimeout = useRef<number | undefined>(undefined);
const [diets, setDiets] = useState<DietView[]>([]);
const [dietId, setDietId] = useState<number | null>(null);
const [dietSaveState, setDietSaveState] = useState<SaveState>("idle");
const [dietSaveError, setDietSaveError] = useState<string | null>(null);
const [allergies, setAllergies] = useState<AllergyView[]>([]);
const [allergyIds, setAllergyIds] = useState<number[]>([]);
const [allergySaveState, setAllergySaveState] = useState<SaveState>("idle");
const [allergySaveError, setAllergySaveError] = useState<string | null>(null);
const allergiesTimeout = useRef<number | undefined>(undefined);
useEffect(() => {
let cancelled = false;
// `apiClient.me()` here (not `useAuth().user.dietId`) — this page can
// be revisited many times over a session without a full reload, and
// AuthContext's `user` only refreshes on app load or after an
// explicit `refreshUser()` call; relying on it directly would show a
// stale `dietId` after navigating away and back post-save.
Promise.all([
apiClient.getCurrentHouse(),
apiClient.getDiets(),
apiClient.getAllergies(),
apiClient.getAllergyIds(),
apiClient.me(),
])
.then(([house, dietsResult, allergiesResult, allergyIdsResult, profile]) => {
if (cancelled) return;
setHouseName(house?.name ?? "");
setDiets(dietsResult);
setAllergies(allergiesResult);
setAllergyIds(allergyIdsResult);
setDietId(profile.dietId);
})
.catch(() => {
if (!cancelled) setLoadError(true);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, []);
// Pending debounced saves must not fire after unmount (e.g. the user
// navigates away mid-debounce).
useEffect(() => {
return () => {
window.clearTimeout(houseNameTimeout.current);
window.clearTimeout(allergiesTimeout.current);
};
}, []);
function handleHouseNameChange(name: string) {
setHouseName(name);
window.clearTimeout(houseNameTimeout.current);
const result = renameHouseSchema.safeParse({ name });
if (!result.success) {
setHouseNameErrors(fieldErrorsFrom(result.error));
setHouseSaveState("idle");
return;
}
setHouseNameErrors({});
setHouseSaveState("saving");
houseNameTimeout.current = window.setTimeout(async () => {
try {
await apiClient.renameHouse(result.data.name);
setHouseSaveState("saved");
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setHouseSaveError(errorMessageService.getLabel(code));
setHouseSaveState("error");
}
}, HOUSE_NAME_DEBOUNCE_MS);
}
async function handleDietChange(newDietId: number | null) {
setDietId(newDietId);
setDietSaveState("saving");
try {
await apiClient.updateDiet(newDietId);
// Keeps AuthContext's `user.dietId` in sync — nothing else reads it
// today, but the sidebar/anywhere else that might in the future
// shouldn't have to know this page exists to stay correct.
await refreshUser();
setDietSaveState("saved");
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setDietSaveError(errorMessageService.getLabel(code));
setDietSaveState("error");
}
}
function handleAllergyIdsChange(newAllergyIds: number[]) {
setAllergyIds(newAllergyIds);
window.clearTimeout(allergiesTimeout.current);
setAllergySaveState("saving");
allergiesTimeout.current = window.setTimeout(async () => {
try {
await apiClient.updateAllergyIds(newAllergyIds);
setAllergySaveState("saved");
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setAllergySaveError(errorMessageService.getLabel(code));
setAllergySaveState("error");
}
}, ALLERGIES_DEBOUNCE_MS);
}
if (isLoading) {
return (
<div className="household-page">
<h1>{t("household.title")}</h1>
<p className="household-page__status">{t("onboarding.loading")}</p>
</div>
);
}
if (loadError) {
return (
<div className="household-page">
<h1>{t("household.title")}</h1>
<p className="household-page__status household-page__status--error">{t("home.error")}</p>
</div>
);
}
return (
<div className="household-page">
<h1>{t("household.title")}</h1>
<div className="household-page__section">
<HouseNameField
value={houseName}
onChange={handleHouseNameChange}
error={houseNameErrors.name}
/>
<SaveStatus state={houseSaveState} error={houseSaveError} t={t} />
</div>
<div className="household-page__section">
<DietSelect diets={diets} value={dietId} onChange={handleDietChange} />
<SaveStatus state={dietSaveState} error={dietSaveError} t={t} />
</div>
<div className="household-page__section">
<AllergySelect
legend={t("household.form.allergiesLabel")}
allergies={allergies.filter((allergy) => allergy.kind === "ALLERGY")}
value={allergyIds}
onChange={handleAllergyIdsChange}
/>
<AllergySelect
legend={t("household.form.intolerancesLabel")}
allergies={allergies.filter((allergy) => allergy.kind === "INTOLERANCE")}
value={allergyIds}
onChange={handleAllergyIdsChange}
/>
<SaveStatus state={allergySaveState} error={allergySaveError} t={t} />
</div>
</div>
);
}
/** Inline "saving…"/"saved ✓"/error feedback shared by every autosaved section — `idle` renders nothing. */
function SaveStatus({
state,
error,
t,
}: {
state: SaveState;
error: string | null;
t: (key: string) => string;
}) {
if (state === "saving") {
return <p className="household-page__saving">{t("household.form.saving")}</p>;
}
if (state === "saved") {
return <p className="household-page__saved">{t("household.form.saved")}</p>;
}
if (state === "error") {
return <p className="field-error">{error}</p>;
}
return null;
}

View file

@ -37,7 +37,7 @@ export function SignupPage() {
const [formError, setFormError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
/** Validates, then submits the form; on success, starts the household/regime/allergens onboarding wizard rather than going straight to the home. */
/** Validates, then submits the form; on success, starts the regime/household/allergens onboarding wizard rather than going straight to the home. */
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setFormError(null);
@ -52,7 +52,7 @@ export function SignupPage() {
setIsSubmitting(true);
try {
await signup(result.data);
navigate("/onboarding/foyer");
navigate("/onboarding/regime");
} catch (err) {
// ApiError.code is looked up through ErrorMessageService so the
// label is centralized and localized — never display err.message

View file

@ -13,8 +13,8 @@ import "./onboarding.scss";
* `/onboarding/allergenes`. Starts from an empty selection a freshly
* signed-up profile has none yet, so there's no need for the extra
* `GET /profile/allergies` round trip a "resume where I left off" flow
* would require (the `/foyer` settings page, task 10, is the always-fetch
* source of truth for editing an existing selection later).
* would require (the `/parametres/preferences` settings page is the
* always-fetch source of truth for editing an existing selection later).
*/
export function OnboardingAllergensPage() {
const { t } = useTranslation();
@ -68,13 +68,13 @@ export function OnboardingAllergensPage() {
) : (
<>
<AllergySelect
legend={t("household.form.allergiesLabel")}
legend={t("preferences.form.allergiesLabel")}
allergies={allergies.filter((allergy) => allergy.kind === "ALLERGY")}
value={allergyIds}
onChange={setAllergyIds}
/>
<AllergySelect
legend={t("household.form.intolerancesLabel")}
legend={t("preferences.form.intolerancesLabel")}
allergies={allergies.filter((allergy) => allergy.kind === "INTOLERANCE")}
value={allergyIds}
onChange={setAllergyIds}

View file

@ -10,7 +10,7 @@ import { errorMessageService } from "../../services/error-message.service";
import "./onboarding.scss";
/**
* Second step of the post-signup onboarding wizard, routed at
* First step of the post-signup onboarding wizard, routed at
* `/onboarding/regime`. Initial selection comes from `useAuth()`'s
* already-loaded profile (`user.dietId`) freshly signed-up, this is
* `null` no extra fetch needed just to know the starting value, unlike
@ -48,7 +48,7 @@ export function OnboardingDietPage() {
setIsSubmitting(true);
try {
await apiClient.updateDiet(dietId);
navigate("/onboarding/allergenes");
navigate("/onboarding/foyer");
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setFormError(errorMessageService.getLabel(code));
@ -60,7 +60,7 @@ export function OnboardingDietPage() {
return (
<main className="onboarding-page">
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
<p className="onboarding-step">{t("onboarding.step", { current: 2, total: 3 })}</p>
<p className="onboarding-step">{t("onboarding.step", { current: 1, total: 3 })}</p>
<h1>{t("onboarding.diet.title")}</h1>
{isLoading ? (

View file

@ -1,4 +1,4 @@
import { ErrorCode, renameHouseSchema } from "@batch-cooking/shared";
import { ErrorCode, type HouseView, renameHouseSchema } from "@batch-cooking/shared";
import { type FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
@ -9,30 +9,32 @@ import { errorMessageService } from "../../services/error-message.service";
import "./onboarding.scss";
/**
* First step of the post-signup onboarding wizard, routed at
* `/onboarding/foyer` behind {@link RequireAuth} (see `App.tsx`), reached
* right after `POST /auth/signup` creates the account and its household
* (auto-named, see `auth.service.ts`). Prefills the current (default) name
* so continuing without editing it is a valid, implicit "skip" there's
* no separate skip button anywhere in this wizard, see `DietSelect`/
* `AllergySelect` for the same choice on the following steps.
* Second step of the post-signup onboarding wizard, routed at
* `/onboarding/foyer` unlike the regime/allergens steps, this one is
* genuinely optional rather than just "skippable via an unedited default":
* no household is created at signup anymore (see the API's
* `auth.service.ts`), so there's nothing to prefill or implicitly keep by
* pressing "Continuer" the visitor explicitly creates one, joins one by
* invite code, or skips the step outright via {@link SkipButton}.
*
* Still checks `getCurrentHouse()` on mount and skips straight to the
* "already have one" state if it finds one defensive against revisiting
* this step (e.g. browser back) after already creating/joining a
* household earlier in the same wizard run.
*/
export function OnboardingHouseholdPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [name, setName] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [house, setHouse] = useState<HouseView | null>(null);
useEffect(() => {
let cancelled = false;
apiClient
.getCurrentHouse()
.then((house) => {
if (!cancelled && house) setName(house.name);
.then((result) => {
if (!cancelled) setHouse(result);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
@ -42,21 +44,58 @@ export function OnboardingHouseholdPage() {
};
}, []);
function goToNextStep() {
navigate("/onboarding/allergenes");
}
return (
<main className="onboarding-page">
<div className="onboarding-card">
<p className="onboarding-step">{t("onboarding.step", { current: 2, total: 3 })}</p>
<h1>{t("onboarding.household.title")}</h1>
{isLoading ? (
<p>{t("onboarding.loading")}</p>
) : house !== null ? (
<>
<p>{t("onboarding.household.alreadyHasHouse", { name: house.name })}</p>
<button type="button" onClick={goToNextStep}>
{t("onboarding.continue")}
</button>
</>
) : (
<>
<p className="onboarding-subtitle">{t("onboarding.household.subtitle")}</p>
<CreateHouseholdForm onDone={goToNextStep} />
<JoinHouseholdForm onDone={goToNextStep} />
<SkipButton onSkip={goToNextStep} />
</>
)}
</div>
</main>
);
}
function CreateHouseholdForm({ onDone }: { onDone: () => void }) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setFormError(null);
const result = renameHouseSchema.safeParse({ name });
if (!result.success) {
setFieldErrors(fieldErrorsFrom(result.error));
return;
}
setFieldErrors({});
setIsSubmitting(true);
try {
await apiClient.renameHouse(result.data.name);
navigate("/onboarding/regime");
await apiClient.createHouse(result.data.name);
onDone();
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setFormError(errorMessageService.getLabel(code));
@ -66,23 +105,62 @@ export function OnboardingHouseholdPage() {
}
return (
<main className="onboarding-page">
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
<p className="onboarding-step">{t("onboarding.step", { current: 1, total: 3 })}</p>
<h1>{t("onboarding.household.title")}</h1>
{isLoading ? (
<p>{t("onboarding.loading")}</p>
) : (
<HouseNameField value={name} onChange={setName} error={fieldErrors.name} />
)}
{formError && <p className="form-error">{formError}</p>}
<button type="submit" disabled={isSubmitting || isLoading}>
{t("onboarding.continue")}
</button>
</form>
</main>
<form onSubmit={handleSubmit} noValidate>
<h2>{t("household.noHouse.createTitle")}</h2>
<HouseNameField value={name} onChange={setName} error={fieldErrors.name} />
{formError && <p className="form-error">{formError}</p>}
<button type="submit" disabled={isSubmitting}>
{t("household.noHouse.createButton")}
</button>
</form>
);
}
function JoinHouseholdForm({ onDone }: { onDone: () => void }) {
const { t } = useTranslation();
const [inviteCode, setInviteCode] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setFormError(null);
setIsSubmitting(true);
try {
await apiClient.joinHouse(inviteCode.trim());
onDone();
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setFormError(errorMessageService.getLabel(code));
} finally {
setIsSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit} noValidate>
<h2>{t("household.noHouse.joinTitle")}</h2>
<label htmlFor="inviteCode">{t("household.noHouse.joinLabel")}</label>
<input
id="inviteCode"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value.toUpperCase())}
autoComplete="off"
/>
{formError && <p className="form-error">{formError}</p>}
<button type="submit" disabled={isSubmitting || inviteCode.trim().length === 0}>
{t("household.noHouse.joinButton")}
</button>
</form>
);
}
/** No API call — skipping just moves on, same "nothing to persist" idea as the regime/allergens steps' own skippability. */
function SkipButton({ onSkip }: { onSkip: () => void }) {
const { t } = useTranslation();
return (
<button type="button" className="onboarding-skip" onClick={onSkip}>
{t("onboarding.household.skip")}
</button>
);
}

View file

@ -36,6 +36,11 @@
margin-bottom: var(--space-sm);
}
h2 {
margin: var(--space-md) 0 var(--space-xs);
font-size: var(--font-size-base);
}
button {
margin-top: var(--space-md);
padding: 0.6rem;
@ -59,6 +64,24 @@
}
}
// The household step's "skip" action — a plain text link, not another
// filled button, so it doesn't visually compete with "Créer"/"Rejoindre"
// right above it (this is the one step of the wizard offering three
// distinct actions instead of one). The extra specificity of
// `.onboarding-card .onboarding-skip` (a class, not just `button`) is what
// lets this win over `.onboarding-card button`'s filled style above.
.onboarding-card .onboarding-skip {
background: none;
color: var(--color-text-muted);
font-weight: 600;
text-decoration: underline;
&:hover:not(:disabled) {
color: var(--color-text);
background: none;
}
}
.onboarding-step {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
@ -68,6 +91,15 @@
margin: 0 0 var(--space-sm);
}
// The household step's "this is optional" hint — same muted tone as
// `.onboarding-step` above, but a normal sentence (no uppercase transform).
.onboarding-subtitle {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
text-align: center;
margin: 0 0 var(--space-sm);
}
.form-error {
color: var(--color-error);
font-size: var(--font-size-sm);

View file

@ -0,0 +1,106 @@
import { ErrorCode } from "@batch-cooking/shared";
import { type FormEvent, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { ApiError } from "../../api/client";
import { useAuth } from "../../features/auth/AuthContext";
import { errorMessageService } from "../../services/error-message.service";
import "./settings-pages.scss";
/**
* Account settings routed at `/parametres/compte`. Read-only identity
* (editing name/email isn't a requested feature yet) plus a "danger zone"
* to permanently delete the account, gated behind re-entering the current
* password (same idea as `login`'s check, see `auth.service.ts`'s
* `deleteAccount`).
*/
export function AccountSettingsPage() {
const { t } = useTranslation();
const { user, deleteAccount } = useAuth();
const navigate = useNavigate();
const [isConfirming, setIsConfirming] = useState(false);
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleDelete(e: FormEvent) {
e.preventDefault();
setError(null);
setIsSubmitting(true);
try {
await deleteAccount(password);
navigate("/login");
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setError(errorMessageService.getLabel(code));
} finally {
setIsSubmitting(false);
}
}
function cancelDelete() {
setIsConfirming(false);
setPassword("");
setError(null);
}
return (
<div className="settings-page">
<h1>{t("account.title")}</h1>
<div className="settings-page__section">
<p>
{t("account.identity.firstNameLabel")}: {user?.firstName}
</p>
<p>
{t("account.identity.lastNameLabel")}: {user?.lastName}
</p>
<p>
{t("account.identity.emailLabel")}: {user?.email}
</p>
</div>
<div className="settings-page__section settings-page__danger-zone">
<h2 className="settings-page__section-title">{t("account.dangerZone.title")}</h2>
<p className="settings-page__hint">{t("account.dangerZone.description")}</p>
{!isConfirming ? (
<div className="settings-page__actions">
<button
type="button"
className="settings-page__danger-button"
onClick={() => setIsConfirming(true)}
>
{t("account.dangerZone.deleteButton")}
</button>
</div>
) : (
<form onSubmit={handleDelete} noValidate>
<label htmlFor="deleteAccountPassword">{t("account.dangerZone.passwordLabel")}</label>
<input
id="deleteAccountPassword"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
{error && <p className="field-error">{error}</p>}
<div className="settings-page__actions">
<button
type="submit"
className="settings-page__danger-button"
disabled={isSubmitting}
>
{t("account.dangerZone.confirmButton")}
</button>
<button type="button" onClick={cancelDelete} disabled={isSubmitting}>
{t("account.dangerZone.cancelButton")}
</button>
</div>
</form>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,386 @@
import { ErrorCode, type HouseView, renameHouseSchema } from "@batch-cooking/shared";
import { type FormEvent, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ApiError, apiClient } from "../../api/client";
import { useAuth } from "../../features/auth/AuthContext";
import { HouseNameField } from "../../features/profile/HouseNameField";
import { fieldErrorsFrom } from "../../lib/zod-errors";
import { errorMessageService } from "../../services/error-message.service";
import "./settings-pages.scss";
/** Status of the household name's own autosave — see `PreferencesPage` for the same hot-saving pattern. */
type SaveState = "idle" | "saving" | "saved" | "error";
/** Debounce for the household name field (typing) — long enough that saves don't fire on every keystroke. */
const HOUSE_NAME_DEBOUNCE_MS = 600;
/**
* Household settings routed at `/parametres/foyer`. Split out of what
* used to be `HouseholdPage` (regime/allergies moved to `PreferencesPage`,
* a personal-profile concern rather than a household one). Two very
* different layouts depending on whether the profile currently belongs to
* a household:
*
* - **No household**: create one, or join an existing one by invite code.
* - **Has a household**: rename it (hot-save, same pattern as before),
* share its invite code, see its members, and either manage it (admin:
* remove a member, delete the household) or leave it (non-admin).
*
* Reloads `getCurrentHouse()` after every mutation (create/join/leave/
* delete/remove) rather than optimistically patching local state these
* are infrequent, deliberate actions, not a hot-saved field, so the extra
* round trip isn't worth the risk of the two ever drifting apart.
*/
export function HouseholdSettingsPage() {
const { t } = useTranslation();
const { user } = useAuth();
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [house, setHouse] = useState<HouseView | null>(null);
useEffect(() => {
loadHouse();
}, []);
function loadHouse() {
setIsLoading(true);
setLoadError(false);
return apiClient
.getCurrentHouse()
.then((result) => setHouse(result))
.catch(() => setLoadError(true))
.finally(() => setIsLoading(false));
}
if (isLoading) {
return (
<div className="settings-page">
<h1>{t("household.title")}</h1>
<p className="settings-page__status">{t("onboarding.loading")}</p>
</div>
);
}
if (loadError) {
return (
<div className="settings-page">
<h1>{t("household.title")}</h1>
<p className="settings-page__status settings-page__status--error">{t("home.error")}</p>
</div>
);
}
return (
<div className="settings-page">
<h1>{t("household.title")}</h1>
{house === null ? (
<NoHousehold onChanged={loadHouse} />
) : (
<HasHousehold house={house} currentUserId={user?.id ?? null} onChanged={loadHouse} />
)}
</div>
);
}
/** Create-or-join forms shown when the profile doesn't belong to a household yet. */
function NoHousehold({ onChanged }: { onChanged: () => void }) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [nameErrors, setNameErrors] = useState<Record<string, string>>({});
const [createError, setCreateError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [inviteCode, setInviteCode] = useState("");
const [joinError, setJoinError] = useState<string | null>(null);
const [isJoining, setIsJoining] = useState(false);
async function handleCreate(e: FormEvent) {
e.preventDefault();
setCreateError(null);
const result = renameHouseSchema.safeParse({ name });
if (!result.success) {
setNameErrors(fieldErrorsFrom(result.error));
return;
}
setNameErrors({});
setIsCreating(true);
try {
await apiClient.createHouse(result.data.name);
onChanged();
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setCreateError(errorMessageService.getLabel(code));
} finally {
setIsCreating(false);
}
}
async function handleJoin(e: FormEvent) {
e.preventDefault();
setJoinError(null);
setIsJoining(true);
try {
await apiClient.joinHouse(inviteCode.trim());
onChanged();
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setJoinError(errorMessageService.getLabel(code));
} finally {
setIsJoining(false);
}
}
return (
<>
<p className="settings-page__hint">{t("household.noHouse.intro")}</p>
<form className="settings-page__section" onSubmit={handleCreate} noValidate>
<h2 className="settings-page__section-title">{t("household.noHouse.createTitle")}</h2>
<HouseNameField value={name} onChange={setName} error={nameErrors.name} />
{createError && <p className="form-error">{createError}</p>}
<div className="settings-page__actions">
<button type="submit" disabled={isCreating}>
{t("household.noHouse.createButton")}
</button>
</div>
</form>
<form className="settings-page__section" onSubmit={handleJoin} noValidate>
<h2 className="settings-page__section-title">{t("household.noHouse.joinTitle")}</h2>
<label htmlFor="inviteCode">{t("household.noHouse.joinLabel")}</label>
<input
id="inviteCode"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value.toUpperCase())}
autoComplete="off"
/>
{joinError && <p className="form-error">{joinError}</p>}
<div className="settings-page__actions">
<button type="submit" disabled={isJoining || inviteCode.trim().length === 0}>
{t("household.noHouse.joinButton")}
</button>
</div>
</form>
</>
);
}
/** Household details/management shown once the profile belongs to one. */
function HasHousehold({
house,
currentUserId,
onChanged,
}: {
house: HouseView;
currentUserId: number | null;
onChanged: () => void;
}) {
const { t } = useTranslation();
const isAdmin = currentUserId !== null && currentUserId === house.adminId;
const [name, setName] = useState(house.name);
const [nameErrors, setNameErrors] = useState<Record<string, string>>({});
const [saveState, setSaveState] = useState<SaveState>("idle");
const [saveError, setSaveError] = useState<string | null>(null);
const nameTimeout = useRef<number | undefined>(undefined);
useEffect(() => {
setName(house.name);
}, [house.name]);
useEffect(() => {
return () => window.clearTimeout(nameTimeout.current);
}, []);
function handleNameChange(newName: string) {
setName(newName);
window.clearTimeout(nameTimeout.current);
const result = renameHouseSchema.safeParse({ name: newName });
if (!result.success) {
setNameErrors(fieldErrorsFrom(result.error));
setSaveState("idle");
return;
}
setNameErrors({});
setSaveState("saving");
nameTimeout.current = window.setTimeout(async () => {
try {
await apiClient.renameHouse(result.data.name);
setSaveState("saved");
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setSaveError(errorMessageService.getLabel(code));
setSaveState("error");
}
}, HOUSE_NAME_DEBOUNCE_MS);
}
return (
<>
<div className="settings-page__section">
<HouseNameField value={name} onChange={handleNameChange} error={nameErrors.name} />
{saveState === "saving" && <p className="settings-page__saving">{t("common.saving")}</p>}
{saveState === "saved" && <p className="settings-page__saved">{t("common.saved")}</p>}
{saveState === "error" && <p className="field-error">{saveError}</p>}
<div className="settings-page__invite-section">
<p className="settings-page__hint">{t("household.inviteCodeLabel")}</p>
<InviteCode code={house.inviteCode} />
</div>
</div>
<div className="settings-page__section">
<h2 className="settings-page__section-title">{t("household.membersTitle")}</h2>
<ul className="settings-page__members">
{house.members.map((member) => (
<li key={member.id} className="settings-page__member">
<span>
{member.firstName} {member.lastName}
{member.id === currentUserId && t("household.youSuffix")}
{member.id === house.adminId && (
<span className="settings-page__member-badge">{t("household.adminBadge")}</span>
)}
</span>
{isAdmin && member.id !== currentUserId && (
<RemoveMemberButton memberId={member.id} onChanged={onChanged} />
)}
</li>
))}
</ul>
</div>
{isAdmin ? (
<DeleteHouseholdSection onChanged={onChanged} />
) : (
<LeaveHouseholdSection onChanged={onChanged} />
)}
</>
);
}
/** Read-only invite code display with a one-click clipboard copy. */
function InviteCode({ code }: { code: string }) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
async function handleCopy() {
await navigator.clipboard.writeText(code);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
}
return (
<div className="settings-page__actions">
<span className="settings-page__invite-code">{code}</span>
<button type="button" onClick={handleCopy}>
{copied ? t("household.copied") : t("household.copyButton")}
</button>
</div>
);
}
/** Admin-only button removing one specific member from the household. */
function RemoveMemberButton({
memberId,
onChanged,
}: {
memberId: number;
onChanged: () => void;
}) {
const { t } = useTranslation();
const [isRemoving, setIsRemoving] = useState(false);
async function handleRemove() {
setIsRemoving(true);
try {
await apiClient.removeHouseMember(memberId);
onChanged();
} finally {
setIsRemoving(false);
}
}
return (
<button type="button" onClick={handleRemove} disabled={isRemoving}>
{t("household.removeButton")}
</button>
);
}
/** Admin-only danger zone: delete the household outright, with an inline two-step confirmation. */
function DeleteHouseholdSection({ onChanged }: { onChanged: () => void }) {
const { t } = useTranslation();
const [isConfirming, setIsConfirming] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
async function handleDelete() {
setIsDeleting(true);
try {
await apiClient.deleteHouse();
onChanged();
} finally {
setIsDeleting(false);
}
}
return (
<div className="settings-page__section settings-page__danger-zone">
<h2 className="settings-page__section-title">{t("household.dangerZone.title")}</h2>
<p className="settings-page__hint">{t("household.dangerZone.description")}</p>
<div className="settings-page__actions">
{!isConfirming ? (
<button
type="button"
className="settings-page__danger-button"
onClick={() => setIsConfirming(true)}
>
{t("household.dangerZone.deleteButton")}
</button>
) : (
<>
<button
type="button"
className="settings-page__danger-button"
onClick={handleDelete}
disabled={isDeleting}
>
{t("household.dangerZone.confirmButton")}
</button>
<button type="button" onClick={() => setIsConfirming(false)} disabled={isDeleting}>
{t("household.dangerZone.cancelButton")}
</button>
</>
)}
</div>
</div>
);
}
/** Non-admin members' way out: leave the household (immediate — no confirmation step, unlike deleting it entirely, since it only affects the leaving member). */
function LeaveHouseholdSection({ onChanged }: { onChanged: () => void }) {
const { t } = useTranslation();
const [isLeaving, setIsLeaving] = useState(false);
async function handleLeave() {
setIsLeaving(true);
try {
await apiClient.leaveHouse();
onChanged();
} finally {
setIsLeaving(false);
}
}
return (
<div className="settings-page__section">
<div className="settings-page__actions">
<button type="button" onClick={handleLeave} disabled={isLeaving}>
{t("household.leaveButton")}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,185 @@
import { type AllergyView, type DietView, ErrorCode } from "@batch-cooking/shared";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ApiError, apiClient } from "../../api/client";
import { useAuth } from "../../features/auth/AuthContext";
import { AllergySelect } from "../../features/profile/AllergySelect";
import { DietSelect } from "../../features/profile/DietSelect";
import { errorMessageService } from "../../services/error-message.service";
import "./settings-pages.scss";
/** Status of one section's own autosave — sections save independently, each with its own feedback. */
type SaveState = "idle" | "saving" | "saved" | "error";
/** Debounce for the allergen checkboxes — coalesces a quick burst of several toggles into one request. */
const ALLERGIES_DEBOUNCE_MS = 500;
/**
* Dietary preferences regime and allergies/intolerances routed at
* `/parametres/preferences`. The always-available counterpart to the
* onboarding wizard's regime/allergens steps (`pages/onboarding/`): same
* concerns, same shared field components, but editable at any time rather
* than run once. Split out of what used to be `HouseholdPage` these are
* personal profile attributes (`UserProfile.dietId`/allergies), not
* household ones, hence their own page distinct from `HouseholdSettingsPage`.
*
* Hot saving (no "Enregistrer" button) see `HouseholdSettingsPage` for the
* same pattern and rationale, shared verbatim.
*/
export function PreferencesPage() {
const { t } = useTranslation();
const { refreshUser } = useAuth();
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [diets, setDiets] = useState<DietView[]>([]);
const [dietId, setDietId] = useState<number | null>(null);
const [dietSaveState, setDietSaveState] = useState<SaveState>("idle");
const [dietSaveError, setDietSaveError] = useState<string | null>(null);
const [allergies, setAllergies] = useState<AllergyView[]>([]);
const [allergyIds, setAllergyIds] = useState<number[]>([]);
const [allergySaveState, setAllergySaveState] = useState<SaveState>("idle");
const [allergySaveError, setAllergySaveError] = useState<string | null>(null);
const allergiesTimeout = useRef<number | undefined>(undefined);
useEffect(() => {
let cancelled = false;
// `apiClient.me()` here (not `useAuth().user.dietId`) — this page can be
// revisited many times over a session without a full reload, and
// AuthContext's `user` only refreshes on app load or after an explicit
// `refreshUser()` call; relying on it directly would show a stale
// `dietId` after navigating away and back post-save.
Promise.all([
apiClient.getDiets(),
apiClient.getAllergies(),
apiClient.getAllergyIds(),
apiClient.me(),
])
.then(([dietsResult, allergiesResult, allergyIdsResult, profile]) => {
if (cancelled) return;
setDiets(dietsResult);
setAllergies(allergiesResult);
setAllergyIds(allergyIdsResult);
setDietId(profile.dietId);
})
.catch(() => {
if (!cancelled) setLoadError(true);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, []);
// A pending debounced save must not fire after unmount (e.g. the user
// navigates away mid-debounce).
useEffect(() => {
return () => {
window.clearTimeout(allergiesTimeout.current);
};
}, []);
async function handleDietChange(newDietId: number | null) {
setDietId(newDietId);
setDietSaveState("saving");
try {
await apiClient.updateDiet(newDietId);
// Keeps AuthContext's `user.dietId` in sync — nothing else reads it
// today, but anything that might in the future shouldn't have to
// know this page exists to stay correct.
await refreshUser();
setDietSaveState("saved");
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setDietSaveError(errorMessageService.getLabel(code));
setDietSaveState("error");
}
}
function handleAllergyIdsChange(newAllergyIds: number[]) {
setAllergyIds(newAllergyIds);
window.clearTimeout(allergiesTimeout.current);
setAllergySaveState("saving");
allergiesTimeout.current = window.setTimeout(async () => {
try {
await apiClient.updateAllergyIds(newAllergyIds);
setAllergySaveState("saved");
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setAllergySaveError(errorMessageService.getLabel(code));
setAllergySaveState("error");
}
}, ALLERGIES_DEBOUNCE_MS);
}
if (isLoading) {
return (
<div className="settings-page">
<h1>{t("preferences.title")}</h1>
<p className="settings-page__status">{t("onboarding.loading")}</p>
</div>
);
}
if (loadError) {
return (
<div className="settings-page">
<h1>{t("preferences.title")}</h1>
<p className="settings-page__status settings-page__status--error">{t("home.error")}</p>
</div>
);
}
return (
<div className="settings-page">
<h1>{t("preferences.title")}</h1>
<div className="settings-page__section">
<DietSelect diets={diets} value={dietId} onChange={handleDietChange} />
<SaveStatus state={dietSaveState} error={dietSaveError} t={t} />
</div>
<div className="settings-page__section">
<AllergySelect
legend={t("preferences.form.allergiesLabel")}
allergies={allergies.filter((allergy) => allergy.kind === "ALLERGY")}
value={allergyIds}
onChange={handleAllergyIdsChange}
/>
<AllergySelect
legend={t("preferences.form.intolerancesLabel")}
allergies={allergies.filter((allergy) => allergy.kind === "INTOLERANCE")}
value={allergyIds}
onChange={handleAllergyIdsChange}
/>
<SaveStatus state={allergySaveState} error={allergySaveError} t={t} />
</div>
</div>
);
}
/** Inline "saving…"/"saved ✓"/error feedback shared by every autosaved section on this page — `idle` renders nothing. */
function SaveStatus({
state,
error,
t,
}: {
state: SaveState;
error: string | null;
t: (key: string) => string;
}) {
if (state === "saving") {
return <p className="settings-page__saving">{t("common.saving")}</p>;
}
if (state === "saved") {
return <p className="settings-page__saved">{t("common.saved")}</p>;
}
if (state === "error") {
return <p className="field-error">{error}</p>;
}
return null;
}

View file

@ -0,0 +1,151 @@
// =============================================================================
// Styles shared by the three settings pages (AccountSettingsPage,
// PreferencesPage, HouseholdSettingsPage) colocated under pages/settings/
// since nothing outside that folder uses these classes. Field/label/input
// styling itself still comes from features/profile/profile-forms.scss
// (shared with the onboarding wizard); this file only covers page layout
// direct continuation of what used to be HouseholdPage.scss before the
// household/regime/allergies page was split in three.
// =============================================================================
.settings-page {
&__status {
color: var(--color-text-muted);
font-size: var(--font-size-md);
}
&__status--error {
color: var(--color-error);
}
}
// Each setting lives in its own card, same surface treatment used
// elsewhere (see .planning-table in HomePage.scss) reads as a distinct,
// self-contained unit rather than one long form.
.settings-page__section {
max-width: 32rem;
margin-top: var(--space-lg);
padding: var(--space-lg);
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.settings-page__section-title {
margin: 0 0 var(--space-sm);
font-size: var(--font-size-md);
}
.settings-page__saving,
.settings-page__saved {
margin: var(--space-sm) 0 0;
font-size: var(--font-size-sm);
font-weight: 600;
}
.settings-page__saving {
color: var(--color-text-muted);
}
.settings-page__saved {
color: var(--color-success);
}
.settings-page__actions {
display: flex;
gap: var(--space-sm);
margin-top: var(--space-sm);
}
.settings-page button {
padding: 0.5rem var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
border-radius: var(--radius-base);
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
&:hover:not(:disabled) {
background: var(--color-surface-alt);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.settings-page__hint {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.settings-page__invite-section {
margin-top: var(--space-md);
}
// Household member list.
.settings-page__members {
list-style: none;
margin: var(--space-sm) 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.settings-page__member {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-xs) 0;
}
.settings-page__member-badge {
margin-left: var(--space-xs);
padding: 0.1rem 0.4rem;
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-primary);
background: var(--color-surface-alt);
border-radius: var(--radius-base);
}
// Invite code meant to be read/copied, so it's set in a monospace font
// and never wraps mid-code.
.settings-page__invite-code {
font-family: monospace;
font-size: var(--font-size-md);
letter-spacing: 0.08em;
}
// Destructive actions (delete household/account, remove a member) get a
// visually distinct, consistent "danger" treatment wherever they appear.
.settings-page__danger-zone {
margin-top: var(--space-lg);
border-color: var(--color-error);
}
.settings-page__danger-button {
color: #fff;
background: var(--color-error);
border-color: var(--color-error);
&:hover {
opacity: 0.9;
}
}
button.settings-page__link-button {
padding: 0;
font: inherit;
color: var(--color-primary);
background: none;
border: none;
cursor: pointer;
text-decoration: underline;
}

View file

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

View file

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

View file

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

View file

@ -10,3 +10,17 @@ export const renameHouseSchema = z.object({
});
/** Inferred TS type for {@link renameHouseSchema}'s validated output. */
export type RenameHouseInput = z.infer<typeof renameHouseSchema>;
/** 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<typeof createHouseSchema>;
/** 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<typeof joinHouseSchema>;

View file

@ -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[];
}