Merge pull request #11 from kyuno053/feat/settings-household-account
Refonte des paramètres compte/foyer + parcours d'inscription
This commit is contained in:
commit
c112eaa43e
48 changed files with 2701 additions and 572 deletions
|
|
@ -33,3 +33,18 @@ Feature: Account creation and login
|
||||||
When I log in with email "alice@example.com" and password "wrong-password"
|
When I log in with email "alice@example.com" and password "wrong-password"
|
||||||
Then the response status should be 401
|
Then the response status should be 401
|
||||||
And the response error code should be "INVALID_CREDENTIALS"
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,67 @@
|
||||||
Feature: Household name
|
Feature: Household
|
||||||
As a signed-in user
|
As a signed-in user
|
||||||
I want to name my household
|
I want to name my household, invite others to it, and manage its members
|
||||||
So that it's recognizable as ours, not the auto-generated default
|
So that my whole household can share the same planning
|
||||||
|
|
||||||
Scenario: A visitor without a session cannot read the household
|
Scenario: A visitor without a session cannot read the household
|
||||||
When I send a GET request to "/house/current"
|
When I send a GET request to "/house/current"
|
||||||
Then the response status should be 401
|
Then the response status should be 401
|
||||||
And the response error code should be "NOT_AUTHENTICATED"
|
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
|
Scenario: A signed-in user renames their household
|
||||||
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
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 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"
|
When I rename my household to "Chez les Martin"
|
||||||
Then the response status should be 200
|
Then the response status should be 200
|
||||||
And my household should be named "Chez les Martin"
|
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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
Then("I am authenticated as {string}", async function (this: CustomWorld, email: string) {
|
||||||
const res = await this.agent.get("/auth/me");
|
const res = await this.agent.get("/auth/me");
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.equal(res.body.email, email);
|
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);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,90 @@
|
||||||
import assert from "node:assert/strict";
|
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";
|
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) {
|
When("I rename my household to {string}", async function (this: CustomWorld, name: string) {
|
||||||
this.response = await this.agent.patch("/house/current").send({ name });
|
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) {
|
Then("my household should be named {string}", async function (this: CustomWorld, name: string) {
|
||||||
const res = await this.agent.get("/house/current");
|
const res = await this.agent.get("/house/current");
|
||||||
assert.equal(res.body.name, name);
|
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);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
// the API — there's no "create a planning" endpoint yet (see
|
||||||
// specs/batch-cooking-architecture.md, "Calcul batch-cooking" is still
|
// 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
|
// 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
|
// has one. A household is no longer created implicitly at signup, so this
|
||||||
// `GET /auth/me`) rather than taking it as a step argument, since the
|
// step creates one via `POST /house` first — the scenario never names it
|
||||||
// scenario never names it explicitly.
|
// explicitly, its name doesn't matter here.
|
||||||
Given(
|
Given(
|
||||||
"my household has a planning covering today with recipe {string} on {string} for {string}",
|
"my household has a planning covering today with recipe {string} on {string} for {string}",
|
||||||
async function (this: CustomWorld, recipeName: string, weekDay: string, meal: string) {
|
async function (this: CustomWorld, recipeName: string, weekDay: string, meal: string) {
|
||||||
const me = await this.agent.get("/auth/me");
|
const houseRes = await this.agent.post("/house").send({ name: "Foyer de test" });
|
||||||
const houseId: number = me.body.houseId;
|
const houseId: number = houseRes.body.id;
|
||||||
|
|
||||||
const recipe = await prisma.recipe.create({ data: { name: recipeName } });
|
const recipe = await prisma.recipe.create({ data: { name: recipeName } });
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,15 @@ export class CustomWorld extends World {
|
||||||
app: Express;
|
app: Express;
|
||||||
agent: ReturnType<typeof request.agent>;
|
agent: ReturnType<typeof request.agent>;
|
||||||
response!: request.Response;
|
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) {
|
constructor(options: IWorldOptions) {
|
||||||
super(options);
|
super(options);
|
||||||
this.app = createApp();
|
this.app = createApp();
|
||||||
this.agent = request.agent(this.app);
|
this.agent = request.agent(this.app);
|
||||||
|
this.secondAgent = request.agent(this.app);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -13,10 +13,19 @@ datasource db {
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
model House {
|
model House {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
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[]
|
plannings Planning[]
|
||||||
|
|
||||||
@@map("house")
|
@@map("house")
|
||||||
|
|
@ -81,9 +90,14 @@ model UserProfile {
|
||||||
houseId Int? @map("house_id")
|
houseId Int? @map("house_id")
|
||||||
dietId Int? @map("diet_id")
|
dietId Int? @map("diet_id")
|
||||||
|
|
||||||
house House? @relation(fields: [houseId], references: [id], onDelete: SetNull)
|
house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull)
|
||||||
diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull)
|
diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull)
|
||||||
allergies UserProfileAllergy[]
|
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")
|
@@map("user_profiles")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
import { loginSchema, signupSchema } from "@batch-cooking/shared";
|
import { deleteAccountSchema, loginSchema, signupSchema } from "@batch-cooking/shared";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import type { CookieOptions, Response } from "express";
|
import type { CookieOptions, Response } from "express";
|
||||||
import { env } from "../../config/env.js";
|
import { env } from "../../config/env.js";
|
||||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||||
import { login, signup } from "./auth.service.js";
|
import { deleteAccount, login, signup } from "./auth.service.js";
|
||||||
|
|
||||||
/** Router mounted at `/auth` in app.ts — signup, login, logout, current-profile. */
|
/** Router mounted at `/auth` in app.ts — signup, login, logout, current-profile. */
|
||||||
export const authRouter = Router();
|
export const authRouter = Router();
|
||||||
|
|
@ -15,7 +15,7 @@ export const authRouter = Router();
|
||||||
// bounds how long the browser keeps *sending* the cookie at all.
|
// bounds how long the browser keeps *sending* the cookie at all.
|
||||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
/** Cookie options shared by every route that sets/clears the session cookie. */
|
/** Cookie options shared by every route that sets the session cookie. */
|
||||||
const cookieOptions: CookieOptions = {
|
const cookieOptions: CookieOptions = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
// Only require HTTPS in production — local dev/CI serve over plain HTTP.
|
// Only require HTTPS in production — local dev/CI serve over plain HTTP.
|
||||||
|
|
@ -24,6 +24,12 @@ const cookieOptions: CookieOptions = {
|
||||||
maxAge: SEVEN_DAYS_MS,
|
maxAge: SEVEN_DAYS_MS,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// `res.clearCookie` sets its own expiry to clear the cookie — passing
|
||||||
|
// `maxAge` alongside is deprecated (and pointless) as of Express 4.20, so
|
||||||
|
// every clearing route reuses `cookieOptions` minus that one field rather
|
||||||
|
// than duplicating the rest by hand.
|
||||||
|
const { maxAge: _maxAge, ...clearCookieOptions } = cookieOptions;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a profile (+ its household) and logs the new user in
|
* Creates a profile (+ its household) and logs the new user in
|
||||||
* immediately. `wrapAsyncHandler` forwards a thrown/rejected error to
|
* immediately. `wrapAsyncHandler` forwards a thrown/rejected error to
|
||||||
|
|
@ -52,7 +58,7 @@ authRouter.post(
|
||||||
|
|
||||||
/** Ends the current session by clearing the cookie. Stateless JWT, so there's nothing to revoke server-side (yet — see tokenVersion). */
|
/** Ends the current session by clearing the cookie. Stateless JWT, so there's nothing to revoke server-side (yet — see tokenVersion). */
|
||||||
authRouter.post("/logout", (_req, res) => {
|
authRouter.post("/logout", (_req, res) => {
|
||||||
res.clearCookie(env.AUTH_COOKIE_NAME, cookieOptions);
|
res.clearCookie(env.AUTH_COOKIE_NAME, clearCookieOptions);
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -60,3 +66,15 @@ authRouter.post("/logout", (_req, res) => {
|
||||||
authRouter.get("/me", requireAuth, (_req, res: Response<unknown, AuthLocals>) => {
|
authRouter.get("/me", requireAuth, (_req, res: Response<unknown, AuthLocals>) => {
|
||||||
res.status(200).json(res.locals.userProfile);
|
res.status(200).json(res.locals.userProfile);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Permanently deletes the current profile (re-verifying its password first) and clears the session cookie. */
|
||||||
|
authRouter.delete(
|
||||||
|
"/me",
|
||||||
|
requireAuth,
|
||||||
|
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||||
|
const input = deleteAccountSchema.parse(req.body);
|
||||||
|
await deleteAccount(res.locals.userProfile.id, input.password);
|
||||||
|
res.clearCookie(env.AUTH_COOKIE_NAME, clearCookieOptions);
|
||||||
|
res.status(204).end();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { env } from "../../config/env.js";
|
||||||
import { prisma } from "../../db/prisma.js";
|
import { prisma } from "../../db/prisma.js";
|
||||||
import { signAuthToken } from "../../lib/jwt.js";
|
import { signAuthToken } from "../../lib/jwt.js";
|
||||||
import { toSafeProfile } from "../../lib/safe-profile.js";
|
import { toSafeProfile } from "../../lib/safe-profile.js";
|
||||||
|
import { leaveCurrentHouse } from "../house/house.service.js";
|
||||||
|
|
||||||
/** Result of a successful signup/login: the safe profile plus the signed session JWT to set as a cookie. */
|
/** Result of a successful signup/login: the safe profile plus the signed session JWT to set as a cookie. */
|
||||||
interface AuthResult {
|
interface AuthResult {
|
||||||
|
|
@ -28,8 +29,8 @@ const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 };
|
||||||
const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
|
const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new household (`house`) and profile (`user_profiles`) together
|
* Creates a profile (`user_profiles`), hashes the password, and issues a
|
||||||
* in one transaction, hashes the password, and issues a session token.
|
* session token.
|
||||||
*
|
*
|
||||||
* @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken.
|
* @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken.
|
||||||
*/
|
*/
|
||||||
|
|
@ -41,29 +42,49 @@ export async function signup(input: SignupInput): Promise<AuthResult> {
|
||||||
|
|
||||||
const passwordHash = await argon2.hash(input.password, hashOptions);
|
const passwordHash = await argon2.hash(input.password, hashOptions);
|
||||||
|
|
||||||
// A profile always belongs to a house; signup creates one, named after
|
// No household is created here — it's now an optional step of the
|
||||||
// the new user for now — renamed via `PATCH /house/current` (the
|
// onboarding wizard (create or join one, or skip — see
|
||||||
// household step of the profile journey). Joining an existing house is a
|
// `house.service.ts`'s `createHouse`/`joinHouse`), not an implicit side
|
||||||
// separate, not-yet-built feature.
|
// effect of signing up. `houseId` starts out `null`, same as `dietId`.
|
||||||
const profile = await prisma.$transaction(async (tx) => {
|
const profile = await prisma.userProfile.create({
|
||||||
const house = await tx.house.create({
|
data: {
|
||||||
data: { name: `Foyer de ${input.firstName}` },
|
firstName: input.firstName,
|
||||||
});
|
lastName: input.lastName,
|
||||||
return tx.userProfile.create({
|
email: input.email,
|
||||||
data: {
|
passwordHash,
|
||||||
firstName: input.firstName,
|
},
|
||||||
lastName: input.lastName,
|
|
||||||
email: input.email,
|
|
||||||
passwordHash,
|
|
||||||
houseId: house.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion });
|
const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion });
|
||||||
return { profile: toSafeProfile(profile), token };
|
return { profile: toSafeProfile(profile), token };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently deletes a profile, after re-verifying its password —
|
||||||
|
* deleting an account is irreversible, so it's gated behind proving the
|
||||||
|
* caller still is who the session says they are, same spirit as the
|
||||||
|
* password check in {@link login}.
|
||||||
|
*
|
||||||
|
* If the profile administers a household with other members, adminship is
|
||||||
|
* handed off before the profile is deleted (see `house.service.ts`'s
|
||||||
|
* `leaveCurrentHouse`); if it's the household's last member, the household
|
||||||
|
* itself is deleted along with it. `UserProfileAllergy` rows cascade via
|
||||||
|
* the schema's `onDelete: Cascade`.
|
||||||
|
*
|
||||||
|
* @throws {HttpError} `401 INVALID_CREDENTIALS` if the password is wrong.
|
||||||
|
*/
|
||||||
|
export async function deleteAccount(profileId: number, password: string): Promise<void> {
|
||||||
|
const profile = await prisma.userProfile.findUnique({ where: { id: profileId } });
|
||||||
|
if (!profile || !(await argon2.verify(profile.passwordHash, password))) {
|
||||||
|
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid password");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.houseId !== null) {
|
||||||
|
await leaveCurrentHouse(profile.id, profile.houseId);
|
||||||
|
}
|
||||||
|
await prisma.userProfile.delete({ where: { id: profile.id } });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies credentials and issues a fresh session token.
|
* Verifies credentials and issues a fresh session token.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,24 @@
|
||||||
|
import { HttpError } from "@batch-cooking/error-tools";
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-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 { Router } from "express";
|
||||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
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();
|
export const houseRouter = Router();
|
||||||
|
|
||||||
houseRouter.get(
|
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(
|
houseRouter.patch(
|
||||||
"/current",
|
"/current",
|
||||||
requireAuth,
|
requireAuth,
|
||||||
|
|
@ -26,3 +40,71 @@ houseRouter.patch(
|
||||||
res.status(200).json(house);
|
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);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,59 @@
|
||||||
|
import { randomInt } from "node:crypto";
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
import { HttpError } from "@batch-cooking/error-tools";
|
||||||
import { ErrorCode, type HouseView } from "@batch-cooking/shared";
|
import { ErrorCode, type HouseView } from "@batch-cooking/shared";
|
||||||
import { prisma } from "../../db/prisma.js";
|
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> {
|
export async function getCurrentHouse(houseId: number | null): Promise<HouseView | null> {
|
||||||
if (houseId === null) {
|
if (houseId === null) {
|
||||||
return 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.
|
* @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");
|
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||||
}
|
}
|
||||||
await findHouseOrThrow(houseId);
|
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
|
* through the API, hence a plain `Error` (500) rather than a
|
||||||
* `HOUSE_NOT_FOUND` HttpError.
|
* `HOUSE_NOT_FOUND` HttpError.
|
||||||
*/
|
*/
|
||||||
async function findHouseOrThrow(houseId: number): Promise<HouseView> {
|
async function findHouseOrThrow(houseId: number) {
|
||||||
const house = await prisma.house.findUnique({ where: { id: houseId } });
|
const house = await prisma.house.findUnique({
|
||||||
|
where: { id: houseId },
|
||||||
|
include: houseWithMembers,
|
||||||
|
});
|
||||||
if (!house) {
|
if (!house) {
|
||||||
throw new Error(`House ${houseId} referenced by a profile but not found`);
|
throw new Error(`House ${houseId} referenced by a profile but not found`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ describe("Auth", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /auth/signup", () => {
|
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 payload = buildSignupPayload();
|
||||||
const res = await request(app).post("/auth/signup").send(payload);
|
const res = await request(app).post("/auth/signup").send(payload);
|
||||||
|
|
||||||
|
|
@ -49,7 +49,9 @@ describe("Auth", () => {
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
});
|
});
|
||||||
expect(res.body).to.not.have.property("passwordHash");
|
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=");
|
expect(res.headers["set-cookie"]?.[0]).to.include("session=");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -131,4 +133,75 @@ describe("Auth", () => {
|
||||||
expect(res.body.email).to.equal(payload.email);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
|
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
|
||||||
import { faker } from "@faker-js/faker";
|
import { faker } from "@faker-js/faker";
|
||||||
import { expect } from "chai";
|
import { expect } from "chai";
|
||||||
|
import type { Express } from "express";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { createApp } from "../src/app.js";
|
import { createApp } from "../src/app.js";
|
||||||
import { prisma } from "../src/db/prisma.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", () => {
|
describe("Household", () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|
||||||
|
|
@ -36,14 +44,28 @@ describe("Household", () => {
|
||||||
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns the household created at signup", async () => {
|
it("returns null when the profile has no household yet", async () => {
|
||||||
const agent = request.agent(app);
|
const { agent } = await signupAgent(app);
|
||||||
const signupRes = await agent.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
|
|
||||||
const res = await agent.get("/house/current");
|
const res = await agent.get("/house/current");
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
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);
|
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 () => {
|
it("renames the household", async () => {
|
||||||
const agent = request.agent(app);
|
const { agent } = await signupAgent(app);
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
await agent.post("/house").send({ name: "Chez Alice" });
|
||||||
|
|
||||||
const res = await agent.patch("/house/current").send({ name: "Chez les Dupont" });
|
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 () => {
|
it("rejects an empty name with 400 VALIDATION_ERROR", async () => {
|
||||||
const agent = request.agent(app);
|
const { agent } = await signupAgent(app);
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
await agent.post("/house").send({ name: "Chez Alice" });
|
||||||
|
|
||||||
const res = await agent.patch("/house/current").send({ name: "" });
|
const res = await agent.patch("/house/current").send({ name: "" });
|
||||||
|
|
||||||
|
|
@ -78,4 +109,231 @@ describe("Household", () => {
|
||||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -49,8 +49,9 @@ describe("Planning", () => {
|
||||||
|
|
||||||
it("returns the household's planning covering today, with recipes resolved", async () => {
|
it("returns the household's planning covering today, with recipes resolved", async () => {
|
||||||
const agent = request.agent(app);
|
const agent = request.agent(app);
|
||||||
const signupRes = await agent.post("/auth/signup").send(buildSignupPayload());
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
const houseId: number = signupRes.body.houseId;
|
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 recipe = await prisma.recipe.create({ data: { name: "Ratatouille" } });
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
|
|
@ -80,8 +81,9 @@ describe("Planning", () => {
|
||||||
|
|
||||||
it("returns null when the household's planning does not cover today", async () => {
|
it("returns null when the household's planning does not cover today", async () => {
|
||||||
const agent = request.agent(app);
|
const agent = request.agent(app);
|
||||||
const signupRes = await agent.post("/auth/signup").send(buildSignupPayload());
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
const houseId: number = signupRes.body.houseId;
|
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".
|
// A planning entirely in the past — shouldn't be picked up as "current".
|
||||||
await prisma.planning.create({
|
await prisma.planning.create({
|
||||||
|
|
|
||||||
68
apps/web/cypress/e2e/account.cy.ts
Normal file
68
apps/web/cypress/e2e/account.cy.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,14 +6,11 @@ import { ErrorCode } from "@batch-cooking/shared";
|
||||||
// suites against a real database.
|
// suites against a real database.
|
||||||
|
|
||||||
describe("Signup", () => {
|
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 });
|
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
||||||
// The onboarding wizard's first step (see onboarding.cy.ts for the full
|
// The onboarding wizard's first step (see onboarding.cy.ts for the full
|
||||||
// walkthrough) reads the household right away to prefill its name field.
|
// walkthrough) is the regime step, which fetches the reference list.
|
||||||
cy.intercept("GET", "**/house/current", {
|
cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] });
|
||||||
statusCode: 200,
|
|
||||||
body: { id: 1, name: "Foyer de Alice" },
|
|
||||||
});
|
|
||||||
cy.intercept("POST", "**/auth/signup", {
|
cy.intercept("POST", "**/auth/signup", {
|
||||||
statusCode: 201,
|
statusCode: 201,
|
||||||
body: {
|
body: {
|
||||||
|
|
@ -22,7 +19,7 @@ describe("Signup", () => {
|
||||||
lastName: "Martin",
|
lastName: "Martin",
|
||||||
email: "alice@example.com",
|
email: "alice@example.com",
|
||||||
tokenVersion: 0,
|
tokenVersion: 0,
|
||||||
houseId: 1,
|
houseId: null,
|
||||||
dietId: null,
|
dietId: null,
|
||||||
},
|
},
|
||||||
}).as("signup");
|
}).as("signup");
|
||||||
|
|
@ -38,8 +35,8 @@ describe("Signup", () => {
|
||||||
// Not the home page directly — signup hands off to the onboarding
|
// Not the home page directly — signup hands off to the onboarding
|
||||||
// wizard first (RedirectIfAuthenticated no longer applies here, it's a
|
// wizard first (RedirectIfAuthenticated no longer applies here, it's a
|
||||||
// RequireAuth-gated route of its own, see App.tsx).
|
// RequireAuth-gated route of its own, see App.tsx).
|
||||||
cy.url().should("include", "/onboarding/foyer");
|
cy.url().should("include", "/onboarding/regime");
|
||||||
cy.get("#houseName").should("have.value", "Foyer de Alice");
|
cy.contains("Étape 1 sur 3").should("be.visible");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a client-side validation error without calling the API", () => {
|
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.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout");
|
||||||
|
|
||||||
cy.visit("/");
|
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.contains("button", "Se déconnecter").click();
|
||||||
|
|
||||||
cy.wait("@logout");
|
cy.wait("@logout");
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ describe("Sidebar navigation", () => {
|
||||||
cy.visit("/");
|
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", () => {
|
it("highlights the current section and navigates between stub pages", () => {
|
||||||
cy.contains("nav a", "Planning").should("have.class", "active");
|
cy.contains("nav a", "Planning").should("have.class", "active");
|
||||||
|
|
||||||
|
|
@ -32,19 +34,15 @@ describe("Sidebar navigation", () => {
|
||||||
cy.url().should("include", "/liste-de-courses");
|
cy.url().should("include", "/liste-de-courses");
|
||||||
cy.contains("h1", "Liste de courses").should("be.visible");
|
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.contains("nav a", "Planning").click();
|
||||||
cy.url().should("eq", `${Cypress.config().baseUrl}/`);
|
cy.url().should("eq", `${Cypress.config().baseUrl}/`);
|
||||||
cy.contains("h1", "Planning de la semaine").should("be.visible");
|
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.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.contains("button", "Se déconnecter").click();
|
||||||
|
|
||||||
cy.wait("@logout");
|
cy.wait("@logout");
|
||||||
|
|
|
||||||
185
apps/web/cypress/e2e/household-settings.cy.ts
Normal file
185
apps/web/cypress/e2e/household-settings.cy.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -8,22 +8,27 @@ const signupResponse = {
|
||||||
lastName: "Martin",
|
lastName: "Martin",
|
||||||
email: "alice@example.com",
|
email: "alice@example.com",
|
||||||
tokenVersion: 0,
|
tokenVersion: 0,
|
||||||
houseId: 1,
|
houseId: null as number | null,
|
||||||
dietId: null,
|
dietId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("Onboarding wizard (household → regime → allergens)", () => {
|
/** Signs up and lands on the wizard's first step (regime) — shared setup for every scenario below. */
|
||||||
it("walks through all three steps after signup and lands on the home", () => {
|
function signupAndReachOnboarding() {
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
||||||
cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: signupResponse }).as("signup");
|
cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: signupResponse }).as("signup");
|
||||||
cy.intercept("GET", "**/house/current", {
|
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
|
||||||
statusCode: 200,
|
|
||||||
body: { id: 1, name: "Foyer de Alice" },
|
cy.visit("/signup");
|
||||||
});
|
cy.get("#firstName").type("Alice");
|
||||||
cy.intercept("PATCH", "**/house/current", {
|
cy.get("#lastName").type("Martin");
|
||||||
statusCode: 200,
|
cy.get("#email").type("alice@example.com");
|
||||||
body: { id: 1, name: "Chez Alice" },
|
cy.get("#password").type("correct-horse-battery-staple");
|
||||||
}).as("renameHouse");
|
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", {
|
cy.intercept("GET", "**/reference/diets", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: [
|
body: [
|
||||||
|
|
@ -35,6 +40,11 @@ describe("Onboarding wizard (household → regime → allergens)", () => {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: { ...signupResponse, dietId: 2 },
|
body: { ...signupResponse, dietId: 2 },
|
||||||
}).as("updateDiet");
|
}).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", {
|
cy.intercept("GET", "**/reference/allergies", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: [
|
body: [
|
||||||
|
|
@ -45,32 +55,23 @@ describe("Onboarding wizard (household → regime → allergens)", () => {
|
||||||
cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [1] }).as(
|
cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [1] }).as(
|
||||||
"updateAllergies",
|
"updateAllergies",
|
||||||
);
|
);
|
||||||
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
|
|
||||||
|
|
||||||
cy.visit("/signup");
|
signupAndReachOnboarding();
|
||||||
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");
|
|
||||||
|
|
||||||
// Step 1/3 — household name, prefilled with the auto-generated default.
|
// Step 1/3 — dietary regime.
|
||||||
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.
|
|
||||||
cy.url().should("include", "/onboarding/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.get("#diet").select("Végétarien");
|
||||||
cy.contains("button", "Continuer").click();
|
cy.contains("button", "Continuer").click();
|
||||||
cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: 2 });
|
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.
|
// Step 3/3 — allergens (grouped into two lists) and intolerances, then finish.
|
||||||
cy.url().should("include", "/onboarding/allergenes");
|
cy.url().should("include", "/onboarding/allergenes");
|
||||||
cy.contains("Étape 3 sur 3").should("be.visible");
|
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");
|
cy.contains("h1", "Planning de la semaine").should("be.visible");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lets every step be skipped without changing anything", () => {
|
it("lets the regime and allergens steps 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");
|
|
||||||
cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] });
|
cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] });
|
||||||
cy.intercept("PATCH", "**/profile/diet", { statusCode: 200, body: signupResponse }).as(
|
cy.intercept("PATCH", "**/profile/diet", { statusCode: 200, body: signupResponse }).as(
|
||||||
"updateDiet",
|
"updateDiet",
|
||||||
);
|
);
|
||||||
|
cy.intercept("GET", "**/house/current", { statusCode: 200, body: null });
|
||||||
cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] });
|
cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] });
|
||||||
cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [] }).as(
|
cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [] }).as(
|
||||||
"updateAllergies",
|
"updateAllergies",
|
||||||
);
|
);
|
||||||
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
|
|
||||||
|
|
||||||
cy.visit("/signup");
|
signupAndReachOnboarding();
|
||||||
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" });
|
|
||||||
|
|
||||||
cy.url().should("include", "/onboarding/regime");
|
cy.url().should("include", "/onboarding/regime");
|
||||||
cy.contains("button", "Continuer").click();
|
cy.contains("button", "Continuer").click();
|
||||||
cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: null });
|
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.url().should("include", "/onboarding/allergenes");
|
||||||
cy.contains("button", "Terminer").click();
|
cy.contains("button", "Terminer").click();
|
||||||
cy.wait("@updateAllergies").its("request.body").should("deep.equal", { allergyIds: [] });
|
cy.wait("@updateAllergies").its("request.body").should("deep.equal", { allergyIds: [] });
|
||||||
|
|
||||||
cy.url().should("eq", `${Cypress.config().baseUrl}/`);
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,9 @@ const authenticatedProfile = {
|
||||||
dietId: 2,
|
dietId: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("Household & profile settings (/foyer) — hot saving", () => {
|
describe("Dietary preferences (/parametres/preferences) — hot saving", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
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", {
|
cy.intercept("GET", "**/reference/diets", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: [
|
body: [
|
||||||
|
|
@ -34,10 +30,9 @@ describe("Household & profile settings (/foyer) — hot saving", () => {
|
||||||
cy.intercept("GET", "**/profile/allergies", { statusCode: 200, body: [2] });
|
cy.intercept("GET", "**/profile/allergies", { statusCode: 200, body: [2] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loads the current household name, regime, and shows allergies/intolerances as two groups", () => {
|
it("loads the current regime, and shows allergies/intolerances as two groups", () => {
|
||||||
cy.visit("/foyer");
|
cy.visit("/parametres/preferences");
|
||||||
|
|
||||||
cy.get("#houseName").should("have.value", "Chez Alice");
|
|
||||||
cy.get("#diet").should("have.value", "2");
|
cy.get("#diet").should("have.value", "2");
|
||||||
cy.contains("legend", "Allergies").should("be.visible");
|
cy.contains("legend", "Allergies").should("be.visible");
|
||||||
cy.contains("legend", "Intolérances").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", () => {
|
it("has no explicit save button anywhere on the page", () => {
|
||||||
cy.visit("/foyer");
|
cy.visit("/parametres/preferences");
|
||||||
cy.contains("button", "Enregistrer").should("not.exist");
|
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", () => {
|
it("autosaves the regime as soon as it's selected", () => {
|
||||||
cy.intercept("PATCH", "**/profile/diet", {
|
cy.intercept("PATCH", "**/profile/diet", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: { ...authenticatedProfile, dietId: 1 },
|
body: { ...authenticatedProfile, dietId: 1 },
|
||||||
}).as("updateDiet");
|
}).as("updateDiet");
|
||||||
|
|
||||||
cy.visit("/foyer");
|
cy.visit("/parametres/preferences");
|
||||||
cy.get("#diet").select("Omnivore");
|
cy.get("#diet").select("Omnivore");
|
||||||
|
|
||||||
cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: 1 });
|
cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: 1 });
|
||||||
|
|
@ -91,11 +62,12 @@ describe("Household & profile settings (/foyer) — hot saving", () => {
|
||||||
"updateAllergies",
|
"updateAllergies",
|
||||||
);
|
);
|
||||||
|
|
||||||
cy.visit("/foyer");
|
cy.visit("/parametres/preferences");
|
||||||
cy.contains("label", "Arachides").find("input[type=checkbox]").check();
|
cy.contains("label", "Arachides").find("input[type=checkbox]").check();
|
||||||
|
|
||||||
cy.wait("@updateAllergies")
|
cy.wait("@updateAllergies")
|
||||||
.its("request.body")
|
.its("request.body")
|
||||||
.should("deep.equal", { allergyIds: [2, 1] });
|
.should("deep.equal", { allergyIds: [2, 1] });
|
||||||
|
cy.contains("Enregistré ✓").should("be.visible");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
50
apps/web/cypress/e2e/sidebar.cy.ts
Normal file
50
apps/web/cypress/e2e/sidebar.cy.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,7 +3,6 @@ import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated
|
||||||
import { RequireAuth } from "./features/auth/RequireAuth";
|
import { RequireAuth } from "./features/auth/RequireAuth";
|
||||||
import { AppLayout } from "./layouts/AppLayout";
|
import { AppLayout } from "./layouts/AppLayout";
|
||||||
import { HomePage } from "./pages/HomePage";
|
import { HomePage } from "./pages/HomePage";
|
||||||
import { HouseholdPage } from "./pages/HouseholdPage";
|
|
||||||
import { LoginPage } from "./pages/LoginPage";
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
import { RecipesPage } from "./pages/RecipesPage";
|
import { RecipesPage } from "./pages/RecipesPage";
|
||||||
import { ShoppingListPage } from "./pages/ShoppingListPage";
|
import { ShoppingListPage } from "./pages/ShoppingListPage";
|
||||||
|
|
@ -11,6 +10,9 @@ import { SignupPage } from "./pages/SignupPage";
|
||||||
import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage";
|
import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage";
|
||||||
import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage";
|
import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage";
|
||||||
import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage";
|
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
|
* 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
|
* {@link RedirectIfAuthenticated}). Anything else falls back to `/`, which
|
||||||
* itself redirects to `/login` if needed.
|
* itself redirects to `/login` if needed.
|
||||||
*
|
*
|
||||||
* `/onboarding/*` (household/regime/allergens) is also `RequireAuth`-gated
|
* `/parametres/*` (compte/préférences/foyer) are the settings pages,
|
||||||
* — reached right after signup, once a session already exists — but
|
* reachable from the sidebar's bottom "Paramètres" menu and the account
|
||||||
* deliberately its own top-level route group, *not* nested under
|
* menu (see `AppLayout`) — nested under `AppLayout` like every other
|
||||||
* `AppLayout`: a focused, distraction-free wizard with no sidebar, same
|
* authenticated section. `/foyer` is the old, pre-split combined page's
|
||||||
* full-page-card language as `/login`/`/signup` (see `onboarding.scss`).
|
* 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() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -39,16 +48,11 @@ export function App() {
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/recettes" element={<RecipesPage />} />
|
<Route path="/recettes" element={<RecipesPage />} />
|
||||||
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
|
<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>
|
||||||
<Route
|
|
||||||
path="/onboarding/foyer"
|
|
||||||
element={
|
|
||||||
<RequireAuth>
|
|
||||||
<OnboardingHouseholdPage />
|
|
||||||
</RequireAuth>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="/onboarding/regime"
|
path="/onboarding/regime"
|
||||||
element={
|
element={
|
||||||
|
|
@ -57,6 +61,14 @@ export function App() {
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/onboarding/foyer"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<OnboardingHouseholdPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/onboarding/allergenes"
|
path="/onboarding/allergenes"
|
||||||
element={
|
element={
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,11 @@ export class ApiClient {
|
||||||
return this.request("/auth/me");
|
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. */
|
/** Fetches the current user's household's planning for today, or `null` if there isn't one yet. */
|
||||||
public getCurrentPlanning(): Promise<PlanningView | null> {
|
public getCurrentPlanning(): Promise<PlanningView | null> {
|
||||||
return this.request("/planning/current");
|
return this.request("/planning/current");
|
||||||
|
|
@ -113,7 +118,7 @@ export class ApiClient {
|
||||||
return this.request("/reference/allergies");
|
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> {
|
public getCurrentHouse(): Promise<HouseView | null> {
|
||||||
return this.request("/house/current");
|
return this.request("/house/current");
|
||||||
}
|
}
|
||||||
|
|
@ -123,6 +128,31 @@ export class ApiClient {
|
||||||
return this.request("/house/current", { method: "PATCH", body: JSON.stringify({ name }) });
|
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. */
|
/** Sets (or clears, with `null`) the current user's dietary regime. */
|
||||||
public updateDiet(dietId: number | null): Promise<SafeUserProfile> {
|
public updateDiet(dietId: number | null): Promise<SafeUserProfile> {
|
||||||
return this.request("/profile/diet", { method: "PATCH", body: JSON.stringify({ dietId }) });
|
return this.request("/profile/diet", { method: "PATCH", body: JSON.stringify({ dietId }) });
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,13 @@ interface AuthContextValue {
|
||||||
login: (input: LoginInput) => Promise<void>;
|
login: (input: LoginInput) => Promise<void>;
|
||||||
/** Ends the session and clears `user`. */
|
/** Ends the session and clears `user`. */
|
||||||
logout: () => Promise<void>;
|
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
|
* Re-fetches the current profile and updates `user`. Needed after
|
||||||
* anything that changes profile fields `user` carries (e.g. `dietId`)
|
* anything that changes profile fields `user` carries (e.g. `dietId`)
|
||||||
* outside of `signup`/`login` — `PATCH /profile/diet` (see
|
* 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.
|
* which doesn't touch this context on its own.
|
||||||
*/
|
*/
|
||||||
refreshUser: () => Promise<void>;
|
refreshUser: () => Promise<void>;
|
||||||
|
|
@ -59,12 +61,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const deleteAccount = useCallback(async (password: string) => {
|
||||||
|
await apiClient.deleteAccount(password);
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const refreshUser = useCallback(async () => {
|
const refreshUser = useCallback(async () => {
|
||||||
setUser(await apiClient.me());
|
setUser(await apiClient.me());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, isLoading, signup, login, logout, refreshUser }}>
|
<AuthContext.Provider
|
||||||
|
value={{ user, isLoading, signup, login, logout, deleteAccount, refreshUser }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ interface AllergySelectProps {
|
||||||
* Multi-select (checkbox grid, not a native `<select multiple>` — far more
|
* Multi-select (checkbox grid, not a native `<select multiple>` — far more
|
||||||
* discoverable/tappable, especially on the mobile viewport this app is
|
* discoverable/tappable, especially on the mobile viewport this app is
|
||||||
* eventually embedded into via Capacitor) for a group of allergens. Used
|
* eventually embedded into via Capacitor) for a group of allergens. Used
|
||||||
* both by the signup wizard's allergens step and the `/foyer` settings
|
* both by the signup wizard's allergens step and the `/parametres/preferences`
|
||||||
* page, and rendered *twice* by each — once for allergies, once for
|
* settings page, and rendered *twice* by each — once for allergies, once for
|
||||||
* intolerances (`AllergyView.kind` groups them; callers filter and pass
|
* intolerances (`AllergyView.kind` groups them; callers filter and pass
|
||||||
* two separate lists rather than this component knowing about the split).
|
* two separate lists rather than this component knowing about the split).
|
||||||
* An empty `value` is a normal, valid state (no declared allergies, or
|
* An empty `value` is a normal, valid state (no declared allergies, or
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,10 @@ interface DietSelectProps {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dropdown picker for a dietary regime — used both by the signup wizard's
|
* Dropdown picker for a dietary regime — used both by the signup wizard's
|
||||||
* regime step and the `/foyer` settings page. Always includes a "none"
|
* regime step and the `/parametres/preferences` settings page. Always
|
||||||
* option (mapped to `null`, not just an empty label) since this step of the
|
* includes a "none" option (mapped to `null`, not just an empty label)
|
||||||
* profile journey is skippable — a profile with no regime is a normal,
|
* since this step of the profile journey is skippable — a profile with no
|
||||||
* valid state, not an incomplete one.
|
* regime is a normal, valid state, not an incomplete one.
|
||||||
*
|
*
|
||||||
* Receives `diets` as a prop rather than fetching them itself: the caller
|
* 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
|
* (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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<label htmlFor="diet">{t("household.form.dietLabel")}</label>
|
<label htmlFor="diet">{t("preferences.form.dietLabel")}</label>
|
||||||
<select
|
<select
|
||||||
id="diet"
|
id="diet"
|
||||||
value={value ?? ""}
|
value={value ?? ""}
|
||||||
onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.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) => (
|
{diets.map((diet) => (
|
||||||
<option key={diet.id} value={diet.id}>
|
<option key={diet.id} value={diet.id}>
|
||||||
{diet.name}
|
{diet.name}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ interface HouseNameFieldProps {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Labeled text input for the household's name — used both by the signup
|
* 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
|
* `profile-forms.scss` for the shared styling both consume). Controlled
|
||||||
* component: the caller owns the value and persists it (`ApiClient.
|
* component: the caller owns the value and persists it (`ApiClient.
|
||||||
* renameHouse`) on submit, not this component.
|
* renameHouse`) on submit, not this component.
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Styles shared by the profile-journey field components (HouseNameField,
|
// Styles shared by the profile-journey field components (HouseNameField,
|
||||||
// DietSelect, AllergySelect) — used both by the signup wizard's steps
|
// DietSelect, AllergySelect) — used both by the signup wizard's steps
|
||||||
// (pages/onboarding/) and the `/foyer` settings page (HouseholdPage). Field
|
// (pages/onboarding/) and the settings pages (PreferencesPage,
|
||||||
// styling only (label/input/select/checkbox) — the surrounding page/card
|
// HouseholdSettingsPage, under pages/settings/). Field styling only
|
||||||
// layout belongs to each consuming page's own .scss, same split as
|
// (label/input/select/checkbox) — the surrounding page/card layout belongs
|
||||||
// features/auth/auth-form.scss vs. LoginPage/SignupPage.
|
// 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
|
// No `@use` of the theme partial needed — every token below is a runtime
|
||||||
|
|
|
||||||
|
|
@ -60,25 +60,51 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__footer {
|
// --- "Paramètres" collapsible menu -------------------------------------
|
||||||
display: flex;
|
&__settings {
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
padding-top: var(--space-md);
|
padding-top: var(--space-md);
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__user {
|
&__settings-toggle {
|
||||||
padding: 0 var(--space-sm);
|
display: flex;
|
||||||
color: var(--color-text-muted);
|
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-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);
|
padding: 0.5rem var(--space-sm);
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--radius-base);
|
border-radius: var(--radius-base);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
|
@ -89,6 +115,40 @@
|
||||||
background: var(--color-surface-alt);
|
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 {
|
.app-content {
|
||||||
|
|
@ -125,8 +185,8 @@
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__settings,
|
||||||
&__footer {
|
&__footer {
|
||||||
flex-direction: row;
|
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
border-top: none;
|
border-top: none;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { useAuth } from "../features/auth/AuthContext";
|
||||||
import "./AppLayout.scss";
|
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
|
* `locales/fr/translation.json` — adding a section is one array entry plus
|
||||||
* one locale key, no other file to touch.
|
* one locale key, no other file to touch.
|
||||||
*/
|
*/
|
||||||
|
|
@ -12,29 +13,30 @@ const NAV_ITEMS = [
|
||||||
{ to: "/", key: "planning" },
|
{ to: "/", key: "planning" },
|
||||||
{ to: "/recettes", key: "recipes" },
|
{ to: "/recettes", key: "recipes" },
|
||||||
{ to: "/liste-de-courses", key: "shoppingList" },
|
{ to: "/liste-de-courses", key: "shoppingList" },
|
||||||
{ to: "/foyer", key: "household" },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shell for every authenticated page: a sidebar (brand, section nav, and
|
* One entry in the "Paramètres" nav, revealed by {@link SettingsMenu}. `key`
|
||||||
* the signed-in user + logout at the bottom) plus a main content area
|
* maps to `layout.settings.nav.<key>`.
|
||||||
* rendering the matched child route via `<Outlet />`.
|
*/
|
||||||
|
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
|
* Mounted once as the parent element of the whole authenticated route
|
||||||
* group, itself wrapped in {@link RequireAuth} (see `App.tsx`) — `user` is
|
* group, itself wrapped in {@link RequireAuth} (see `App.tsx`) — `user` is
|
||||||
* therefore guaranteed non-null by the time this renders.
|
* therefore guaranteed non-null by the time this renders.
|
||||||
*/
|
*/
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const { user, logout } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
/** Ends the session and returns to the login page. */
|
|
||||||
async function handleLogout() {
|
|
||||||
await logout();
|
|
||||||
navigate("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-layout">
|
<div className="app-layout">
|
||||||
<aside className="app-sidebar">
|
<aside className="app-sidebar">
|
||||||
|
|
@ -57,14 +59,8 @@ export function AppLayout() {
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="app-sidebar__footer">
|
<SettingsMenu />
|
||||||
<span className="app-sidebar__user">
|
<AccountMenu />
|
||||||
{t("layout.greeting", { firstName: user?.firstName })}
|
|
||||||
</span>
|
|
||||||
<button type="button" onClick={handleLogout}>
|
|
||||||
{t("layout.logout")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="app-content">
|
<main className="app-content">
|
||||||
|
|
@ -73,3 +69,90 @@ export function AppLayout() {
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,20 @@
|
||||||
{
|
{
|
||||||
|
"common": {
|
||||||
|
"saving": "Enregistrement…",
|
||||||
|
"saved": "Enregistré ✓"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"VALIDATION_ERROR": "Erreur de validation",
|
"VALIDATION_ERROR": "Erreur de validation",
|
||||||
"EMAIL_ALREADY_IN_USE": "Cet email est déjà utilisé",
|
"EMAIL_ALREADY_IN_USE": "Cet email est déjà utilisé",
|
||||||
"INVALID_CREDENTIALS": "Email ou mot de passe incorrect",
|
"INVALID_CREDENTIALS": "Email ou mot de passe incorrect",
|
||||||
"NOT_AUTHENTICATED": "Vous devez être connecté",
|
"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",
|
"NOT_FOUND": "Ressource introuvable",
|
||||||
"HOUSE_NOT_FOUND": "Votre profil n'a pas de foyer",
|
"HOUSE_NOT_FOUND": "Votre profil n'a pas de foyer",
|
||||||
"DIET_NOT_FOUND": "Ce régime alimentaire n'existe pas",
|
"DIET_NOT_FOUND": "Ce régime alimentaire n'existe pas",
|
||||||
"ALLERGY_NOT_FOUND": "Un des allergènes sélectionnés 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"
|
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
|
|
@ -37,12 +44,15 @@
|
||||||
"continue": "Continuer",
|
"continue": "Continuer",
|
||||||
"finish": "Terminer",
|
"finish": "Terminer",
|
||||||
"loading": "Chargement…",
|
"loading": "Chargement…",
|
||||||
"household": {
|
|
||||||
"title": "Comment s'appelle votre foyer ?"
|
|
||||||
},
|
|
||||||
"diet": {
|
"diet": {
|
||||||
"title": "Un régime alimentaire particulier ?"
|
"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": {
|
"allergens": {
|
||||||
"title": "Des allergies ou intolérances ?"
|
"title": "Des allergies ou intolérances ?"
|
||||||
}
|
}
|
||||||
|
|
@ -51,8 +61,18 @@
|
||||||
"nav": {
|
"nav": {
|
||||||
"planning": "Planning",
|
"planning": "Planning",
|
||||||
"recipes": "Recettes",
|
"recipes": "Recettes",
|
||||||
"shoppingList": "Liste de courses",
|
"shoppingList": "Liste de courses"
|
||||||
"household": "Foyer & profil"
|
},
|
||||||
|
"settings": {
|
||||||
|
"toggle": "Paramètres",
|
||||||
|
"nav": {
|
||||||
|
"account": "Compte",
|
||||||
|
"preferences": "Préférences",
|
||||||
|
"household": "Foyer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"accountMenu": {
|
||||||
|
"myAccount": "Mon compte"
|
||||||
},
|
},
|
||||||
"greeting": "Bonjour {{firstName}} 👋",
|
"greeting": "Bonjour {{firstName}} 👋",
|
||||||
"logout": "Se déconnecter"
|
"logout": "Se déconnecter"
|
||||||
|
|
@ -76,16 +96,58 @@
|
||||||
"title": "Liste de courses",
|
"title": "Liste de courses",
|
||||||
"comingSoon": "Cette section arrive bientôt."
|
"comingSoon": "Cette section arrive bientôt."
|
||||||
},
|
},
|
||||||
"household": {
|
"account": {
|
||||||
"title": "Foyer & profil",
|
"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": {
|
"form": {
|
||||||
"nameLabel": "Nom du foyer",
|
|
||||||
"dietLabel": "Régime alimentaire",
|
"dietLabel": "Régime alimentaire",
|
||||||
"dietNone": "Aucun régime particulier",
|
"dietNone": "Aucun régime particulier",
|
||||||
"allergiesLabel": "Allergies",
|
"allergiesLabel": "Allergies",
|
||||||
"intolerancesLabel": "Intolérances",
|
"intolerancesLabel": "Intolérances"
|
||||||
"saving": "Enregistrement…",
|
}
|
||||||
"saved": "Enregistré ✓"
|
},
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -37,7 +37,7 @@ export function SignupPage() {
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
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) {
|
async function handleSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
|
@ -52,7 +52,7 @@ export function SignupPage() {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await signup(result.data);
|
await signup(result.data);
|
||||||
navigate("/onboarding/foyer");
|
navigate("/onboarding/regime");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// ApiError.code is looked up through ErrorMessageService so the
|
// ApiError.code is looked up through ErrorMessageService so the
|
||||||
// label is centralized and localized — never display err.message
|
// label is centralized and localized — never display err.message
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ import "./onboarding.scss";
|
||||||
* `/onboarding/allergenes`. Starts from an empty selection — a freshly
|
* `/onboarding/allergenes`. Starts from an empty selection — a freshly
|
||||||
* signed-up profile has none yet, so there's no need for the extra
|
* 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
|
* `GET /profile/allergies` round trip a "resume where I left off" flow
|
||||||
* would require (the `/foyer` settings page, task 10, is the always-fetch
|
* would require (the `/parametres/preferences` settings page is the
|
||||||
* source of truth for editing an existing selection later).
|
* always-fetch source of truth for editing an existing selection later).
|
||||||
*/
|
*/
|
||||||
export function OnboardingAllergensPage() {
|
export function OnboardingAllergensPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -68,13 +68,13 @@ export function OnboardingAllergensPage() {
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<AllergySelect
|
<AllergySelect
|
||||||
legend={t("household.form.allergiesLabel")}
|
legend={t("preferences.form.allergiesLabel")}
|
||||||
allergies={allergies.filter((allergy) => allergy.kind === "ALLERGY")}
|
allergies={allergies.filter((allergy) => allergy.kind === "ALLERGY")}
|
||||||
value={allergyIds}
|
value={allergyIds}
|
||||||
onChange={setAllergyIds}
|
onChange={setAllergyIds}
|
||||||
/>
|
/>
|
||||||
<AllergySelect
|
<AllergySelect
|
||||||
legend={t("household.form.intolerancesLabel")}
|
legend={t("preferences.form.intolerancesLabel")}
|
||||||
allergies={allergies.filter((allergy) => allergy.kind === "INTOLERANCE")}
|
allergies={allergies.filter((allergy) => allergy.kind === "INTOLERANCE")}
|
||||||
value={allergyIds}
|
value={allergyIds}
|
||||||
onChange={setAllergyIds}
|
onChange={setAllergyIds}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { errorMessageService } from "../../services/error-message.service";
|
||||||
import "./onboarding.scss";
|
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
|
* `/onboarding/regime`. Initial selection comes from `useAuth()`'s
|
||||||
* already-loaded profile (`user.dietId`) — freshly signed-up, this is
|
* already-loaded profile (`user.dietId`) — freshly signed-up, this is
|
||||||
* `null` — no extra fetch needed just to know the starting value, unlike
|
* `null` — no extra fetch needed just to know the starting value, unlike
|
||||||
|
|
@ -48,7 +48,7 @@ export function OnboardingDietPage() {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await apiClient.updateDiet(dietId);
|
await apiClient.updateDiet(dietId);
|
||||||
navigate("/onboarding/allergenes");
|
navigate("/onboarding/foyer");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||||
setFormError(errorMessageService.getLabel(code));
|
setFormError(errorMessageService.getLabel(code));
|
||||||
|
|
@ -60,7 +60,7 @@ export function OnboardingDietPage() {
|
||||||
return (
|
return (
|
||||||
<main className="onboarding-page">
|
<main className="onboarding-page">
|
||||||
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
|
<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>
|
<h1>{t("onboarding.diet.title")}</h1>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|
|
||||||
|
|
@ -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 { type FormEvent, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
@ -9,30 +9,32 @@ import { errorMessageService } from "../../services/error-message.service";
|
||||||
import "./onboarding.scss";
|
import "./onboarding.scss";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* First step of the post-signup onboarding wizard, routed at
|
* Second step of the post-signup onboarding wizard, routed at
|
||||||
* `/onboarding/foyer` — behind {@link RequireAuth} (see `App.tsx`), reached
|
* `/onboarding/foyer` — unlike the regime/allergens steps, this one is
|
||||||
* right after `POST /auth/signup` creates the account and its household
|
* genuinely optional rather than just "skippable via an unedited default":
|
||||||
* (auto-named, see `auth.service.ts`). Prefills the current (default) name
|
* no household is created at signup anymore (see the API's
|
||||||
* so continuing without editing it is a valid, implicit "skip" — there's
|
* `auth.service.ts`), so there's nothing to prefill or implicitly keep by
|
||||||
* no separate skip button anywhere in this wizard, see `DietSelect`/
|
* pressing "Continuer" — the visitor explicitly creates one, joins one by
|
||||||
* `AllergySelect` for the same choice on the following steps.
|
* 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() {
|
export function OnboardingHouseholdPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
const [house, setHouse] = useState<HouseView | null>(null);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
apiClient
|
apiClient
|
||||||
.getCurrentHouse()
|
.getCurrentHouse()
|
||||||
.then((house) => {
|
.then((result) => {
|
||||||
if (!cancelled && house) setName(house.name);
|
if (!cancelled) setHouse(result);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setIsLoading(false);
|
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) {
|
async function handleSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
|
||||||
const result = renameHouseSchema.safeParse({ name });
|
const result = renameHouseSchema.safeParse({ name });
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setFieldErrors(fieldErrorsFrom(result.error));
|
setFieldErrors(fieldErrorsFrom(result.error));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFieldErrors({});
|
setFieldErrors({});
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await apiClient.renameHouse(result.data.name);
|
await apiClient.createHouse(result.data.name);
|
||||||
navigate("/onboarding/regime");
|
onDone();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||||
setFormError(errorMessageService.getLabel(code));
|
setFormError(errorMessageService.getLabel(code));
|
||||||
|
|
@ -66,23 +105,62 @@ export function OnboardingHouseholdPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="onboarding-page">
|
<form onSubmit={handleSubmit} noValidate>
|
||||||
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
|
<h2>{t("household.noHouse.createTitle")}</h2>
|
||||||
<p className="onboarding-step">{t("onboarding.step", { current: 1, total: 3 })}</p>
|
<HouseNameField value={name} onChange={setName} error={fieldErrors.name} />
|
||||||
<h1>{t("onboarding.household.title")}</h1>
|
{formError && <p className="form-error">{formError}</p>}
|
||||||
|
<button type="submit" disabled={isSubmitting}>
|
||||||
{isLoading ? (
|
{t("household.noHouse.createButton")}
|
||||||
<p>{t("onboarding.loading")}</p>
|
</button>
|
||||||
) : (
|
</form>
|
||||||
<HouseNameField value={name} onChange={setName} error={fieldErrors.name} />
|
);
|
||||||
)}
|
}
|
||||||
|
|
||||||
{formError && <p className="form-error">{formError}</p>}
|
function JoinHouseholdForm({ onDone }: { onDone: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
<button type="submit" disabled={isSubmitting || isLoading}>
|
const [inviteCode, setInviteCode] = useState("");
|
||||||
{t("onboarding.continue")}
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
</button>
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
</form>
|
|
||||||
</main>
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,11 @@
|
||||||
margin-bottom: var(--space-sm);
|
margin-bottom: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: var(--space-md) 0 var(--space-xs);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
margin-top: var(--space-md);
|
margin-top: var(--space-md);
|
||||||
padding: 0.6rem;
|
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 {
|
.onboarding-step {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
|
|
@ -68,6 +91,15 @@
|
||||||
margin: 0 0 var(--space-sm);
|
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 {
|
.form-error {
|
||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
|
|
|
||||||
106
apps/web/src/pages/settings/AccountSettingsPage.tsx
Normal file
106
apps/web/src/pages/settings/AccountSettingsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
386
apps/web/src/pages/settings/HouseholdSettingsPage.tsx
Normal file
386
apps/web/src/pages/settings/HouseholdSettingsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
apps/web/src/pages/settings/PreferencesPage.tsx
Normal file
185
apps/web/src/pages/settings/PreferencesPage.tsx
Normal 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;
|
||||||
|
}
|
||||||
151
apps/web/src/pages/settings/settings-pages.scss
Normal file
151
apps/web/src/pages/settings/settings-pages.scss
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,8 @@
|
||||||
* code families —
|
* code families —
|
||||||
* - `4000`–`4099`: request validation
|
* - `4000`–`4099`: request validation
|
||||||
* - `4010`–`4019`: authentication
|
* - `4010`–`4019`: authentication
|
||||||
|
* - `4020`–`4029`: conflicting/invalid state transition
|
||||||
|
* - `4030`–`4039`: authorization (caller authenticated, but not allowed to)
|
||||||
* - `4040`–`4049`: not found
|
* - `4040`–`4049`: not found
|
||||||
* - `5000`–`5099`: internal/unexpected
|
* - `5000`–`5099`: internal/unexpected
|
||||||
*
|
*
|
||||||
|
|
@ -32,6 +34,10 @@ export enum ErrorCode {
|
||||||
INVALID_CREDENTIALS = 4010,
|
INVALID_CREDENTIALS = 4010,
|
||||||
/** Request required a session cookie/JWT that is missing, invalid, or stale. */
|
/** Request required a session cookie/JWT that is missing, invalid, or stale. */
|
||||||
NOT_AUTHENTICATED = 4011,
|
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. */
|
/** No route/resource matches the request. */
|
||||||
NOT_FOUND = 4040,
|
NOT_FOUND = 4040,
|
||||||
/** The profile making the request has no household yet (`houseId` is `null`). */
|
/** The profile making the request has no household yet (`houseId` is `null`). */
|
||||||
|
|
@ -40,6 +46,8 @@ export enum ErrorCode {
|
||||||
DIET_NOT_FOUND = 4042,
|
DIET_NOT_FOUND = 4042,
|
||||||
/** One or more `allergyIds` don't match any reference `Allergy` row. */
|
/** One or more `allergyIds` don't match any reference `Allergy` row. */
|
||||||
ALLERGY_NOT_FOUND = 4043,
|
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. */
|
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
|
||||||
INTERNAL_ERROR = 5000,
|
INTERNAL_ERROR = 5000,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
// detail specific to one side.
|
// detail specific to one side.
|
||||||
|
|
||||||
export * from "./errors/error-codes.js";
|
export * from "./errors/error-codes.js";
|
||||||
|
export * from "./schemas/account.js";
|
||||||
export * from "./schemas/auth.js";
|
export * from "./schemas/auth.js";
|
||||||
export * from "./schemas/household.js";
|
export * from "./schemas/household.js";
|
||||||
export * from "./schemas/profile.js";
|
export * from "./schemas/profile.js";
|
||||||
|
|
|
||||||
15
packages/shared/src/schemas/account.ts
Normal file
15
packages/shared/src/schemas/account.ts
Normal 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>;
|
||||||
|
|
@ -10,3 +10,17 @@ export const renameHouseSchema = z.object({
|
||||||
});
|
});
|
||||||
/** Inferred TS type for {@link renameHouseSchema}'s validated output. */
|
/** Inferred TS type for {@link renameHouseSchema}'s validated output. */
|
||||||
export type RenameHouseInput = z.infer<typeof renameHouseSchema>;
|
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>;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,27 @@
|
||||||
/**
|
/**
|
||||||
* A household, as returned by `GET /house/current` / `PATCH /house/current`.
|
* One member of a household, as embedded in {@link HouseView}. Deliberately
|
||||||
* Unlike `DietView`/`AllergyView` this isn't reference data — it's the
|
* a small subset of `SafeUserProfile` — just enough for the household
|
||||||
* current user's own 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 {
|
export interface HouseView {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
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[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue