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]" }, );