diff --git a/apps/api/src/modules/admin/admin-users.routes.ts b/apps/api/src/modules/admin/admin-users.routes.ts new file mode 100644 index 0000000..35122c9 --- /dev/null +++ b/apps/api/src/modules/admin/admin-users.routes.ts @@ -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)); + }), +); diff --git a/apps/api/src/modules/admin/admin-users.service.ts b/apps/api/src/modules/admin/admin-users.service.ts new file mode 100644 index 0000000..62da73e --- /dev/null +++ b/apps/api/src/modules/admin/admin-users.service.ts @@ -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 { + 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 { + 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 + } +} diff --git a/apps/api/src/modules/admin/admin.routes.ts b/apps/api/src/modules/admin/admin.routes.ts index adaea93..eecae1c 100644 --- a/apps/api/src/modules/admin/admin.routes.ts +++ b/apps/api/src/modules/admin/admin.routes.ts @@ -5,6 +5,7 @@ import { adminContactRouter } from "./admin-contact.routes.js"; import { adminMetricsRouter } from "./admin-metrics.routes.js"; import { adminTechStepsRouter } from "./admin-tech-steps.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 @@ -28,3 +29,4 @@ adminRouter.use("/tech-steps", adminTechStepsRouter); adminRouter.use("/catalog", adminCatalogRouter); adminRouter.use("/tours", adminToursRouter); adminRouter.use("/contact", adminContactRouter); +adminRouter.use("/users", adminUsersRouter); diff --git a/apps/api/test/admin-users.test.ts b/apps/api/test/admin-users.test.ts new file mode 100644 index 0000000..0d9d8df --- /dev/null +++ b/apps/api/test/admin-users.test.ts @@ -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"); + } + }); +});