| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436 |
- 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) {
- 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
- 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,
- });
- }
- 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 (return one field OR both fields)
- const [existingUsername, existingEmail] = await Promise.all([
- User.findOne({ username }).select("_id").exec(),
- User.findOne({ email }).select("_id").exec(),
- ]);
- const duplicateFields = [];
- if (existingUsername) duplicateFields.push("username");
- if (existingEmail) duplicateFields.push("email");
- if (duplicateFields.length === 1) {
- const field = duplicateFields[0];
- throw badRequest(
- "VALIDATION_INVALID_FIELD",
- field === "username"
- ? "Username already exists"
- : "Email already exists",
- { field },
- );
- }
- if (duplicateFields.length > 1) {
- throw badRequest(
- "VALIDATION_INVALID_FIELD",
- "Username and email already exist",
- { fields: duplicateFields },
- );
- }
- 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]" },
- );
|