@actuate-media/cms-core 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/__tests__/api/users-detail.test.d.ts +2 -0
  2. package/dist/__tests__/api/users-detail.test.d.ts.map +1 -0
  3. package/dist/__tests__/api/users-detail.test.js +349 -0
  4. package/dist/__tests__/api/users-detail.test.js.map +1 -0
  5. package/dist/__tests__/api/users-invite.test.js +80 -26
  6. package/dist/__tests__/api/users-invite.test.js.map +1 -1
  7. package/dist/__tests__/auth/invites.test.d.ts +2 -0
  8. package/dist/__tests__/auth/invites.test.d.ts.map +1 -0
  9. package/dist/__tests__/auth/invites.test.js +181 -0
  10. package/dist/__tests__/auth/invites.test.js.map +1 -0
  11. package/dist/__tests__/auth/roles.test.d.ts +2 -0
  12. package/dist/__tests__/auth/roles.test.d.ts.map +1 -0
  13. package/dist/__tests__/auth/roles.test.js +60 -0
  14. package/dist/__tests__/auth/roles.test.js.map +1 -0
  15. package/dist/__tests__/auth/team.test.d.ts +2 -0
  16. package/dist/__tests__/auth/team.test.d.ts.map +1 -0
  17. package/dist/__tests__/auth/team.test.js +139 -0
  18. package/dist/__tests__/auth/team.test.js.map +1 -0
  19. package/dist/__tests__/auth/user-admin.test.d.ts +2 -0
  20. package/dist/__tests__/auth/user-admin.test.d.ts.map +1 -0
  21. package/dist/__tests__/auth/user-admin.test.js +162 -0
  22. package/dist/__tests__/auth/user-admin.test.js.map +1 -0
  23. package/dist/api/handlers.d.ts.map +1 -1
  24. package/dist/api/handlers.js +837 -16
  25. package/dist/api/handlers.js.map +1 -1
  26. package/dist/auth/index.d.ts +4 -2
  27. package/dist/auth/index.d.ts.map +1 -1
  28. package/dist/auth/index.js +2 -1
  29. package/dist/auth/index.js.map +1 -1
  30. package/dist/auth/invites.d.ts +71 -0
  31. package/dist/auth/invites.d.ts.map +1 -0
  32. package/dist/auth/invites.js +279 -0
  33. package/dist/auth/invites.js.map +1 -0
  34. package/dist/auth/reset.d.ts.map +1 -1
  35. package/dist/auth/reset.js +27 -4
  36. package/dist/auth/reset.js.map +1 -1
  37. package/dist/auth/roles.d.ts +51 -0
  38. package/dist/auth/roles.d.ts.map +1 -0
  39. package/dist/auth/roles.js +159 -0
  40. package/dist/auth/roles.js.map +1 -0
  41. package/dist/auth/team.d.ts +86 -0
  42. package/dist/auth/team.d.ts.map +1 -0
  43. package/dist/auth/team.js +270 -0
  44. package/dist/auth/team.js.map +1 -0
  45. package/dist/auth/user-admin.d.ts +91 -0
  46. package/dist/auth/user-admin.d.ts.map +1 -0
  47. package/dist/auth/user-admin.js +250 -0
  48. package/dist/auth/user-admin.js.map +1 -0
  49. package/dist/index.d.ts +2 -0
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +2 -0
  52. package/dist/index.js.map +1 -1
  53. package/package.json +6 -1
  54. package/prisma/migrations/0011_user_roles_owner_viewer/migration.sql +6 -0
  55. package/prisma/migrations/0012_invites_and_user_lifecycle/migration.sql +40 -0
  56. package/prisma/schema.prisma +43 -0
  57. package/dist/__tests__/auth/invite.test.d.ts +0 -2
  58. package/dist/__tests__/auth/invite.test.d.ts.map +0 -1
  59. package/dist/__tests__/auth/invite.test.js +0 -151
  60. package/dist/__tests__/auth/invite.test.js.map +0 -1
  61. package/dist/auth/invite.d.ts +0 -37
  62. package/dist/auth/invite.d.ts.map +0 -1
  63. package/dist/auth/invite.js +0 -104
  64. package/dist/auth/invite.js.map +0 -1
@@ -3,7 +3,11 @@ import { populateRelations, parsePopulateParam } from '../fields/relations.js';
3
3
  import { verifyPassword, hashPassword, needsRehash, compareToDummyHash } from '../auth/password.js';
4
4
  import { createSession, verifySession, revokeSession } from '../auth/session.js';
5
5
  import { createPasswordReset, executePasswordReset } from '../auth/reset.js';
6
- import { createUserInvite } from '../auth/invite.js';
6
+ import { resolveEmailSender } from '../email/sender.js';
7
+ import { createInvite, listInvites, resendInvite, cancelInvite, updateInviteRole, extendInvite, } from '../auth/invites.js';
8
+ import { listTeamMembers, getUserStats, computeAccessReview, workspaceDomainFromUrl, } from '../auth/team.js';
9
+ import { getUserDetail, listUserSessions, listUserActivity, getUserPermissionView, } from '../auth/user-admin.js';
10
+ import { normalizeAssignableRole, roleRank, isOwnerRole } from '../auth/roles.js';
7
11
  import { checkSetupRequired, createInitialAdmin, isSchemaMissingError } from '../setup/index.js';
8
12
  import { getDB } from '../db.js';
9
13
  import { generateCodeVerifier, generateCodeChallenge, generateState, generateOAuthNonce, getAuthorizationUrl, handleOAuthCallback, } from '../auth/oauth.js';
@@ -1068,14 +1072,40 @@ function buildActionContext(session, db, locale) {
1068
1072
  db,
1069
1073
  };
1070
1074
  }
1071
- const WRITE_ROLES = new Set(['ADMIN', 'EDITOR', 'AUTHOR']);
1072
- const ADMIN_ROLES = new Set(['ADMIN']);
1075
+ const WRITE_ROLES = new Set(['OWNER', 'ADMIN', 'EDITOR', 'AUTHOR']);
1076
+ const ADMIN_ROLES = new Set(['OWNER', 'ADMIN']);
1073
1077
  function requireRole(role, allowedRoles) {
1074
1078
  if (!allowedRoles.has(role)) {
1075
1079
  return errorResponse('Insufficient permissions', 403);
1076
1080
  }
1077
1081
  return null;
1078
1082
  }
1083
+ /**
1084
+ * Build the shared {@link TeamOptions} for the Users endpoints from config +
1085
+ * session: the workspace domain (for external-domain detection) and the seat
1086
+ * cap (config-provided; null = unlimited until billing exists).
1087
+ */
1088
+ function teamOptionsFor(session) {
1089
+ const cfg = getActuateConfig();
1090
+ const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ??
1091
+ cfg?.seo?.siteUrl ??
1092
+ null;
1093
+ const seatLimit = cfg?.users?.seatLimit ??
1094
+ cfg?.admin?.seatLimit ??
1095
+ null;
1096
+ return {
1097
+ workspaceDomain: workspaceDomainFromUrl(siteUrl),
1098
+ seatLimit,
1099
+ currentUserId: session.userId,
1100
+ };
1101
+ }
1102
+ /** Whether the configured email provider can actually send mail. */
1103
+ function isEmailConfigured() {
1104
+ const cfg = getActuateConfig();
1105
+ if (cfg?.platform?.email)
1106
+ return true;
1107
+ return !!resolveEmailSender();
1108
+ }
1079
1109
  const loginLimiter = createRateLimiter({ maxRequests: 5, windowMs: 15 * 60 * 1000 });
1080
1110
  // Admin-gated, but bounds invitation-email volume (and accidental loops) per
1081
1111
  // admin: enough to onboard a team, low enough to deter abuse.
@@ -1438,6 +1468,16 @@ export function registerCMSRoutes(router) {
1438
1468
  if (!user.isActive) {
1439
1469
  return errorResponse('Account is deactivated', 403);
1440
1470
  }
1471
+ // Suspended and locked accounts keep `isActive` true (so they remain
1472
+ // distinct lifecycle states with preserved content/history) but must not
1473
+ // be able to sign in. Checked here so an admin "Suspend"/"Lock" takes
1474
+ // effect on the very next login attempt, not just on session revocation.
1475
+ if (user.suspendedAt) {
1476
+ return errorResponse('Account is suspended', 403);
1477
+ }
1478
+ if (user.lockedAt) {
1479
+ return errorResponse('Account is locked', 403);
1480
+ }
1441
1481
  // H6: opportunistically upgrade stored hashes that use weaker
1442
1482
  // PBKDF2 parameters than the current policy. We have the plaintext
1443
1483
  // here (and we know it's correct) — re-hash and persist. Wrapped in
@@ -4267,18 +4307,42 @@ export function registerCMSRoutes(router) {
4267
4307
  const auth = await requireAuth(request);
4268
4308
  if (auth.error)
4269
4309
  return auth.error;
4270
- if (auth.session.role !== 'ADMIN') {
4271
- return errorResponse('Forbidden', 403);
4272
- }
4273
- const users = await db().user.findMany({
4274
- select: { id: true, email: true, name: true, role: true, isActive: true, createdAt: true },
4275
- });
4276
- return json({ data: users });
4310
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4311
+ if (roleErr)
4312
+ return roleErr;
4313
+ const d = db();
4314
+ if (!hasModel(d, 'user'))
4315
+ return modelNotAvailable('user');
4316
+ const url = new URL(request.url);
4317
+ const members = await listTeamMembers(d, {
4318
+ search: url.searchParams.get('search') ?? undefined,
4319
+ role: url.searchParams.get('role') ?? undefined,
4320
+ status: url.searchParams.get('status') ?? undefined,
4321
+ }, teamOptionsFor(auth.session));
4322
+ return json({ data: members });
4277
4323
  }
4278
4324
  catch (err) {
4279
4325
  return internalError(err);
4280
4326
  }
4281
4327
  });
4328
+ router.get('/users/stats', async (request) => {
4329
+ try {
4330
+ const auth = await requireAuth(request);
4331
+ if (auth.error)
4332
+ return auth.error;
4333
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4334
+ if (roleErr)
4335
+ return roleErr;
4336
+ const d = db();
4337
+ if (!hasModel(d, 'user'))
4338
+ return modelNotAvailable('user');
4339
+ const stats = await getUserStats(d, teamOptionsFor(auth.session));
4340
+ return json({ data: { ...stats, emailConfigured: isEmailConfigured() } });
4341
+ }
4342
+ catch (err) {
4343
+ return internalError(err, 'user stats');
4344
+ }
4345
+ });
4282
4346
  // Lightweight directory lookup used by the comment mention picker.
4283
4347
  // Any authenticated user can query this — mentions are a
4284
4348
  // collaboration primitive, not an admin tool. Returns ONLY the
@@ -4349,19 +4413,37 @@ export function registerCMSRoutes(router) {
4349
4413
  const d = db();
4350
4414
  if (!hasModel(d, 'user'))
4351
4415
  return modelNotAvailable('user');
4416
+ if (!hasModel(d, 'invite'))
4417
+ return modelNotAvailable('invite');
4352
4418
  const body = (await request.json());
4353
4419
  if (!body.email)
4354
4420
  return errorResponse('Email is required', 400);
4421
+ // Only Owners may invite Owners.
4422
+ if ((body.role ?? '').toUpperCase() === 'OWNER' && auth.session.role !== 'OWNER') {
4423
+ return errorResponse('Only an Owner can assign the Owner role.', 403);
4424
+ }
4425
+ const opts = teamOptionsFor(auth.session);
4426
+ // Seat enforcement: active members + pending invites reserve seats.
4427
+ const stats = await getUserStats(d, opts);
4428
+ if (stats.seatsAvailable != null && stats.seatsAvailable <= 0) {
4429
+ return errorResponse(`All ${stats.seatLimit} seats are in use. Remove a member or raise the seat limit before inviting.`, 409);
4430
+ }
4355
4431
  const cmsConfig = getActuateConfig();
4356
4432
  const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? new URL(request.url).origin;
4357
4433
  const inviter = await d.user.findUnique({
4358
4434
  where: { id: auth.session.userId },
4359
4435
  select: { name: true },
4360
4436
  });
4361
- const result = await createUserInvite(d, { email: body.email, name: body.name, role: body.role ?? 'AUTHOR' }, {
4437
+ const result = await createInvite(d, {
4438
+ email: body.email,
4439
+ role: body.role ?? 'VIEWER',
4440
+ message: body.message,
4441
+ expiresInDays: body.expiresInDays,
4442
+ }, {
4362
4443
  siteUrl,
4363
4444
  adminPath: cmsConfig?.admin?.path ?? '/admin',
4364
4445
  invitedByName: inviter?.name ?? undefined,
4446
+ invitedByUserId: auth.session.userId,
4365
4447
  siteName: cmsConfig?.admin?.branding?.name ??
4366
4448
  undefined,
4367
4449
  });
@@ -4372,15 +4454,16 @@ export function registerCMSRoutes(router) {
4372
4454
  event: 'user_invited',
4373
4455
  userId: auth.session.userId,
4374
4456
  details: {
4375
- targetEmail: result.user?.email,
4376
- role: result.user?.role,
4457
+ targetEmail: result.invite?.email,
4458
+ role: result.invite?.role,
4459
+ inviteId: result.invite?.id,
4377
4460
  emailed: result.emailed,
4378
4461
  },
4379
4462
  });
4380
4463
  return json({
4381
4464
  data: {
4382
4465
  success: true,
4383
- user: result.user,
4466
+ invite: result.invite,
4384
4467
  emailed: result.emailed,
4385
4468
  inviteUrl: result.inviteUrl,
4386
4469
  },
@@ -4399,14 +4482,26 @@ export function registerCMSRoutes(router) {
4399
4482
  if (roleErr)
4400
4483
  return roleErr;
4401
4484
  if (auth.session.userId === params.id) {
4402
- return errorResponse('Cannot delete your own account', 400);
4485
+ return errorResponse('Cannot revoke your own access', 400);
4403
4486
  }
4404
4487
  const user = await db().user.findUnique({ where: { id: params.id } });
4405
4488
  if (!user)
4406
4489
  return errorResponse('User not found', 404);
4490
+ // Last-Owner protection: never deactivate the only remaining active Owner.
4491
+ if (isOwnerRole(user.role)) {
4492
+ if (auth.session.role !== 'OWNER') {
4493
+ return errorResponse('Only an Owner can revoke another Owner.', 403);
4494
+ }
4495
+ const otherOwners = await db().user.count({
4496
+ where: { role: 'OWNER', isActive: true, id: { not: params.id } },
4497
+ });
4498
+ if (otherOwners === 0) {
4499
+ return errorResponse('Cannot revoke access for the last Owner.', 409);
4500
+ }
4501
+ }
4407
4502
  await db().user.update({
4408
4503
  where: { id: params.id },
4409
- data: { isActive: false },
4504
+ data: { isActive: false, suspendedAt: new Date() },
4410
4505
  });
4411
4506
  await db().session.updateMany({
4412
4507
  where: { userId: params.id },
@@ -4423,6 +4518,732 @@ export function registerCMSRoutes(router) {
4423
4518
  return internalError(err);
4424
4519
  }
4425
4520
  });
4521
+ // Change a user's role, with Owner / last-Owner / self-escalation protections.
4522
+ router.patch('/users/:id/role', async (request, params) => {
4523
+ try {
4524
+ const auth = await requireAuth(request);
4525
+ if (auth.error)
4526
+ return auth.error;
4527
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4528
+ if (roleErr)
4529
+ return roleErr;
4530
+ const d = db();
4531
+ if (!hasModel(d, 'user'))
4532
+ return modelNotAvailable('user');
4533
+ const body = (await request.json());
4534
+ const newRole = normalizeAssignableRole(body.role);
4535
+ if (!newRole)
4536
+ return errorResponse('Select a valid role.', 400);
4537
+ const target = await d.user.findUnique({ where: { id: params.id } });
4538
+ if (!target)
4539
+ return errorResponse('User not found', 404);
4540
+ const targetIsOwner = isOwnerRole(target.role);
4541
+ const promotingToOwner = newRole === 'OWNER';
4542
+ // Only an Owner may grant or remove the Owner role.
4543
+ if ((promotingToOwner || targetIsOwner) && auth.session.role !== 'OWNER') {
4544
+ return errorResponse('Only an Owner can assign or change the Owner role.', 403);
4545
+ }
4546
+ // No self-escalation.
4547
+ if (auth.session.userId === target.id && roleRank(newRole) > roleRank(auth.session.role)) {
4548
+ return errorResponse('You cannot raise your own role.', 403);
4549
+ }
4550
+ // Last-Owner protection: don't demote the only remaining active Owner.
4551
+ if (targetIsOwner && !promotingToOwner) {
4552
+ const otherOwners = await d.user.count({
4553
+ where: { role: 'OWNER', isActive: true, id: { not: target.id } },
4554
+ });
4555
+ if (otherOwners === 0) {
4556
+ return errorResponse('Cannot change the role of the last Owner.', 409);
4557
+ }
4558
+ }
4559
+ if (target.role === newRole) {
4560
+ return json({ data: { success: true, role: newRole } });
4561
+ }
4562
+ const downgrade = roleRank(newRole) < roleRank(target.role);
4563
+ await d.user.update({ where: { id: target.id }, data: { role: newRole } });
4564
+ // Revoke sessions on downgrade so reduced permissions take effect at once.
4565
+ if (downgrade) {
4566
+ await d.session.updateMany({
4567
+ where: { userId: target.id, revokedAt: null },
4568
+ data: { revokedAt: new Date() },
4569
+ });
4570
+ }
4571
+ await logEvent({
4572
+ event: 'user_role_changed',
4573
+ userId: auth.session.userId,
4574
+ details: {
4575
+ targetUserId: target.id,
4576
+ targetEmail: target.email,
4577
+ previousRole: target.role,
4578
+ newRole,
4579
+ sessionsRevoked: downgrade,
4580
+ },
4581
+ });
4582
+ return json({ data: { success: true, role: newRole, sessionsRevoked: downgrade } });
4583
+ }
4584
+ catch (err) {
4585
+ return internalError(err, 'user role change');
4586
+ }
4587
+ });
4588
+ // ---------------------------------------------------------------------------
4589
+ // User detail drawer routes (overview / security / sessions / activity /
4590
+ // permissions) + per-user security actions.
4591
+ // ---------------------------------------------------------------------------
4592
+ /**
4593
+ * Load the target user for a security action and run the guards common to all
4594
+ * of them. Returns either the user row or an error Response. `requireOwner`
4595
+ * is set true for actions where acting on an Owner is owner-only; `blockSelf`
4596
+ * for actions a user must not perform on their own account.
4597
+ */
4598
+ async function loadActionTarget(auth, id, { blockSelf = false } = {}) {
4599
+ const d = db();
4600
+ if (!hasModel(d, 'user'))
4601
+ return { error: modelNotAvailable('user') };
4602
+ const user = await d.user.findUnique({ where: { id } });
4603
+ if (!user)
4604
+ return { error: errorResponse('User not found', 404) };
4605
+ if (blockSelf && auth.session.userId === id) {
4606
+ return { error: errorResponse('You cannot perform this action on your own account.', 400) };
4607
+ }
4608
+ // Acting on an Owner is Owner-only.
4609
+ if (isOwnerRole(user.role) && auth.session.role !== 'OWNER') {
4610
+ return { error: errorResponse('Only an Owner can manage another Owner.', 403) };
4611
+ }
4612
+ return { user };
4613
+ }
4614
+ /** Whether deactivating/suspending/locking `userId` would remove the last Owner. */
4615
+ async function wouldOrphanOwnership(user, userId) {
4616
+ if (!isOwnerRole(user.role))
4617
+ return false;
4618
+ const otherOwners = await db().user.count({
4619
+ where: { role: 'OWNER', isActive: true, id: { not: userId } },
4620
+ });
4621
+ return otherOwners === 0;
4622
+ }
4623
+ router.get('/users/:id', async (request, params) => {
4624
+ try {
4625
+ const auth = await requireAuth(request);
4626
+ if (auth.error)
4627
+ return auth.error;
4628
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4629
+ if (roleErr)
4630
+ return roleErr;
4631
+ const d = db();
4632
+ if (!hasModel(d, 'user'))
4633
+ return modelNotAvailable('user');
4634
+ const opts = teamOptionsFor(auth.session);
4635
+ const detail = await getUserDetail(d, params.id, {
4636
+ workspaceDomain: opts.workspaceDomain,
4637
+ currentUserId: auth.session.userId,
4638
+ });
4639
+ if (!detail)
4640
+ return errorResponse('User not found', 404);
4641
+ return json({ data: detail });
4642
+ }
4643
+ catch (err) {
4644
+ return internalError(err, 'user detail');
4645
+ }
4646
+ });
4647
+ router.get('/users/:id/sessions', async (request, params) => {
4648
+ try {
4649
+ const auth = await requireAuth(request);
4650
+ if (auth.error)
4651
+ return auth.error;
4652
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4653
+ if (roleErr)
4654
+ return roleErr;
4655
+ const d = db();
4656
+ if (!hasModel(d, 'session'))
4657
+ return modelNotAvailable('session');
4658
+ // Mark "this device" only when an admin views their own sessions.
4659
+ const currentSessionId = auth.session.userId === params.id ? auth.session.sessionId : undefined;
4660
+ const sessions = await listUserSessions(d, params.id, currentSessionId);
4661
+ return json({ data: sessions });
4662
+ }
4663
+ catch (err) {
4664
+ return internalError(err, 'user sessions');
4665
+ }
4666
+ });
4667
+ router.delete('/users/:id/sessions/:sessionId', async (request, params) => {
4668
+ try {
4669
+ const auth = await requireAuth(request);
4670
+ if (auth.error)
4671
+ return auth.error;
4672
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4673
+ if (roleErr)
4674
+ return roleErr;
4675
+ const d = db();
4676
+ if (!hasModel(d, 'session'))
4677
+ return modelNotAvailable('session');
4678
+ const target = await loadActionTarget(auth, params.id);
4679
+ if (target.error)
4680
+ return target.error;
4681
+ const session = await d.session.findUnique({ where: { id: params.sessionId } });
4682
+ if (!session || session.userId !== params.id) {
4683
+ return errorResponse('Session not found', 404);
4684
+ }
4685
+ await d.session.update({
4686
+ where: { id: params.sessionId },
4687
+ data: { revokedAt: new Date() },
4688
+ });
4689
+ await logEvent({
4690
+ event: 'session_revoked',
4691
+ userId: auth.session.userId,
4692
+ ipAddress: clientIp(request),
4693
+ userAgent: request.headers.get('user-agent') ?? undefined,
4694
+ details: { targetUserId: params.id, sessionId: params.sessionId },
4695
+ });
4696
+ return json({ data: { success: true } });
4697
+ }
4698
+ catch (err) {
4699
+ return internalError(err, 'revoke session');
4700
+ }
4701
+ });
4702
+ router.post('/users/:id/sessions/revoke-all', async (request, params) => {
4703
+ try {
4704
+ const auth = await requireAuth(request);
4705
+ if (auth.error)
4706
+ return auth.error;
4707
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4708
+ if (roleErr)
4709
+ return roleErr;
4710
+ const d = db();
4711
+ if (!hasModel(d, 'session'))
4712
+ return modelNotAvailable('session');
4713
+ const target = await loadActionTarget(auth, params.id);
4714
+ if (target.error)
4715
+ return target.error;
4716
+ // When an admin revokes their own sessions, keep the current one so the
4717
+ // action doesn't log them out mid-flow (the UI offers a separate path for
4718
+ // that). For any other user, revoke everything.
4719
+ const keepCurrent = auth.session.userId === params.id;
4720
+ await d.session.updateMany({
4721
+ where: {
4722
+ userId: params.id,
4723
+ revokedAt: null,
4724
+ ...(keepCurrent ? { id: { not: auth.session.sessionId } } : {}),
4725
+ },
4726
+ data: { revokedAt: new Date() },
4727
+ });
4728
+ await logEvent({
4729
+ event: 'sessions_revoked_all',
4730
+ userId: auth.session.userId,
4731
+ ipAddress: clientIp(request),
4732
+ userAgent: request.headers.get('user-agent') ?? undefined,
4733
+ details: { targetUserId: params.id, keptCurrent: keepCurrent },
4734
+ });
4735
+ return json({ data: { success: true } });
4736
+ }
4737
+ catch (err) {
4738
+ return internalError(err, 'revoke all sessions');
4739
+ }
4740
+ });
4741
+ router.get('/users/:id/activity', async (request, params) => {
4742
+ try {
4743
+ const auth = await requireAuth(request);
4744
+ if (auth.error)
4745
+ return auth.error;
4746
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4747
+ if (roleErr)
4748
+ return roleErr;
4749
+ const d = db();
4750
+ if (!hasModel(d, 'auditLog'))
4751
+ return json({ data: [] });
4752
+ const url = new URL(request.url);
4753
+ const limit = Number(url.searchParams.get('limit') ?? '50');
4754
+ const events = await listUserActivity(d, params.id, Number.isFinite(limit) ? limit : 50);
4755
+ return json({ data: events });
4756
+ }
4757
+ catch (err) {
4758
+ return internalError(err, 'user activity');
4759
+ }
4760
+ });
4761
+ router.get('/users/:id/permissions', async (request, params) => {
4762
+ try {
4763
+ const auth = await requireAuth(request);
4764
+ if (auth.error)
4765
+ return auth.error;
4766
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4767
+ if (roleErr)
4768
+ return roleErr;
4769
+ const d = db();
4770
+ if (!hasModel(d, 'user'))
4771
+ return modelNotAvailable('user');
4772
+ const user = await d.user.findUnique({
4773
+ where: { id: params.id },
4774
+ select: { role: true },
4775
+ });
4776
+ if (!user)
4777
+ return errorResponse('User not found', 404);
4778
+ return json({ data: getUserPermissionView(user.role) });
4779
+ }
4780
+ catch (err) {
4781
+ return internalError(err, 'user permissions');
4782
+ }
4783
+ });
4784
+ // Send a password reset email to the user. Admins never set or see passwords;
4785
+ // this triggers the standard single-use, expiring, hashed-token reset flow.
4786
+ router.post('/users/:id/password-reset', async (request, params) => {
4787
+ try {
4788
+ const auth = await requireAuth(request);
4789
+ if (auth.error)
4790
+ return auth.error;
4791
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4792
+ if (roleErr)
4793
+ return roleErr;
4794
+ const target = await loadActionTarget(auth, params.id);
4795
+ if (target.error)
4796
+ return target.error;
4797
+ const user = target.user;
4798
+ // SSO/OAuth-only accounts have no local password to reset.
4799
+ if (!user.passwordHash) {
4800
+ return errorResponse('This account signs in with an external identity provider and has no password to reset.', 409);
4801
+ }
4802
+ // Resetting an Admin/Owner password is high-impact — require reauth.
4803
+ if (isOwnerRole(user.role) || String(user.role).toUpperCase() === 'ADMIN') {
4804
+ const reauthErr = await requirePasswordReauth(request, auth.session.userId);
4805
+ if (reauthErr)
4806
+ return reauthErr;
4807
+ }
4808
+ if (!isEmailConfigured()) {
4809
+ return errorResponse('Email is not configured, so a reset link cannot be sent. Configure an email provider first.', 409);
4810
+ }
4811
+ const cmsConfig = getActuateConfig();
4812
+ const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? new URL(request.url).origin;
4813
+ await createPasswordReset(db(), user.email, {
4814
+ siteUrl,
4815
+ adminPath: cmsConfig?.admin?.path ?? '/admin',
4816
+ platform: cmsConfig?.platform,
4817
+ });
4818
+ await logEvent({
4819
+ event: 'password_reset_sent',
4820
+ userId: auth.session.userId,
4821
+ ipAddress: clientIp(request),
4822
+ userAgent: request.headers.get('user-agent') ?? undefined,
4823
+ details: { targetUserId: user.id, targetEmail: user.email },
4824
+ });
4825
+ return json({ data: { success: true, emailed: true } });
4826
+ }
4827
+ catch (err) {
4828
+ return internalError(err, 'admin password reset');
4829
+ }
4830
+ });
4831
+ // Toggle "must reset password on next login". Body: { enabled: boolean }.
4832
+ router.post('/users/:id/force-password-reset', async (request, params) => {
4833
+ try {
4834
+ const auth = await requireAuth(request);
4835
+ if (auth.error)
4836
+ return auth.error;
4837
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4838
+ if (roleErr)
4839
+ return roleErr;
4840
+ const target = await loadActionTarget(auth, params.id);
4841
+ if (target.error)
4842
+ return target.error;
4843
+ const body = (await request.json().catch(() => ({})));
4844
+ const enabled = body.enabled !== false;
4845
+ await db().user.update({
4846
+ where: { id: params.id },
4847
+ data: { forcePasswordReset: enabled },
4848
+ });
4849
+ await logEvent({
4850
+ event: enabled ? 'force_password_reset_enabled' : 'force_password_reset_cleared',
4851
+ userId: auth.session.userId,
4852
+ details: { targetUserId: params.id },
4853
+ });
4854
+ return json({ data: { success: true, forcePasswordReset: enabled } });
4855
+ }
4856
+ catch (err) {
4857
+ return internalError(err, 'force password reset');
4858
+ }
4859
+ });
4860
+ // Reset MFA enrollment: clears the TOTP secret + backup codes and revokes the
4861
+ // user's sessions. High-impact — requires reauth and protects the last Owner.
4862
+ router.post('/users/:id/mfa-reset', async (request, params) => {
4863
+ try {
4864
+ const auth = await requireAuth(request);
4865
+ if (auth.error)
4866
+ return auth.error;
4867
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4868
+ if (roleErr)
4869
+ return roleErr;
4870
+ const target = await loadActionTarget(auth, params.id);
4871
+ if (target.error)
4872
+ return target.error;
4873
+ const user = target.user;
4874
+ const reauthErr = await requirePasswordReauth(request, auth.session.userId);
4875
+ if (reauthErr)
4876
+ return reauthErr;
4877
+ // Don't strip the last Owner's MFA — that would leave the most privileged
4878
+ // account with the weakest protection and no recovery-safe path.
4879
+ if (isOwnerRole(user.role) && (await wouldOrphanOwnership(user, user.id))) {
4880
+ return errorResponse('Cannot reset MFA for the last Owner. Add another Owner first.', 409);
4881
+ }
4882
+ const d = db();
4883
+ await d.user.update({
4884
+ where: { id: user.id },
4885
+ data: { totpEnabled: false, totpSecret: null, backupCodes: null },
4886
+ });
4887
+ if (hasModel(d, 'session')) {
4888
+ await d.session.updateMany({
4889
+ where: { userId: user.id, revokedAt: null },
4890
+ data: { revokedAt: new Date() },
4891
+ });
4892
+ }
4893
+ await logEvent({
4894
+ event: 'mfa_reset',
4895
+ userId: auth.session.userId,
4896
+ ipAddress: clientIp(request),
4897
+ userAgent: request.headers.get('user-agent') ?? undefined,
4898
+ details: { targetUserId: user.id, targetEmail: user.email },
4899
+ });
4900
+ return json({ data: { success: true } });
4901
+ }
4902
+ catch (err) {
4903
+ return internalError(err, 'mfa reset');
4904
+ }
4905
+ });
4906
+ // Require the user to enrol MFA before next access. Body: { required: boolean }.
4907
+ router.post('/users/:id/require-mfa', async (request, params) => {
4908
+ try {
4909
+ const auth = await requireAuth(request);
4910
+ if (auth.error)
4911
+ return auth.error;
4912
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4913
+ if (roleErr)
4914
+ return roleErr;
4915
+ const target = await loadActionTarget(auth, params.id);
4916
+ if (target.error)
4917
+ return target.error;
4918
+ const body = (await request.json().catch(() => ({})));
4919
+ const required = body.required !== false;
4920
+ await db().user.update({
4921
+ where: { id: params.id },
4922
+ data: { mfaRequired: required },
4923
+ });
4924
+ await logEvent({
4925
+ event: required ? 'mfa_required' : 'mfa_requirement_cleared',
4926
+ userId: auth.session.userId,
4927
+ details: { targetUserId: params.id },
4928
+ });
4929
+ return json({ data: { success: true, mfaRequired: required } });
4930
+ }
4931
+ catch (err) {
4932
+ return internalError(err, 'require mfa');
4933
+ }
4934
+ });
4935
+ router.post('/users/:id/suspend', async (request, params) => {
4936
+ try {
4937
+ const auth = await requireAuth(request);
4938
+ if (auth.error)
4939
+ return auth.error;
4940
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4941
+ if (roleErr)
4942
+ return roleErr;
4943
+ const target = await loadActionTarget(auth, params.id, { blockSelf: true });
4944
+ if (target.error)
4945
+ return target.error;
4946
+ if (await wouldOrphanOwnership(target.user, params.id)) {
4947
+ return errorResponse('Cannot suspend the last Owner.', 409);
4948
+ }
4949
+ const d = db();
4950
+ await d.user.update({ where: { id: params.id }, data: { suspendedAt: new Date() } });
4951
+ if (hasModel(d, 'session')) {
4952
+ await d.session.updateMany({
4953
+ where: { userId: params.id, revokedAt: null },
4954
+ data: { revokedAt: new Date() },
4955
+ });
4956
+ }
4957
+ await logEvent({
4958
+ event: 'user_suspended',
4959
+ userId: auth.session.userId,
4960
+ details: { targetUserId: params.id, targetEmail: target.user.email },
4961
+ });
4962
+ return json({ data: { success: true } });
4963
+ }
4964
+ catch (err) {
4965
+ return internalError(err, 'suspend user');
4966
+ }
4967
+ });
4968
+ router.post('/users/:id/reactivate', async (request, params) => {
4969
+ try {
4970
+ const auth = await requireAuth(request);
4971
+ if (auth.error)
4972
+ return auth.error;
4973
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4974
+ if (roleErr)
4975
+ return roleErr;
4976
+ const target = await loadActionTarget(auth, params.id);
4977
+ if (target.error)
4978
+ return target.error;
4979
+ // Reactivate restores access from any non-active lifecycle state.
4980
+ await db().user.update({
4981
+ where: { id: params.id },
4982
+ data: { suspendedAt: null, lockedAt: null, isActive: true, failedLoginCount: 0 },
4983
+ });
4984
+ await logEvent({
4985
+ event: 'user_reactivated',
4986
+ userId: auth.session.userId,
4987
+ details: { targetUserId: params.id, targetEmail: target.user.email },
4988
+ });
4989
+ return json({ data: { success: true } });
4990
+ }
4991
+ catch (err) {
4992
+ return internalError(err, 'reactivate user');
4993
+ }
4994
+ });
4995
+ router.post('/users/:id/lock', async (request, params) => {
4996
+ try {
4997
+ const auth = await requireAuth(request);
4998
+ if (auth.error)
4999
+ return auth.error;
5000
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
5001
+ if (roleErr)
5002
+ return roleErr;
5003
+ const target = await loadActionTarget(auth, params.id, { blockSelf: true });
5004
+ if (target.error)
5005
+ return target.error;
5006
+ if (await wouldOrphanOwnership(target.user, params.id)) {
5007
+ return errorResponse('Cannot lock the last Owner.', 409);
5008
+ }
5009
+ const d = db();
5010
+ await d.user.update({ where: { id: params.id }, data: { lockedAt: new Date() } });
5011
+ if (hasModel(d, 'session')) {
5012
+ await d.session.updateMany({
5013
+ where: { userId: params.id, revokedAt: null },
5014
+ data: { revokedAt: new Date() },
5015
+ });
5016
+ }
5017
+ await logEvent({
5018
+ event: 'user_locked',
5019
+ userId: auth.session.userId,
5020
+ details: { targetUserId: params.id, targetEmail: target.user.email },
5021
+ });
5022
+ return json({ data: { success: true } });
5023
+ }
5024
+ catch (err) {
5025
+ return internalError(err, 'lock user');
5026
+ }
5027
+ });
5028
+ router.post('/users/:id/unlock', async (request, params) => {
5029
+ try {
5030
+ const auth = await requireAuth(request);
5031
+ if (auth.error)
5032
+ return auth.error;
5033
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
5034
+ if (roleErr)
5035
+ return roleErr;
5036
+ const target = await loadActionTarget(auth, params.id);
5037
+ if (target.error)
5038
+ return target.error;
5039
+ await db().user.update({
5040
+ where: { id: params.id },
5041
+ data: { lockedAt: null, failedLoginCount: 0 },
5042
+ });
5043
+ await logEvent({
5044
+ event: 'user_unlocked',
5045
+ userId: auth.session.userId,
5046
+ details: { targetUserId: params.id, targetEmail: target.user.email },
5047
+ });
5048
+ return json({ data: { success: true } });
5049
+ }
5050
+ catch (err) {
5051
+ return internalError(err, 'unlock user');
5052
+ }
5053
+ });
5054
+ // ---------------------------------------------------------------------------
5055
+ // Invite management routes
5056
+ // ---------------------------------------------------------------------------
5057
+ router.get('/invites', async (request) => {
5058
+ try {
5059
+ const auth = await requireAuth(request);
5060
+ if (auth.error)
5061
+ return auth.error;
5062
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
5063
+ if (roleErr)
5064
+ return roleErr;
5065
+ const d = db();
5066
+ if (!hasModel(d, 'invite'))
5067
+ return modelNotAvailable('invite');
5068
+ const url = new URL(request.url);
5069
+ const invites = await listInvites(d, {
5070
+ status: url.searchParams.get('status') ?? undefined,
5071
+ search: url.searchParams.get('search') ?? undefined,
5072
+ });
5073
+ return json({ data: invites });
5074
+ }
5075
+ catch (err) {
5076
+ return internalError(err, 'list invites');
5077
+ }
5078
+ });
5079
+ function inviteConfigFor(request, inviterName) {
5080
+ const cfg = getActuateConfig();
5081
+ return {
5082
+ siteUrl: process.env.NEXT_PUBLIC_SITE_URL ?? new URL(request.url).origin,
5083
+ adminPath: cfg?.admin?.path ?? '/admin',
5084
+ invitedByName: inviterName,
5085
+ siteName: cfg?.admin?.branding?.name ?? undefined,
5086
+ };
5087
+ }
5088
+ router.post('/invites/:id/resend', async (request, params) => {
5089
+ try {
5090
+ const auth = await requireAuth(request);
5091
+ if (auth.error)
5092
+ return auth.error;
5093
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
5094
+ if (roleErr)
5095
+ return roleErr;
5096
+ if (!(await checkRateLimitAsync(inviteLimiter, `invite:${auth.session.userId}`))) {
5097
+ return errorResponse('Too many invitations. Please try again later.', 429);
5098
+ }
5099
+ const d = db();
5100
+ if (!hasModel(d, 'invite'))
5101
+ return modelNotAvailable('invite');
5102
+ const result = await resendInvite(d, params.id, inviteConfigFor(request));
5103
+ if (!result.success) {
5104
+ return errorResponse(result.error ?? 'Could not resend invitation', 400);
5105
+ }
5106
+ await logEvent({
5107
+ event: 'invite_resent',
5108
+ userId: auth.session.userId,
5109
+ details: { inviteId: params.id, emailed: result.emailed },
5110
+ });
5111
+ return json({
5112
+ data: { success: true, emailed: result.emailed, inviteUrl: result.inviteUrl },
5113
+ });
5114
+ }
5115
+ catch (err) {
5116
+ return internalError(err, 'resend invite');
5117
+ }
5118
+ });
5119
+ router.post('/invites/:id/cancel', async (request, params) => {
5120
+ try {
5121
+ const auth = await requireAuth(request);
5122
+ if (auth.error)
5123
+ return auth.error;
5124
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
5125
+ if (roleErr)
5126
+ return roleErr;
5127
+ const d = db();
5128
+ if (!hasModel(d, 'invite'))
5129
+ return modelNotAvailable('invite');
5130
+ const result = await cancelInvite(d, params.id);
5131
+ if (!result.success) {
5132
+ return errorResponse(result.error ?? 'Could not cancel invitation', 400);
5133
+ }
5134
+ await logEvent({
5135
+ event: 'invite_cancelled',
5136
+ userId: auth.session.userId,
5137
+ details: { inviteId: params.id },
5138
+ });
5139
+ return json({ data: { success: true } });
5140
+ }
5141
+ catch (err) {
5142
+ return internalError(err, 'cancel invite');
5143
+ }
5144
+ });
5145
+ router.patch('/invites/:id', async (request, params) => {
5146
+ try {
5147
+ const auth = await requireAuth(request);
5148
+ if (auth.error)
5149
+ return auth.error;
5150
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
5151
+ if (roleErr)
5152
+ return roleErr;
5153
+ const d = db();
5154
+ if (!hasModel(d, 'invite'))
5155
+ return modelNotAvailable('invite');
5156
+ const body = (await request.json());
5157
+ if (body.role !== undefined) {
5158
+ if (body.role.toUpperCase() === 'OWNER' && auth.session.role !== 'OWNER') {
5159
+ return errorResponse('Only an Owner can assign the Owner role.', 403);
5160
+ }
5161
+ const result = await updateInviteRole(d, params.id, body.role);
5162
+ if (!result.success) {
5163
+ return errorResponse(result.error ?? 'Could not update invitation', 400);
5164
+ }
5165
+ await logEvent({
5166
+ event: 'invite_role_changed',
5167
+ userId: auth.session.userId,
5168
+ details: { inviteId: params.id, newRole: result.invite?.role },
5169
+ });
5170
+ return json({ data: { success: true, invite: result.invite } });
5171
+ }
5172
+ if (body.extendDays !== undefined) {
5173
+ const result = await extendInvite(d, params.id, body.extendDays);
5174
+ if (!result.success) {
5175
+ return errorResponse(result.error ?? 'Could not extend invitation', 400);
5176
+ }
5177
+ await logEvent({
5178
+ event: 'invite_extended',
5179
+ userId: auth.session.userId,
5180
+ details: { inviteId: params.id, expiresAt: result.invite?.expiresAt },
5181
+ });
5182
+ return json({ data: { success: true, invite: result.invite } });
5183
+ }
5184
+ return errorResponse('Nothing to update', 400);
5185
+ }
5186
+ catch (err) {
5187
+ return internalError(err, 'update invite');
5188
+ }
5189
+ });
5190
+ // ---------------------------------------------------------------------------
5191
+ // Access review routes
5192
+ // ---------------------------------------------------------------------------
5193
+ router.get('/access-review', async (request) => {
5194
+ try {
5195
+ const auth = await requireAuth(request);
5196
+ if (auth.error)
5197
+ return auth.error;
5198
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
5199
+ if (roleErr)
5200
+ return roleErr;
5201
+ const d = db();
5202
+ if (!hasModel(d, 'user'))
5203
+ return modelNotAvailable('user');
5204
+ // Dismissals are persisted as audit events — no extra table needed.
5205
+ let dismissedIds = [];
5206
+ try {
5207
+ const rows = await d.auditLog.findMany({
5208
+ where: { event: 'access_review_dismissed' },
5209
+ select: { details: true },
5210
+ });
5211
+ dismissedIds = rows
5212
+ .map((r) => r.details?.recommendationId)
5213
+ .filter((x) => typeof x === 'string');
5214
+ }
5215
+ catch {
5216
+ dismissedIds = [];
5217
+ }
5218
+ const recommendations = await computeAccessReview(d, {
5219
+ ...teamOptionsFor(auth.session),
5220
+ dismissedIds,
5221
+ });
5222
+ return json({ data: recommendations });
5223
+ }
5224
+ catch (err) {
5225
+ return internalError(err, 'access review');
5226
+ }
5227
+ });
5228
+ router.post('/access-review/:id/dismiss', async (request, params) => {
5229
+ try {
5230
+ const auth = await requireAuth(request);
5231
+ if (auth.error)
5232
+ return auth.error;
5233
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
5234
+ if (roleErr)
5235
+ return roleErr;
5236
+ await logEvent({
5237
+ event: 'access_review_dismissed',
5238
+ userId: auth.session.userId,
5239
+ details: { recommendationId: params.id },
5240
+ });
5241
+ return json({ data: { success: true } });
5242
+ }
5243
+ catch (err) {
5244
+ return internalError(err, 'dismiss access review');
5245
+ }
5246
+ });
4426
5247
  // ---------------------------------------------------------------------------
4427
5248
  // Forms routes
4428
5249
  // ---------------------------------------------------------------------------