feat(admin): espace « Utilisateurs » pour créer des comptes #144
8 changed files with 613 additions and 0 deletions
111
apps/web/cypress/e2e/admin-users.cy.ts
Normal file
111
apps/web/cypress/e2e/admin-users.cy.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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() {
|
|||
}
|
||||
>
|
||||
<Route index element={<AdminDashboardPage />} />
|
||||
<Route path="utilisateurs" element={<AdminUsersPage />} />
|
||||
<Route path="monitoring" element={<AdminMonitoringPage />} />
|
||||
<Route path="corrections" element={<AdminCorrectionsPage />} />
|
||||
<Route path="catalogue" element={<AdminCatalogPage />} />
|
||||
|
|
|
|||
|
|
@ -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<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. */
|
||||
public getSuggestions(filters: {
|
||||
status?: string;
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
269
apps/web/src/pages/admin/users/UsersPage.tsx
Normal file
269
apps/web/src/pages/admin/users/UsersPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
apps/web/src/pages/admin/users/users-page.scss
Normal file
148
apps/web/src/pages/admin/users/users-page.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
32
apps/web/src/pages/admin/users/users.ts
Normal file
32
apps/web/src/pages/admin/users/users.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue