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