route.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. import bcrypt from "bcryptjs";
  2. import User, { USER_ROLES } from "@/models/user";
  3. import { getDb } from "@/lib/db";
  4. import { getSession } from "@/lib/auth/session";
  5. import { requireUserManagement } from "@/lib/auth/permissions";
  6. import { validateNewPassword } from "@/lib/auth/passwordPolicy";
  7. import {
  8. withErrorHandling,
  9. json,
  10. badRequest,
  11. unauthorized,
  12. } from "@/lib/api/errors";
  13. export const dynamic = "force-dynamic";
  14. const BRANCH_RE = /^NL\d+$/;
  15. const OBJECT_ID_RE = /^[a-f0-9]{24}$/i;
  16. const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{2,31}$/; // 3..32, conservative
  17. const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  18. const BCRYPT_SALT_ROUNDS = 12;
  19. const DEFAULT_LIMIT = 50;
  20. const MIN_LIMIT = 1;
  21. const MAX_LIMIT = 200;
  22. function isPlainObject(value) {
  23. return Boolean(value && typeof value === "object" && !Array.isArray(value));
  24. }
  25. function isNonEmptyString(value) {
  26. return typeof value === "string" && value.trim().length > 0;
  27. }
  28. function escapeRegExp(input) {
  29. return String(input).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  30. }
  31. function normalizeUsername(value) {
  32. return String(value || "")
  33. .trim()
  34. .toLowerCase();
  35. }
  36. function normalizeEmail(value) {
  37. return String(value || "")
  38. .trim()
  39. .toLowerCase();
  40. }
  41. function normalizeBranchId(value) {
  42. return String(value || "")
  43. .trim()
  44. .toUpperCase();
  45. }
  46. function parseLimitOrThrow(raw) {
  47. if (raw === null || raw === undefined || raw === "") return DEFAULT_LIMIT;
  48. const s = String(raw).trim();
  49. if (!/^\d+$/.test(s)) {
  50. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid limit parameter", {
  51. field: "limit",
  52. value: raw,
  53. min: MIN_LIMIT,
  54. max: MAX_LIMIT,
  55. });
  56. }
  57. const n = Number(s);
  58. if (!Number.isInteger(n)) {
  59. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid limit parameter", {
  60. field: "limit",
  61. value: raw,
  62. min: MIN_LIMIT,
  63. max: MAX_LIMIT,
  64. });
  65. }
  66. return Math.min(MAX_LIMIT, Math.max(MIN_LIMIT, n));
  67. }
  68. function encodeCursor(payload) {
  69. if (!isPlainObject(payload)) {
  70. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
  71. field: "cursor",
  72. });
  73. }
  74. const v = payload.v ?? 1;
  75. const lastId = payload.lastId;
  76. if (v !== 1 || typeof lastId !== "string" || !OBJECT_ID_RE.test(lastId)) {
  77. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
  78. field: "cursor",
  79. });
  80. }
  81. return Buffer.from(JSON.stringify({ v: 1, lastId }), "utf8").toString(
  82. "base64url",
  83. );
  84. }
  85. function decodeCursorOrThrow(raw) {
  86. if (raw === null || raw === undefined || String(raw).trim() === "") {
  87. return null;
  88. }
  89. const s = String(raw).trim();
  90. let decoded;
  91. try {
  92. decoded = Buffer.from(s, "base64url").toString("utf8");
  93. } catch {
  94. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
  95. field: "cursor",
  96. });
  97. }
  98. let parsed;
  99. try {
  100. parsed = JSON.parse(decoded);
  101. } catch {
  102. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
  103. field: "cursor",
  104. });
  105. }
  106. if (!isPlainObject(parsed) || parsed.v !== 1) {
  107. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
  108. field: "cursor",
  109. });
  110. }
  111. const lastId = parsed.lastId;
  112. if (typeof lastId !== "string" || !OBJECT_ID_RE.test(lastId)) {
  113. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
  114. field: "cursor",
  115. });
  116. }
  117. return lastId;
  118. }
  119. function toIsoOrNull(value) {
  120. if (!value) return null;
  121. try {
  122. return new Date(value).toISOString();
  123. } catch {
  124. return null;
  125. }
  126. }
  127. function toSafeUser(doc) {
  128. return {
  129. id: String(doc?._id),
  130. username: typeof doc?.username === "string" ? doc.username : "",
  131. email: typeof doc?.email === "string" ? doc.email : "",
  132. role: typeof doc?.role === "string" ? doc.role : "",
  133. branchId: doc?.branchId ?? null,
  134. mustChangePassword: Boolean(doc?.mustChangePassword),
  135. createdAt: toIsoOrNull(doc?.createdAt),
  136. updatedAt: toIsoOrNull(doc?.updatedAt),
  137. };
  138. }
  139. function pickDuplicateField(err) {
  140. if (!err || typeof err !== "object") return null;
  141. const keyValue =
  142. err.keyValue && typeof err.keyValue === "object" ? err.keyValue : null;
  143. if (keyValue) {
  144. const keys = Object.keys(keyValue);
  145. if (keys.length > 0) return keys[0];
  146. }
  147. const keyPattern =
  148. err.keyPattern && typeof err.keyPattern === "object"
  149. ? err.keyPattern
  150. : null;
  151. if (keyPattern) {
  152. const keys = Object.keys(keyPattern);
  153. if (keys.length > 0) return keys[0];
  154. }
  155. return null;
  156. }
  157. const ALLOWED_ROLES = new Set(Object.values(USER_ROLES));
  158. export const GET = withErrorHandling(
  159. async function GET(request) {
  160. const session = await getSession();
  161. if (!session) {
  162. throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
  163. }
  164. requireUserManagement(session);
  165. const { searchParams } = new URL(request.url);
  166. const qRaw = searchParams.get("q");
  167. const q =
  168. typeof qRaw === "string" && qRaw.trim().length > 0 ? qRaw.trim() : null;
  169. const roleRaw = searchParams.get("role");
  170. const role =
  171. typeof roleRaw === "string" && roleRaw.trim().length > 0
  172. ? roleRaw.trim()
  173. : null;
  174. const branchIdRaw = searchParams.get("branchId");
  175. const branchId =
  176. typeof branchIdRaw === "string" && branchIdRaw.trim().length > 0
  177. ? branchIdRaw.trim()
  178. : null;
  179. const limit = parseLimitOrThrow(searchParams.get("limit"));
  180. const cursor = decodeCursorOrThrow(searchParams.get("cursor"));
  181. if (role && !ALLOWED_ROLES.has(role)) {
  182. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid role", {
  183. field: "role",
  184. value: role,
  185. allowed: Array.from(ALLOWED_ROLES),
  186. });
  187. }
  188. if (branchId && !BRANCH_RE.test(branchId)) {
  189. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid branchId", {
  190. field: "branchId",
  191. value: branchId,
  192. pattern: "^NL\\d+$",
  193. });
  194. }
  195. const filter = {};
  196. if (q) {
  197. const re = new RegExp(escapeRegExp(q), "i");
  198. filter.$or = [{ username: { $regex: re } }, { email: { $regex: re } }];
  199. }
  200. if (role) filter.role = role;
  201. if (branchId) filter.branchId = branchId;
  202. if (cursor) filter._id = { $lt: cursor };
  203. await getDb();
  204. const docs = await User.find(filter)
  205. .sort({ _id: -1 })
  206. .limit(limit + 1)
  207. .select(
  208. "_id username email role branchId mustChangePassword createdAt updatedAt",
  209. )
  210. .exec();
  211. const list = Array.isArray(docs) ? docs : [];
  212. const hasMore = list.length > limit;
  213. const page = hasMore ? list.slice(0, limit) : list;
  214. const nextCursor =
  215. hasMore && page.length > 0
  216. ? encodeCursor({ v: 1, lastId: String(page[page.length - 1]._id) })
  217. : null;
  218. return json(
  219. {
  220. items: page.map(toSafeUser),
  221. nextCursor,
  222. },
  223. 200,
  224. );
  225. },
  226. { logPrefix: "[api/admin/users]" },
  227. );
  228. export const POST = withErrorHandling(
  229. async function POST(request) {
  230. const session = await getSession();
  231. if (!session) {
  232. throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
  233. }
  234. requireUserManagement(session);
  235. let body;
  236. try {
  237. body = await request.json();
  238. } catch {
  239. throw badRequest("VALIDATION_INVALID_JSON", "Invalid request body");
  240. }
  241. if (!body || typeof body !== "object" || Array.isArray(body)) {
  242. throw badRequest("VALIDATION_INVALID_BODY", "Invalid request body");
  243. }
  244. const usernameRaw = body.username;
  245. const emailRaw = body.email;
  246. const roleRaw = body.role;
  247. const branchIdRaw = body.branchId;
  248. const initialPasswordRaw = body.initialPassword;
  249. const role =
  250. typeof roleRaw === "string" && roleRaw.trim() ? roleRaw.trim() : null;
  251. const missing = [];
  252. if (!isNonEmptyString(usernameRaw)) missing.push("username");
  253. if (!isNonEmptyString(emailRaw)) missing.push("email");
  254. if (!isNonEmptyString(roleRaw)) missing.push("role");
  255. if (!isNonEmptyString(initialPasswordRaw)) missing.push("initialPassword");
  256. // branchId required iff role is branch
  257. if (role === USER_ROLES.BRANCH) {
  258. if (!isNonEmptyString(branchIdRaw)) missing.push("branchId");
  259. }
  260. if (missing.length > 0) {
  261. throw badRequest("VALIDATION_MISSING_FIELD", "Missing required fields", {
  262. fields: missing,
  263. });
  264. }
  265. if (!ALLOWED_ROLES.has(role)) {
  266. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid role", {
  267. field: "role",
  268. value: role,
  269. allowed: Array.from(ALLOWED_ROLES),
  270. });
  271. }
  272. const username = normalizeUsername(usernameRaw);
  273. if (!USERNAME_RE.test(username)) {
  274. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid username", {
  275. field: "username",
  276. value: username,
  277. pattern: String(USERNAME_RE),
  278. });
  279. }
  280. const email = normalizeEmail(emailRaw);
  281. if (!EMAIL_RE.test(email)) {
  282. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid email", {
  283. field: "email",
  284. value: email,
  285. });
  286. }
  287. let branchId = null;
  288. if (role === USER_ROLES.BRANCH) {
  289. branchId = normalizeBranchId(branchIdRaw);
  290. if (!BRANCH_RE.test(branchId)) {
  291. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid branchId", {
  292. field: "branchId",
  293. value: branchId,
  294. pattern: "^NL\\d+$",
  295. });
  296. }
  297. }
  298. const initialPassword = String(initialPasswordRaw);
  299. const policyCheck = validateNewPassword({ newPassword: initialPassword });
  300. if (!policyCheck.ok) {
  301. throw badRequest("VALIDATION_WEAK_PASSWORD", "Weak password", {
  302. ...policyCheck.policy,
  303. reasons: policyCheck.reasons,
  304. });
  305. }
  306. await getDb();
  307. // Uniqueness checks (return one field OR both fields)
  308. const [existingUsername, existingEmail] = await Promise.all([
  309. User.findOne({ username }).select("_id").exec(),
  310. User.findOne({ email }).select("_id").exec(),
  311. ]);
  312. const duplicateFields = [];
  313. if (existingUsername) duplicateFields.push("username");
  314. if (existingEmail) duplicateFields.push("email");
  315. if (duplicateFields.length === 1) {
  316. const field = duplicateFields[0];
  317. throw badRequest(
  318. "VALIDATION_INVALID_FIELD",
  319. field === "username"
  320. ? "Username already exists"
  321. : "Email already exists",
  322. { field },
  323. );
  324. }
  325. if (duplicateFields.length > 1) {
  326. throw badRequest(
  327. "VALIDATION_INVALID_FIELD",
  328. "Username and email already exist",
  329. { fields: duplicateFields },
  330. );
  331. }
  332. const passwordHash = await bcrypt.hash(initialPassword, BCRYPT_SALT_ROUNDS);
  333. try {
  334. const created = await User.create({
  335. username,
  336. email,
  337. passwordHash,
  338. role,
  339. branchId,
  340. mustChangePassword: true,
  341. passwordResetToken: null,
  342. passwordResetExpiresAt: null,
  343. });
  344. return json({ ok: true, user: toSafeUser(created) }, 200);
  345. } catch (err) {
  346. // Race-condition safe duplicate mapping
  347. if (err && err.code === 11000) {
  348. const field = pickDuplicateField(err) || "unknown";
  349. throw badRequest("VALIDATION_INVALID_FIELD", "Duplicate key", {
  350. field,
  351. });
  352. }
  353. throw err;
  354. }
  355. },
  356. { logPrefix: "[api/admin/users]" },
  357. );