Compare commits

..

No commits in common. "main" and "feat/missing-tech-steps" have entirely different histories.

42 changed files with 303 additions and 1923 deletions

View file

@ -15,14 +15,6 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# (docker-compose.yml), serving both the API and the built frontend. # (docker-compose.yml), serving both the API and the built frontend.
# APP_PORT=3000 # APP_PORT=3000
# Optional — tag of the prebuilt images the release CI pushes to the Forgejo
# container registry (git.dev.kyuno.fr/kyuno/batchcooking-{app,intent-service,
# llm-worker}). Used by `docker compose pull` and a pull-based Portainer
# stack; leave it `latest` or pin a release tag (e.g. v0.2.4). Ignored when
# building locally instead (`docker compose build`, or `up` with no image
# present) — see docker-compose.yml's `image:`/`build:` comment.
# IMAGE_TAG=latest
# --- Admin surface (apps/web's /admin/* routes + the /admin/* API) -------- # --- Admin surface (apps/web's /admin/* routes + the /admin/* API) --------
# All optional: an instance that doesn't run the admin app needs none of # All optional: an instance that doesn't run the admin app needs none of
# these. `requireAdmin` fails closed when ADMIN_JWT_SECRET is unset, so # these. `requireAdmin` fails closed when ADMIN_JWT_SECRET is unset, so
@ -40,17 +32,6 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# ADMIN_INITIAL_EMAIL=ops@example.com # ADMIN_INITIAL_EMAIL=ops@example.com
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars # ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
# ADMIN_INITIAL_NAME=Ops # ADMIN_INITIAL_NAME=Ops
#
# Optional — lets an admin open a Forgejo ticket from a contact message
# (apps/web's /admin/messages, issue #81). All three needed together: the
# button stays hidden and the endpoint answers 503 unless all are set to a
# non-empty value. docker-compose.yml forwards these into the "app"
# container, so setting them here is enough (a blank value counts as
# unset). FORGEJO_TOKEN is a personal access token with the write:issue
# scope — generate it in Forgejo, never commit the real value.
# FORGEJO_BASE_URL=https://git.example.com
# FORGEJO_TOKEN=changeme-a-forgejo-personal-access-token
# FORGEJO_REPO=owner/repo
# Optional — only set this to false if THIS deployment is served over # Optional — only set this to false if THIS deployment is served over
# plain HTTP (no TLS in front of it). Left unset, the session cookie # plain HTTP (no TLS in front of it). Left unset, the session cookie

View file

@ -1,34 +1,20 @@
name: Release name: Release
# On a `vX.Y.Z` tag: build the three service images and push them to this # No image registry involved: Portainer is wired to this repo's Git remote
# Forgejo instance's built-in container registry (git.dev.kyuno.fr/<owner>/ # and builds/redeploys apps/api/Dockerfile itself. This workflow sanity-checks
# batchcooking-{app,intent-service,llm-worker}, tagged with the release tag # that the image actually builds at the tagged commit, builds+attaches a
# and `latest`), build+attach a signed Android APK, publish a Forgejo # signed Android APK, publishes a Forgejo Release, then (best-effort) pings
# Release, then (best-effort) ping Portainer's deploy webhook. Portainer can # Portainer's deploy webhook so it doesn't have to wait for its own polling
# pull those prebuilt images (docker-compose.yml references them via # interval.
# ${IMAGE_TAG}) or keep building from the Git remote — `docker-compose.yml`
# keeps a `build:` fallback next to every `image:`.
on: on:
push: push:
tags: ["v*.*.*"] tags: ["v*.*.*"]
permissions: permissions:
contents: write contents: write
# Lets this workflow's GITHUB_TOKEN push to the Forgejo container registry
# (the `build-and-push-images` job below `docker login`s with it). If a
# future instance policy scopes the run token tighter, swap that login's
# password for a `write:package` PAT added as a repo secret.
packages: write
jobs: jobs:
# Build the three service images at the tagged commit and push them to the sanity-build:
# Forgejo container registry. A successful push also *is* the "does it
# still build" check the old `sanity-build` job did (which only covered
# apps/api). One sequential job, not a per-image matrix: three concurrent
# `docker build`s hammer this single persistent runner's Docker daemon
# (the contended resource per project memory `ci-forgejo-runner-constraints`)
# for no real gain on a release, which is rare and usually runs alone.
build-and-push-images:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# This runner's job containers don't come with docker preinstalled — # This runner's job containers don't come with docker preinstalled —
@ -39,36 +25,10 @@ jobs:
- uses: https://github.com/actions/checkout@v4 - uses: https://github.com/actions/checkout@v4
- name: Build et push des 3 images vers le registre Forgejo # Build only, no push, no registry — just confirms Portainer will be
env: # able to build this same Dockerfile successfully at this tag before
# The per-run token Forgejo Actions mints — has `packages: write` # anyone points it at this ref.
# via this workflow's `permissions:` block. `docker login` accepts - run: docker build -f apps/api/Dockerfile .
# it as the password for the actor who pushed the tag.
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
# $GITHUB_SERVER_URL is "https://git.dev.kyuno.fr" — the registry
# is that same host. $GITHUB_REPOSITORY is "<owner>/<repo>"; the
# registry namespace is the owner, lower-cased (Forgejo package
# names are case-insensitive but the CLI wants a stable form).
registry="${GITHUB_SERVER_URL#http*://}"
owner="$(printf '%s' "${GITHUB_REPOSITORY%%/*}" | tr '[:upper:]' '[:lower:]')"
tag="$GITHUB_REF_NAME"
echo "$REGISTRY_TOKEN" | docker login "$registry" -u "$GITHUB_ACTOR" --password-stdin
# Every Dockerfile's build context is the repo root (see
# docker-compose.yml's `build.context: .`), hence the trailing `.`.
build_push() {
image="$registry/$owner/$1"
echo "::group::$1"
docker build -f "$2" -t "$image:$tag" -t "$image:latest" .
docker push "$image:$tag"
docker push "$image:latest"
echo "::endgroup::"
}
build_push batchcooking-app apps/api/Dockerfile
build_push batchcooking-intent-service services/tech-step-intent-service/Dockerfile
build_push batchcooking-llm-worker services/tech-step-llm-worker/Dockerfile
# Builds the same signed APK as ci.yml's build-android job (issue #64), # Builds the same signed APK as ci.yml's build-android job (issue #64),
# with one difference: the version baked into the APK is derived from the # with one difference: the version baked into the APK is derived from the
@ -164,11 +124,11 @@ jobs:
forgejo-release: forgejo-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [build-and-push-images, build-android-apk] needs: [sanity-build, build-android-apk]
steps: steps:
# Ce runner ne fournit ni docker ni curl par défaut dans ses # Ce runner ne fournit ni docker ni curl par défaut dans ses
# conteneurs de job — même constat déjà fait pour docker (voir # conteneurs de job — même constat déjà fait pour docker (voir
# build-and-push-images) et pour curl dans ci.yml's test job. # sanity-build) et pour curl dans ci.yml's test job.
- name: Install curl - name: Install curl
run: apt-get update && apt-get install -y curl run: apt-get update && apt-get install -y curl

View file

@ -207,18 +207,10 @@ depuis la même origine. Les deux autres services du fichier
(`tech-step-intent-service`, `tech-step-llm-worker`) n'exposent eux aucun port (`tech-step-intent-service`, `tech-step-llm-worker`) n'exposent eux aucun port
au host — voir leurs propres README pour leur rôle. au host — voir leurs propres README pour leur rôle.
**Registre d'image** : la CI de release (voir plus bas) construit et pousse les **Pas de registre d'image** dans cette configuration : l'instance **Portainer** de
trois images de service — `batchcooking-app`, `batchcooking-intent-service`, production est reliée directement au dépôt Git et reconstruit elle-même
`batchcooking-llm-worker` — sur le **registre de conteneurs intégré à Forgejo** `docker-compose.yml`/`apps/api/Dockerfile` à chaque déploiement — la CI ne pousse
(`git.dev.kyuno.fr/kyuno/…`), taguées à la fois avec le tag de release (`vX.Y.Z`) donc aucune image nulle part.
et `latest`. `docker-compose.yml` référence ces images via `IMAGE_TAG` (variable
d'environnement, défaut `latest` — voir `.env.example`), tout en gardant un bloc
`build:` local à côté de chaque `image:` : `docker compose pull` récupère les
images pré-construites, `docker compose up`/`build` retombe sur un build local si
elles sont absentes. L'instance **Portainer** de production peut donc désormais
tirer les images pré-construites (stack en mode *pull*, `IMAGE_TAG` pinné sur la
release voulue) **ou** continuer à builder elle-même depuis le dépôt Git — les
deux modes fonctionnent avec le même `docker-compose.yml`.
### Release (`.github/workflows/release.yml`) ### Release (`.github/workflows/release.yml`)
@ -229,16 +221,14 @@ git tag vX.Y.Z
git push --tags git push --tags
``` ```
Le pipeline enchaîne quatre jobs : `build-and-push-images` (build des 3 images à Le pipeline enchaîne trois jobs : `sanity-build` (build de l'image Docker sans
ce tag + push sur le registre Forgejo sous `vX.Y.Z` et `latest` — un push push, juste pour vérifier qu'elle build encore à ce tag avant de laisser Portainer
réussi vaut aussi vérification que tout build encore à ce commit), redéployer dessus), `github-release` (crée une Release GitHub avec changelog
`build-android-apk` (APK signé, `versionName` dérivé du tag), `forgejo-release` auto-généré à partir des PRs mergées), puis `notify-portainer` — envoie une requête
(crée la Release Forgejo `vX.Y.Z` et y attache `batchcooking.apk`), puis au webhook de redeploy de Portainer si le secret de dépôt `PORTAINER_WEBHOOK_URL`
`notify-portainer` — envoie une requête au webhook de redeploy de Portainer si est configuré (sinon Portainer se resynchronise simplement à son prochain
le secret de dépôt `PORTAINER_WEBHOOK_URL` est configuré (sinon Portainer se polling Git). Pour l'activer : récupérer l'URL du webhook depuis les réglages du
resynchronise à son prochain polling). Pour l'activer : récupérer l'URL du stack Portainer, puis l'ajouter comme secret GitHub `PORTAINER_WEBHOOK_URL`.
webhook depuis les réglages du stack Portainer, puis l'ajouter comme secret de
dépôt `PORTAINER_WEBHOOK_URL`.
## Application mobile (Android) ## Application mobile (Android)

View file

@ -13,26 +13,12 @@ import { z } from "zod";
// separate test database this now requires. // separate test database this now requires.
dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" }); dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" });
/**
* Treats a blank env var (`FOO=`) as unset. `docker-compose.yml` passes
* every optional var through as `${FOO:-}`, which resolves to `""` (not an
* absent key) when the stack's `.env` leaves it empty — and a `.env` file
* is `.dockerignore`d, so that passthrough is the container's only source
* of these. Fields that must *fail closed* on a blank value rather than run
* their own format check against `""` (and crash `parse()` at boot) wrap
* their schema in this. Used for the `FORGEJO_*` trio below; `COOKIE_SECURE`
* folds the same `"" -> undefined` step into its own `.transform`.
*/
const blankAsUnset = (value: unknown): unknown => (value === "" ? undefined : value);
/** /**
* Schema for every environment variable the API reads. Parsing (below) * Schema for every environment variable the API reads. Parsing (below)
* fails fast at startup if something required is missing/invalid, instead * fails fast at startup if something required is missing/invalid, instead
* of surfacing as a confusing runtime error later. Exported so the parsing * of surfacing as a confusing runtime error later.
* rules (e.g. `blankAsUnset` on the `FORGEJO_*` trio) can be unit-tested
* without the process-wide `process.env` the singleton below parses.
*/ */
export const envSchema = z.object({ const envSchema = z.object({
/** Runtime mode — also toggles test-only behavior (e.g. cheaper argon2 cost, see auth.service.ts). */ /** Runtime mode — also toggles test-only behavior (e.g. cheaper argon2 cost, see auth.service.ts). */
NODE_ENV: z.enum(["development", "test", "production"]).default("development"), NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
/** Port the HTTP server listens on. */ /** Port the HTTP server listens on. */
@ -166,17 +152,14 @@ export const envSchema = z.object({
* `INTERNAL_WORKER_SECRET`). Only this one admin action reads them; the * `INTERNAL_WORKER_SECRET`). Only this one admin action reads them; the
* rest of the app never talks to Forgejo. * rest of the app never talks to Forgejo.
*/ */
FORGEJO_BASE_URL: z.preprocess(blankAsUnset, z.string().url().optional()), FORGEJO_BASE_URL: z.string().url().optional(),
/** Forgejo personal access token with the `write:issue` (or `issue`) scope. Secret — `.env.example` shows only a `changeme`. */ /** Forgejo personal access token with the `write:issue` (or `issue`) scope. Secret — `.env.example` shows only a `changeme`. */
FORGEJO_TOKEN: z.preprocess(blankAsUnset, z.string().optional()), FORGEJO_TOKEN: z.string().optional(),
/** Target repo as `owner/repo` (e.g. `kyuno/batchCooking`) — issues are always created here, never a repo chosen at call time. */ /** Target repo as `owner/repo` (e.g. `kyuno/batchCooking`) — issues are always created here, never a repo chosen at call time. */
FORGEJO_REPO: z.preprocess( FORGEJO_REPO: z
blankAsUnset,
z
.string() .string()
.regex(/^[^/\s]+\/[^/\s]+$/, "FORGEJO_REPO must look like 'owner/repo'") .regex(/^[^/\s]+\/[^/\s]+$/, "FORGEJO_REPO must look like 'owner/repo'")
.optional(), .optional(),
),
}); });
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */ /** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */

View file

@ -1,31 +0,0 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { adminCreateUserSchema } from "@batch-cooking/shared";
import { Router } from "express";
import { requireAdmin } from "../../middlewares/require-admin.js";
import { createUserProfile, listUsers } from "./admin-users.service.js";
/**
* Router mounted at `/admin/api/users` (via `admin.routes.ts`) — every route
* behind {@link requireAdmin}. Backs the `/admin/utilisateurs` page: a
* read-only list of end-user accounts and a "create an account by hand"
* form. The server generates a one-time temporary password on create and
* returns it in the response (see `admin-users.service.ts`).
*/
export const adminUsersRouter = Router();
adminUsersRouter.get(
"/",
requireAdmin,
wrapAsyncHandler(async (_req, res) => {
res.status(200).json(await listUsers());
}),
);
adminUsersRouter.post(
"/",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
const input = adminCreateUserSchema.parse(req.body);
res.status(201).json(await createUserProfile(input));
}),
);

View file

@ -1,145 +0,0 @@
import { randomInt } from "node:crypto";
import { HttpError } from "@batch-cooking/error-tools";
import {
type AdminCreateUserInput,
type AdminUserCreatedView,
type AdminUserListItemView,
ErrorCode,
} from "@batch-cooking/shared";
import argon2 from "argon2";
import { env } from "../../config/env.js";
import { prisma } from "../../db/prisma.js";
import { analytics } from "../../lib/analytics.service.js";
/**
* How many accounts `listUsers` returns per call. Like `CONTACT_MESSAGES_PAGE_SIZE`
* this is a triage view, not an export — the admin creates/looks up a handful
* of accounts, it never needs the whole table paginated.
*/
const USERS_PAGE_SIZE = 200;
// Same reasoning as `admin-auth.service.ts`'s / `auth.service.ts`'s `hashOptions`:
// argon2's real defaults are deliberately expensive; the test suite hashes
// against throwaway data many times per run, so a cheaper cost keeps it fast
// without weakening anything real. Never applies outside NODE_ENV=test.
const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 };
const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
/**
* Charset for {@link generateTemporaryPassword} — upper/lower letters + digits,
* minus the visually ambiguous ones (`0`/`O`/`1`/`l`/`I`), same spirit as
* `house.service.ts`'s `INVITE_CODE_CHARS`: this string is read off one screen
* and typed into another (or pasted, but not always).
*/
const TEMP_PASSWORD_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
/** Length of a generated temporary password — comfortably above `signupSchema`'s 8-char floor. */
const TEMP_PASSWORD_LENGTH = 16;
/**
* A fresh random temporary password for an admin-created account. Uses
* `node:crypto`'s `randomInt` (not `Math.random`) exactly like
* `generateInviteCode()` — this value guards a real account until its owner
* changes it. Returned to the operator once and never stored in plain text.
*/
function generateTemporaryPassword(): string {
let password = "";
for (let i = 0; i < TEMP_PASSWORD_LENGTH; i++) {
password += TEMP_PASSWORD_CHARS[randomInt(TEMP_PASSWORD_CHARS.length)];
}
return password;
}
/** A `UserProfile` row with its household name joined in — the shape {@link toListItem} maps from. */
type UserRow = Awaited<
ReturnType<
typeof prisma.userProfile.findFirstOrThrow<{
include: { house: { select: { name: true } } };
}>
>
>;
/** Shapes one Prisma `UserProfile` row (with its `house` name) into the admin list view — drops `passwordHash`/`tokenVersion`, resolves the household name. */
function toListItem(row: UserRow): AdminUserListItemView {
return {
id: row.id,
firstName: row.firstName,
lastName: row.lastName,
email: row.email,
householdName: row.house?.name ?? null,
createdAt: row.createdAt.toISOString(),
};
}
/**
* Every end-user account for the admin `/admin/utilisateurs` list — newest
* first, capped at {@link USERS_PAGE_SIZE}. Read-only: this first version has
* no edit/disable/delete (deleting an account is irreversible and stays a
* deliberate self-service action in `AccountSettingsPage`).
*/
export async function listUsers(): Promise<AdminUserListItemView[]> {
try {
const rows = await prisma.userProfile.findMany({
// `id` as a tiebreaker so the order is total even if two accounts share
// a `createdAt` (bulk-provisioned in the same tick) — `id` ascends with
// `createdAt` anyway.
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: USERS_PAGE_SIZE,
include: { house: { select: { name: true } } },
});
return rows.map(toListItem);
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware handles it,
// this layer just isn't allowed a bare `await`. Same as `admin-contact.service.ts`.
throw err;
}
}
/**
* Provisions a new end-user account by hand and returns it together with the
* one-time temporary password the caller must relay to the user. Mirrors
* `auth.service.ts`'s `signup` for the parts that matter (email-uniqueness
* check, argon2 hash, `UserProfile` row with `houseId`/`dietId` left `null`)
* but issues **no session token** — the operator isn't logging in as this
* user — and generates the password instead of accepting one.
*
* @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken.
*/
export async function createUserProfile(
input: AdminCreateUserInput,
): Promise<AdminUserCreatedView> {
try {
const existing = await prisma.userProfile.findUnique({ where: { email: input.email } });
if (existing) {
throw new HttpError(409, ErrorCode.EMAIL_ALREADY_IN_USE, "Email already in use");
}
const temporaryPassword = generateTemporaryPassword();
const passwordHash = await argon2.hash(temporaryPassword, hashOptions);
const profile = await prisma.userProfile.create({
// No household/diet here, same as `signup` — the user picks those in the
// onboarding wizard after their first login.
data: {
firstName: input.firstName,
lastName: input.lastName,
email: input.email,
passwordHash,
},
include: { house: { select: { name: true } } },
});
// Fire-and-forget, one line, no migration (see `analytics.service.ts`). A
// distinct type from `user.signup` so the dashboard can tell hand-provisioned
// accounts apart; `actorType: "system"` because the actor is an operator, not
// the new user (and `AnalyticsEvent.actorId` only ever means a `UserProfile.id`).
analytics.recordEvent("admin.user_created", {
actorType: "system",
context: { userId: profile.id },
});
return { user: toListItem(profile), temporaryPassword };
} catch (err) {
throw err; // see listUsers()'s catch comment
}
}

View file

@ -5,7 +5,6 @@ import { adminContactRouter } from "./admin-contact.routes.js";
import { adminMetricsRouter } from "./admin-metrics.routes.js"; import { adminMetricsRouter } from "./admin-metrics.routes.js";
import { adminTechStepsRouter } from "./admin-tech-steps.routes.js"; import { adminTechStepsRouter } from "./admin-tech-steps.routes.js";
import { adminToursRouter } from "./admin-tours.routes.js"; import { adminToursRouter } from "./admin-tours.routes.js";
import { adminUsersRouter } from "./admin-users.routes.js";
/** /**
* Aggregator for the admin application's API surface, mounted at * Aggregator for the admin application's API surface, mounted at
@ -29,4 +28,3 @@ adminRouter.use("/tech-steps", adminTechStepsRouter);
adminRouter.use("/catalog", adminCatalogRouter); adminRouter.use("/catalog", adminCatalogRouter);
adminRouter.use("/tours", adminToursRouter); adminRouter.use("/tours", adminToursRouter);
adminRouter.use("/contact", adminContactRouter); adminRouter.use("/contact", adminContactRouter);
adminRouter.use("/users", adminUsersRouter);

View file

@ -1,160 +0,0 @@
import { ErrorCode } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
import { resetDatabase } from "../test-support/reset-db.js";
async function seedAdmin(): Promise<{ email: string; password: string }> {
const email = faker.internet.email().toLowerCase();
const password = faker.internet.password({ length: 16 });
await prisma.adminUser.create({
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
});
return { email, password };
}
/** A fresh, fake (never real-looking) create-user payload — same rationale as `auth.test.ts`'s `buildSignupPayload`. */
function buildCreateUserPayload() {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
};
}
/**
* `/admin/api/users/*` — the admin surface for provisioning end-user
* accounts by hand (`/admin/utilisateurs` page). Every route behind
* `requireAdmin`. Creation generates a one-time temporary password and
* returns it in the response; the account starts with no household, exactly
* like a self-service signup.
*/
describe("Admin users — account provisioning", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
async function adminAgent() {
const { email, password } = await seedAdmin();
const agent = request.agent(app);
await agent.post("/admin/api/auth/login").send({ email, password });
return agent;
}
it("rejects every route without an admin session", async () => {
expect((await request(app).get("/admin/api/users")).status).to.equal(401);
expect(
(await request(app).post("/admin/api/users").send(buildCreateUserPayload())).status,
).to.equal(401);
});
it("creates a profile with no household and returns a one-time temporary password", async () => {
const agent = await adminAgent();
const payload = buildCreateUserPayload();
const res = await agent.post("/admin/api/users").send(payload);
expect(res.status).to.equal(201);
expect(res.body.user).to.include({
firstName: payload.firstName,
lastName: payload.lastName,
email: payload.email,
householdName: null,
});
expect(res.body.user).to.not.have.property("passwordHash");
expect(res.body.user).to.not.have.property("tokenVersion");
expect(res.body.temporaryPassword).to.be.a("string").with.length.greaterThan(8);
const stored = await prisma.userProfile.findUnique({ where: { email: payload.email } });
expect(stored?.houseId).to.equal(null);
expect(stored?.dietId).to.equal(null);
});
it("issues a password the new account can actually log in with", async () => {
const agent = await adminAgent();
const payload = buildCreateUserPayload();
const created = await agent.post("/admin/api/users").send(payload);
const login = await request(app)
.post("/auth/login")
.send({ email: payload.email, password: created.body.temporaryPassword });
expect(login.status).to.equal(200);
expect(login.body.email).to.equal(payload.email);
});
it("rejects a duplicate email with 409 EMAIL_ALREADY_IN_USE", async () => {
const agent = await adminAgent();
const payload = buildCreateUserPayload();
await agent.post("/admin/api/users").send(payload);
const res = await agent
.post("/admin/api/users")
.send({ ...buildCreateUserPayload(), email: payload.email });
expect(res.status).to.equal(409);
expect(res.body.code).to.equal(ErrorCode.EMAIL_ALREADY_IN_USE);
});
it("rejects an invalid body with 400 VALIDATION_ERROR", async () => {
const agent = await adminAgent();
const badEmail = await agent
.post("/admin/api/users")
.send({ ...buildCreateUserPayload(), email: "not-an-email" });
expect(badEmail.status).to.equal(400);
expect(badEmail.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
const noFirstName = await agent
.post("/admin/api/users")
.send({ ...buildCreateUserPayload(), firstName: "" });
expect(noFirstName.status).to.equal(400);
});
it("lists accounts newest-first with their household name resolved, never the password", async () => {
const agent = await adminAgent();
const first = buildCreateUserPayload();
const second = buildCreateUserPayload();
const firstRes = await agent.post("/admin/api/users").send(first);
await agent.post("/admin/api/users").send(second);
// Put the first account in a household so `householdName` has something to
// resolve — `House.adminId` is required, so the account is its own admin.
const house = await prisma.house.create({
data: {
name: "Le Foyer Test",
adminId: firstRes.body.user.id,
inviteCode: faker.string.alphanumeric(8).toUpperCase(),
},
});
await prisma.userProfile.update({
where: { email: first.email },
data: { houseId: house.id },
});
const res = await agent.get("/admin/api/users");
expect(res.status).to.equal(200);
expect(res.body).to.have.length(2);
// Newest first: `second` was created after `first`.
expect(res.body[0].email).to.equal(second.email);
expect(res.body[1].email).to.equal(first.email);
expect(res.body[1].householdName).to.equal("Le Foyer Test");
expect(res.body[0].householdName).to.equal(null);
for (const row of res.body) {
expect(row).to.not.have.property("temporaryPassword");
expect(row).to.not.have.property("passwordHash");
}
});
});

View file

@ -1,59 +0,0 @@
import { expect } from "chai";
import { envSchema } from "../src/config/env.js";
/**
* Pure schema test — no DB, no `process.env`. Covers `blankAsUnset` on the
* `FORGEJO_*` trio: `docker-compose.yml` forwards every optional var as
* `${FOO:-}`, which the container receives as `""` (not an absent key) when
* the stack's `.env` leaves it blank. Those three must read a blank value
* as "not configured" and still boot, rather than trip `FORGEJO_BASE_URL`'s
* `.url()` / `FORGEJO_REPO`'s regex and abort `parse()` at startup.
*/
describe("envSchema — FORGEJO_* blank handling", () => {
// The only two vars with no default and no `.optional()`.
const base = {
JWT_SECRET: "x".repeat(32),
INTENT_SERVICE_SECRET: "y".repeat(32),
};
it("treats blank FORGEJO_* values as unset instead of failing validation", () => {
const parsed = envSchema.parse({
...base,
FORGEJO_BASE_URL: "",
FORGEJO_TOKEN: "",
FORGEJO_REPO: "",
});
expect(parsed.FORGEJO_BASE_URL).to.equal(undefined);
expect(parsed.FORGEJO_TOKEN).to.equal(undefined);
expect(parsed.FORGEJO_REPO).to.equal(undefined);
});
it("treats a fully absent FORGEJO_* trio the same way", () => {
const parsed = envSchema.parse({ ...base });
expect(parsed.FORGEJO_BASE_URL).to.equal(undefined);
expect(parsed.FORGEJO_TOKEN).to.equal(undefined);
expect(parsed.FORGEJO_REPO).to.equal(undefined);
});
it("still passes through and validates non-blank FORGEJO_* values", () => {
const parsed = envSchema.parse({
...base,
FORGEJO_BASE_URL: "https://git.example.com",
FORGEJO_TOKEN: "a-token",
FORGEJO_REPO: "owner/repo",
});
expect(parsed.FORGEJO_BASE_URL).to.equal("https://git.example.com");
expect(parsed.FORGEJO_TOKEN).to.equal("a-token");
expect(parsed.FORGEJO_REPO).to.equal("owner/repo");
});
it("still rejects a non-blank but malformed FORGEJO_BASE_URL", () => {
expect(() => envSchema.parse({ ...base, FORGEJO_BASE_URL: "git.example.com" })).to.throw();
});
it("still rejects a non-blank but malformed FORGEJO_REPO", () => {
expect(() => envSchema.parse({ ...base, FORGEJO_REPO: "not-a-repo" })).to.throw();
});
});

View file

@ -1,105 +0,0 @@
import { useState } from "react";
import "../../src/i18n/i18n";
import { PasswordInput } from "../../src/components/ui/PasswordInput";
// Mounts PasswordInput in isolation (no form, no router, no backend) — the
// same "generic component test" posture as CheckboxOption.cy.tsx. Needs
// `../../src/i18n/i18n` imported for its side effect: unlike Checkbox/Radio,
// this component calls `useTranslation()` for the eye button's aria-label,
// and with no <I18nextProvider> in the tree it falls back to that default
// instance (see TechStepCorrectionPopover.cy.tsx for the same note).
const SHOW_LABEL = "Afficher le mot de passe";
const HIDE_LABEL = "Masquer le mot de passe";
/**
* Controlled wrapper — PasswordInput holds no text state of its own, so a
* real caller (LoginPage, SignupPage) always owns the value. `onSpy` lets a
* test assert on what each change reports.
*/
function Wrapper({ onSpy }: { onSpy?: (value: string) => void }) {
const [value, setValue] = useState("");
return (
<PasswordInput
id="password"
value={value}
onChange={(next) => {
setValue(next);
onSpy?.(next);
}}
autoComplete="current-password"
/>
);
}
describe("PasswordInput", () => {
it("renders a masked password field by default", () => {
cy.mount(<Wrapper />);
cy.get("input#password").should("have.attr", "type", "password");
});
it("forwards id and autoComplete to the native input", () => {
cy.mount(<Wrapper />);
cy.get("input#password").should("have.attr", "autocomplete", "current-password");
});
it("reveals the text when the eye is clicked, then re-masks it on a second click", () => {
cy.mount(<Wrapper />);
cy.get("input#password").type("hunter2").should("have.attr", "type", "password");
cy.get("button.password-input__toggle").click();
cy.get("input#password").should("have.attr", "type", "text").and("have.value", "hunter2");
cy.get("button.password-input__toggle").click();
cy.get("input#password").should("have.attr", "type", "password").and("have.value", "hunter2");
});
it("labels the toggle with the action the next click performs, and exposes state via aria-pressed", () => {
cy.mount(<Wrapper />);
cy.get("button.password-input__toggle")
.should("have.attr", "aria-label", SHOW_LABEL)
.and("have.attr", "aria-pressed", "false");
cy.get("button.password-input__toggle").click();
cy.get("button.password-input__toggle")
.should("have.attr", "aria-label", HIDE_LABEL)
.and("have.attr", "aria-pressed", "true");
});
it("is a controlled input — it shows the parent's value and reports every change", () => {
const onSpy = cy.stub().as("onSpy");
cy.mount(<Wrapper onSpy={onSpy} />);
cy.get("input#password").type("abc");
cy.get("input#password").should("have.value", "abc");
cy.get("@onSpy").should("have.been.calledWith", "abc");
});
it("does not submit a surrounding form when the eye is clicked — the toggle is a plain button", () => {
const onSubmit = cy.stub().as("onSubmit");
cy.mount(
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
>
<Wrapper />
<button type="submit">Go</button>
</form>,
);
cy.get("button.password-input__toggle").click();
cy.get("@onSubmit").should("not.have.been.called");
});
it("starts hidden again on a fresh mount — visibility is never persisted", () => {
cy.mount(<Wrapper />);
cy.get("button.password-input__toggle").click();
cy.get("input#password").should("have.attr", "type", "text");
// A brand-new mount, as if the visitor navigated back to the page later.
cy.mount(<Wrapper />);
cy.get("input#password").should("have.attr", "type", "password");
});
});

View file

@ -1,111 +0,0 @@
// Mocks the admin API via cy.intercept — no live backend. Covers the
// `/admin/utilisateurs` page: the account list, the create dialog, the
// one-time temporary-password reveal, and the duplicate-email error.
const adminBody = {
id: 1,
email: "ops@example.com",
name: "Ops",
createdAt: "2026-08-01T00:00:00.000Z",
lastLoginAt: "2026-08-28T09:00:00.000Z",
};
function users() {
return [
{
id: 2,
firstName: "Alice",
lastName: "Martin",
email: "alice@example.com",
householdName: "Foyer Martin",
createdAt: "2026-09-09T10:00:00.000Z",
},
{
id: 3,
firstName: "Bob",
lastName: "Durand",
email: "bob@example.com",
householdName: null,
createdAt: "2026-09-08T08:00:00.000Z",
},
];
}
describe("Admin — utilisateurs", () => {
beforeEach(() => {
cy.viewport(1400, 900);
cy.intercept("GET", "**/admin/api/auth/me", { statusCode: 200, body: adminBody });
cy.intercept("GET", "**/admin/api/users", { statusCode: 200, body: users() }).as("list");
});
it("lists every account with its household (or a dash when none)", () => {
cy.visit("/admin/utilisateurs");
cy.wait("@list");
cy.contains(".users-table tr", "alice@example.com").should("contain.text", "Foyer Martin");
cy.contains(".users-table tr", "bob@example.com").should("contain.text", "—");
});
it("creates an account and reveals the one-time temporary password", () => {
cy.intercept("POST", "**/admin/api/users", (req) => {
req.reply({
statusCode: 201,
body: {
user: {
id: 4,
firstName: req.body.firstName,
lastName: req.body.lastName,
email: req.body.email,
householdName: null,
createdAt: "2026-09-10T12:00:00.000Z",
},
temporaryPassword: "Tmp4kR7qWzP2xY9n",
},
});
}).as("create");
cy.visit("/admin/utilisateurs");
cy.wait("@list");
cy.contains("button", "Nouvel utilisateur").click();
cy.get('[data-testid="create-user-form"]').within(() => {
cy.get("#newUserFirstName").type("Chloé");
cy.get("#newUserLastName").type("Petit");
cy.get("#newUserEmail").type("chloe@example.com");
});
cy.contains(".dialog-panel__footer button", "Créer le compte").click();
cy.wait("@create").its("request.body").should("deep.equal", {
firstName: "Chloé",
lastName: "Petit",
email: "chloe@example.com",
});
cy.get('[data-testid="temp-password-reveal"]')
.find("#tempPassword")
.should("have.value", "Tmp4kR7qWzP2xY9n");
});
it("shows the localized error when the email is already in use", () => {
cy.intercept("POST", "**/admin/api/users", {
statusCode: 409,
body: { code: 4001, message: "Email already in use" },
}).as("createDup");
cy.visit("/admin/utilisateurs");
cy.wait("@list");
cy.contains("button", "Nouvel utilisateur").click();
cy.get('[data-testid="create-user-form"]').within(() => {
cy.get("#newUserFirstName").type("Alice");
cy.get("#newUserLastName").type("Martin");
cy.get("#newUserEmail").type("alice@example.com");
});
cy.contains(".dialog-panel__footer button", "Créer le compte").click();
cy.wait("@createDup");
cy.get('[data-testid="create-user-form"]').should("contain.text", "Cet email est déjà utilisé");
// Still on the form — no password reveal.
cy.get('[data-testid="temp-password-reveal"]').should("not.exist");
});
});

View file

@ -1,50 +0,0 @@
// Mocks the API via cy.intercept — this job runs no live backend (see
// .github/workflows/ci.yml). The signup/login *journeys* live in
// auth.feature; this plain layout spec covers only the show/hide password
// toggle (PasswordInput) wired into both auth pages — "a section of an
// existing page" in specs/dev-conventions.md's test taxonomy, so a plain
// `.cy.ts`, not a Gherkin scenario.
describe("Auth forms — password show/hide toggle", () => {
beforeEach(() => {
// Both pages sit behind RedirectIfAuthenticated; a 401 on the session
// check keeps the visitor logged out so the form actually renders.
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
});
for (const { label, path } of [
{ label: "login", path: "/login" },
{ label: "signup", path: "/signup" },
]) {
it(`reveals then re-masks the password field on ${label}`, () => {
cy.visit(path);
// Starts masked, with the toggle offering to reveal it.
cy.get("#password").should("have.attr", "type", "password");
cy.get('[aria-label="Afficher le mot de passe"]')
.should("be.visible")
.and("have.attr", "aria-pressed", "false");
// Reveal.
cy.get('[aria-label="Afficher le mot de passe"]').click();
cy.get("#password").should("have.attr", "type", "text");
cy.get('[aria-label="Masquer le mot de passe"]').should("have.attr", "aria-pressed", "true");
// Re-mask.
cy.get('[aria-label="Masquer le mot de passe"]').click();
cy.get("#password").should("have.attr", "type", "password");
});
}
it("does not submit the login form when the eye is clicked", () => {
cy.intercept("POST", "**/auth/login", cy.spy().as("loginRequest"));
cy.visit("/login");
cy.get("#email").type("alice@example.com");
cy.get("#password").type("correct-horse-battery-staple");
cy.get('[aria-label="Afficher le mot de passe"]').click();
cy.get("#password").should("have.attr", "type", "text");
cy.get("@loginRequest").should("not.have.been.called");
});
});

View file

@ -1,54 +1,41 @@
Feature: Onboarding wizard Feature: Onboarding wizard
As a newly signed-up user As a newly signed-up user
I want to set my preferences (diet + allergens), then my household I want to set my diet, household, and allergens
So that my profile is ready before I start planning meals So that my profile is ready before I start planning meals
# Order since #129: compte -> régime -> allergènes -> foyer -> sources,
# and the sources step only when the foyer step *created* a household
# (joining one or skipping ends the wizard at the home page).
Background: Background:
Given the planning request returns nothing Given the planning request returns nothing
Scenario: Walks through every step, creating a household (which surfaces the sources step), and lands on the home page Scenario: Walks through the wizard, creating a household on the way (which surfaces the sources step), and lands on the home page
Given the diets reference list has options Given the diets reference list has options
And selecting the diet will succeed And selecting the diet will succeed
And the household request returns no household And the household request returns no household
And creating a household will succeed
And the sources reference list is empty
And the allergies reference list has options And the allergies reference list has options
And updating allergies will succeed And updating allergies will succeed
And creating a household will succeed
And the sources reference list has options
And the household's enabled sources are empty
And saving the source selection will succeed
And I have signed up And I have signed up
Then the URL should include "/onboarding/regime" Then the URL should include "/onboarding/regime"
And I should see "Étape 1 sur 3" And I should see "Étape 1 sur 3"
When I select "Végétarien" from the "diet" field When I select "Végétarien" from the "diet" field
And I click the button "Continuer" And I click the button "Continuer"
Then the diet update request should have been made with diet id 2 Then the diet update request should have been made with diet id 2
And the URL should include "/onboarding/allergenes"
And I should see "Étape 2 sur 3"
And I should see the section "Allergies"
And I should see the section "Intolérances"
When I check the checkbox "Arachides"
And I click the button "Continuer"
Then the allergies update request should have been made with allergy id 1
And the URL should include "/onboarding/foyer" And the URL should include "/onboarding/foyer"
And I should see "Étape 3 sur 3" And I should see "Étape 2 sur 3"
When I fill in the "houseName" field with "Chez Alice" When I fill in the "houseName" field with "Chez Alice"
And I click the button "Créer" And I click the button "Créer"
Then the household creation request should have been made with name "Chez Alice" Then the household creation request should have been made with name "Chez Alice"
And the URL should include "/onboarding/sources" And the URL should include "/onboarding/allergenes"
And I should see "Étape 4 sur 4" And I should see "Étape 4 sur 4"
And I should see the section "Sources disponibles" And I should see the section "Allergies"
And I should see "TheMealDB" And I should see the section "Intolérances"
When I check the checkbox "TheMealDB" When I check the checkbox "Arachides"
And I click the button "Terminer" And I click the button "Terminer"
Then the source selection update request should have been made with source id 1 Then the allergies update request should have been made with allergy id 1
And the URL should be the home page And the URL should be the home page
And I should see the heading "Planning de la semaine" And I should see the heading "Planning de la semaine"
Scenario: Lets the preferences steps be skipped without changing anything, then skips the household step too Scenario: Lets the regime and allergens steps be skipped without changing anything
Given the diets reference list is empty Given the diets reference list is empty
And selecting the diet will succeed And selecting the diet will succeed
And the household request returns no household And the household request returns no household
@ -58,32 +45,39 @@ Feature: Onboarding wizard
Then the URL should include "/onboarding/regime" Then the URL should include "/onboarding/regime"
When I click the button "Continuer" When I click the button "Continuer"
Then the diet update request should have been made with no diet id Then the diet update request should have been made with no diet id
And the URL should include "/onboarding/allergenes"
When I click the button "Continuer"
Then the allergies update request should have been made with no allergy ids
And the URL should include "/onboarding/foyer" And the URL should include "/onboarding/foyer"
When I click the button "Passer cette étape" When I click the button "Passer cette étape"
Then the URL should be the home page Then the URL should include "/onboarding/allergenes"
When I click the button "Terminer"
Then the allergies update request should have been made with no allergy ids
And the URL should be the home page
Scenario: Creating a household with no sources implemented ends the wizard through the silently-skipped sources step Scenario: Shows and saves the sources step instead of skipping it, when sources are available
Given the diets reference list is empty Given the diets reference list is empty
And selecting the diet will succeed And selecting the diet will succeed
And the household request returns no household And the household request returns no household
And creating a household will succeed And creating a household will succeed
And the sources reference list is empty And the sources reference list has options
And the household's enabled sources are empty
And saving the source selection will succeed
And the allergies reference list is empty And the allergies reference list is empty
And updating allergies will succeed And updating allergies will succeed
And I have signed up And I have signed up
When I click the button "Continuer" When I click the button "Continuer"
Then the URL should include "/onboarding/allergenes"
When I click the button "Continuer"
Then the URL should include "/onboarding/foyer" Then the URL should include "/onboarding/foyer"
When I fill in the "houseName" field with "Chez Alice" When I fill in the "houseName" field with "Chez Alice"
And I click the button "Créer" And I click the button "Créer"
Then the household creation request should have been made with name "Chez Alice" Then the household creation request should have been made with name "Chez Alice"
And the URL should be the home page And the URL should include "/onboarding/sources"
And I should see "Étape 3 sur 4"
And I should see the section "Sources disponibles"
And I should see "TheMealDB"
When I check the checkbox "TheMealDB"
And I click the button "Continuer"
Then the source selection update request should have been made with source id 1
And the URL should include "/onboarding/allergenes"
Scenario: Joining an existing household skips the sources step entirely and ends the wizard Scenario: Lets the household step be completed by joining an existing household instead of creating one
Given the diets reference list is empty Given the diets reference list is empty
And selecting the diet will succeed And selecting the diet will succeed
And the household request returns no household And the household request returns no household
@ -93,10 +87,8 @@ Feature: Onboarding wizard
And updating allergies will succeed And updating allergies will succeed
And I have signed up And I have signed up
When I click the button "Continuer" When I click the button "Continuer"
Then the URL should include "/onboarding/allergenes"
When I click the button "Continuer"
Then the URL should include "/onboarding/foyer" Then the URL should include "/onboarding/foyer"
When I fill in the "inviteCode" field with "abcd2345" When I fill in the "inviteCode" field with "abcd2345"
And I click the button "Rejoindre" And I click the button "Rejoindre"
Then the household join request should have been made with invite code "ABCD2345" Then the household join request should have been made with invite code "ABCD2345"
And the URL should be the home page And the URL should include "/onboarding/allergenes"

View file

@ -80,19 +80,6 @@ Feature: Browsing external recipe sources
When I click the button "Réessayer" When I click the button "Réessayer"
Then I should see the source item "Beef Wellington" Then I should see the source item "Beef Wellington"
Scenario: Keeps the search term when switching between source tabs
Given the recipe catalog contains nothing
And the sources reference list has options
And the household has enabled TheMealDB and Marmiton
And browsing any enabled source echoes back its query
When I visit "/recettes"
And I click the button "TheMealDB"
And I type "poulet" into the recipe search field
Then "TheMealDB" should have been browsed with the query "poulet"
When I click the button "Marmiton"
Then the recipe search field should contain "poulet"
And "Marmiton" should have been browsed with the query "poulet"
Scenario: Deep-links straight to a not-yet-imported item's own page, with no import affordance at all Scenario: Deep-links straight to a not-yet-imported item's own page, with no import affordance at all
Given the recipe catalog contains nothing Given the recipe catalog contains nothing
And the sources reference list has options And the sources reference list has options

View file

@ -56,60 +56,6 @@ Given("the household has enabled TheMealDB", () => {
cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1] }); cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1] });
}); });
Given("the household has enabled TheMealDB and Marmiton", () => {
cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1, 2] });
});
// Issue #133 — the search field is shared across every source tab. Both
// sources answer `browse` the same way and each request is aliased so a
// scenario can assert which `query` reached which source.
Given("browsing any enabled source echoes back its query", () => {
const reply = (req: { url: string; reply: (r: unknown) => void }) => {
const query = new URL(req.url).searchParams.get("query") ?? "";
req.reply({
statusCode: 200,
body: {
items: [
{
externalId: `${query || "all"}-1`,
title: query ? `Résultat « ${query} »` : "Tout le catalogue",
picture: null,
url: "https://example.test/1",
alreadyImported: false,
recipeId: null,
},
],
nextCursor: null,
},
});
};
cy.intercept("GET", "**/sources/theMealDb/browse*", reply).as("browseTheMealDb");
cy.intercept("GET", "**/sources/marmiton/browse*", reply).as("browseMarmiton");
});
When("I type {string} into the recipe search field", (text: string) => {
cy.get(".recipes-page__search").clear().type(text);
});
Then("the recipe search field should contain {string}", (text: string) => {
cy.get(".recipes-page__search").should("have.value", text);
});
Then(
"{string} should have been browsed with the query {string}",
(source: string, query: string) => {
const alias = source === "Marmiton" ? "@browseMarmiton.all" : "@browseTheMealDb.all";
// `.all` + a retried `.should` — an empty-query browse fires first (tab
// opened before anything was typed) and React StrictMode double-invokes
// the fetch effect in the dev server, so assert "one of the requests
// carried the term" rather than waiting on a specific occurrence.
cy.get(alias).should((calls: { request: { url: string } }[]) => {
const hit = calls.some((c) => c.request.url.includes(`query=${encodeURIComponent(query)}`));
expect(hit, `a ${source} browse used query="${query}"`).to.equal(true);
});
},
);
Given("browsing TheMealDB returns some items", () => { Given("browsing TheMealDB returns some items", () => {
cy.intercept("GET", "**/sources/theMealDb/browse*", { cy.intercept("GET", "**/sources/theMealDb/browse*", {
statusCode: 200, statusCode: 200,

View file

@ -32,10 +32,10 @@ Given("the allergies reference list is empty", () => {
cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] }); cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] });
}); });
// An onboarding scenario whose household step *creates* a household then // Every onboarding scenario that reaches the household step also reaches
// reaches `/onboarding/sources` (see `OnboardingHouseholdPage`'s // `/onboarding/sources` right after (when a household got created/joined —
// `goToNextStep`), which reads this before self-skipping straight to the // see `OnboardingHouseholdPage`'s `goToNextStep`), which reads this before
// home page if it comes back empty. // self-skipping to `/onboarding/allergenes` if it comes back empty.
Given("the sources reference list is empty", () => { Given("the sources reference list is empty", () => {
cy.intercept("GET", "**/reference/sources", { statusCode: 200, body: [] }); cy.intercept("GET", "**/reference/sources", { statusCode: 200, body: [] });
}); });

View file

