Merge pull request 'feat(admin): espace « Utilisateurs » pour créer des comptes' (#144) from feat/admin-user-accounts into main
Some checks failed
Some checks failed
Reviewed-on: #144
This commit is contained in:
commit
5a915a6cee
14 changed files with 1000 additions and 0 deletions
31
apps/api/src/modules/admin/admin-users.routes.ts
Normal file
31
apps/api/src/modules/admin/admin-users.routes.ts
Normal file
|
|
@ -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));
|
||||||
|
}),
|
||||||
|
);
|
||||||
145
apps/api/src/modules/admin/admin-users.service.ts
Normal file
145
apps/api/src/modules/admin/admin-users.service.ts
Normal file
|
|
@ -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<AdminUserListItemView[]> {
|
||||||
|
try {
|
||||||
|
const rows = await prisma.userProfile.findMany({
|
||||||
|
// `id` as a tiebreaker so the order is total even if two accounts share
|
||||||
|
// a `createdAt` (bulk-provisioned in the same tick) — `id` ascends with
|
||||||
|
// `createdAt` anyway.
|
||||||
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||||
|
take: USERS_PAGE_SIZE,
|
||||||
|
include: { house: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
return rows.map(toListItem);
|
||||||
|
} catch (err) {
|
||||||
|
// Rethrown as-is — `wrapAsyncHandler`/the error middleware handles it,
|
||||||
|
// this layer just isn't allowed a bare `await`. Same as `admin-contact.service.ts`.
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provisions a new end-user account by hand and returns it together with the
|
||||||
|
* one-time temporary password the caller must relay to the user. Mirrors
|
||||||
|
* `auth.service.ts`'s `signup` for the parts that matter (email-uniqueness
|
||||||
|
* check, argon2 hash, `UserProfile` row with `houseId`/`dietId` left `null`)
|
||||||
|
* but issues **no session token** — the operator isn't logging in as this
|
||||||
|
* user — and generates the password instead of accepting one.
|
||||||
|
*
|
||||||
|
* @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken.
|
||||||
|
*/
|
||||||
|
export async function createUserProfile(
|
||||||
|
input: AdminCreateUserInput,
|
||||||
|
): Promise<AdminUserCreatedView> {
|
||||||
|
try {
|
||||||
|
const existing = await prisma.userProfile.findUnique({ where: { email: input.email } });
|
||||||
|
if (existing) {
|
||||||
|
throw new HttpError(409, ErrorCode.EMAIL_ALREADY_IN_USE, "Email already in use");
|
||||||
|
}
|
||||||
|
|
||||||
|
const temporaryPassword = generateTemporaryPassword();
|
||||||
|
const passwordHash = await argon2.hash(temporaryPassword, hashOptions);
|
||||||
|
|
||||||
|
const profile = await prisma.userProfile.create({
|
||||||
|
// No household/diet here, same as `signup` — the user picks those in the
|
||||||
|
// onboarding wizard after their first login.
|
||||||
|
data: {
|
||||||
|
firstName: input.firstName,
|
||||||
|
lastName: input.lastName,
|
||||||
|
email: input.email,
|
||||||
|
passwordHash,
|
||||||
|
},
|
||||||
|
include: { house: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fire-and-forget, one line, no migration (see `analytics.service.ts`). A
|
||||||
|
// distinct type from `user.signup` so the dashboard can tell hand-provisioned
|
||||||
|
// accounts apart; `actorType: "system"` because the actor is an operator, not
|
||||||
|
// the new user (and `AnalyticsEvent.actorId` only ever means a `UserProfile.id`).
|
||||||
|
analytics.recordEvent("admin.user_created", {
|
||||||
|
actorType: "system",
|
||||||
|
context: { userId: profile.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { user: toListItem(profile), temporaryPassword };
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see listUsers()'s catch comment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { adminContactRouter } from "./admin-contact.routes.js";
|
||||||
import { adminMetricsRouter } from "./admin-metrics.routes.js";
|
import { adminMetricsRouter } from "./admin-metrics.routes.js";
|
||||||
import { adminTechStepsRouter } from "./admin-tech-steps.routes.js";
|
import { adminTechStepsRouter } from "./admin-tech-steps.routes.js";
|
||||||
import { adminToursRouter } from "./admin-tours.routes.js";
|
import { adminToursRouter } from "./admin-tours.routes.js";
|
||||||
|
import { adminUsersRouter } from "./admin-users.routes.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggregator for the admin application's API surface, mounted at
|
* Aggregator for the admin application's API surface, mounted at
|
||||||
|
|
@ -28,3 +29,4 @@ adminRouter.use("/tech-steps", adminTechStepsRouter);
|
||||||
adminRouter.use("/catalog", adminCatalogRouter);
|
adminRouter.use("/catalog", adminCatalogRouter);
|
||||||
adminRouter.use("/tours", adminToursRouter);
|
adminRouter.use("/tours", adminToursRouter);
|
||||||
adminRouter.use("/contact", adminContactRouter);
|
adminRouter.use("/contact", adminContactRouter);
|
||||||
|
adminRouter.use("/users", adminUsersRouter);
|
||||||
|
|
|
||||||
160
apps/api/test/admin-users.test.ts
Normal file
160
apps/api/test/admin-users.test.ts
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
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 { 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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,24 @@ export const adminLoginSchema = z.object({
|
||||||
/** Inferred TS type for {@link adminLoginSchema}'s validated output. */
|
/** Inferred TS type for {@link adminLoginSchema}'s validated output. */
|
||||||
export type AdminLoginInput = z.infer<typeof adminLoginSchema>;
|
export type AdminLoginInput = z.infer<typeof adminLoginSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload accepted by `POST /admin/api/users` — an operator provisioning an
|
||||||
|
* end-user account by hand (test accounts, pre-registration, support). Its
|
||||||
|
* own contract, not a re-export of `signupSchema` (same stance as
|
||||||
|
* {@link adminLoginSchema} vs `loginSchema`), and notably **without a
|
||||||
|
* `password` field**: the server generates a one-time temporary password
|
||||||
|
* and returns it once (see `apps/api`'s `admin-users.service.ts`). The
|
||||||
|
* name/email rules mirror `signupSchema` so a hand-created account is
|
||||||
|
* indistinguishable from a self-signed-up one afterwards.
|
||||||
|
*/
|
||||||
|
export const adminCreateUserSchema = z.object({
|
||||||
|
firstName: z.string().trim().min(1, "Le prénom est requis").max(100),
|
||||||
|
lastName: z.string().trim().min(1, "Le nom est requis").max(100),
|
||||||
|
email: z.string().trim().toLowerCase().email("Email invalide"),
|
||||||
|
});
|
||||||
|
/** Inferred TS type for {@link adminCreateUserSchema}'s validated output. */
|
||||||
|
export type AdminCreateUserInput = z.infer<typeof adminCreateUserSchema>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query params for `GET /admin/api/metrics` — `?days=` bounds how far back the
|
* Query params for `GET /admin/api/metrics` — `?days=` bounds how far back the
|
||||||
* time-series go (and how many daily buckets they carry). Coerced from the
|
* time-series go (and how many daily buckets they carry). Coerced from the
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,37 @@ export interface AdminUserView {
|
||||||
lastLoginAt: string | null;
|
lastLoginAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of `GET /admin/api/users` — an end-user account as shown in the
|
||||||
|
* admin's user list. Declared by hand rather than derived from the Prisma
|
||||||
|
* `UserProfile` type (same reason as {@link AdminUserView} / {@link
|
||||||
|
* SafeUserProfile}: apps/web must not depend on `@prisma/client`), and
|
||||||
|
* deliberately *not* {@link SafeUserProfile}: this view drops `tokenVersion`
|
||||||
|
* (internal) and adds `householdName` (resolved server-side) + `createdAt`.
|
||||||
|
* `householdName` is `null` when the account belongs to no household;
|
||||||
|
* `createdAt` is an ISO 8601 string.
|
||||||
|
*/
|
||||||
|
export interface AdminUserListItemView {
|
||||||
|
id: number;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
householdName: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response of `POST /admin/api/users`. Carries the freshly-created account
|
||||||
|
* plus the **one-time** temporary password the server generated for it —
|
||||||
|
* never stored in plain text, never retrievable again, so the operator must
|
||||||
|
* copy it now and relay it to the user out of band.
|
||||||
|
*/
|
||||||
|
export interface AdminUserCreatedView {
|
||||||
|
user: AdminUserListItemView;
|
||||||
|
/** Plain-text temporary password, shown exactly once. */
|
||||||
|
temporaryPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** One `{ key, count }` breakdown row — e.g. recipes per source, corrections per status. */
|
/** One `{ key, count }` breakdown row — e.g. recipes per source, corrections per status. */
|
||||||
export interface MetricsBreakdownRow {
|
export interface MetricsBreakdownRow {
|
||||||
key: string;
|
key: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue