From 9123f6358ec2c53e64cd542b4985f3310c020296 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 10 Sep 2026 20:59:08 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(shared):=20sch=C3=A9ma=20et=20vues=20p?= =?UTF-8?q?our=20la=20cr=C3=A9ation=20de=20comptes=20par=20un=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `adminCreateUserSchema` (prénom/nom/email, sans mot de passe — généré côté serveur) + les vues `AdminUserListItemView` / `AdminUserCreatedView` consommées par `POST`/`GET /admin/api/users`. Co-Authored-By: Claude Sonnet 5 --- packages/shared/src/schemas/admin.ts | 18 ++++++++++++++++ packages/shared/src/types/admin.ts | 31 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/packages/shared/src/schemas/admin.ts b/packages/shared/src/schemas/admin.ts index 7f6bfb8..f7fc3e7 100644 --- a/packages/shared/src/schemas/admin.ts +++ b/packages/shared/src/schemas/admin.ts @@ -17,6 +17,24 @@ export const adminLoginSchema = z.object({ /** Inferred TS type for {@link adminLoginSchema}'s validated output. */ export type AdminLoginInput = z.infer; +/** + * 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; + /** * 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 diff --git a/packages/shared/src/types/admin.ts b/packages/shared/src/types/admin.ts index 978b583..a0ae6aa 100644 --- a/packages/shared/src/types/admin.ts +++ b/packages/shared/src/types/admin.ts @@ -15,6 +15,37 @@ export interface AdminUserView { 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. */ export interface MetricsBreakdownRow { key: string; -- 2.45.2 From 0f8a640da2c7f6af4b2ac8f0418fc193bfad8621 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 10 Sep 2026 20:59:15 +0200 Subject: [PATCH 2/3] feat(api): endpoints admin de gestion des comptes utilisateurs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /admin/api/users` (liste, plus récent d'abord, plafonnée à 200) et `POST /admin/api/users` (création à la main), tous deux derrière `requireAdmin`. La création génère un mot de passe temporaire aléatoire (`randomInt`, charset sans caractères ambigus, comme `generateInviteCode`) renvoyé une seule fois ; le compte démarre sans foyer, comme une inscription self-service. Évènement `admin.user_created` (fire-and-forget) pour distinguer les comptes provisionnés à la main dans le dashboard. Co-Authored-By: Claude Sonnet 5 --- .../src/modules/admin/admin-users.routes.ts | 31 ++++ .../src/modules/admin/admin-users.service.ts | 145 ++++++++++++++++ apps/api/src/modules/admin/admin.routes.ts | 2 + apps/api/test/admin-users.test.ts | 160 ++++++++++++++++++ 4 files changed, 338 insertions(+) create mode 100644 apps/api/src/modules/admin/admin-users.routes.ts create mode 100644 apps/api/src/modules/admin/admin-users.service.ts create mode 100644 apps/api/test/admin-users.test.ts diff --git a/apps/api/src/modules/admin/admin-users.routes.ts b/apps/api/src/modules/admin/admin-users.routes.ts new file mode 100644 index 0000000..35122c9 --- /dev/null +++ b/apps/api/src/modules/admin/admin-users.routes.ts @@ -0,0 +1,31 @@ +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)); + }), +); diff --git a/apps/api/src/modules/admin/admin-users.service.ts b/apps/api/src/modules/admin/admin-users.service.ts new file mode 100644 index 0000000..62da73e --- /dev/null +++ b/apps/api/src/modules/admin/admin-users.service.ts @@ -0,0 +1,145 @@ +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 { + 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 { + 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 + } +} diff --git a/apps/api/src/modules/admin/admin.routes.ts b/apps/api/src/modules/admin/admin.routes.ts index adaea93..eecae1c 100644 --- a/apps/api/src/modules/admin/admin.routes.ts +++ b/apps/api/src/modules/admin/admin.routes.ts @@ -5,6 +5,7 @@ import { adminContactRouter } from "./admin-contact.routes.js"; import { adminMetricsRouter } from "./admin-metrics.routes.js"; import { adminTechStepsRouter } from "./admin-tech-steps.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 @@ -28,3 +29,4 @@ adminRouter.use("/tech-steps", adminTechStepsRouter); adminRouter.use("/catalog", adminCatalogRouter); adminRouter.use("/tours", adminToursRouter); adminRouter.use("/contact", adminContactRouter); +adminRouter.use("/users", adminUsersRouter); diff --git a/apps/api/test/admin-users.test.ts b/apps/api/test/admin-users.test.ts new file mode 100644 index 0000000..0d9d8df --- /dev/null +++ b/apps/api/test/admin-users.test.ts @@ -0,0 +1,160 @@ +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"); + } + }); +}); -- 2.45.2 From 9e847e15a15e94fa31ddbd78e3dc5408b52f4ad2 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 10 Sep 2026 20:59:23 +0200 Subject: [PATCH 3/3] =?UTF-8?q?feat(admin):=20page=20=C2=AB=20Utilisateurs?= =?UTF-8?q?=20=C2=BB=20pour=20cr=C3=A9er=20des=20comptes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouvelle section `/admin/utilisateurs` (nav + route) : tableau des comptes (date, nom, email, foyer) et un `Dialog` de création en deux temps — formulaire prénom/nom/email, puis affichage unique du mot de passe temporaire généré, avec bouton « Copier ». Validation cliente via `adminCreateUserSchema`, erreurs API traduites (`EMAIL_ALREADY_IN_USE` déjà mappé). Spec Cypress `admin-users.cy.ts` (API mockée). Co-Authored-By: Claude Sonnet 5 --- apps/web/cypress/e2e/admin-users.cy.ts | 111 ++++++++ apps/web/src/App.tsx | 2 + apps/web/src/api/admin-client.ts | 17 ++ apps/web/src/layouts/AdminLayout.tsx | 2 + apps/web/src/locales/fr/translation.json | 32 +++ apps/web/src/pages/admin/users/UsersPage.tsx | 269 ++++++++++++++++++ .../web/src/pages/admin/users/users-page.scss | 148 ++++++++++ apps/web/src/pages/admin/users/users.ts | 32 +++ 8 files changed, 613 insertions(+) create mode 100644 apps/web/cypress/e2e/admin-users.cy.ts create mode 100644 apps/web/src/pages/admin/users/UsersPage.tsx create mode 100644 apps/web/src/pages/admin/users/users-page.scss create mode 100644 apps/web/src/pages/admin/users/users.ts diff --git a/apps/web/cypress/e2e/admin-users.cy.ts b/apps/web/cypress/e2e/admin-users.cy.ts new file mode 100644 index 0000000..cad74ea --- /dev/null +++ b/apps/web/cypress/e2e/admin-users.cy.ts @@ -0,0 +1,111 @@ +// 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"); + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 94400c8..c4d8143 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -14,6 +14,7 @@ import { AdminLoginPage } from "./pages/admin/login/AdminLoginPage"; import { MessagesPage as AdminMessagesPage } from "./pages/admin/messages/MessagesPage"; import { MonitoringPage as AdminMonitoringPage } from "./pages/admin/monitoring/MonitoringPage"; 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 { SignupPage } from "./pages/auth/SignupPage"; import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage"; @@ -198,6 +199,7 @@ export function App() { } > } /> + } /> } /> } /> } /> diff --git a/apps/web/src/api/admin-client.ts b/apps/web/src/api/admin-client.ts index 1b51bae..9a4ff6a 100644 --- a/apps/web/src/api/admin-client.ts +++ b/apps/web/src/api/admin-client.ts @@ -1,6 +1,9 @@ import { + type AdminCreateUserInput, type AdminLoginInput, type AdminTourScenarioView, + type AdminUserCreatedView, + type AdminUserListItemView, type AdminUserView, type ApiErrorResponse, type CatalogPlaceholderGroupView, @@ -131,6 +134,20 @@ export class AdminApiClient { return this._request(`/metrics?days=${days}`); } + /** End-user accounts for the `/admin/utilisateurs` list — newest first, capped server-side at 200. */ + public getUsers(): Promise { + 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 { + return this._request("/users", { method: "POST", body: JSON.stringify(input) }); + } + /** Training suggestions, grouped by technique, filtered by the given (all-optional) criteria. */ public getSuggestions(filters: { status?: string; diff --git a/apps/web/src/layouts/AdminLayout.tsx b/apps/web/src/layouts/AdminLayout.tsx index 9b1547a..58aa8f4 100644 --- a/apps/web/src/layouts/AdminLayout.tsx +++ b/apps/web/src/layouts/AdminLayout.tsx @@ -5,6 +5,7 @@ import { LayoutDashboard, ListChecks, PackageSearch, + Users, } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; @@ -20,6 +21,7 @@ import "./AdminLayout.scss"; */ const NAV_ITEMS = [ { 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/corrections", key: "corrections", Icon: ListChecks, end: false }, { to: "/admin/catalogue", key: "catalog", Icon: PackageSearch, end: false }, diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 54af2a2..8ea3ff7 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -1234,6 +1234,7 @@ }, "nav": { "dashboard": "Tableau de bord", + "users": "Utilisateurs", "monitoring": "Monitoring", "corrections": "Corrections", "catalog": "Catalogue", @@ -1243,6 +1244,37 @@ "layout": { "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": { "title": "Tableau de bord", "lead": "Métriques d'utilisation de l'application.", diff --git a/apps/web/src/pages/admin/users/UsersPage.tsx b/apps/web/src/pages/admin/users/UsersPage.tsx new file mode 100644 index 0000000..854b0be --- /dev/null +++ b/apps/web/src/pages/admin/users/UsersPage.tsx @@ -0,0 +1,269 @@ +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({ 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 ( +
+
+
+

{t("admin.users.title")}

+

{t("admin.users.lead")}

+
+ +
+ + {state.status === "loading" && ( +

{t("admin.common.loading")}

+ )} + {state.status === "error" && ( +

{t("admin.common.loadError")}

+ )} + {state.status === "loaded" && state.rows.length === 0 && ( +

{t("admin.users.empty")}

+ )} + {state.status === "loaded" && state.rows.length > 0 && ( +
+ + + + + + + + + + + {state.rows.map((row) => ( + + + + + + + ))} + +
{t("admin.users.col.date")}{t("admin.users.col.name")}{t("admin.users.col.email")}{t("admin.users.col.household")}
{formatCreatedAt(row.createdAt)}{fullName(row)}{row.email}{row.householdName ?? t("admin.users.noHousehold")}
+
+ )} + + {isCreating && ( + { + setIsCreating(false); + if (created) load(); + }} + /> + )} +
+ ); +} + +/** + * 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>({}); + const [formError, setFormError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [result, setResult] = useState(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 onClose(true)} />; + } + + return ( + onClose(false)} + className="user-create-dialog" + footer={ + <> + + + + } + > +
{ + event.preventDefault(); + void submit(); + }} + > + + setFirstName(event.target.value)} + /> + {fieldErrors.firstName &&

{fieldErrors.firstName}

} + + + setLastName(event.target.value)} + /> + {fieldErrors.lastName &&

{fieldErrors.lastName}

} + + + setEmail(event.target.value)} + /> + {fieldErrors.email &&

{fieldErrors.email}

} + + {formError &&

{formError}

} + {/* Submit on Enter without showing a second button — the footer's is the visible one. */} +
+ ); +} + +/** 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 ( + + {t("admin.users.created.done")} + + } + > +
+

+ {t("admin.users.created.intro")} {fullName(result.user)} ( + {result.user.email}) +

+ +
+ event.target.select()} + /> + +
+

{t("admin.users.created.warning")}

+
+
+ ); +} diff --git a/apps/web/src/pages/admin/users/users-page.scss b/apps/web/src/pages/admin/users/users-page.scss new file mode 100644 index 0000000..90d9d37 --- /dev/null +++ b/apps/web/src/pages/admin/users/users-page.scss @@ -0,0 +1,148 @@ +// ============================================================================= +// 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; + } +} diff --git a/apps/web/src/pages/admin/users/users.ts b/apps/web/src/pages/admin/users/users.ts new file mode 100644 index 0000000..f711eae --- /dev/null +++ b/apps/web/src/pages/admin/users/users.ts @@ -0,0 +1,32 @@ +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): 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 { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } +} -- 2.45.2