@ -1,6 +1,6 @@
{ {
"name": "web", "name": "web",
"version": "0.2.3", "version": "0.2.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View file

@ -14,7 +14,6 @@ import { AdminLoginPage } from "./pages/admin/login/AdminLoginPage";
import { MessagesPage as AdminMessagesPage } from "./pages/admin/messages/MessagesPage"; import { MessagesPage as AdminMessagesPage } from "./pages/admin/messages/MessagesPage";
import { MonitoringPage as AdminMonitoringPage } from "./pages/admin/monitoring/MonitoringPage"; import { MonitoringPage as AdminMonitoringPage } from "./pages/admin/monitoring/MonitoringPage";
import { ToursPage as AdminToursPage } from "./pages/admin/tours/ToursPage"; import { ToursPage as AdminToursPage } from "./pages/admin/tours/ToursPage";
import { UsersPage as AdminUsersPage } from "./pages/admin/users/UsersPage";
import { LoginPage } from "./pages/auth/LoginPage"; import { LoginPage } from "./pages/auth/LoginPage";
import { SignupPage } from "./pages/auth/SignupPage"; import { SignupPage } from "./pages/auth/SignupPage";
import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage"; import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage";
@ -52,16 +51,14 @@ import { ShoppingListPage } from "./pages/shopping-list/ShoppingListPage";
* just redirects to `/parametres/foyer` so an existing bookmark/link keeps * just redirects to `/parametres/foyer` so an existing bookmark/link keeps
* working. * working.
* *
* `/onboarding/*` (regime/allergenes/foyer/sources, in that order) is also * `/onboarding/*` (regime/foyer/sources/allergens, in that order) is also
* `RequireAuth`-gated — reached right after signup, once a session already * `RequireAuth`-gated — reached right after signup, once a session already
* exists — but deliberately its own top-level route group, *not* nested * exists — but deliberately its own top-level route group, *not* nested
* under `AppLayout`: a focused, distraction-free wizard with no sidebar, * under `AppLayout`: a focused, distraction-free wizard with no sidebar,
* same full-page-card language as `/login`/`/signup` (see `onboarding.scss`). * same full-page-card language as `/login`/`/signup` (see `onboarding.scss`).
* `regime` + `allergenes` are the two "préférences" screens; `/onboarding/sources` * `/onboarding/sources` is conditional — only reached when the `foyer` step
* is conditional — only reached when the `foyer` step *created* a household * created/joined a household (see `OnboardingHouseholdPage`'s `goToNextStep`);
* (see `OnboardingHouseholdPage`'s `goToNextStep`), every other path ending * skipped otherwise, straight to `/onboarding/allergenes`.
* the wizard straight to `/`. Step order and the "sur 3" / "4 sur 4"
* indicators live in `pages/onboarding/onboarding-steps.ts`.
* *
* `/telecharger` is the mobile-app-redirect chantier's fallback landing spot * `/telecharger` is the mobile-app-redirect chantier's fallback landing spot
* (see `lib/mobile-app-redirect.ts`'s own doc comment) — public like * (see `lib/mobile-app-redirect.ts`'s own doc comment) — public like
@ -141,14 +138,6 @@ export function App() {
</RequireAuth> </RequireAuth>
} }
/> />
<Route
path="/onboarding/allergenes"
element={
<RequireAuth>
<OnboardingAllergensPage />
</RequireAuth>
}
/>
<Route <Route
path="/onboarding/foyer" path="/onboarding/foyer"
element={ element={
@ -165,6 +154,14 @@ export function App() {
</RequireAuth> </RequireAuth>
} }
/> />
<Route
path="/onboarding/allergenes"
element={
<RequireAuth>
<OnboardingAllergensPage />
</RequireAuth>
}
/>
<Route <Route
path="/login" path="/login"
element={ element={
@ -199,7 +196,6 @@ export function App() {
} }
> >
<Route index element={<AdminDashboardPage />} /> <Route index element={<AdminDashboardPage />} />
<Route path="utilisateurs" element={<AdminUsersPage />} />
<Route path="monitoring" element={<AdminMonitoringPage />} /> <Route path="monitoring" element={<AdminMonitoringPage />} />
<Route path="corrections" element={<AdminCorrectionsPage />} /> <Route path="corrections" element={<AdminCorrectionsPage />} />
<Route path="catalogue" element={<AdminCatalogPage />} /> <Route path="catalogue" element={<AdminCatalogPage />} />

View file

@ -1,9 +1,6 @@
import { import {
type AdminCreateUserInput,
type AdminLoginInput, type AdminLoginInput,
type AdminTourScenarioView, type AdminTourScenarioView,
type AdminUserCreatedView,
type AdminUserListItemView,
type AdminUserView, type AdminUserView,
type ApiErrorResponse, type ApiErrorResponse,
type CatalogPlaceholderGroupView, type CatalogPlaceholderGroupView,
@ -134,20 +131,6 @@ export class AdminApiClient {
return this._request(`/metrics?days=${days}`); return this._request(`/metrics?days=${days}`);
} }
/** End-user accounts for the `/admin/utilisateurs` list — newest first, capped server-side at 200. */
public getUsers(): Promise<AdminUserListItemView[]> {
return this._request("/users");
}
/**
* Provisions an end-user account by hand. Returns the created account plus
* the one-time temporary password the server generated — show it once,
* it's never retrievable again. Rejects with `EMAIL_ALREADY_IN_USE`.
*/
public createUser(input: AdminCreateUserInput): Promise<AdminUserCreatedView> {
return this._request("/users", { method: "POST", body: JSON.stringify(input) });
}
/** Training suggestions, grouped by technique, filtered by the given (all-optional) criteria. */ /** Training suggestions, grouped by technique, filtered by the given (all-optional) criteria. */
public getSuggestions(filters: { public getSuggestions(filters: {
status?: string; status?: string;

View file

@ -1,78 +0,0 @@
import { Eye, EyeOff } from "lucide-react";
import { type ChangeEvent, useState } from "react";
import { useTranslation } from "react-i18next";
import "./password-input.scss";
/**
* A password `<input>` paired with a show/hide "eye" toggle — the button
* swaps the field's `type` between `"password"` and `"text"` so a visitor
* can re-read what they typed before submitting. A bare password field
* gives no feedback at all, and a mistyped-but-invisible password is one
* of the most common causes of a failed login; this makes it checkable
* without a password manager.
*
* Lives in `components/ui/` rather than `features/auth/` because nothing
* about it is auth-specific — it is just a password field. Used today by
* {@link LoginPage} and {@link SignupPage}.
*
* Visibility is local state that always starts hidden on mount and is
* never persisted: revealing a password is a deliberate, momentary action,
* not a preference worth remembering across page loads or sessions.
*
* The field's own look (border, padding, background) is inherited from the
* surrounding context — the same arrangement as {@link CheckboxOption},
* which is styled through `global.scss` rather than carrying its own CSS.
* The colocated `password-input.scss` only positions the toggle inside the
* field and reserves horizontal room for it.
*/
export function PasswordInput({
id,
value,
onChange,
autoComplete,
}: {
/** Forwarded to the `<input>` so an external `<label htmlFor>` keeps targeting it. */
id: string;
/** Controlled value — this component holds no text state of its own. */
value: string;
/** Called with the new text on each change (event unwrapped, like {@link CheckboxOption}). */
onChange: (value: string) => void;
/**
* Password-manager hint, forwarded verbatim — callers pass
* `"current-password"` on a login form, `"new-password"` on signup.
*/
autoComplete?: string;
}) {
const { t } = useTranslation();
// Always hidden on mount — see the component doc comment on why this is
// never persisted.
const [visible, setVisible] = useState(false);
// The label always names the action the *next* click performs; the
// pressed state is carried separately by `aria-pressed` below.
const toggleLabel = visible ? t("common.passwordInput.hide") : t("common.passwordInput.show");
return (
<span className="password-input">
<input
id={id}
type={visible ? "text" : "password"}
value={value}
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
autoComplete={autoComplete}
/>
<button
type="button"
className="password-input__toggle"
// `type="button"` (not the implicit `"submit"`) so toggling
// visibility never submits the form the field sits in.
aria-pressed={visible}
aria-label={toggleLabel}
title={toggleLabel}
onClick={() => setVisible((v) => !v)}
>
{visible ? <EyeOff aria-hidden="true" /> : <Eye aria-hidden="true" />}
</button>
</span>
);
}

View file

@ -1,58 +0,0 @@
// =============================================================================
// PasswordInput (components/ui/PasswordInput.tsx) — positions the show/hide
// "eye" toggle inside the password field and keeps typed text clear of it.
//
// The field's own look (border, padding, colours) is inherited from the
// surrounding context — e.g. `.auth-card input` in features/auth/auth-form.scss
// — exactly as CheckboxOption leans on global.scss. This sheet owns only the
// wrapper, the toggle button, and the space the icon needs.
// =============================================================================
// No `@use` of the theme partial: every token below (--color-*, --space-*) is a
// CSS custom property declared on :root in styles/global.scss, available at
// runtime without a compile-time import.
.password-input {
// Positioning context for the absolutely-placed toggle. `display: flex` lets
// the inner <input> fill the row on its own (it replaces a bare <input> that
// used to be a direct flex child of the form column).
position: relative;
display: flex;
input {
flex: 1;
min-width: 0;
// Keep the caret and text from sliding under the icon: field padding on the
// left + the ~20px glyph + matching breathing room on the right.
padding-right: calc(var(--space-sm) + 20px + var(--space-sm));
}
}
// The eye button: sits flush inside the field's right edge, full height so its
// hit area matches the input, no surface of its own so it reads as part of the
// field rather than a second control.
.password-input__toggle {
position: absolute;
top: 0;
right: 0;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 0 var(--space-sm);
border: none;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
&:hover {
color: var(--color-text);
}
// lucide-react renders a 24×24 <svg> by default — bring it closer to the
// field's text size.
svg {
width: 20px;
height: 20px;
}
}

View file

@ -61,11 +61,7 @@
} }
// Submit button: full-width, visually separated from the fields above it. // Submit button: full-width, visually separated from the fields above it.
// Scoped to `[type="submit"]` specifically — the card also contains the button {
// PasswordInput's `type="button"` eye toggle, which must NOT inherit this
// full-width primary-coloured treatment (it styles itself in
// components/ui/password-input.scss).
button[type="submit"] {
margin-top: var(--space-md); margin-top: var(--space-md);
padding: 0.6rem; padding: 0.6rem;
font-family: var(--font-body); font-family: var(--font-body);

View file

@ -451,9 +451,7 @@ export function RecipePickerDialog({
</> </>
} }
> >
{/* The search field shows on every tab — real ones and each source's {activeSourceKey === null && (
(issue #133), matching `RecipesPage`; the ingredient/diet/household
filters below it only apply to the saved-recipe tabs. */}
<div className="recipe-picker__filters"> <div className="recipe-picker__filters">
<input <input
type="search" type="search"
@ -463,8 +461,6 @@ export function RecipePickerDialog({
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
{activeSourceKey === null && (
<>
<div className="recipe-picker__ingredient-filter"> <div className="recipe-picker__ingredient-filter">
<span className="recipe-picker__filter-label"> <span className="recipe-picker__filter-label">
{t("planning.picker.ingredientsFilterLabel")} {t("planning.picker.ingredientsFilterLabel")}
@ -517,9 +513,8 @@ export function RecipePickerDialog({
{t("planning.picker.suitableForHouseholdLabel")} {t("planning.picker.suitableForHouseholdLabel")}
</CheckboxOption> </CheckboxOption>
)} )}
</>
)}
</div> </div>
)}
<RecipeTabs <RecipeTabs
active={activeTab} active={activeTab}
@ -531,7 +526,6 @@ export function RecipePickerDialog({
<RecipeSourcesPanel <RecipeSourcesPanel
key={activeSourceKey} key={activeSourceKey}
sourceKey={activeSourceKey} sourceKey={activeSourceKey}
debouncedSearch={debouncedSearch}
onSelectImportedRecipe={handleSelectImportedRecipe} onSelectImportedRecipe={handleSelectImportedRecipe}
onDraftSelected={handleDraftSelected} onDraftSelected={handleDraftSelected}
/> />

View file

@ -360,10 +360,13 @@
} }
// --- Sources tab (RecipeSourcesPanel) --------------------------------------- // --- Sources tab (RecipeSourcesPanel) ---------------------------------------
// Reuses .recipes-page__catalog and .recipe-table(-wrap) as-is (see // Reuses .recipes-page__header/__search/__catalog and .recipe-table(-wrap)
// RecipeSourcesPanel.tsx/SourceItemTable.tsx); the search field is the // as-is (see RecipeSourcesPanel.tsx/SourceItemTable.tsx) — only what's
// page header's own now (issue #133) — only what's actually new to this // actually new to this tab gets its own rules here.
// tab gets its own rules here.
.recipes-page__header--sources {
gap: var(--space-sm);
}
.source-item-table__imported-badge { .source-item-table__imported-badge {
padding: 0.1rem 0.5rem; padding: 0.1rem 0.5rem;

View file

@ -6,6 +6,9 @@ import { RecipeDetailPanel, type RecipeDetailState } from "../RecipeDetailPanel"
import { SourceItemTable } from "./SourceItemTable"; import { SourceItemTable } from "./SourceItemTable";
import "../recipes.scss"; import "../recipes.scss";
/** Debounce for the search field — same idea/value as `RecipesPage`'s own search. */
const SEARCH_DEBOUNCE_MS = 300;
/** How many pulsing skeleton rows `handleLoadMore` shows while its fetch is in flight — see `loadMoreStatus`. Not tied to any source's real page size (that varies per source, and isn't known client-side); just enough to visibly fill the gap below the list without over-promising. */ /** How many pulsing skeleton rows `handleLoadMore` shows while its fetch is in flight — see `loadMoreStatus`. Not tied to any source's real page size (that varies per source, and isn't known client-side); just enough to visibly fill the gap below the list without over-promising. */
const LOAD_MORE_PLACEHOLDER_COUNT = 4; const LOAD_MORE_PLACEHOLDER_COUNT = 4;
@ -61,25 +64,15 @@ type BrowseState =
* upward, and re-previews on mount/prop-change if handed one back. * upward, and re-previews on mount/prop-change if handed one back.
* `RecipePickerDialog` leaves both unset: previewing inside that dialog * `RecipePickerDialog` leaves both unset: previewing inside that dialog
* has no URL of its own to keep in sync. * has no URL of its own to keep in sync.
*
* `debouncedSearch` is owned by the caller (issue #133): one search field
* in `RecipesPage`'s header serves every tab — real ones and each
* source's — so the query survives switching between sources (this panel
* still remounts on `sourceKey` via its `key`, but the term lives above
* that). The debounce happens in the caller too; this panel just refetches
* whenever the prop changes.
*/ */
export function RecipeSourcesPanel({ export function RecipeSourcesPanel({
sourceKey, sourceKey,
debouncedSearch,
onSelectImportedRecipe, onSelectImportedRecipe,
onDraftSelected, onDraftSelected,
initialSelection, initialSelection,
onItemSelected, onItemSelected,
}: { }: {
sourceKey: string; sourceKey: string;
/** Already-debounced search term from `RecipesPage`'s shared field — drives `browseSource`'s `query`. */
debouncedSearch: string;
/** Fired once an already-imported row's own fetch resolves — the full recipe, already what this panel is itself previewing, handed up so the caller (`RecipePickerDialog`) knows a real recipe is now the pending selection without fetching it again itself. */ /** Fired once an already-imported row's own fetch resolves — the full recipe, already what this panel is itself previewing, handed up so the caller (`RecipePickerDialog`) knows a real recipe is now the pending selection without fetching it again itself. */
onSelectImportedRecipe: (recipe: RecipeView) => void; onSelectImportedRecipe: (recipe: RecipeView) => void;
/** Fired the moment a not-yet-imported row is clicked (before its own preview fetch even resolves) — same "which one is pending" role as `onSelectImportedRecipe`, just for a draft instead of a real recipe. Only `RecipePickerDialog` sets this; `RecipesPage` has nothing to do with "pending" since browsing there is never building up to a confirm step. */ /** Fired the moment a not-yet-imported row is clicked (before its own preview fetch even resolves) — same "which one is pending" role as `onSelectImportedRecipe`, just for a draft instead of a real recipe. Only `RecipePickerDialog` sets this; `RecipesPage` has nothing to do with "pending" since browsing there is never building up to a confirm step. */
@ -89,6 +82,8 @@ export function RecipeSourcesPanel({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" }); const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" });
const [loadMoreStatus, setLoadMoreStatus] = useState<"idle" | "loading" | "error">("idle"); const [loadMoreStatus, setLoadMoreStatus] = useState<"idle" | "loading" | "error">("idle");
// The *next* page, fetched ahead of time as soon as the current one is on // The *next* page, fetched ahead of time as soon as the current one is on
@ -157,6 +152,11 @@ export function RecipeSourcesPanel({
}; };
}, [initialSelection?.externalId]); }, [initialSelection?.externalId]);
useEffect(() => {
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(timeout);
}, [search]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setBrowseState({ status: "loading" }); setBrowseState({ status: "loading" });
@ -294,8 +294,17 @@ export function RecipeSourcesPanel({
} }
return ( return (
// The search field lives in `RecipesPage`'s header now (issue #133) — <>
// this panel is just the source's list + preview. <div className="recipes-page__header recipes-page__header--sources">
<input
type="search"
className="recipes-page__search"
placeholder={t("recipes.sources.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="recipes-page__catalog"> <div className="recipes-page__catalog">
{browseState.status === "loading" && ( {browseState.status === "loading" && (
<p className="recipes-page__status">{t("recipes.sources.loading")}</p> <p className="recipes-page__status">{t("recipes.sources.loading")}</p>
@ -331,5 +340,6 @@ export function RecipeSourcesPanel({
<RecipeDetailPanel state={previewState} showActions={false} /> <RecipeDetailPanel state={previewState} showActions={false} />
</div> </div>
</>
); );
} }

View file

@ -5,7 +5,6 @@ import {
LayoutDashboard, LayoutDashboard,
ListChecks, ListChecks,
PackageSearch, PackageSearch,
Users,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -21,7 +20,6 @@ import "./AdminLayout.scss";
*/ */
const NAV_ITEMS = [ const NAV_ITEMS = [
{ to: "/admin", key: "dashboard", Icon: LayoutDashboard, end: true }, { to: "/admin", key: "dashboard", Icon: LayoutDashboard, end: true },
{ to: "/admin/utilisateurs", key: "users", Icon: Users, end: false },
{ to: "/admin/monitoring", key: "monitoring", Icon: Activity, end: false }, { to: "/admin/monitoring", key: "monitoring", Icon: Activity, end: false },
{ to: "/admin/corrections", key: "corrections", Icon: ListChecks, end: false }, { to: "/admin/corrections", key: "corrections", Icon: ListChecks, end: false },
{ to: "/admin/catalogue", key: "catalog", Icon: PackageSearch, end: false }, { to: "/admin/catalogue", key: "catalog", Icon: PackageSearch, end: false },

View file

@ -27,10 +27,6 @@
"vendredi": "Vendredi", "vendredi": "Vendredi",
"samedi": "Samedi", "samedi": "Samedi",
"dimanche": "Dimanche" "dimanche": "Dimanche"
},
"passwordInput": {
"show": "Afficher le mot de passe",
"hide": "Masquer le mot de passe"
} }
}, },
"errors": { "errors": {
@ -259,6 +255,7 @@
"diets": "Régime associé" "diets": "Régime associé"
}, },
"sources": { "sources": {
"searchPlaceholder": "Rechercher…",
"empty": "Aucune recette trouvée.", "empty": "Aucune recette trouvée.",
"alreadyImported": "Déjà importée", "alreadyImported": "Déjà importée",
"loading": "Chargement…", "loading": "Chargement…",
@ -1234,7 +1231,6 @@
}, },
"nav": { "nav": {
"dashboard": "Tableau de bord", "dashboard": "Tableau de bord",
"users": "Utilisateurs",
"monitoring": "Monitoring", "monitoring": "Monitoring",
"corrections": "Corrections", "corrections": "Corrections",
"catalog": "Catalogue", "catalog": "Catalogue",
@ -1244,37 +1240,6 @@
"layout": { "layout": {
"logout": "Se déconnecter" "logout": "Se déconnecter"
}, },
"users": {
"title": "Utilisateurs",
"lead": "Comptes utilisateurs de l'application.",
"newButton": "Nouvel utilisateur",
"empty": "Aucun compte pour le moment.",
"noHousehold": "—",
"col": {
"date": "Créé le",
"name": "Nom",
"email": "Email",
"household": "Foyer"
},
"form": {
"title": "Créer un compte",
"firstName": "Prénom",
"lastName": "Nom",
"email": "Email",
"submit": "Créer le compte",
"submitting": "Création…",
"cancel": "Annuler"
},
"created": {
"title": "Compte créé",
"intro": "Transmettez ce mot de passe temporaire à",
"passwordLabel": "Mot de passe temporaire",
"copy": "Copier",
"copied": "Copié",
"warning": "Notez-le maintenant : il ne sera plus affiché.",
"done": "Terminé"
}
},
"dashboard": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
"lead": "Métriques d'utilisation de l'application.", "lead": "Métriques d'utilisation de l'application.",

View file

@ -1,269 +0,0 @@
import {
type AdminUserCreatedView,
type AdminUserListItemView,
adminCreateUserSchema,
ErrorCode,
} from "@batch-cooking/shared";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { AdminApiError, adminApiClient } from "../../../api/admin-client";
import { Dialog } from "../../../components/ui/Dialog";
import { fieldErrorsFrom } from "../../../lib/zod-errors";
import { errorMessageService } from "../../../services/error-message.service";
import "../admin-page.scss";
import "./users-page.scss";
import { copyToClipboard, formatCreatedAt, fullName } from "./users";
/** Load state for `GET /admin/api/users` — same discriminated-union shape as the other admin pages. */
type UsersState =
| { status: "loading" }
| { status: "loaded"; rows: AdminUserListItemView[] }
| { status: "error" };
/**
* Admin user management (`/admin/utilisateurs`). A read-only table of every
* end-user account plus a "create an account by hand" dialog: the operator
* fills in name + email, the server generates a one-time temporary password
* and hands it back for the operator to relay. No edit/disable/delete —
* deleting an account stays a deliberate self-service action.
*/
export function UsersPage() {
const { t } = useTranslation();
const [state, setState] = useState<UsersState>({ status: "loading" });
const [isCreating, setIsCreating] = useState(false);
const requestSeq = useRef(0);
const load = useCallback(() => {
const seq = ++requestSeq.current;
setState({ status: "loading" });
adminApiClient
.getUsers()
.then((rows) => {
if (seq === requestSeq.current) setState({ status: "loaded", rows });
})
.catch(() => {
if (seq === requestSeq.current) setState({ status: "error" });
});
}, []);
useEffect(() => {
load();
}, [load]);
return (
<div className="admin-page users-page">
<header className="users-page__header">
<div>
<h1 className="admin-page__title">{t("admin.users.title")}</h1>
<p className="admin-page__lead">{t("admin.users.lead")}</p>
</div>
<button type="button" className="users-page__new" onClick={() => setIsCreating(true)}>
{t("admin.users.newButton")}
</button>
</header>
{state.status === "loading" && (
<p className="admin-page__placeholder">{t("admin.common.loading")}</p>
)}
{state.status === "error" && (
<p className="admin-page__placeholder">{t("admin.common.loadError")}</p>
)}
{state.status === "loaded" && state.rows.length === 0 && (
<p className="admin-page__placeholder">{t("admin.users.empty")}</p>
)}
{state.status === "loaded" && state.rows.length > 0 && (
<div className="users-table-wrap">
<table className="users-table">
<thead>
<tr>
<th>{t("admin.users.col.date")}</th>
<th>{t("admin.users.col.name")}</th>
<th>{t("admin.users.col.email")}</th>
<th>{t("admin.users.col.household")}</th>
</tr>
</thead>
<tbody>
{state.rows.map((row) => (
<tr key={row.id}>
<td className="users-table__date">{formatCreatedAt(row.createdAt)}</td>
<td>{fullName(row)}</td>
<td className="users-table__email">{row.email}</td>
<td>{row.householdName ?? t("admin.users.noHousehold")}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{isCreating && (
<CreateUserDialog
onClose={(created) => {
setIsCreating(false);
if (created) load();
}}
/>
)}
</div>
);
}
/**
* Two-step create dialog: the form, then — on success — a one-time reveal of
* the generated temporary password. `onClose(created)` tells the parent
* whether to refetch the list (`true` once an account was created, even if
* the operator then closes from the reveal step).
*/
function CreateUserDialog({ onClose }: { onClose: (created: boolean) => void }) {
const { t } = useTranslation();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [result, setResult] = useState<AdminUserCreatedView | null>(null);
async function submit() {
setFormError(null);
const parsed = adminCreateUserSchema.safeParse({ firstName, lastName, email });
if (!parsed.success) {
setFieldErrors(fieldErrorsFrom(parsed.error));
return;
}
setFieldErrors({});
setIsSubmitting(true);
try {
setResult(await adminApiClient.createUser(parsed.data));
} catch (err) {
setFormError(
errorMessageService.getLabel(
err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR,
),
);
setIsSubmitting(false);
}
}
if (result) {
return <PasswordRevealDialog result={result} onDone={() => onClose(true)} />;
}
return (
<Dialog
title={t("admin.users.form.title")}
onClose={() => onClose(false)}
className="user-create-dialog"
footer={
<>
<button type="button" onClick={() => onClose(false)} disabled={isSubmitting}>
{t("admin.users.form.cancel")}
</button>
<button
type="button"
className="user-create-dialog__submit"
onClick={() => void submit()}
disabled={isSubmitting}
>
{isSubmitting ? t("admin.users.form.submitting") : t("admin.users.form.submit")}
</button>
</>
}
>
<form
data-testid="create-user-form"
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
>
<label htmlFor="newUserFirstName">{t("admin.users.form.firstName")}</label>
<input
id="newUserFirstName"
type="text"
value={firstName}
maxLength={100}
autoComplete="off"
onChange={(event) => setFirstName(event.target.value)}
/>
{fieldErrors.firstName && <p className="field-error">{fieldErrors.firstName}</p>}
<label htmlFor="newUserLastName">{t("admin.users.form.lastName")}</label>
<input
id="newUserLastName"
type="text"
value={lastName}
maxLength={100}
autoComplete="off"
onChange={(event) => setLastName(event.target.value)}
/>
{fieldErrors.lastName && <p className="field-error">{fieldErrors.lastName}</p>}
<label htmlFor="newUserEmail">{t("admin.users.form.email")}</label>
<input
id="newUserEmail"
type="email"
value={email}
autoComplete="off"
onChange={(event) => setEmail(event.target.value)}
/>
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
{formError && <p className="form-error">{formError}</p>}
{/* Submit on Enter without showing a second button — the footer's is the visible one. */}
<button type="submit" hidden />
</form>
</Dialog>
);
}
/** The one-time temporary-password reveal shown after a successful create. */
function PasswordRevealDialog({
result,
onDone,
}: {
result: AdminUserCreatedView;
onDone: () => void;
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
return (
<Dialog
title={t("admin.users.created.title")}
onClose={onDone}
className="user-create-dialog"
footer={
<button type="button" className="user-create-dialog__submit" onClick={onDone}>
{t("admin.users.created.done")}
</button>
}
>
<div data-testid="temp-password-reveal">
<p>
{t("admin.users.created.intro")} <strong>{fullName(result.user)}</strong> (
{result.user.email})
</p>
<label htmlFor="tempPassword">{t("admin.users.created.passwordLabel")}</label>
<div className="user-create-dialog__password-row">
<input
id="tempPassword"
type="text"
readOnly
value={result.temporaryPassword}
onFocus={(event) => event.target.select()}
/>
<button
type="button"
onClick={() => {
void copyToClipboard(result.temporaryPassword).then(setCopied);
}}
>
{copied ? t("admin.users.created.copied") : t("admin.users.created.copy")}
</button>
</div>
<p className="user-create-dialog__warning">{t("admin.users.created.warning")}</p>
</div>
</Dialog>
);
}

View file

@ -1,148 +0,0 @@
// =============================================================================
// UsersPage — the admin user-account list + "create an account" dialog.
// Shares the table language of messages-page.scss / corrections-page.scss;
// its own file since nothing outside this page uses these classes.
// =============================================================================
.users-page__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
flex-wrap: wrap;
}
// "Nouvel utilisateur" — the page's single primary action.
.users-page__new {
flex-shrink: 0;
padding: var(--space-sm) var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
color: #fff;
background: var(--color-primary);
border: 1px solid var(--color-primary);
border-radius: var(--radius-base);
cursor: pointer;
&:hover {
opacity: 0.9;
}
}
.users-table-wrap {
overflow-x: auto;
}
.users-table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
th,
td {
padding: var(--space-xs) var(--space-sm);
border-bottom: 1px solid var(--color-border);
text-align: left;
vertical-align: top;
}
th {
color: var(--color-text-muted);
font-weight: 600;
}
&__date {
white-space: nowrap;
}
&__email {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
}
// Create-user modal — the `Dialog` primitive supplies the panel/scroll/close;
// this only sizes the fields, the footer buttons and the password reveal.
.user-create-dialog {
&.dialog-panel {
max-width: 32rem;
width: 100%;
}
label {
display: block;
margin: var(--space-sm) 0 var(--space-xs);
font-size: var(--font-size-sm);
font-weight: 600;
}
input {
width: 100%;
padding: var(--space-sm);
font-family: var(--font-body);
font-size: var(--font-size-sm);
color: var(--color-text);
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
}
.field-error {
margin: var(--space-xs) 0 0;
font-size: var(--font-size-xs);
color: var(--color-error);
}
.form-error {
margin: var(--space-sm) 0 0;
font-size: var(--font-size-sm);
color: var(--color-error);
}
&__submit {
color: #fff;
background: var(--color-primary);
border-color: var(--color-primary);
&:hover:not(:disabled) {
opacity: 0.9;
}
}
// Read-only temp password + its "Copier" button, side by side.
&__password-row {
display: flex;
gap: var(--space-sm);
input {
font-family: var(--font-mono);
letter-spacing: 0.03em;
}
button {
flex-shrink: 0;
padding: var(--space-sm) var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
cursor: pointer;
&:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
}
}
&__warning {
margin: var(--space-sm) 0 0;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
font-style: italic;
}
}

View file

@ -1,32 +0,0 @@
import type { AdminUserListItemView } from "@batch-cooking/shared";
/**
* Pure helpers for `UsersPage` — kept out of the `.tsx` (repo convention: no
* derivation/formatting logic in a component file) so they're trivially
* testable and the component stays markup + wiring only.
*/
/** Full name of an account, for the list's "Nom" column. */
export function fullName(user: Pick<AdminUserListItemView, "firstName" | "lastName">): string {
return `${user.firstName} ${user.lastName}`.trim();
}
/** `createdAt` (ISO 8601) rendered for the "Créé le" column, French locale. */
export function formatCreatedAt(isoDate: string): string {
return new Date(isoDate).toLocaleString("fr-FR");
}
/**
* Copies `text` to the clipboard, resolving `true` on success and `false`
* when the browser blocks it (insecure context, permission denied, no
* `navigator.clipboard`). Never throws — the caller just shows/withholds a
* "Copié" confirmation.
*/
export async function copyToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}

View file

@ -3,7 +3,6 @@ import { type FormEvent, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { ApiError } from "../../api/client"; import { ApiError } from "../../api/client";
import { PasswordInput } from "../../components/ui/PasswordInput";
import { useAuth } from "../../features/auth/AuthContext"; import { useAuth } from "../../features/auth/AuthContext";
// Shared with SignupPage — see the file for why it's colocated in // Shared with SignupPage — see the file for why it's colocated in
// features/auth/ rather than duplicated per page. // features/auth/ rather than duplicated per page.
@ -79,10 +78,11 @@ export function LoginPage() {
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>} {fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
<label htmlFor="password">{t("auth.login.passwordLabel")}</label> <label htmlFor="password">{t("auth.login.passwordLabel")}</label>
<PasswordInput <input
id="password" id="password"
type="password"
value={password} value={password}
onChange={setPassword} onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password" autoComplete="current-password"
/> />
{fieldErrors.password && <p className="field-error">{fieldErrors.password}</p>} {fieldErrors.password && <p className="field-error">{fieldErrors.password}</p>}

View file

@ -3,7 +3,6 @@ import { type FormEvent, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { ApiError } from "../../api/client"; import { ApiError } from "../../api/client";
import { PasswordInput } from "../../components/ui/PasswordInput";
import { useAuth } from "../../features/auth/AuthContext"; import { useAuth } from "../../features/auth/AuthContext";
// Shared with LoginPage — see the file for why it's colocated in // Shared with LoginPage — see the file for why it's colocated in
// features/auth/ rather than duplicated per page. // features/auth/ rather than duplicated per page.
@ -38,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 régime → allergènes → foyer 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);
@ -99,10 +98,11 @@ export function SignupPage() {
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>} {fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
<label htmlFor="password">{t("auth.signup.passwordLabel")}</label> <label htmlFor="password">{t("auth.signup.passwordLabel")}</label>
<PasswordInput <input
id="password" id="password"
type="password"
value={password} value={password}
onChange={setPassword} onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password" autoComplete="new-password"
/> />
{fieldErrors.password && <p className="field-error">{fieldErrors.password}</p>} {fieldErrors.password && <p className="field-error">{fieldErrors.password}</p>}

View file

@ -6,23 +6,22 @@ import { useNavigate } from "react-router-dom";
import { ApiError, apiClient } from "../../api/client"; import { ApiError, apiClient } from "../../api/client";
import { AllergySelect } from "../../features/profile/AllergySelect"; import { AllergySelect } from "../../features/profile/AllergySelect";
import { errorMessageService } from "../../services/error-message.service"; import { errorMessageService } from "../../services/error-message.service";
import { onboardingStepIndicator } from "./onboarding-steps";
import "./onboarding.scss"; import "./onboarding.scss";
/** /**
* Second step of the post-signup onboarding wizard, routed at * Last step of the post-signup onboarding wizard, routed at
* `/onboarding/allergenes` — the allergens half of "préférences" (régime * `/onboarding/allergenes`. Starts from an empty selection — a freshly
* is the first half), now ahead of the household step rather than the * signed-up profile has none yet, so there's no need for the extra
* wizard's final screen (see #129). Starts from an empty selection — a
* freshly signed-up profile has none yet, so there's no need for the extra
* `GET /profile/allergies` round trip a "resume where I left off" flow * `GET /profile/allergies` round trip a "resume where I left off" flow
* would require (the `/parametres/preferences` settings page is the * would require (the `/parametres/preferences` settings page is the
* always-fetch source of truth for editing an existing selection later). * always-fetch source of truth for editing an existing selection later).
* *
* No household fetch anymore: this step no longer needs to retroactively * Also fetches the current household on mount, only to know whether the
* work out whether a sources step ran, since it now comes before the * `/onboarding/sources` step ran before this one (household step created
* household step entirely — the "sur 3" vs "4 sur 4" split lives in * one and it had a source to configure) — the step count shown reflects
* {@link onboardingStepIndicator}. * whichever path the visitor actually took, "3 sur 3" or "4 sur 4"
* (there's no shared wizard state to read this from — see
* `OnboardingHouseholdPage`'s doc comment on why each step is independent).
*/ */
export function OnboardingAllergensPage() { export function OnboardingAllergensPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -30,22 +29,27 @@ export function OnboardingAllergensPage() {
const [allergies, setAllergies] = useState<AllergyView[]>([]); const [allergies, setAllergies] = useState<AllergyView[]>([]);
const [allergyIds, setAllergyIds] = useState<number[]>([]); const [allergyIds, setAllergyIds] = useState<number[]>([]);
const [totalSteps, setTotalSteps] = useState(3);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
apiClient Promise.all([apiClient.getAllergies(), apiClient.getCurrentHouse()])
.getAllergies() .then(([allergiesResult, house]) => {
.then((allergiesResult) => { if (cancelled) return;
if (!cancelled) setAllergies(allergiesResult); setAllergies(allergiesResult);
setTotalSteps(house !== null ? 4 : 3);
})
.catch(() => {
// Neither piece is essential for this last step to render — the
// allergy list just stays empty (nothing to pick from) and the
// step count keeps its 3-of-3 default, same "don't strand the
// visitor over a transient failure" reasoning as
// OnboardingSourcesPage's own catch, just with nothing to
// navigate away to since this already is the last step.
}) })
// Not essential for this step to render — the allergy list just
// stays empty (nothing to pick from) rather than stranding the
// visitor over a transient failure, same reasoning as
// OnboardingSourcesPage's own catch.
.catch(() => {})
.finally(() => { .finally(() => {
if (!cancelled) setIsLoading(false); if (!cancelled) setIsLoading(false);
}); });
@ -54,14 +58,14 @@ export function OnboardingAllergensPage() {
}; };
}, []); }, []);
/** Moves on to the household step — preferences (régime + allergènes) are done. */ /** Finishes the wizard — the profile journey is complete, back to the app itself. */
async function handleSubmit(e: FormEvent) { async function handleSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
setFormError(null); setFormError(null);
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await apiClient.updateAllergyIds(allergyIds); await apiClient.updateAllergyIds(allergyIds);
void navigate("/onboarding/foyer"); void navigate("/");
} 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));
@ -74,7 +78,7 @@ export function OnboardingAllergensPage() {
<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"> <p className="onboarding-step">
{t("onboarding.step", onboardingStepIndicator("allergens"))} {t("onboarding.step", { current: totalSteps, total: totalSteps })}
</p> </p>
<h1>{t("onboarding.allergens.title")}</h1> <h1>{t("onboarding.allergens.title")}</h1>
@ -100,7 +104,7 @@ export function OnboardingAllergensPage() {
{formError && <p className="form-error">{formError}</p>} {formError && <p className="form-error">{formError}</p>}
<button type="submit" disabled={isSubmitting || isLoading}> <button type="submit" disabled={isSubmitting || isLoading}>
{t("onboarding.continue")} {t("onboarding.finish")}
</button> </button>
</form> </form>
</main> </main>

View file

@ -7,7 +7,6 @@ import { ApiError, apiClient } from "../../api/client";
import { useAuth } from "../../features/auth/AuthContext"; import { useAuth } from "../../features/auth/AuthContext";
import { DietSelect } from "../../features/profile/DietSelect"; import { DietSelect } from "../../features/profile/DietSelect";
import { errorMessageService } from "../../services/error-message.service"; import { errorMessageService } from "../../services/error-message.service";
import { onboardingStepIndicator } from "./onboarding-steps";
import "./onboarding.scss"; import "./onboarding.scss";
/** /**
@ -16,9 +15,6 @@ import "./onboarding.scss";
* 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
* the household step which has no such shortcut for the current name. * the household step which has no such shortcut for the current name.
*
* Followed by the allergens step (both make up "préférences" — see #129);
* the household step comes after those, not between them.
*/ */
export function OnboardingDietPage() { export function OnboardingDietPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -57,7 +53,7 @@ export function OnboardingDietPage() {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await apiClient.updateDiet(dietId); await apiClient.updateDiet(dietId);
void navigate("/onboarding/allergenes"); void 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));
@ -69,7 +65,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", onboardingStepIndicator("regime"))}</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 ? (

View file

@ -6,24 +6,16 @@ import { ApiError, apiClient } from "../../api/client";
import { HouseNameField } from "../../features/profile/HouseNameField"; import { HouseNameField } from "../../features/profile/HouseNameField";
import { fieldErrorsFrom } from "../../lib/zod-errors"; import { fieldErrorsFrom } from "../../lib/zod-errors";
import { errorMessageService } from "../../services/error-message.service"; import { errorMessageService } from "../../services/error-message.service";
import { onboardingStepIndicator } from "./onboarding-steps";
import "./onboarding.scss"; import "./onboarding.scss";
/** /**
* Third step of the post-signup onboarding wizard, routed at * Second step of the post-signup onboarding wizard, routed at
* `/onboarding/foyer` — after the régime + allergènes "préférences" steps * `/onboarding/foyer` — unlike the regime/allergens steps, this one is
* (see #129). Unlike those, this one is genuinely optional rather than * genuinely optional rather than just "skippable via an unedited default":
* just "skippable via an unedited default": no household is created at * no household is created at signup anymore (see the API's
* signup anymore (see the API's `auth.service.ts`), so there's nothing to * `auth.service.ts`), so there's nothing to prefill or implicitly keep by
* prefill or implicitly keep by pressing "Continuer" — the visitor * pressing "Continuer" — the visitor explicitly creates one, joins one by
* explicitly creates one, joins one by invite code, or skips the step * invite code, or skips the step outright via {@link SkipButton}.
* outright via {@link SkipButton}.
*
* This step's outcome decides whether the wizard has a 4th step: only
* *creating* a household leads on to `/onboarding/sources` (a per-house
* preference to seed while the visitor is here); joining one or skipping
* ends the wizard straight to `/` (a joined household's sources were set
* by whoever created it; a skipped step has no household to attach any to).
* *
* Still checks `getCurrentHouse()` on mount and skips straight to the * Still checks `getCurrentHouse()` on mount and skips straight to the
* "already have one" state if it finds one — defensive against revisiting * "already have one" state if it finds one — defensive against revisiting
@ -58,22 +50,19 @@ export function OnboardingHouseholdPage() {
}, []); }, []);
/** /**
* `/onboarding/sources` only makes sense for a household the visitor just * A household is what the next step (`/onboarding/sources`) needs to
* *created* — that's the sole caller passing `true`. Joining one, skipping * attach a preference to — created/joined/already-there goes on to it,
* the step, or finding one already there (browser back — can't tell * skipped goes straight to `/onboarding/allergenes` instead (nothing to
* created from joined, and the sources step self-skips when there's * configure sources for without a household).
* nothing to configure anyway) all end the wizard at `/`.
*/ */
function goToNextStep(createdHousehold: boolean) { function goToNextStep(hasHousehold: boolean) {
void navigate(createdHousehold ? "/onboarding/sources" : "/"); void navigate(hasHousehold ? "/onboarding/sources" : "/onboarding/allergenes");
} }
return ( return (
<main className="onboarding-page"> <main className="onboarding-page">
<div className="onboarding-card"> <div className="onboarding-card">
<p className="onboarding-step"> <p className="onboarding-step">{t("onboarding.step", { current: 2, total: 3 })}</p>
{t("onboarding.step", onboardingStepIndicator("household"))}
</p>
<h1>{t("onboarding.household.title")}</h1> <h1>{t("onboarding.household.title")}</h1>
{isLoading ? ( {isLoading ? (
@ -81,15 +70,15 @@ export function OnboardingHouseholdPage() {
) : house !== null ? ( ) : house !== null ? (
<> <>
<p>{t("onboarding.household.alreadyHasHouse", { name: house.name })}</p> <p>{t("onboarding.household.alreadyHasHouse", { name: house.name })}</p>
<button type="button" onClick={() => goToNextStep(false)}> <button type="button" onClick={() => goToNextStep(true)}>
{t("onboarding.finish")} {t("onboarding.continue")}
</button> </button>
</> </>
) : ( ) : (
<> <>
<p className="onboarding-subtitle">{t("onboarding.household.subtitle")}</p> <p className="onboarding-subtitle">{t("onboarding.household.subtitle")}</p>
<CreateHouseholdForm onDone={() => goToNextStep(true)} /> <CreateHouseholdForm onDone={() => goToNextStep(true)} />
<JoinHouseholdForm onDone={() => goToNextStep(false)} /> <JoinHouseholdForm onDone={() => goToNextStep(true)} />
<SkipButton onSkip={() => goToNextStep(false)} /> <SkipButton onSkip={() => goToNextStep(false)} />
</> </>
)} )}

View file

@ -6,23 +6,22 @@ import { useNavigate } from "react-router-dom";
import { ApiError, apiClient } from "../../api/client"; import { ApiError, apiClient } from "../../api/client";
import { SourceSelect } from "../../features/house/SourceSelect"; import { SourceSelect } from "../../features/house/SourceSelect";
import { errorMessageService } from "../../services/error-message.service"; import { errorMessageService } from "../../services/error-message.service";
import { onboardingStepIndicator } from "./onboarding-steps";
import "./onboarding.scss"; import "./onboarding.scss";
/** /**
* Final step of the post-signup onboarding wizard, routed at * Third step of the post-signup onboarding wizard, routed at
* `/onboarding/sources` — reached only when the previous step *created* a * `/onboarding/sources` — only reached when a household now exists (see
* household (see `OnboardingHouseholdPage`'s `goToNextStep`, which ends the * `OnboardingHouseholdPage`'s `goToStep`, which skips straight to
* wizard at `/` on every other path): enabled sources are a per-household * `/onboarding/allergenes` when the previous step was skipped): enabled
* preference (`HouseSource`, schema.prisma), so there's nothing to attach * sources are a per-household preference (`HouseSource`, schema.prisma),
* a selection to otherwise. * so there's nothing to attach a selection to without one.
* *
* Starts from an empty selection — a newly created household has nothing * Starts from an empty selection — a newly created/joined household has
* enabled yet by design (opt-in, see `house.service.ts`'s * nothing enabled yet by design (opt-in, see `house.service.ts`'s
* `getHouseSourceIds`). If no source is implemented yet (`getSources()` * `getHouseSourceIds`). If no source is implemented yet (`getSources()`
* comes back empty — currently always the case), there's nothing to pick * comes back empty — currently always the case), there's nothing to pick
* from either, so this step silently finishes the wizard (straight to `/`) * from either, so this step silently steps itself over rather than showing
* rather than showing an empty screen. * an empty screen.
*/ */
export function OnboardingSourcesPage() { export function OnboardingSourcesPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -41,7 +40,7 @@ export function OnboardingSourcesPage() {
.then((result) => { .then((result) => {
if (cancelled) return; if (cancelled) return;
if (result.length === 0) { if (result.length === 0) {
void navigate("/", { replace: true }); void navigate("/onboarding/allergenes", { replace: true });
return; return;
} }
setSources(result); setSources(result);
@ -51,7 +50,7 @@ export function OnboardingSourcesPage() {
// Nothing to configure sources for if we can't even list them — the // Nothing to configure sources for if we can't even list them — the
// wizard shouldn't strand the visitor here over a transient failure // wizard shouldn't strand the visitor here over a transient failure
// fetching an optional step's own data. // fetching an optional step's own data.
if (!cancelled) void navigate("/", { replace: true }); if (!cancelled) void navigate("/onboarding/allergenes", { replace: true });
}); });
return () => { return () => {
cancelled = true; cancelled = true;
@ -64,7 +63,7 @@ export function OnboardingSourcesPage() {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await apiClient.updateHouseSourceIds(sourceIds); await apiClient.updateHouseSourceIds(sourceIds);
void navigate("/"); void navigate("/onboarding/allergenes");
} 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));
@ -76,9 +75,7 @@ export function OnboardingSourcesPage() {
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"> <p className="onboarding-step">{t("onboarding.step", { current: 3, total: 4 })}</p>
{t("onboarding.step", onboardingStepIndicator("sources", { hasSources: true }))}
</p>
<h1>{t("onboarding.sources.title")}</h1> <h1>{t("onboarding.sources.title")}</h1>
<p className="onboarding-subtitle">{t("onboarding.sources.subtitle")}</p> <p className="onboarding-subtitle">{t("onboarding.sources.subtitle")}</p>
@ -96,7 +93,7 @@ export function OnboardingSourcesPage() {
{formError && <p className="form-error">{formError}</p>} {formError && <p className="form-error">{formError}</p>}
<button type="submit" disabled={isSubmitting || isLoading}> <button type="submit" disabled={isSubmitting || isLoading}>
{t("onboarding.finish")} {t("onboarding.continue")}
</button> </button>
</form> </form>
</main> </main>

View file

@ -1,45 +0,0 @@
/**
* The post-signup onboarding wizard's step order, in one place so the
* per-page "Étape N sur M" indicators can't drift out of sync when the
* sequence changes.
*
* Order: régime → allergènes → foyer → [sources]. `"sources"` is a
* conditional 4th step, reached only when the visitor *creates* a
* household on the foyer step — joining one or skipping the step ends the
* wizard at foyer (a joined household's sources were already configured by
* whoever created it, and there's nothing to attach a source selection to
* without a household at all).
*
* Each page renders its own indicator with no shared runtime wizard state
* to read from (see {@link OnboardingHouseholdPage}'s doc on why the steps
* are independent), so régime/allergènes/foyer — all upstream of the
* "create vs join/skip" decision — can't yet know whether a 4th step is
* coming and show "sur 3"; only the `"sources"` step, which by definition
* is only ever reached on the 4-step path, shows "4 sur 4". This mirrors
* the pre-#129 flow, whose last step likewise only learned the real total
* after the fact.
*/
export type OnboardingStep = "regime" | "allergens" | "household" | "sources";
/** The wizard's two possible step sequences, keyed by whether the sources step runs. */
export const ONBOARDING_STEP_SEQUENCE = {
/** régime → allergènes → foyer: household joined or skipped (or still upstream of that choice). */
withoutSources: ["regime", "allergens", "household"],
/** régime → allergènes → foyer → sources: the visitor created a household. */
withSources: ["regime", "allergens", "household", "sources"],
} as const satisfies Record<string, readonly OnboardingStep[]>;
/**
* `{ current, total }` to feed `t("onboarding.step", …)` for a given step.
* Defaults to the 3-step sequence; the `"sources"` step passes
* `hasSources: true` for its "4 sur 4".
*/
export function onboardingStepIndicator(
step: OnboardingStep,
{ hasSources = false }: { hasSources?: boolean } = {},
): { current: number; total: number } {
const sequence: readonly OnboardingStep[] = hasSources
? ONBOARDING_STEP_SEQUENCE.withSources
: ONBOARDING_STEP_SEQUENCE.withoutSources;
return { current: sequence.indexOf(step) + 1, total: sequence.length };
}

View file

@ -179,9 +179,7 @@ export function RecipesPage() {
<div className="recipes-page"> <div className="recipes-page">
<div className="recipes-page__header"> <div className="recipes-page__header">
<h1>{t("recipes.title")}</h1> <h1>{t("recipes.title")}</h1>
{/* One field for every tab — real ones and each source's (issue {activeSourceKey === null && (
#133). The term is kept here, above `RecipeSourcesPanel`'s
`key`-forced remount, so switching sources keeps the query. */}
<input <input
type="search" type="search"
className="recipes-page__search" className="recipes-page__search"
@ -189,6 +187,7 @@ export function RecipesPage() {
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
)}
<Link to="/recettes/nouvelle" className="recipes-page__new-button"> <Link to="/recettes/nouvelle" className="recipes-page__new-button">
{t("recipes.newButton")} {t("recipes.newButton")}
</Link> </Link>
@ -204,7 +203,6 @@ export function RecipesPage() {
<RecipeSourcesPanel <RecipeSourcesPanel
key={activeSourceKey} key={activeSourceKey}
sourceKey={activeSourceKey} sourceKey={activeSourceKey}
debouncedSearch={debouncedSearch}
// Only meaningful when it actually belongs to this source — e.g. // Only meaningful when it actually belongs to this source — e.g.
// clicking straight from TheMealDB's item "9999" to Marmiton's tab // clicking straight from TheMealDB's item "9999" to Marmiton's tab
// changes `activeSourceKey` before `selectedSourceItem` (URL-driven) // changes `activeSourceKey` before `selectedSourceItem` (URL-driven)

View file

@ -23,13 +23,6 @@ services:
# apps/api/Dockerfile) — no separate nginx/web container, no cross-origin # apps/api/Dockerfile) — no separate nginx/web container, no cross-origin
# CORS_ORIGIN to keep in sync between two ports. # CORS_ORIGIN to keep in sync between two ports.
app: app:
# Prebuilt image pushed by .github/workflows/release.yml to the Forgejo
# container registry on every `vX.Y.Z` tag. `IMAGE_TAG` (root .env,
# default `latest`) pins which release to run — a pull-based Portainer
# stack / `docker compose pull` uses this. The `build:` block below stays
# as a local fallback: `docker compose up` builds it if the image isn't
# present, `docker compose build` (re)creates it under this same name.
image: git.dev.kyuno.fr/kyuno/batchcooking-app:${IMAGE_TAG:-latest}
build: build:
context: . context: .
dockerfile: apps/api/Dockerfile dockerfile: apps/api/Dockerfile
@ -61,16 +54,6 @@ services:
# has every /admin/* route 401. Must be a *different* secret than # has every /admin/* route 401. Must be a *different* secret than
# JWT_SECRET. # JWT_SECRET.
ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET:-} ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET:-}
# Forgejo integration for "create a ticket from a contact message"
# (apps/web's /admin/messages, issue #81). All three read from this
# stack's .env; the feature fails closed — the button is hidden and
# the endpoint answers 503 — unless all three are set to a non-empty
# value, same posture as ADMIN_JWT_SECRET above. A `.dockerignore`d
# `.env` never reaches this container, so they must be passed here
# explicitly; a blank value counts as unset (see config/env.ts).
FORGEJO_BASE_URL: ${FORGEJO_BASE_URL:-}
FORGEJO_TOKEN: ${FORGEJO_TOKEN:-}
FORGEJO_REPO: ${FORGEJO_REPO:-}
# Compose network service name, not localhost — same reasoning as # Compose network service name, not localhost — same reasoning as
# DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback: # DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback:
# tech-step-intent-service is a core dependency (see its own entry # tech-step-intent-service is a core dependency (see its own entry
@ -94,9 +77,6 @@ services:
# port — reachable only from `app` on the compose network, nothing ever # port — reachable only from `app` on the compose network, nothing ever
# calls into it from outside. # calls into it from outside.
tech-step-intent-service: tech-step-intent-service:
# See the `app` service's `image:` comment — same release-pushed / local
# fallback split, shared `IMAGE_TAG`.
image: git.dev.kyuno.fr/kyuno/batchcooking-intent-service:${IMAGE_TAG:-latest}
build: build:
context: . context: .
dockerfile: services/tech-step-intent-service/Dockerfile dockerfile: services/tech-step-intent-service/Dockerfile
@ -141,9 +121,6 @@ services:
# less deployment can omit this service entirely and `app` still runs # less deployment can omit this service entirely and `app` still runs
# fine, just without the offline audit/feedback-loop jobs. # fine, just without the offline audit/feedback-loop jobs.
tech-step-llm-worker: tech-step-llm-worker:
# See the `app` service's `image:` comment — same release-pushed / local
# fallback split, shared `IMAGE_TAG`.
image: git.dev.kyuno.fr/kyuno/batchcooking-llm-worker:${IMAGE_TAG:-latest}
build: build:
context: . context: .
dockerfile: services/tech-step-llm-worker/Dockerfile dockerfile: services/tech-step-llm-worker/Dockerfile

View file

@ -17,24 +17,6 @@ export const adminLoginSchema = z.object({
/** Inferred TS type for {@link adminLoginSchema}'s validated output. */ /** Inferred TS type for {@link adminLoginSchema}'s validated output. */
export type AdminLoginInput = z.infer<typeof adminLoginSchema>; export type AdminLoginInput = z.infer<typeof adminLoginSchema>;
/**
* Payload accepted by `POST /admin/api/users` — an operator provisioning an
* end-user account by hand (test accounts, pre-registration, support). Its
* own contract, not a re-export of `signupSchema` (same stance as
* {@link adminLoginSchema} vs `loginSchema`), and notably **without a
* `password` field**: the server generates a one-time temporary password
* and returns it once (see `apps/api`'s `admin-users.service.ts`). The
* name/email rules mirror `signupSchema` so a hand-created account is
* indistinguishable from a self-signed-up one afterwards.
*/
export const adminCreateUserSchema = z.object({
firstName: z.string().trim().min(1, "Le prénom est requis").max(100),
lastName: z.string().trim().min(1, "Le nom est requis").max(100),
email: z.string().trim().toLowerCase().email("Email invalide"),
});
/** Inferred TS type for {@link adminCreateUserSchema}'s validated output. */
export type AdminCreateUserInput = z.infer<typeof adminCreateUserSchema>;
/** /**
* Query params for `GET /admin/api/metrics` — `?days=` bounds how far back the * Query params for `GET /admin/api/metrics` — `?days=` bounds how far back the
* time-series go (and how many daily buckets they carry). Coerced from the * time-series go (and how many daily buckets they carry). Coerced from the

View file

@ -15,37 +15,6 @@ export interface AdminUserView {
lastLoginAt: string | null; lastLoginAt: string | null;
} }
/**
* One row of `GET /admin/api/users` — an end-user account as shown in the
* admin's user list. Declared by hand rather than derived from the Prisma
* `UserProfile` type (same reason as {@link AdminUserView} / {@link
* SafeUserProfile}: apps/web must not depend on `@prisma/client`), and
* deliberately *not* {@link SafeUserProfile}: this view drops `tokenVersion`
* (internal) and adds `householdName` (resolved server-side) + `createdAt`.
* `householdName` is `null` when the account belongs to no household;
* `createdAt` is an ISO 8601 string.
*/
export interface AdminUserListItemView {
id: number;
firstName: string;
lastName: string;
email: string;
householdName: string | null;
createdAt: string;
}
/**
* Response of `POST /admin/api/users`. Carries the freshly-created account
* plus the **one-time** temporary password the server generated for it —
* never stored in plain text, never retrievable again, so the operator must
* copy it now and relay it to the user out of band.
*/
export interface AdminUserCreatedView {
user: AdminUserListItemView;
/** Plain-text temporary password, shown exactly once. */
temporaryPassword: string;
}
/** One `{ key, count }` breakdown row — e.g. recipes per source, corrections per status. */ /** One `{ key, count }` breakdown row — e.g. recipes per source, corrections per status. */
export interface MetricsBreakdownRow { export interface MetricsBreakdownRow {
key: string; key: string;

View file

@ -71,7 +71,7 @@ apps/web/src/
│ │ ├── AccountSettingsPage.tsx / HouseholdSettingsPage.tsx / PreferencesPage.tsx │ │ ├── AccountSettingsPage.tsx / HouseholdSettingsPage.tsx / PreferencesPage.tsx
│ │ ├── UserPreferencesPage.tsx / CreditsPage.tsx │ │ ├── UserPreferencesPage.tsx / CreditsPage.tsx
│ │ └── settings-pages.scss │ │ └── settings-pages.scss
│ └── onboarding/ # wizard d'inscription (régime → allergènes → foyer → [sources]), voir plus bas │ └── onboarding/ # wizard d'inscription (régime → foyer → [sources] → allergènes), voir plus bas
│ ├── OnboardingDietPage.tsx / OnboardingHouseholdPage.tsx / OnboardingSourcesPage.tsx / OnboardingAllergensPage.tsx │ ├── OnboardingDietPage.tsx / OnboardingHouseholdPage.tsx / OnboardingSourcesPage.tsx / OnboardingAllergensPage.tsx
│ └── onboarding.scss │ └── onboarding.scss
├── styles/ ├── styles/
@ -156,11 +156,9 @@ flowchart TB
l'ancienne page combinée) redirige vers `/parametres/foyer` pour ne pas casser l'ancienne page combinée) redirige vers `/parametres/foyer` pour ne pas casser
un lien existant. un lien existant.
- **`/onboarding/*`** reste son propre groupe de routes top-level (pas nichées - **`/onboarding/*`** reste son propre groupe de routes top-level (pas nichées
sous `AppLayout` — wizard plein écran sans sidebar) : `regime` → `allergenes` sous `AppLayout` — wizard plein écran sans sidebar) : `regime` → `foyer` →
(les deux « préférences ») → `foyer` → `sources` (conditionnelle — seulement `sources` (conditionnelle — seulement si l'étape `foyer` a créé/rejoint un
si l'étape `foyer` a **créé** un foyer ; rejoindre/passer termine le wizard foyer, sinon on saute directement à l'étape suivante) → `allergenes`.
vers `/`). L'ordre et les compteurs « sur 3 » / « 4 sur 4 » vivent dans
`pages/onboarding/onboarding-steps.ts`.
--- ---
@ -228,11 +226,11 @@ Deux surfaces partagent les mêmes composants de champ (`features/profile/` +
```mermaid ```mermaid
flowchart LR flowchart LR
SIGNUP["SignupPage<br/>(POST /auth/signup)"] --> OB1["/onboarding/regime"] SIGNUP["SignupPage<br/>(POST /auth/signup)"] --> OB1["/onboarding/regime"]
OB1 --> OB2["/onboarding/allergenes"] OB1 --> OB2["/onboarding/foyer"]
OB2 --> OB3["/onboarding/foyer"] OB2 -->|"foyer créé/rejoint"| OB3["/onboarding/sources"]
OB3 -->|"foyer créé"| OB4["/onboarding/sources"] OB2 -->|"pas de foyer"| OB4["/onboarding/allergenes"]
OB3 -->|"rejoint / passé"| HOME["/ (PlanningPage)"] OB3 --> OB4
OB4 --> HOME OB4 --> HOME["/ (PlanningPage)"]
SETTINGSMENU["Sidebar : Paramètres"] --> S1["/parametres/compte"] SETTINGSMENU["Sidebar : Paramètres"] --> S1["/parametres/compte"]
SETTINGSMENU --> S2["/parametres/preferences"] SETTINGSMENU --> S2["/parametres/preferences"]
@ -242,23 +240,18 @@ flowchart LR
SETTINGSMENU --> S6["/parametres/credits"] SETTINGSMENU --> S6["/parametres/credits"]
``` ```
- **`pages/onboarding/`** — wizard de **3 écrans** (régime → allergènes → - **`pages/onboarding/`** — wizard de **4 écrans** (régime → foyer → sources →
foyer), + un **4ᵉ conditionnel** (sources), lancé une seule fois juste après allergènes), lancé une seule fois juste après l'inscription. Routes
l'inscription. Routes top-level `RequireAuth`, **pas** nichées sous top-level `RequireAuth`, **pas** nichées sous `AppLayout` : wizard plein
`AppLayout` : wizard plein écran sans sidebar (`onboarding.scss`, même écran sans sidebar (`onboarding.scss`, même langage visuel que
langage visuel que `/login`/`/signup`). Régime et allergènes forment les `/login`/`/signup`). Chaque étape a un unique bouton "Continuer" qui envoie
deux « préférences » ; chacun a un unique bouton "Continuer" qui envoie la la valeur courante — pas de bouton "Passer" séparé, une valeur
valeur courante — une valeur "aucune"/vide *est* le skip. L'étape `foyer`, "aucune"/vide *est* le skip. `/onboarding/sources` est **conditionnelle** :
elle, est explicitement optionnelle (créer / rejoindre / "Passer cette seulement atteinte si l'étape `foyer` vient de créer/rejoindre un foyer
étape"). `/onboarding/sources` est **conditionnelle** : seulement atteinte (`OnboardingHouseholdPage`'s `goToNextStep`) — sautée sinon, directement vers
si l'étape `foyer` vient de **créer** un foyer `/onboarding/allergenes`. `OnboardingSourcesPage` elle-même redirige en
(`OnboardingHouseholdPage`'s `goToNextStep`) — rejoindre ou passer termine silence vers l'étape suivante si `GET /reference/sources` revient vide ou en
le wizard vers `/`. `OnboardingSourcesPage` elle-même redirige en silence erreur, plutôt que d'afficher une étape sans rien à choisir.
vers `/` si `GET /reference/sources` revient vide ou en erreur, plutôt que
d'afficher une étape sans rien à choisir. L'ordre des étapes et les
compteurs affichés (« Étape 1 sur 3 » … « 4 sur 4 » pour le chemin avec
sources) sont centralisés dans `pages/onboarding/onboarding-steps.ts` —
chaque page les lisait auparavant en dur, ce qu'un réordonnancement cassait.
- **Pages `/parametres/*`** (`pages/settings/`, dans `AppLayout`) — l'ancienne - **Pages `/parametres/*`** (`pages/settings/`, dans `AppLayout`) — l'ancienne
`HouseholdPage` combinée a été **éclatée en 5 pages** dédiées (voir la `HouseholdPage` combinée a été **éclatée en 5 pages** dédiées (voir la
section suivante pour le détail de chacune), toutes en **hot saving** (pas section suivante pour le détail de chacune), toutes en **hot saving** (pas
@ -575,14 +568,6 @@ Contenu de l'onglet d'une source : sa propre paire maître-détail —
`sourceKey` (prop fixe) — remonté avec `key={sourceKey}` en changeant de `sourceKey` (prop fixe) — remonté avec `key={sourceKey}` en changeant de
source, même convention "monté seulement tant que pertinent" que `Dialog`. source, même convention "monté seulement tant que pertinent" que `Dialog`.
Le champ de recherche est **commun à tous les onglets** (issue #133) : il vit
dans l'en-tête de `RecipesPage` (et de `RecipePickerDialog`), pas dans le
panneau. `search`/`debouncedSearch` sont portés par la page ; `RecipeSourcesPanel`
reçoit `debouncedSearch` en prop et refait son `browse` quand elle change. La
requête tapée survit donc au changement de source (le `key={sourceKey}` remonte
toujours la liste + le préchargement, mais le terme est au-dessus). Sur les
onglets réels le même terme alimente `listRecipes({ search })`.
Pagination en scroll infini, pas de bouton "voir plus" : une ligne Pagination en scroll infini, pas de bouton "voir plus" : une ligne
sentinelle invisible en fin de liste (`SourceItemTable`, un sentinelle invisible en fin de liste (`SourceItemTable`, un
`IntersectionObserver` scopé à son propre conteneur scrollable) déclenche le `IntersectionObserver` scopé à son propre conteneur scrollable) déclenche le
@ -762,14 +747,6 @@ Clic sur une ligne :
`:hover`/`:focus-within` (aucun état JS). `children` doit être un seul `:hover`/`:focus-within` (aucun état JS). `children` doit être un seul
élément focusable ; cloné pour y attacher `aria-describedby` (lecteurs élément focusable ; cloné pour y attacher `aria-describedby` (lecteurs
d'écran). Utilisé par `StepDescription.tsx` pour l'infobulle des techniques. d'écran). Utilisé par `StepDescription.tsx` pour l'infobulle des techniques.
- **`PasswordInput.tsx`** (+ `password-input.scss`) — un `<input>` de mot de
passe avec un bouton œil (`lucide-react` `Eye`/`EyeOff`) qui bascule son
`type` entre `"password"` et `"text"`. Visibilité en état local, toujours
masquée au montage, jamais persistée. Bouton `type="button"` +
`aria-label` traduit (`common.passwordInput.*`) + `aria-pressed`. Le style
du champ lui-même est hérité du contexte (comme `CheckboxOption` via
`global.scss`) ; la feuille colocalisée ne fait que positionner le bouton.
Utilisé par `LoginPage` et `SignupPage`.
--- ---