feat(api): endpoints admin de gestion des comptes utilisateurs
`GET /admin/api/users` (liste, plus récent d'abord, plafonnée à 200) et `POST /admin/api/users` (création à la main), tous deux derrière `requireAdmin`. La création génère un mot de passe temporaire aléatoire (`randomInt`, charset sans caractères ambigus, comme `generateInviteCode`) renvoyé une seule fois ; le compte démarre sans foyer, comme une inscription self-service. Évènement `admin.user_created` (fire-and-forget) pour distinguer les comptes provisionnés à la main dans le dashboard. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
9123f6358e
commit
0f8a640da2
4 changed files with 338 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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue