Ver código fonte

RHL-012 feat(api): implement user management endpoints for PATCH and POST requests

- Added PATCH endpoint for updating user details, including validation for username, email, role, branchId, and mustChangePassword fields.
- Implemented error handling for various scenarios such as missing parameters, invalid fields, and duplicate entries.
- Introduced POST endpoint for creating new users with validation for required fields and password strength.
- Added tests for both endpoints to ensure proper functionality and error handling.
- Enhanced user data safety by returning only necessary fields in responses.
Code_Uwe 1 mês atrás
pai
commit
2e3c7a3c49

+ 351 - 0
app/api/admin/users/[userId]/route.js

@@ -0,0 +1,351 @@
+import User, { USER_ROLES } from "@/models/user";
+import { getDb } from "@/lib/db";
+import { getSession } from "@/lib/auth/session";
+import { requireUserManagement } from "@/lib/auth/permissions";
+import {
+	withErrorHandling,
+	json,
+	badRequest,
+	unauthorized,
+	notFound,
+} from "@/lib/api/errors";
+
+export const dynamic = "force-dynamic";
+
+const BRANCH_RE = /^NL\d+$/;
+const OBJECT_ID_RE = /^[a-f0-9]{24}$/i;
+
+const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{2,31}$/; // 3..32, conservative
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+const ALLOWED_ROLES = new Set(Object.values(USER_ROLES));
+const ALLOWED_UPDATE_FIELDS = Object.freeze([
+	"username",
+	"email",
+	"role",
+	"branchId",
+	"mustChangePassword",
+]);
+
+function isPlainObject(value) {
+	return Boolean(value && typeof value === "object" && !Array.isArray(value));
+}
+
+function isNonEmptyString(value) {
+	return typeof value === "string" && value.trim().length > 0;
+}
+
+function normalizeUsername(value) {
+	return String(value || "")
+		.trim()
+		.toLowerCase();
+}
+
+function normalizeEmail(value) {
+	return String(value || "")
+		.trim()
+		.toLowerCase();
+}
+
+function normalizeBranchId(value) {
+	return String(value || "")
+		.trim()
+		.toUpperCase();
+}
+
+function toIsoOrNull(value) {
+	if (!value) return null;
+	try {
+		return new Date(value).toISOString();
+	} catch {
+		return null;
+	}
+}
+
+function toSafeUser(doc) {
+	return {
+		id: String(doc?._id),
+		username: typeof doc?.username === "string" ? doc.username : "",
+		email: typeof doc?.email === "string" ? doc.email : "",
+		role: typeof doc?.role === "string" ? doc.role : "",
+		branchId: doc?.branchId ?? null,
+		mustChangePassword: Boolean(doc?.mustChangePassword),
+		createdAt: toIsoOrNull(doc?.createdAt),
+		updatedAt: toIsoOrNull(doc?.updatedAt),
+	};
+}
+
+function pickDuplicateField(err) {
+	if (!err || typeof err !== "object") return null;
+
+	const keyValue =
+		err.keyValue && typeof err.keyValue === "object" ? err.keyValue : null;
+	if (keyValue) {
+		const keys = Object.keys(keyValue);
+		if (keys.length > 0) return keys[0];
+	}
+
+	const keyPattern =
+		err.keyPattern && typeof err.keyPattern === "object"
+			? err.keyPattern
+			: null;
+	if (keyPattern) {
+		const keys = Object.keys(keyPattern);
+		if (keys.length > 0) return keys[0];
+	}
+
+	return null;
+}
+
+export const PATCH = withErrorHandling(
+	async function PATCH(request, ctx) {
+		const session = await getSession();
+
+		if (!session) {
+			throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
+		}
+
+		requireUserManagement(session);
+
+		const { userId } = await ctx.params;
+
+		if (!userId) {
+			throw badRequest(
+				"VALIDATION_MISSING_PARAM",
+				"Missing required route parameter(s)",
+				{ params: ["userId"] },
+			);
+		}
+
+		if (!OBJECT_ID_RE.test(String(userId))) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid userId", {
+				field: "userId",
+				value: userId,
+			});
+		}
+
+		let body;
+		try {
+			body = await request.json();
+		} catch {
+			throw badRequest("VALIDATION_INVALID_JSON", "Invalid request body");
+		}
+
+		if (!isPlainObject(body)) {
+			throw badRequest("VALIDATION_INVALID_BODY", "Invalid request body");
+		}
+
+		const hasUpdateField = Object.keys(body).some((k) =>
+			ALLOWED_UPDATE_FIELDS.includes(k),
+		);
+
+		if (!hasUpdateField) {
+			throw badRequest("VALIDATION_MISSING_FIELD", "Missing fields to update", {
+				fields: [...ALLOWED_UPDATE_FIELDS],
+			});
+		}
+
+		await getDb();
+
+		const user = await User.findById(String(userId)).exec();
+
+		if (!user) {
+			throw notFound("USER_NOT_FOUND", "Not found", { userId: String(userId) });
+		}
+
+		const patch = {};
+
+		// username (optional)
+		if (Object.prototype.hasOwnProperty.call(body, "username")) {
+			if (!isNonEmptyString(body.username)) {
+				throw badRequest("VALIDATION_INVALID_FIELD", "Invalid username", {
+					field: "username",
+					value: body.username,
+				});
+			}
+
+			const username = normalizeUsername(body.username);
+
+			if (!USERNAME_RE.test(username)) {
+				throw badRequest("VALIDATION_INVALID_FIELD", "Invalid username", {
+					field: "username",
+					value: username,
+					pattern: String(USERNAME_RE),
+				});
+			}
+
+			const existing = await User.findOne({
+				username,
+				_id: { $ne: String(userId) },
+			})
+				.select("_id")
+				.exec();
+
+			if (existing) {
+				throw badRequest(
+					"VALIDATION_INVALID_FIELD",
+					"Username already exists",
+					{
+						field: "username",
+						value: username,
+					},
+				);
+			}
+
+			patch.username = username;
+		}
+
+		// email (optional)
+		if (Object.prototype.hasOwnProperty.call(body, "email")) {
+			if (!isNonEmptyString(body.email)) {
+				throw badRequest("VALIDATION_INVALID_FIELD", "Invalid email", {
+					field: "email",
+					value: body.email,
+				});
+			}
+
+			const email = normalizeEmail(body.email);
+
+			if (!EMAIL_RE.test(email)) {
+				throw badRequest("VALIDATION_INVALID_FIELD", "Invalid email", {
+					field: "email",
+					value: email,
+				});
+			}
+
+			const existing = await User.findOne({
+				email,
+				_id: { $ne: String(userId) },
+			})
+				.select("_id")
+				.exec();
+
+			if (existing) {
+				throw badRequest("VALIDATION_INVALID_FIELD", "Email already exists", {
+					field: "email",
+					value: email,
+				});
+			}
+
+			patch.email = email;
+		}
+
+		// role (optional)
+		if (Object.prototype.hasOwnProperty.call(body, "role")) {
+			if (!isNonEmptyString(body.role)) {
+				throw badRequest("VALIDATION_INVALID_FIELD", "Invalid role", {
+					field: "role",
+					value: body.role,
+					allowed: Array.from(ALLOWED_ROLES),
+				});
+			}
+
+			const role = String(body.role).trim();
+
+			if (!ALLOWED_ROLES.has(role)) {
+				throw badRequest("VALIDATION_INVALID_FIELD", "Invalid role", {
+					field: "role",
+					value: role,
+					allowed: Array.from(ALLOWED_ROLES),
+				});
+			}
+
+			patch.role = role;
+		}
+
+		// branchId (optional, can be null)
+		if (Object.prototype.hasOwnProperty.call(body, "branchId")) {
+			if (body.branchId === null) {
+				patch.branchId = null;
+			} else if (isNonEmptyString(body.branchId)) {
+				patch.branchId = normalizeBranchId(body.branchId);
+			} else {
+				throw badRequest("VALIDATION_INVALID_FIELD", "Invalid branchId", {
+					field: "branchId",
+					value: body.branchId,
+					pattern: "^NL\\d+$",
+				});
+			}
+		}
+
+		// mustChangePassword (optional)
+		if (Object.prototype.hasOwnProperty.call(body, "mustChangePassword")) {
+			if (typeof body.mustChangePassword !== "boolean") {
+				throw badRequest(
+					"VALIDATION_INVALID_FIELD",
+					"Invalid mustChangePassword",
+					{
+						field: "mustChangePassword",
+						value: body.mustChangePassword,
+					},
+				);
+			}
+			patch.mustChangePassword = body.mustChangePassword;
+		}
+
+		// --- Enforce role <-> branchId consistency --------------------------------
+		const nextRole = patch.role ?? user.role;
+
+		const nextBranchId = Object.prototype.hasOwnProperty.call(patch, "branchId")
+			? patch.branchId
+			: (user.branchId ?? null);
+
+		if (nextRole === USER_ROLES.BRANCH) {
+			if (!isNonEmptyString(nextBranchId)) {
+				throw badRequest(
+					"VALIDATION_MISSING_FIELD",
+					"Missing required fields",
+					{
+						fields: ["branchId"],
+					},
+				);
+			}
+
+			const normalized = normalizeBranchId(nextBranchId);
+
+			if (!BRANCH_RE.test(normalized)) {
+				throw badRequest("VALIDATION_INVALID_FIELD", "Invalid branchId", {
+					field: "branchId",
+					value: normalized,
+					pattern: "^NL\\d+$",
+				});
+			}
+
+			patch.branchId = normalized;
+		} else {
+			// For non-branch users, always clear branchId
+			patch.branchId = null;
+		}
+
+		// --- Apply patch ----------------------------------------------------------
+		if (Object.prototype.hasOwnProperty.call(patch, "username"))
+			user.username = patch.username;
+
+		if (Object.prototype.hasOwnProperty.call(patch, "email"))
+			user.email = patch.email;
+
+		if (Object.prototype.hasOwnProperty.call(patch, "role"))
+			user.role = patch.role;
+
+		if (Object.prototype.hasOwnProperty.call(patch, "branchId"))
+			user.branchId = patch.branchId;
+
+		if (Object.prototype.hasOwnProperty.call(patch, "mustChangePassword"))
+			user.mustChangePassword = patch.mustChangePassword;
+
+		try {
+			await user.save();
+		} catch (err) {
+			if (err && err.code === 11000) {
+				const field = pickDuplicateField(err) || "unknown";
+				throw badRequest("VALIDATION_INVALID_FIELD", "Duplicate key", {
+					field,
+				});
+			}
+			throw err;
+		}
+
+		return json({ ok: true, user: toSafeUser(user) }, 200);
+	},
+	{ logPrefix: "[api/admin/users/[userId]]" },
+);

+ 337 - 0
app/api/admin/users/[userId]/route.test.js

@@ -0,0 +1,337 @@
+/* @vitest-environment node */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@/lib/auth/session", () => ({
+	getSession: vi.fn(),
+}));
+
+vi.mock("@/lib/db", () => ({
+	getDb: vi.fn(),
+}));
+
+vi.mock("@/models/user", () => {
+	const USER_ROLES = Object.freeze({
+		BRANCH: "branch",
+		ADMIN: "admin",
+		SUPERADMIN: "superadmin",
+		DEV: "dev",
+	});
+
+	return {
+		default: {
+			findById: vi.fn(),
+			findOne: vi.fn(),
+		},
+		USER_ROLES,
+	};
+});
+
+import { getSession } from "@/lib/auth/session";
+import { getDb } from "@/lib/db";
+import User from "@/models/user";
+
+import { PATCH, dynamic } from "./route.js";
+
+function createRequestStub(body) {
+	return {
+		async json() {
+			return body;
+		},
+	};
+}
+
+describe("PATCH /api/admin/users/[userId]", () => {
+	beforeEach(() => {
+		vi.clearAllMocks();
+		getDb.mockResolvedValue({});
+	});
+
+	it('exports dynamic="force-dynamic"', () => {
+		expect(dynamic).toBe("force-dynamic");
+	});
+
+	it("returns 401 when unauthenticated", async () => {
+		getSession.mockResolvedValue(null);
+
+		const res = await PATCH(createRequestStub({}), {
+			params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
+		});
+
+		expect(res.status).toBe(401);
+		expect(await res.json()).toEqual({
+			error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
+		});
+	});
+
+	it("returns 403 when authenticated but not allowed (admin)", async () => {
+		getSession.mockResolvedValue({
+			userId: "u1",
+			role: "admin",
+			branchId: null,
+			email: "admin@example.com",
+		});
+
+		const res = await PATCH(createRequestStub({ email: "x@y.de" }), {
+			params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
+		});
+
+		expect(res.status).toBe(403);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Forbidden",
+				code: "AUTH_FORBIDDEN_USER_MANAGEMENT",
+			},
+		});
+	});
+
+	it("returns 400 when JSON parsing fails", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "superadmin",
+			branchId: null,
+			email: "superadmin@example.com",
+		});
+
+		const req = { json: vi.fn().mockRejectedValue(new Error("invalid json")) };
+
+		const res = await PATCH(req, {
+			params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
+		});
+
+		expect(res.status).toBe(400);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Invalid request body",
+				code: "VALIDATION_INVALID_JSON",
+			},
+		});
+	});
+
+	it("returns 400 when body is not an object", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "superadmin",
+			branchId: null,
+			email: "superadmin@example.com",
+		});
+
+		const res = await PATCH(createRequestStub("nope"), {
+			params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
+		});
+
+		expect(res.status).toBe(400);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Invalid request body",
+				code: "VALIDATION_INVALID_BODY",
+			},
+		});
+	});
+
+	it("returns 400 when userId param is missing", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "dev",
+			branchId: null,
+			email: "dev@example.com",
+		});
+
+		const res = await PATCH(createRequestStub({ email: "x@y.de" }), {
+			params: Promise.resolve({ userId: undefined }),
+		});
+
+		expect(res.status).toBe(400);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Missing required route parameter(s)",
+				code: "VALIDATION_MISSING_PARAM",
+				details: { params: ["userId"] },
+			},
+		});
+	});
+
+	it("returns 400 when userId param is invalid", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "dev",
+			branchId: null,
+			email: "dev@example.com",
+		});
+
+		const res = await PATCH(createRequestStub({ email: "x@y.de" }), {
+			params: Promise.resolve({ userId: "nope" }),
+		});
+
+		expect(res.status).toBe(400);
+		expect(await res.json()).toMatchObject({
+			error: { code: "VALIDATION_INVALID_FIELD" },
+		});
+	});
+
+	it("returns 404 when user does not exist", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "superadmin",
+			branchId: null,
+			email: "superadmin@example.com",
+		});
+
+		User.findById.mockReturnValue({
+			exec: vi.fn().mockResolvedValue(null),
+		});
+
+		const res = await PATCH(createRequestStub({ email: "x@y.de" }), {
+			params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
+		});
+
+		expect(res.status).toBe(404);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Not found",
+				code: "USER_NOT_FOUND",
+				details: { userId: "507f1f77bcf86cd799439011" },
+			},
+		});
+	});
+
+	it("returns 400 when switching to role=branch without branchId (existing has none)", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "dev",
+			branchId: null,
+			email: "dev@example.com",
+		});
+
+		const user = {
+			_id: "507f1f77bcf86cd799439011",
+			username: "x",
+			email: "x@example.com",
+			role: "admin",
+			branchId: null,
+			mustChangePassword: false,
+			createdAt: new Date(),
+			updatedAt: new Date(),
+			save: vi.fn(),
+		};
+
+		User.findById.mockReturnValue({
+			exec: vi.fn().mockResolvedValue(user),
+		});
+
+		const res = await PATCH(createRequestStub({ role: "branch" }), {
+			params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
+		});
+
+		expect(res.status).toBe(400);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Missing required fields",
+				code: "VALIDATION_MISSING_FIELD",
+				details: { fields: ["branchId"] },
+			},
+		});
+	});
+
+	it("returns 200 and updates fields; clears branchId for non-branch roles", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "superadmin",
+			branchId: null,
+			email: "superadmin@example.com",
+		});
+
+		const user = {
+			_id: "507f1f77bcf86cd799439011",
+			username: "olduser",
+			email: "old@example.com",
+			role: "branch",
+			branchId: "NL01",
+			mustChangePassword: true,
+			createdAt: new Date("2026-02-01T10:00:00.000Z"),
+			updatedAt: new Date("2026-02-02T10:00:00.000Z"),
+			save: vi.fn().mockResolvedValue(true),
+		};
+
+		User.findById.mockReturnValue({
+			exec: vi.fn().mockResolvedValue(user),
+		});
+
+		// No uniqueness checks needed here (we only change role + mustChangePassword)
+		const res = await PATCH(
+			createRequestStub({
+				role: "admin",
+				mustChangePassword: false,
+			}),
+			{
+				params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
+			},
+		);
+
+		expect(res.status).toBe(200);
+
+		// Role changed => branchId must be cleared
+		expect(user.role).toBe("admin");
+		expect(user.branchId).toBe(null);
+		expect(user.mustChangePassword).toBe(false);
+		expect(user.save).toHaveBeenCalledTimes(1);
+
+		const body = await res.json();
+		expect(body).toMatchObject({
+			ok: true,
+			user: {
+				id: "507f1f77bcf86cd799439011",
+				username: "olduser",
+				email: "old@example.com",
+				role: "admin",
+				branchId: null,
+				mustChangePassword: false,
+			},
+		});
+	});
+
+	it("returns 400 when username is already taken by another user", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "dev",
+			branchId: null,
+			email: "dev@example.com",
+		});
+
+		const user = {
+			_id: "507f1f77bcf86cd799439011",
+			username: "olduser",
+			email: "old@example.com",
+			role: "admin",
+			branchId: null,
+			mustChangePassword: false,
+			createdAt: new Date(),
+			updatedAt: new Date(),
+			save: vi.fn(),
+		};
+
+		User.findById.mockReturnValue({
+			exec: vi.fn().mockResolvedValue(user),
+		});
+
+		User.findOne.mockReturnValue({
+			select: vi.fn().mockReturnThis(),
+			exec: vi.fn().mockResolvedValue({ _id: "507f1f77bcf86cd799439099" }),
+		});
+
+		const res = await PATCH(createRequestStub({ username: "TakenUser" }), {
+			params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
+		});
+
+		expect(res.status).toBe(400);
+
+		const body = await res.json();
+		expect(body).toEqual({
+			error: {
+				message: "Username already exists",
+				code: "VALIDATION_INVALID_FIELD",
+				details: { field: "username", value: "takenuser" },
+			},
+		});
+	});
+});

+ 430 - 0
app/api/admin/users/route.js

@@ -0,0 +1,430 @@
+import bcrypt from "bcryptjs";
+
+import User, { USER_ROLES } from "@/models/user";
+import { getDb } from "@/lib/db";
+import { getSession } from "@/lib/auth/session";
+import { requireUserManagement } from "@/lib/auth/permissions";
+import { validateNewPassword } from "@/lib/auth/passwordPolicy";
+import {
+	withErrorHandling,
+	json,
+	badRequest,
+	unauthorized,
+} from "@/lib/api/errors";
+
+export const dynamic = "force-dynamic";
+
+const BRANCH_RE = /^NL\d+$/;
+const OBJECT_ID_RE = /^[a-f0-9]{24}$/i;
+
+const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{2,31}$/; // 3..32, conservative
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+const BCRYPT_SALT_ROUNDS = 12;
+
+const DEFAULT_LIMIT = 50;
+const MIN_LIMIT = 1;
+const MAX_LIMIT = 200;
+
+function isPlainObject(value) {
+	return Boolean(value && typeof value === "object" && !Array.isArray(value));
+}
+
+function isNonEmptyString(value) {
+	return typeof value === "string" && value.trim().length > 0;
+}
+
+function escapeRegExp(input) {
+	return String(input).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+function normalizeUsername(value) {
+	return String(value || "")
+		.trim()
+		.toLowerCase();
+}
+
+function normalizeEmail(value) {
+	return String(value || "")
+		.trim()
+		.toLowerCase();
+}
+
+function normalizeBranchId(value) {
+	return String(value || "")
+		.trim()
+		.toUpperCase();
+}
+
+function parseLimitOrThrow(raw) {
+	if (raw === null || raw === undefined || raw === "") return DEFAULT_LIMIT;
+
+	const s = String(raw).trim();
+	if (!/^\d+$/.test(s)) {
+		throw badRequest("VALIDATION_INVALID_FIELD", "Invalid limit parameter", {
+			field: "limit",
+			value: raw,
+			min: MIN_LIMIT,
+			max: MAX_LIMIT,
+		});
+	}
+
+	const n = Number(s);
+	if (!Number.isInteger(n)) {
+		throw badRequest("VALIDATION_INVALID_FIELD", "Invalid limit parameter", {
+			field: "limit",
+			value: raw,
+			min: MIN_LIMIT,
+			max: MAX_LIMIT,
+		});
+	}
+
+	return Math.min(MAX_LIMIT, Math.max(MIN_LIMIT, n));
+}
+
+function encodeCursor(payload) {
+	if (!isPlainObject(payload)) {
+		throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+			field: "cursor",
+		});
+	}
+
+	const v = payload.v ?? 1;
+	const lastId = payload.lastId;
+
+	if (v !== 1 || typeof lastId !== "string" || !OBJECT_ID_RE.test(lastId)) {
+		throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+			field: "cursor",
+		});
+	}
+
+	return Buffer.from(JSON.stringify({ v: 1, lastId }), "utf8").toString(
+		"base64url",
+	);
+}
+
+function decodeCursorOrThrow(raw) {
+	if (raw === null || raw === undefined || String(raw).trim() === "") {
+		return null;
+	}
+
+	const s = String(raw).trim();
+
+	let decoded;
+	try {
+		decoded = Buffer.from(s, "base64url").toString("utf8");
+	} catch {
+		throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+			field: "cursor",
+		});
+	}
+
+	let parsed;
+	try {
+		parsed = JSON.parse(decoded);
+	} catch {
+		throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+			field: "cursor",
+		});
+	}
+
+	if (!isPlainObject(parsed) || parsed.v !== 1) {
+		throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+			field: "cursor",
+		});
+	}
+
+	const lastId = parsed.lastId;
+	if (typeof lastId !== "string" || !OBJECT_ID_RE.test(lastId)) {
+		throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+			field: "cursor",
+		});
+	}
+
+	return lastId;
+}
+
+function toIsoOrNull(value) {
+	if (!value) return null;
+	try {
+		return new Date(value).toISOString();
+	} catch {
+		return null;
+	}
+}
+
+function toSafeUser(doc) {
+	return {
+		id: String(doc?._id),
+		username: typeof doc?.username === "string" ? doc.username : "",
+		email: typeof doc?.email === "string" ? doc.email : "",
+		role: typeof doc?.role === "string" ? doc.role : "",
+		branchId: doc?.branchId ?? null,
+		mustChangePassword: Boolean(doc?.mustChangePassword),
+		createdAt: toIsoOrNull(doc?.createdAt),
+		updatedAt: toIsoOrNull(doc?.updatedAt),
+	};
+}
+
+function pickDuplicateField(err) {
+	// Mongo duplicate key errors are typically: err.code === 11000
+	// and may include keyPattern / keyValue.
+	if (!err || typeof err !== "object") return null;
+
+	const keyValue =
+		err.keyValue && typeof err.keyValue === "object" ? err.keyValue : null;
+	if (keyValue) {
+		const keys = Object.keys(keyValue);
+		if (keys.length > 0) return keys[0];
+	}
+
+	const keyPattern =
+		err.keyPattern && typeof err.keyPattern === "object"
+			? err.keyPattern
+			: null;
+	if (keyPattern) {
+		const keys = Object.keys(keyPattern);
+		if (keys.length > 0) return keys[0];
+	}
+
+	return null;
+}
+
+const ALLOWED_ROLES = new Set(Object.values(USER_ROLES));
+
+export const GET = withErrorHandling(
+	async function GET(request) {
+		const session = await getSession();
+
+		if (!session) {
+			throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
+		}
+
+		requireUserManagement(session);
+
+		const { searchParams } = new URL(request.url);
+
+		const qRaw = searchParams.get("q");
+		const q =
+			typeof qRaw === "string" && qRaw.trim().length > 0 ? qRaw.trim() : null;
+
+		const roleRaw = searchParams.get("role");
+		const role =
+			typeof roleRaw === "string" && roleRaw.trim().length > 0
+				? roleRaw.trim()
+				: null;
+
+		const branchIdRaw = searchParams.get("branchId");
+		const branchId =
+			typeof branchIdRaw === "string" && branchIdRaw.trim().length > 0
+				? branchIdRaw.trim()
+				: null;
+
+		const limit = parseLimitOrThrow(searchParams.get("limit"));
+		const cursor = decodeCursorOrThrow(searchParams.get("cursor"));
+
+		if (role && !ALLOWED_ROLES.has(role)) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid role", {
+				field: "role",
+				value: role,
+				allowed: Array.from(ALLOWED_ROLES),
+			});
+		}
+
+		if (branchId && !BRANCH_RE.test(branchId)) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid branchId", {
+				field: "branchId",
+				value: branchId,
+				pattern: "^NL\\d+$",
+			});
+		}
+
+		const filter = {};
+
+		if (q) {
+			const re = new RegExp(escapeRegExp(q), "i");
+			filter.$or = [{ username: { $regex: re } }, { email: { $regex: re } }];
+		}
+
+		if (role) filter.role = role;
+		if (branchId) filter.branchId = branchId;
+		if (cursor) filter._id = { $lt: cursor };
+
+		await getDb();
+
+		const docs = await User.find(filter)
+			.sort({ _id: -1 })
+			.limit(limit + 1)
+			.select(
+				"_id username email role branchId mustChangePassword createdAt updatedAt",
+			)
+			.exec();
+
+		const list = Array.isArray(docs) ? docs : [];
+
+		const hasMore = list.length > limit;
+		const page = hasMore ? list.slice(0, limit) : list;
+
+		const nextCursor =
+			hasMore && page.length > 0
+				? encodeCursor({ v: 1, lastId: String(page[page.length - 1]._id) })
+				: null;
+
+		return json(
+			{
+				items: page.map(toSafeUser),
+				nextCursor,
+			},
+			200,
+		);
+	},
+	{ logPrefix: "[api/admin/users]" },
+);
+
+export const POST = withErrorHandling(
+	async function POST(request) {
+		const session = await getSession();
+
+		if (!session) {
+			throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
+		}
+
+		requireUserManagement(session);
+
+		let body;
+		try {
+			body = await request.json();
+		} catch {
+			throw badRequest("VALIDATION_INVALID_JSON", "Invalid request body");
+		}
+
+		if (!body || typeof body !== "object" || Array.isArray(body)) {
+			throw badRequest("VALIDATION_INVALID_BODY", "Invalid request body");
+		}
+
+		const usernameRaw = body.username;
+		const emailRaw = body.email;
+		const roleRaw = body.role;
+		const branchIdRaw = body.branchId;
+		const initialPasswordRaw = body.initialPassword;
+
+		const role =
+			typeof roleRaw === "string" && roleRaw.trim() ? roleRaw.trim() : null;
+
+		const missing = [];
+		if (!isNonEmptyString(usernameRaw)) missing.push("username");
+		if (!isNonEmptyString(emailRaw)) missing.push("email");
+		if (!isNonEmptyString(roleRaw)) missing.push("role");
+		if (!isNonEmptyString(initialPasswordRaw)) missing.push("initialPassword");
+
+		// branchId required iff role is branch (only if role is present)
+		if (role === USER_ROLES.BRANCH) {
+			if (!isNonEmptyString(branchIdRaw)) missing.push("branchId");
+		}
+
+		if (missing.length > 0) {
+			throw badRequest("VALIDATION_MISSING_FIELD", "Missing required fields", {
+				fields: missing,
+			});
+		}
+
+		// Validate role
+		if (!ALLOWED_ROLES.has(role)) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid role", {
+				field: "role",
+				value: role,
+				allowed: Array.from(ALLOWED_ROLES),
+			});
+		}
+
+		const username = normalizeUsername(usernameRaw);
+		if (!USERNAME_RE.test(username)) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid username", {
+				field: "username",
+				value: username,
+				pattern: String(USERNAME_RE),
+			});
+		}
+
+		const email = normalizeEmail(emailRaw);
+		if (!EMAIL_RE.test(email)) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid email", {
+				field: "email",
+				value: email,
+			});
+		}
+
+		let branchId = null;
+		if (role === USER_ROLES.BRANCH) {
+			branchId = normalizeBranchId(branchIdRaw);
+			if (!BRANCH_RE.test(branchId)) {
+				throw badRequest("VALIDATION_INVALID_FIELD", "Invalid branchId", {
+					field: "branchId",
+					value: branchId,
+					pattern: "^NL\\d+$",
+				});
+			}
+		}
+
+		const initialPassword = String(initialPasswordRaw);
+
+		const policyCheck = validateNewPassword({ newPassword: initialPassword });
+		if (!policyCheck.ok) {
+			throw badRequest("VALIDATION_WEAK_PASSWORD", "Weak password", {
+				...policyCheck.policy,
+				reasons: policyCheck.reasons,
+			});
+		}
+
+		await getDb();
+
+		// Uniqueness checks (explicit, predictable errors)
+		const existingUsername = await User.findOne({ username })
+			.select("_id")
+			.exec();
+
+		if (existingUsername) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Username already exists", {
+				field: "username",
+				value: username,
+			});
+		}
+
+		const existingEmail = await User.findOne({ email }).select("_id").exec();
+
+		if (existingEmail) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Email already exists", {
+				field: "email",
+				value: email,
+			});
+		}
+
+		const passwordHash = await bcrypt.hash(initialPassword, BCRYPT_SALT_ROUNDS);
+
+		try {
+			const created = await User.create({
+				username,
+				email,
+				passwordHash,
+				role,
+				branchId,
+				mustChangePassword: true,
+				passwordResetToken: null,
+				passwordResetExpiresAt: null,
+			});
+
+			return json({ ok: true, user: toSafeUser(created) }, 200);
+		} catch (err) {
+			// Race-condition safe duplicate mapping
+			if (err && err.code === 11000) {
+				const field = pickDuplicateField(err) || "unknown";
+				throw badRequest("VALIDATION_INVALID_FIELD", "Duplicate key", {
+					field,
+				});
+			}
+
+			throw err;
+		}
+	},
+	{ logPrefix: "[api/admin/users]" },
+);

+ 409 - 0
app/api/admin/users/route.test.js

@@ -0,0 +1,409 @@
+/* @vitest-environment node */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@/lib/auth/session", () => ({
+	getSession: vi.fn(),
+}));
+
+vi.mock("@/lib/db", () => ({
+	getDb: vi.fn(),
+}));
+
+vi.mock("@/models/user", () => {
+	const USER_ROLES = Object.freeze({
+		BRANCH: "branch",
+		ADMIN: "admin",
+		SUPERADMIN: "superadmin",
+		DEV: "dev",
+	});
+
+	return {
+		default: {
+			find: vi.fn(),
+			findOne: vi.fn(),
+			create: vi.fn(),
+		},
+		USER_ROLES,
+	};
+});
+
+vi.mock("bcryptjs", () => {
+	const hash = vi.fn();
+	return {
+		default: { hash },
+		hash,
+	};
+});
+
+import { getSession } from "@/lib/auth/session";
+import { getDb } from "@/lib/db";
+import User from "@/models/user";
+import { hash as bcryptHash } from "bcryptjs";
+
+import { GET, POST, dynamic } from "./route.js";
+
+function buildCursor(lastId) {
+	return Buffer.from(JSON.stringify({ v: 1, lastId }), "utf8").toString(
+		"base64url",
+	);
+}
+
+function createRequestStub(body) {
+	return {
+		async json() {
+			return body;
+		},
+	};
+}
+
+describe("GET /api/admin/users", () => {
+	beforeEach(() => {
+		vi.clearAllMocks();
+		getDb.mockResolvedValue({});
+	});
+
+	it('exports dynamic="force-dynamic"', () => {
+		expect(dynamic).toBe("force-dynamic");
+	});
+
+	it("returns 401 when unauthenticated", async () => {
+		getSession.mockResolvedValue(null);
+
+		const res = await GET(new Request("http://localhost/api/admin/users"));
+		expect(res.status).toBe(401);
+
+		expect(await res.json()).toEqual({
+			error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
+		});
+	});
+
+	it("returns 403 when authenticated but not allowed (admin)", async () => {
+		getSession.mockResolvedValue({
+			userId: "u1",
+			role: "admin",
+			branchId: null,
+			email: "admin@example.com",
+		});
+
+		const res = await GET(new Request("http://localhost/api/admin/users"));
+		expect(res.status).toBe(403);
+
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Forbidden",
+				code: "AUTH_FORBIDDEN_USER_MANAGEMENT",
+			},
+		});
+
+		expect(User.find).not.toHaveBeenCalled();
+	});
+
+	it("returns 200 with items and nextCursor (superadmin, limit + cursor)", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "superadmin",
+			branchId: null,
+			email: "superadmin@example.com",
+		});
+
+		const d1 = {
+			_id: "507f1f77bcf86cd799439013",
+			username: "u3",
+			email: "u3@example.com",
+			role: "admin",
+			branchId: null,
+			mustChangePassword: false,
+			createdAt: new Date("2026-02-01T10:00:00.000Z"),
+			updatedAt: new Date("2026-02-02T10:00:00.000Z"),
+		};
+		const d2 = {
+			_id: "507f1f77bcf86cd799439012",
+			username: "u2",
+			email: "u2@example.com",
+			role: "branch",
+			branchId: "NL01",
+			mustChangePassword: true,
+			createdAt: new Date("2026-02-01T09:00:00.000Z"),
+			updatedAt: new Date("2026-02-02T09:00:00.000Z"),
+		};
+		const d3 = {
+			_id: "507f1f77bcf86cd799439011",
+			username: "u1",
+			email: "u1@example.com",
+			role: "dev",
+			branchId: null,
+			mustChangePassword: false,
+			createdAt: new Date("2026-02-01T08:00:00.000Z"),
+			updatedAt: new Date("2026-02-02T08:00:00.000Z"),
+		};
+
+		const chain = {
+			sort: vi.fn().mockReturnThis(),
+			limit: vi.fn().mockReturnThis(),
+			select: vi.fn().mockReturnThis(),
+			exec: vi.fn().mockResolvedValue([d1, d2, d3]),
+		};
+
+		User.find.mockReturnValue(chain);
+
+		const res = await GET(
+			new Request("http://localhost/api/admin/users?limit=2"),
+		);
+
+		expect(res.status).toBe(200);
+
+		expect(chain.sort).toHaveBeenCalledWith({ _id: -1 });
+		expect(chain.limit).toHaveBeenCalledWith(3); // limit + 1
+
+		const body = await res.json();
+
+		expect(body.items).toHaveLength(2);
+		expect(body.nextCursor).toBe(buildCursor("507f1f77bcf86cd799439012"));
+	});
+});
+
+describe("POST /api/admin/users", () => {
+	beforeEach(() => {
+		vi.clearAllMocks();
+		getDb.mockResolvedValue({});
+		bcryptHash.mockResolvedValue("hashed");
+	});
+
+	it("returns 401 when unauthenticated", async () => {
+		getSession.mockResolvedValue(null);
+
+		const res = await POST(createRequestStub({}));
+		expect(res.status).toBe(401);
+
+		expect(await res.json()).toEqual({
+			error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
+		});
+	});
+
+	it("returns 403 when authenticated but not allowed (admin)", async () => {
+		getSession.mockResolvedValue({
+			userId: "u1",
+			role: "admin",
+			branchId: null,
+			email: "admin@example.com",
+		});
+
+		const res = await POST(createRequestStub({}));
+		expect(res.status).toBe(403);
+
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Forbidden",
+				code: "AUTH_FORBIDDEN_USER_MANAGEMENT",
+			},
+		});
+	});
+
+	it("returns 400 when JSON parsing fails", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "superadmin",
+			branchId: null,
+			email: "superadmin@example.com",
+		});
+
+		const req = { json: vi.fn().mockRejectedValue(new Error("invalid json")) };
+
+		const res = await POST(req);
+		expect(res.status).toBe(400);
+
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Invalid request body",
+				code: "VALIDATION_INVALID_JSON",
+			},
+		});
+	});
+
+	it("returns 400 when body is not an object", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "superadmin",
+			branchId: null,
+			email: "superadmin@example.com",
+		});
+
+		const res = await POST(createRequestStub("nope"));
+		expect(res.status).toBe(400);
+
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Invalid request body",
+				code: "VALIDATION_INVALID_BODY",
+			},
+		});
+	});
+
+	it("returns 400 when fields are missing", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "dev",
+			branchId: null,
+			email: "dev@example.com",
+		});
+
+		const res = await POST(createRequestStub({}));
+		expect(res.status).toBe(400);
+
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Missing required fields",
+				code: "VALIDATION_MISSING_FIELD",
+				details: { fields: ["username", "email", "role", "initialPassword"] },
+			},
+		});
+	});
+
+	it("returns 400 for invalid role", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "superadmin",
+			branchId: null,
+			email: "superadmin@example.com",
+		});
+
+		const res = await POST(
+			createRequestStub({
+				username: "newuser",
+				email: "new@example.com",
+				role: "nope",
+				initialPassword: "StrongPassword123",
+			}),
+		);
+
+		expect(res.status).toBe(400);
+
+		const body = await res.json();
+		expect(body.error.code).toBe("VALIDATION_INVALID_FIELD");
+		expect(body.error.details.field).toBe("role");
+	});
+
+	it("returns 400 for invalid branchId when role=branch", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "superadmin",
+			branchId: null,
+			email: "superadmin@example.com",
+		});
+
+		const res = await POST(
+			createRequestStub({
+				username: "newuser",
+				email: "new@example.com",
+				role: "branch",
+				branchId: "XX1",
+				initialPassword: "StrongPassword123",
+			}),
+		);
+
+		expect(res.status).toBe(400);
+
+		const body = await res.json();
+		expect(body.error.code).toBe("VALIDATION_INVALID_FIELD");
+		expect(body.error.details.field).toBe("branchId");
+	});
+
+	it("returns 400 for weak initialPassword", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "dev",
+			branchId: null,
+			email: "dev@example.com",
+		});
+
+		const res = await POST(
+			createRequestStub({
+				username: "newuser",
+				email: "new@example.com",
+				role: "admin",
+				initialPassword: "short1",
+			}),
+		);
+
+		expect(res.status).toBe(400);
+
+		const body = await res.json();
+		expect(body.error.code).toBe("VALIDATION_WEAK_PASSWORD");
+		expect(body.error.details).toMatchObject({
+			minLength: 8,
+			requireLetter: true,
+			requireNumber: true,
+		});
+		expect(Array.isArray(body.error.details.reasons)).toBe(true);
+	});
+
+	it("returns 200 and creates user with hashed password + mustChangePassword=true", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "superadmin",
+			branchId: null,
+			email: "superadmin@example.com",
+		});
+
+		User.findOne.mockImplementation((query) => {
+			return {
+				select: vi.fn().mockReturnThis(),
+				exec: vi.fn().mockResolvedValue(null),
+			};
+		});
+
+		User.create.mockResolvedValue({
+			_id: "507f1f77bcf86cd799439099",
+			username: "newuser",
+			email: "new@example.com",
+			role: "branch",
+			branchId: "NL01",
+			mustChangePassword: true,
+			createdAt: new Date("2026-02-06T10:00:00.000Z"),
+			updatedAt: new Date("2026-02-06T10:00:00.000Z"),
+		});
+
+		const res = await POST(
+			createRequestStub({
+				username: "NewUser",
+				email: "NEW@EXAMPLE.COM",
+				role: "branch",
+				branchId: "nl01",
+				initialPassword: "StrongPassword123",
+			}),
+		);
+
+		expect(res.status).toBe(200);
+
+		expect(getDb).toHaveBeenCalledTimes(1);
+		expect(bcryptHash).toHaveBeenCalledWith("StrongPassword123", 12);
+
+		expect(User.findOne).toHaveBeenCalledWith({ username: "newuser" });
+		expect(User.findOne).toHaveBeenCalledWith({ email: "new@example.com" });
+
+		expect(User.create).toHaveBeenCalledWith(
+			expect.objectContaining({
+				username: "newuser",
+				email: "new@example.com",
+				role: "branch",
+				branchId: "NL01",
+				passwordHash: "hashed",
+				mustChangePassword: true,
+			}),
+		);
+
+		const body = await res.json();
+		expect(body).toMatchObject({
+			ok: true,
+			user: {
+				id: "507f1f77bcf86cd799439099",
+				username: "newuser",
+				email: "new@example.com",
+				role: "branch",
+				branchId: "NL01",
+				mustChangePassword: true,
+			},
+		});
+	});
+});