route.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  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. // Mongo duplicate key errors are typically: err.code === 11000
  141. // and may include keyPattern / keyValue.
  142. if (!err || typeof err !== "object") return null;
  143. const keyValue =
  144. err.keyValue && typeof err.keyValue === "object" ? err.keyValue : null;
  145. if (keyValue) {
  146. const keys = Object.keys(keyValue);
  147. if (keys.length > 0) return keys[0];
  148. }
  149. const keyPattern =
  150. err.keyPattern && typeof err.keyPattern === "object"
  151. ? err.keyPattern
  152. : null;
  153. if (keyPattern) {
  154. const keys = Object.keys(keyPattern);
  155. if (keys.length > 0) return keys[0];
  156. }
  157. return null;
  158. }
  159. const ALLOWED_ROLES = new Set(Object.values(USER_ROLES));
  160. export const GET = withErrorHandling(
  161. async function GET(request) {
  162. const session = await getSession();
  163. if (!session) {
  164. throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
  165. }
  166. requireUserManagement(session);
  167. const { searchParams } = new URL(request.url);
  168. const qRaw = searchParams.get("q");
  169. const q =
  170. typeof qRaw === "string" && qRaw.trim().length > 0 ? qRaw.trim() : null;
  171. const roleRaw = searchParams.get("role");
  172. const role =
  173. typeof roleRaw === "string" && roleRaw.trim().length > 0
  174. ? roleRaw.trim()
  175. : null;
  176. const branchIdRaw = searchParams.get("branchId");
  177. const branchId =
  178. typeof branchIdRaw === "string" && branchIdRaw.trim().length > 0
  179. ? branchIdRaw.trim()
  180. : null;
  181. const limit = parseLimitOrThrow(searchParams.get("limit"));
  182. const cursor = decodeCursorOrThrow(searchParams.get("cursor"));
  183. if (role && !ALLOWED_ROLES.has(role)) {
  184. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid role", {
  185. field: "role",
  186. value: role,
  187. allowed: Array.from(ALLOWED_ROLES),
  188. });
  189. }
  190. if (branchId && !BRANCH_RE.test(branchId)) {
  191. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid branchId", {
  192. field: "branchId",
  193. value: branchId,
  194. pattern: "^NL\\d+$",
  195. });
  196. }
  197. const filter = {};
  198. if (q) {
  199. const re = new RegExp(escapeRegExp(q), "i");
  200. filter.$or = [{ username: { $regex: re } }, { email: { $regex: re } }];
  201. }
  202. if (role) filter.role = role;
  203. if (branchId) filter.branchId = branchId;
  204. if (cursor) filter._id = { $lt: cursor };
  205. await getDb();
  206. const docs = await User.find(filter)
  207. .sort({ _id: -1 })
  208. .limit(limit + 1)
  209. .select(
  210. "_id username email role branchId mustChangePassword createdAt updatedAt",
  211. )
  212. .exec();
  213. const list = Array.isArray(docs) ? docs : [];
  214. const hasMore = list.length > limit;
  215. const page = hasMore ? list.slice(0, limit) : list;
  216. const nextCursor =
  217. hasMore && page.length > 0
  218. ? encodeCursor({ v: 1, lastId: String(page[page.length - 1]._id) })
  219. : null;
  220. return json(
  221. {
  222. items: page.map(toSafeUser),
  223. nextCursor,
  224. },
  225. 200,
  226. );
  227. },
  228. { logPrefix: "[api/admin/users]" },
  229. );
  230. export const POST = withErrorHandling(
  231. async function POST(request) {
  232. const session = await getSession();
  233. if (!session) {
  234. throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
  235. }
  236. requireUserManagement(session);
  237. let body;
  238. try {
  239. body = await request.json();
  240. } catch {
  241. throw badRequest("VALIDATION_INVALID_JSON", "Invalid request body");
  242. }
  243. if (!body || typeof body !== "object" || Array.isArray(body)) {
  244. throw badRequest("VALIDATION_INVALID_BODY", "Invalid request body");
  245. }
  246. const usernameRaw = body.username;
  247. const emailRaw = body.email;
  248. const roleRaw = body.role;
  249. const branchIdRaw = body.branchId;
  250. const initialPasswordRaw = body.initialPassword;
  251. const role =
  252. typeof roleRaw === "string" && roleRaw.trim() ? roleRaw.trim() : null;
  253. const missing = [];
  254. if (!isNonEmptyString(usernameRaw)) missing.push("username");
  255. if (!isNonEmptyString(emailRaw)) missing.push("email");
  256. if (!isNonEmptyString(roleRaw)) missing.push("role");
  257. if (!isNonEmptyString(initialPasswordRaw)) missing.push("initialPassword");
  258. // branchId required iff role is branch (only if role is present)
  259. if (role === USER_ROLES.BRANCH) {
  260. if (!isNonEmptyString(branchIdRaw)) missing.push("branchId");
  261. }
  262. if (missing.length > 0) {
  263. throw badRequest("VALIDATION_MISSING_FIELD", "Missing required fields", {
  264. fields: missing,
  265. });
  266. }
  267. // Validate role
  268. if (!ALLOWED_ROLES.has(role)) {
  269. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid role", {
  270. field: "role",
  271. value: role,
  272. allowed: Array.from(ALLOWED_ROLES),
  273. });
  274. }
  275. const username = normalizeUsername(usernameRaw);
  276. if (!USERNAME_RE.test(username)) {
  277. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid username", {
  278. field: "username",
  279. value: username,
  280. pattern: String(USERNAME_RE),
  281. });
  282. }
  283. const email = normalizeEmail(emailRaw);
  284. if (!EMAIL_RE.test(email)) {
  285. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid email", {
  286. field: "email",
  287. value: email,
  288. });
  289. }
  290. let branchId = null;
  291. if (role === USER_ROLES.BRANCH) {
  292. branchId = normalizeBranchId(branchIdRaw);
  293. if (!BRANCH_RE.test(branchId)) {
  294. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid branchId", {
  295. field: "branchId",
  296. value: branchId,
  297. pattern: "^NL\\d+$",
  298. });
  299. }
  300. }
  301. const initialPassword = String(initialPasswordRaw);
  302. const policyCheck = validateNewPassword({ newPassword: initialPassword });
  303. if (!policyCheck.ok) {
  304. throw badRequest("VALIDATION_WEAK_PASSWORD", "Weak password", {
  305. ...policyCheck.policy,
  306. reasons: policyCheck.reasons,
  307. });
  308. }
  309. await getDb();
  310. // Uniqueness checks (explicit, predictable errors)
  311. const existingUsername = await User.findOne({ username })
  312. .select("_id")
  313. .exec();
  314. if (existingUsername) {
  315. throw badRequest("VALIDATION_INVALID_FIELD", "Username already exists", {
  316. field: "username",
  317. value: username,
  318. });
  319. }
  320. const existingEmail = await User.findOne({ email }).select("_id").exec();
  321. if (existingEmail) {
  322. throw badRequest("VALIDATION_INVALID_FIELD", "Email already exists", {
  323. field: "email",
  324. value: email,
  325. });
  326. }
  327. const passwordHash = await bcrypt.hash(initialPassword, BCRYPT_SALT_ROUNDS);
  328. try {
  329. const created = await User.create({
  330. username,
  331. email,
  332. passwordHash,
  333. role,
  334. branchId,
  335. mustChangePassword: true,
  336. passwordResetToken: null,
  337. passwordResetExpiresAt: null,
  338. });
  339. return json({ ok: true, user: toSafeUser(created) }, 200);
  340. } catch (err) {
  341. // Race-condition safe duplicate mapping
  342. if (err && err.code === 11000) {
  343. const field = pickDuplicateField(err) || "unknown";
  344. throw badRequest("VALIDATION_INVALID_FIELD", "Duplicate key", {
  345. field,
  346. });
  347. }
  348. throw err;
  349. }
  350. },
  351. { logPrefix: "[api/admin/users]" },
  352. );