Compare commits
No commits in common. "main" and "feat/missing-tech-steps" have entirely different histories.
main
...
feat/missi
42 changed files with 303 additions and 1923 deletions
19
.env.example
19
.env.example
|
|
@ -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
|
||||||
|
|
|
||||||
66
.github/workflows/release.yml
vendored
66
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
34
README.md
34
README.md
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
.string()
|
||||||
z
|
.regex(/^[^/\s]+\/[^/\s]+$/, "FORGEJO_REPO must look like 'owner/repo'")
|
||||||
.string()
|
.optional(),
|
||||||
.regex(/^[^/\s]+\/[^/\s]+$/, "FORGEJO_REPO must look like 'owner/repo'")
|
|
||||||
.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. */
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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: [] });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -451,75 +451,70 @@ 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
|
<div className="recipe-picker__filters">
|
||||||
filters below it only apply to the saved-recipe tabs. */}
|
<input
|
||||||
<div className="recipe-picker__filters">
|
type="search"
|
||||||
<input
|
className="recipe-picker__search"
|
||||||
type="search"
|
placeholder={t("planning.picker.searchPlaceholder")}
|
||||||
className="recipe-picker__search"
|
value={search}
|
||||||
placeholder={t("planning.picker.searchPlaceholder")}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
value={search}
|
/>
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{activeSourceKey === null && (
|
<div className="recipe-picker__ingredient-filter">
|
||||||
<>
|
<span className="recipe-picker__filter-label">
|
||||||
<div className="recipe-picker__ingredient-filter">
|
{t("planning.picker.ingredientsFilterLabel")}
|
||||||
<span className="recipe-picker__filter-label">
|
</span>
|
||||||
{t("planning.picker.ingredientsFilterLabel")}
|
<div className="recipe-picker__chips">
|
||||||
</span>
|
{selectedIngredients.map((ingredient) => (
|
||||||
<div className="recipe-picker__chips">
|
<span key={ingredient.id} className="filter-chip">
|
||||||
{selectedIngredients.map((ingredient) => (
|
{ingredientLabel(ingredient, t)}
|
||||||
<span key={ingredient.id} className="filter-chip">
|
<button
|
||||||
{ingredientLabel(ingredient, t)}
|
type="button"
|
||||||
<button
|
onClick={() =>
|
||||||
type="button"
|
setSelectedIngredientIds((ids) => ids.filter((id) => id !== ingredient.id))
|
||||||
onClick={() =>
|
}
|
||||||
setSelectedIngredientIds((ids) => ids.filter((id) => id !== ingredient.id))
|
>
|
||||||
}
|
✕
|
||||||
>
|
</button>
|
||||||
✕
|
</span>
|
||||||
</button>
|
))}
|
||||||
</span>
|
<button
|
||||||
))}
|
type="button"
|
||||||
<button
|
className="recipe-picker__toggle-ingredient-picker"
|
||||||
type="button"
|
onClick={() => setIsIngredientPickerOpen((open) => !open)}
|
||||||
className="recipe-picker__toggle-ingredient-picker"
|
aria-expanded={isIngredientPickerOpen}
|
||||||
onClick={() => setIsIngredientPickerOpen((open) => !open)}
|
>
|
||||||
aria-expanded={isIngredientPickerOpen}
|
+{" "}
|
||||||
>
|
{isIngredientPickerOpen
|
||||||
+{" "}
|
? t("planning.picker.hideIngredientPicker")
|
||||||
{isIngredientPickerOpen
|
: t("planning.picker.addIngredientFilter")}
|
||||||
? t("planning.picker.hideIngredientPicker")
|
</button>
|
||||||
: t("planning.picker.addIngredientFilter")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{isIngredientPickerOpen && (
|
|
||||||
<IngredientPicker
|
|
||||||
ingredients={ingredientsCatalog}
|
|
||||||
excludeIds={selectedIngredientIds}
|
|
||||||
onSelect={(ingredient) =>
|
|
||||||
setSelectedIngredientIds((ids) => [...ids, ingredient.id])
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{isIngredientPickerOpen && (
|
||||||
<DietTagSelect
|
<IngredientPicker
|
||||||
diets={dietsCatalog}
|
ingredients={ingredientsCatalog}
|
||||||
value={selectedDietIds}
|
excludeIds={selectedIngredientIds}
|
||||||
onChange={setSelectedDietIds}
|
onSelect={(ingredient) =>
|
||||||
/>
|
setSelectedIngredientIds((ids) => [...ids, ingredient.id])
|
||||||
|
}
|
||||||
{hasHousehold && (
|
/>
|
||||||
<CheckboxOption checked={suitableForHousehold} onChange={setSuitableForHousehold}>
|
|
||||||
{t("planning.picker.suitableForHouseholdLabel")}
|
|
||||||
</CheckboxOption>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
<DietTagSelect
|
||||||
|
diets={dietsCatalog}
|
||||||
|
value={selectedDietIds}
|
||||||
|
onChange={setSelectedDietIds}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasHousehold && (
|
||||||
|
<CheckboxOption checked={suitableForHousehold} onChange={setSuitableForHousehold}>
|
||||||
|
{t("planning.picker.suitableForHouseholdLabel")}
|
||||||
|
</CheckboxOption>
|
||||||
|
)}
|
||||||
|
</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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,42 +294,52 @@ 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">
|
||||||
<div className="recipes-page__catalog">
|
<input
|
||||||
{browseState.status === "loading" && (
|
type="search"
|
||||||
<p className="recipes-page__status">{t("recipes.sources.loading")}</p>
|
className="recipes-page__search"
|
||||||
)}
|
placeholder={t("recipes.sources.searchPlaceholder")}
|
||||||
{browseState.status === "error" && (
|
value={search}
|
||||||
<p className="recipes-page__status recipes-page__status--error">
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
{t("recipes.sources.loadError")}
|
/>
|
||||||
</p>
|
</div>
|
||||||
)}
|
|
||||||
{browseState.status === "loaded" && browseState.items.length === 0 && (
|
|
||||||
<p className="recipes-page__status">{t("recipes.sources.empty")}</p>
|
|
||||||
)}
|
|
||||||
{browseState.status === "loaded" && browseState.items.length > 0 && (
|
|
||||||
<div className="source-items-column">
|
|
||||||
<SourceItemTable
|
|
||||||
items={browseState.items}
|
|
||||||
selectedExternalId={selectedExternalId}
|
|
||||||
onSelect={handleSelectItem}
|
|
||||||
placeholderCount={loadMoreStatus === "loading" ? LOAD_MORE_PLACEHOLDER_COUNT : 0}
|
|
||||||
hasMore={browseState.nextCursor !== null}
|
|
||||||
onLoadMore={handleLoadMore}
|
|
||||||
/>
|
|
||||||
{loadMoreStatus === "error" && (
|
|
||||||
<p className="recipes-page__status recipes-page__status--error">
|
|
||||||
{t("recipes.sources.loadMoreError")}{" "}
|
|
||||||
<button type="button" className="source-items-retry" onClick={handleLoadMore}>
|
|
||||||
{t("recipes.sources.retry")}
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<RecipeDetailPanel state={previewState} showActions={false} />
|
<div className="recipes-page__catalog">
|
||||||
</div>
|
{browseState.status === "loading" && (
|
||||||
|
<p className="recipes-page__status">{t("recipes.sources.loading")}</p>
|
||||||
|
)}
|
||||||
|
{browseState.status === "error" && (
|
||||||
|
<p className="recipes-page__status recipes-page__status--error">
|
||||||
|
{t("recipes.sources.loadError")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{browseState.status === "loaded" && browseState.items.length === 0 && (
|
||||||
|
<p className="recipes-page__status">{t("recipes.sources.empty")}</p>
|
||||||
|
)}
|
||||||
|
{browseState.status === "loaded" && browseState.items.length > 0 && (
|
||||||
|
<div className="source-items-column">
|
||||||
|
<SourceItemTable
|
||||||
|
items={browseState.items}
|
||||||
|
selectedExternalId={selectedExternalId}
|
||||||
|
onSelect={handleSelectItem}
|
||||||
|
placeholderCount={loadMoreStatus === "loading" ? LOAD_MORE_PLACEHOLDER_COUNT : 0}
|
||||||
|
hasMore={browseState.nextCursor !== null}
|
||||||
|
onLoadMore={handleLoadMore}
|
||||||
|
/>
|
||||||
|
{loadMoreStatus === "error" && (
|
||||||
|
<p className="recipes-page__status recipes-page__status--error">
|
||||||
|
{t("recipes.sources.loadMoreError")}{" "}
|
||||||
|
<button type="button" className="source-items-retry" onClick={handleLoadMore}>
|
||||||
|
{t("recipes.sources.retry")}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RecipeDetailPanel state={previewState} showActions={false} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>}
|
||||||
|
|
|
||||||
|
|
@ -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>}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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)} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
|
|
@ -179,16 +179,15 @@ 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
|
<input
|
||||||
`key`-forced remount, so switching sources keeps the query. */}
|
type="search"
|
||||||
<input
|
className="recipes-page__search"
|
||||||
type="search"
|
placeholder={t("recipes.searchPlaceholder")}
|
||||||
className="recipes-page__search"
|
value={search}
|
||||||
placeholder={t("recipes.searchPlaceholder")}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
value={search}
|
/>
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue