feat(admin): espace « Utilisateurs » pour créer des comptes #144

Merged
kyuno merged 3 commits from feat/admin-user-accounts into main 2026-09-10 21:48:31 +02:00
8 changed files with 613 additions and 0 deletions
Showing only changes of commit 9e847e15a1 - Show all commits

View file

@ -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");
});
});

View file

@ -14,6 +14,7 @@ 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";
@ -198,6 +199,7 @@ export function App() {
} }
> >
<Route index element={<AdminDashboardPage />} /> <Route index element={<AdminDashboardPage />} />
<Route path="utilisateurs" element={<AdminUsersPage />} />
<Route path="monitoring" element={<AdminMonitoringPage />} /> <Route path="monitoring" element={<AdminMonitoringPage />} />
<Route path="corrections" element={<AdminCorrectionsPage />} /> <Route path="corrections" element={<AdminCorrectionsPage />} />
<Route path="catalogue" element={<AdminCatalogPage />} /> <Route path="catalogue" element={<AdminCatalogPage />} />

View file

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

View file

@ -5,6 +5,7 @@ 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";
@ -20,6 +21,7 @@ import "./AdminLayout.scss";
*/ */
const NAV_ITEMS = [ const NAV_ITEMS = [
{ to: "/admin", key: "dashboard", Icon: LayoutDashboard, end: true }, { to: "/admin", key: "dashboard", Icon: LayoutDashboard, end: true },
{ to: "/admin/utilisateurs", key: "users", Icon: Users, end: false },
{ to: "/admin/monitoring", key: "monitoring", Icon: Activity, end: false }, { to: "/admin/monitoring", key: "monitoring", Icon: Activity, end: false },
{ to: "/admin/corrections", key: "corrections", Icon: ListChecks, end: false }, { to: "/admin/corrections", key: "corrections", Icon: ListChecks, end: false },
{ to: "/admin/catalogue", key: "catalog", Icon: PackageSearch, end: false }, { to: "/admin/catalogue", key: "catalog", Icon: PackageSearch, end: false },

View file

@ -1234,6 +1234,7 @@
}, },
"nav": { "nav": {
"dashboard": "Tableau de bord", "dashboard": "Tableau de bord",
"users": "Utilisateurs",
"monitoring": "Monitoring", "monitoring": "Monitoring",
"corrections": "Corrections", "corrections": "Corrections",
"catalog": "Catalogue", "catalog": "Catalogue",
@ -1243,6 +1244,37 @@
"layout": { "layout": {
"logout": "Se déconnecter" "logout": "Se déconnecter"
}, },
"users": {
"title": "Utilisateurs",
"lead": "Comptes utilisateurs de l'application.",
"newButton": "Nouvel utilisateur",
"empty": "Aucun compte pour le moment.",
"noHousehold": "—",
"col": {
"date": "Créé le",
"name": "Nom",
"email": "Email",
"household": "Foyer"
},
"form": {
"title": "Créer un compte",
"firstName": "Prénom",
"lastName": "Nom",
"email": "Email",
"submit": "Créer le compte",
"submitting": "Création…",
"cancel": "Annuler"
},
"created": {
"title": "Compte créé",
"intro": "Transmettez ce mot de passe temporaire à",
"passwordLabel": "Mot de passe temporaire",
"copy": "Copier",
"copied": "Copié",
"warning": "Notez-le maintenant : il ne sera plus affiché.",
"done": "Terminé"
}
},
"dashboard": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
"lead": "Métriques d'utilisation de l'application.", "lead": "Métriques d'utilisation de l'application.",

View file

@ -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<UsersState>({ status: "loading" });
const [isCreating, setIsCreating] = useState(false);
const requestSeq = useRef(0);
const load = useCallback(() => {
const seq = ++requestSeq.current;
setState({ status: "loading" });
adminApiClient
.getUsers()
.then((rows) => {
if (seq === requestSeq.current) setState({ status: "loaded", rows });
})
.catch(() => {
if (seq === requestSeq.current) setState({ status: "error" });
});
}, []);
useEffect(() => {
load();
}, [load]);
return (
<div className="admin-page users-page">
<header className="users-page__header">
<div>
<h1 className="admin-page__title">{t("admin.users.title")}</h1>
<p className="admin-page__lead">{t("admin.users.lead")}</p>
</div>
<button type="button" className="users-page__new" onClick={() => setIsCreating(true)}>
{t("admin.users.newButton")}
</button>
</header>
{state.status === "loading" && (
<p className="admin-page__placeholder">{t("admin.common.loading")}</p>
)}
{state.status === "error" && (
<p className="admin-page__placeholder">{t("admin.common.loadError")}</p>
)}
{state.status === "loaded" && state.rows.length === 0 && (
<p className="admin-page__placeholder">{t("admin.users.empty")}</p>
)}
{state.status === "loaded" && state.rows.length > 0 && (
<div className="users-table-wrap">
<table className="users-table">
<thead>
<tr>
<th>{t("admin.users.col.date")}</th>
<th>{t("admin.users.col.name")}</th>
<th>{t("admin.users.col.email")}</th>
<th>{t("admin.users.col.household")}</th>
</tr>
</thead>
<tbody>
{state.rows.map((row) => (
<tr key={row.id}>
<td className="users-table__date">{formatCreatedAt(row.createdAt)}</td>
<td>{fullName(row)}</td>
<td className="users-table__email">{row.email}</td>
<td>{row.householdName ?? t("admin.users.noHousehold")}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{isCreating && (
<CreateUserDialog
onClose={(created) => {
setIsCreating(false);
if (created) load();
}}
/>
)}
</div>
);
}
/**
* Two-step create dialog: the form, then — on success — a one-time reveal of
* the generated temporary password. `onClose(created)` tells the parent
* whether to refetch the list (`true` once an account was created, even if
* the operator then closes from the reveal step).
*/
function CreateUserDialog({ onClose }: { onClose: (created: boolean) => void }) {
const { t } = useTranslation();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [result, setResult] = useState<AdminUserCreatedView | null>(null);
async function submit() {
setFormError(null);
const parsed = adminCreateUserSchema.safeParse({ firstName, lastName, email });
if (!parsed.success) {
setFieldErrors(fieldErrorsFrom(parsed.error));
return;
}
setFieldErrors({});
setIsSubmitting(true);
try {
setResult(await adminApiClient.createUser(parsed.data));
} catch (err) {
setFormError(
errorMessageService.getLabel(
err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR,
),
);
setIsSubmitting(false);
}
}
if (result) {
return <PasswordRevealDialog result={result} onDone={() => onClose(true)} />;
}
return (
<Dialog
title={t("admin.users.form.title")}
onClose={() => onClose(false)}
className="user-create-dialog"
footer={
<>
<button type="button" onClick={() => onClose(false)} disabled={isSubmitting}>
{t("admin.users.form.cancel")}
</button>
<button
type="button"
className="user-create-dialog__submit"
onClick={() => void submit()}
disabled={isSubmitting}
>
{isSubmitting ? t("admin.users.form.submitting") : t("admin.users.form.submit")}
</button>
</>
}
>
<form
data-testid="create-user-form"
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
>
<label htmlFor="newUserFirstName">{t("admin.users.form.firstName")}</label>
<input
id="newUserFirstName"
type="text"
value={firstName}
maxLength={100}
autoComplete="off"
onChange={(event) => setFirstName(event.target.value)}
/>
{fieldErrors.firstName && <p className="field-error">{fieldErrors.firstName}</p>}
<label htmlFor="newUserLastName">{t("admin.users.form.lastName")}</label>
<input
id="newUserLastName"
type="text"
value={lastName}
maxLength={100}
autoComplete="off"
onChange={(event) => setLastName(event.target.value)}
/>
{fieldErrors.lastName && <p className="field-error">{fieldErrors.lastName}</p>}
<label htmlFor="newUserEmail">{t("admin.users.form.email")}</label>
<input
id="newUserEmail"
type="email"
value={email}
autoComplete="off"
onChange={(event) => setEmail(event.target.value)}
/>
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
{formError && <p className="form-error">{formError}</p>}
{/* Submit on Enter without showing a second button — the footer's is the visible one. */}
<button type="submit" hidden />
</form>
</Dialog>
);
}
/** The one-time temporary-password reveal shown after a successful create. */
function PasswordRevealDialog({
result,
onDone,
}: {
result: AdminUserCreatedView;
onDone: () => void;
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
return (
<Dialog
title={t("admin.users.created.title")}
onClose={onDone}
className="user-create-dialog"
footer={
<button type="button" className="user-create-dialog__submit" onClick={onDone}>
{t("admin.users.created.done")}
</button>
}
>
<div data-testid="temp-password-reveal">
<p>
{t("admin.users.created.intro")} <strong>{fullName(result.user)}</strong> (
{result.user.email})
</p>
<label htmlFor="tempPassword">{t("admin.users.created.passwordLabel")}</label>
<div className="user-create-dialog__password-row">
<input
id="tempPassword"
type="text"
readOnly
value={result.temporaryPassword}
onFocus={(event) => event.target.select()}
/>
<button
type="button"
onClick={() => {
void copyToClipboard(result.temporaryPassword).then(setCopied);
}}
>
{copied ? t("admin.users.created.copied") : t("admin.users.created.copy")}
</button>
</div>
<p className="user-create-dialog__warning">{t("admin.users.created.warning")}</p>
</div>
</Dialog>
);
}

View file

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

View file

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