route.js 13 KB

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