@actuate-media/cms-core 0.38.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 (91) hide show
  1. package/dist/__tests__/api/ai-status.test.d.ts +2 -0
  2. package/dist/__tests__/api/ai-status.test.d.ts.map +1 -0
  3. package/dist/__tests__/api/ai-status.test.js +110 -0
  4. package/dist/__tests__/api/ai-status.test.js.map +1 -0
  5. package/dist/__tests__/api/users-detail.test.d.ts +2 -0
  6. package/dist/__tests__/api/users-detail.test.d.ts.map +1 -0
  7. package/dist/__tests__/api/users-detail.test.js +349 -0
  8. package/dist/__tests__/api/users-detail.test.js.map +1 -0
  9. package/dist/__tests__/api/users-invite.test.d.ts +2 -0
  10. package/dist/__tests__/api/users-invite.test.d.ts.map +1 -0
  11. package/dist/__tests__/api/users-invite.test.js +221 -0
  12. package/dist/__tests__/api/users-invite.test.js.map +1 -0
  13. package/dist/__tests__/auth/invites.test.d.ts +2 -0
  14. package/dist/__tests__/auth/invites.test.d.ts.map +1 -0
  15. package/dist/__tests__/auth/invites.test.js +181 -0
  16. package/dist/__tests__/auth/invites.test.js.map +1 -0
  17. package/dist/__tests__/auth/reset.test.js +52 -1
  18. package/dist/__tests__/auth/reset.test.js.map +1 -1
  19. package/dist/__tests__/auth/roles.test.d.ts +2 -0
  20. package/dist/__tests__/auth/roles.test.d.ts.map +1 -0
  21. package/dist/__tests__/auth/roles.test.js +60 -0
  22. package/dist/__tests__/auth/roles.test.js.map +1 -0
  23. package/dist/__tests__/auth/team.test.d.ts +2 -0
  24. package/dist/__tests__/auth/team.test.d.ts.map +1 -0
  25. package/dist/__tests__/auth/team.test.js +139 -0
  26. package/dist/__tests__/auth/team.test.js.map +1 -0
  27. package/dist/__tests__/auth/user-admin.test.d.ts +2 -0
  28. package/dist/__tests__/auth/user-admin.test.d.ts.map +1 -0
  29. package/dist/__tests__/auth/user-admin.test.js +162 -0
  30. package/dist/__tests__/auth/user-admin.test.js.map +1 -0
  31. package/dist/__tests__/email/sender.test.d.ts +2 -0
  32. package/dist/__tests__/email/sender.test.d.ts.map +1 -0
  33. package/dist/__tests__/email/sender.test.js +33 -0
  34. package/dist/__tests__/email/sender.test.js.map +1 -0
  35. package/dist/__tests__/middleware.test.js +30 -0
  36. package/dist/__tests__/middleware.test.js.map +1 -1
  37. package/dist/__tests__/setup/index.test.js +37 -2
  38. package/dist/__tests__/setup/index.test.js.map +1 -1
  39. package/dist/api/handlers.d.ts.map +1 -1
  40. package/dist/api/handlers.js +1116 -194
  41. package/dist/api/handlers.js.map +1 -1
  42. package/dist/auth/index.d.ts +4 -0
  43. package/dist/auth/index.d.ts.map +1 -1
  44. package/dist/auth/index.js +2 -0
  45. package/dist/auth/index.js.map +1 -1
  46. package/dist/auth/invite-email.d.ts +12 -0
  47. package/dist/auth/invite-email.d.ts.map +1 -0
  48. package/dist/auth/invite-email.js +57 -0
  49. package/dist/auth/invite-email.js.map +1 -0
  50. package/dist/auth/invites.d.ts +71 -0
  51. package/dist/auth/invites.d.ts.map +1 -0
  52. package/dist/auth/invites.js +279 -0
  53. package/dist/auth/invites.js.map +1 -0
  54. package/dist/auth/reset.d.ts +6 -0
  55. package/dist/auth/reset.d.ts.map +1 -1
  56. package/dist/auth/reset.js +38 -7
  57. package/dist/auth/reset.js.map +1 -1
  58. package/dist/auth/roles.d.ts +51 -0
  59. package/dist/auth/roles.d.ts.map +1 -0
  60. package/dist/auth/roles.js +159 -0
  61. package/dist/auth/roles.js.map +1 -0
  62. package/dist/auth/team.d.ts +86 -0
  63. package/dist/auth/team.d.ts.map +1 -0
  64. package/dist/auth/team.js +270 -0
  65. package/dist/auth/team.js.map +1 -0
  66. package/dist/auth/user-admin.d.ts +91 -0
  67. package/dist/auth/user-admin.d.ts.map +1 -0
  68. package/dist/auth/user-admin.js +250 -0
  69. package/dist/auth/user-admin.js.map +1 -0
  70. package/dist/email/sender.d.ts +35 -0
  71. package/dist/email/sender.d.ts.map +1 -0
  72. package/dist/email/sender.js +25 -0
  73. package/dist/email/sender.js.map +1 -0
  74. package/dist/forms/notify.d.ts.map +1 -1
  75. package/dist/forms/notify.js +3 -2
  76. package/dist/forms/notify.js.map +1 -1
  77. package/dist/index.d.ts +3 -0
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +4 -0
  80. package/dist/index.js.map +1 -1
  81. package/dist/middleware.d.ts.map +1 -1
  82. package/dist/middleware.js +60 -1
  83. package/dist/middleware.js.map +1 -1
  84. package/dist/setup/index.d.ts +14 -0
  85. package/dist/setup/index.d.ts.map +1 -1
  86. package/dist/setup/index.js +29 -3
  87. package/dist/setup/index.js.map +1 -1
  88. package/package.json +11 -1
  89. package/prisma/migrations/0011_user_roles_owner_viewer/migration.sql +6 -0
  90. package/prisma/migrations/0012_invites_and_user_lifecycle/migration.sql +40 -0
  91. package/prisma/schema.prisma +43 -0
@@ -3,7 +3,12 @@ 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 { checkSetupRequired, createInitialAdmin } from '../setup/index.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';
11
+ import { checkSetupRequired, createInitialAdmin, isSchemaMissingError } from '../setup/index.js';
7
12
  import { getDB } from '../db.js';
8
13
  import { generateCodeVerifier, generateCodeChallenge, generateState, generateOAuthNonce, getAuthorizationUrl, handleOAuthCallback, } from '../auth/oauth.js';
9
14
  import { createMfaPendingToken, verifyMfaPendingToken, computeRequestFingerprint, } from '../auth/mfa-pending.js';
@@ -1067,15 +1072,44 @@ function buildActionContext(session, db, locale) {
1067
1072
  db,
1068
1073
  };
1069
1074
  }
1070
- const WRITE_ROLES = new Set(['ADMIN', 'EDITOR', 'AUTHOR']);
1071
- const ADMIN_ROLES = new Set(['ADMIN']);
1075
+ const WRITE_ROLES = new Set(['OWNER', 'ADMIN', 'EDITOR', 'AUTHOR']);
1076
+ const ADMIN_ROLES = new Set(['OWNER', 'ADMIN']);
1072
1077
  function requireRole(role, allowedRoles) {
1073
1078
  if (!allowedRoles.has(role)) {
1074
1079
  return errorResponse('Insufficient permissions', 403);
1075
1080
  }
1076
1081
  return null;
1077
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
+ }
1078
1109
  const loginLimiter = createRateLimiter({ maxRequests: 5, windowMs: 15 * 60 * 1000 });
1110
+ // Admin-gated, but bounds invitation-email volume (and accidental loops) per
1111
+ // admin: enough to onboard a team, low enough to deter abuse.
1112
+ const inviteLimiter = createRateLimiter({ maxRequests: 20, windowMs: 60 * 60 * 1000 });
1079
1113
  const totpLimiter = createRateLimiter({ maxRequests: 10, windowMs: 15 * 60 * 1000 });
1080
1114
  const formLimiterGlobal = createRateLimiter({ maxRequests: 10, windowMs: 60_000 });
1081
1115
  const aiGenerateLimiter = createRateLimiter({ maxRequests: 20, windowMs: 60 * 60 * 1000 });
@@ -1434,6 +1468,16 @@ export function registerCMSRoutes(router) {
1434
1468
  if (!user.isActive) {
1435
1469
  return errorResponse('Account is deactivated', 403);
1436
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
+ }
1437
1481
  // H6: opportunistically upgrade stored hashes that use weaker
1438
1482
  // PBKDF2 parameters than the current policy. We have the plaintext
1439
1483
  // here (and we know it's correct) — re-hash and persist. Wrapped in
@@ -1687,6 +1731,7 @@ export function registerCMSRoutes(router) {
1687
1731
  const cmsConfig = getActuateConfig();
1688
1732
  await createPasswordReset(d, email.toLowerCase().trim(), {
1689
1733
  siteUrl,
1734
+ adminPath: cmsConfig?.admin?.path ?? '/admin',
1690
1735
  platform: cmsConfig?.platform,
1691
1736
  });
1692
1737
  await logEvent({
@@ -3471,6 +3516,9 @@ export function registerCMSRoutes(router) {
3471
3516
  return json({ data: result }, 201);
3472
3517
  }
3473
3518
  catch (err) {
3519
+ if (isSchemaMissingError(err)) {
3520
+ return errorResponse('Database not migrated: the Actuate CMS tables do not exist yet. Run `prisma migrate deploy` against your database, then try again.', 503);
3521
+ }
3474
3522
  return internalError(err);
3475
3523
  }
3476
3524
  });
@@ -4100,234 +4148,1084 @@ export function registerCMSRoutes(router) {
4100
4148
  // clients (AI agents, CI, integrations) against the same REST surface as a
4101
4149
  // user session, but skip CSRF and use scope-based authorization.
4102
4150
  // ---------------------------------------------------------------------------
4103
- router.get('/api-keys', async (request) => {
4151
+ router.get('/api-keys', async (request) => {
4152
+ try {
4153
+ const auth = await requireAuth(request);
4154
+ if (auth.error)
4155
+ return auth.error;
4156
+ const adminErr = requireAdminScope(auth.session);
4157
+ if (adminErr)
4158
+ return adminErr;
4159
+ const d = db();
4160
+ if (!hasModel(d, 'apiKey'))
4161
+ return json({ data: [] });
4162
+ const keys = await d.apiKey.findMany({
4163
+ orderBy: { createdAt: 'desc' },
4164
+ select: {
4165
+ id: true,
4166
+ name: true,
4167
+ keyPrefix: true,
4168
+ scopes: true,
4169
+ ipRestrictions: true,
4170
+ expiresAt: true,
4171
+ lastUsedAt: true,
4172
+ revokedAt: true,
4173
+ createdAt: true,
4174
+ user: { select: { id: true, name: true, email: true } },
4175
+ },
4176
+ });
4177
+ return json({ data: keys });
4178
+ }
4179
+ catch (err) {
4180
+ return internalError(err, 'api-keys/list');
4181
+ }
4182
+ });
4183
+ router.post('/api-keys', async (request) => {
4184
+ try {
4185
+ const auth = await requireAuth(request);
4186
+ if (auth.error)
4187
+ return auth.error;
4188
+ const adminErr = requireAdminScope(auth.session);
4189
+ if (adminErr)
4190
+ return adminErr;
4191
+ // Issuing a new credential is a sensitive action — require reauth for
4192
+ // session-authenticated callers. API-key-authenticated requests are
4193
+ // already proving possession of a long-lived credential.
4194
+ if (!auth.session.apiKey) {
4195
+ const reauthErr = await requirePasswordReauth(request, auth.session.userId);
4196
+ if (reauthErr)
4197
+ return reauthErr;
4198
+ }
4199
+ const body = (await request.json());
4200
+ if (!body.name || typeof body.name !== 'string' || body.name.trim().length === 0) {
4201
+ return errorResponse('name is required', 400);
4202
+ }
4203
+ if (body.name.length > 100) {
4204
+ return errorResponse('name must be 100 characters or fewer', 400);
4205
+ }
4206
+ const scopes = body.scopes ?? {};
4207
+ // Sanity check the scope shape so we don't store garbage that fails
4208
+ // every authorization check at runtime.
4209
+ if (!scopes.admin &&
4210
+ !scopes.media &&
4211
+ !scopes.pageBuilder &&
4212
+ (!scopes.collections || scopes.collections.length === 0) &&
4213
+ (!scopes.globals || scopes.globals.length === 0)) {
4214
+ return errorResponse('scopes must grant at least one capability (admin, media, pageBuilder, collections, or globals)', 400);
4215
+ }
4216
+ let expiresAt;
4217
+ if (body.expiresAt) {
4218
+ const parsed = new Date(body.expiresAt);
4219
+ if (isNaN(parsed.getTime()))
4220
+ return errorResponse('Invalid expiresAt date', 400);
4221
+ if (parsed.getTime() < Date.now())
4222
+ return errorResponse('expiresAt must be in the future', 400);
4223
+ expiresAt = parsed;
4224
+ }
4225
+ const { key, keyHash, keyPrefix } = await generateApiKey({
4226
+ prefix: 'act_sk',
4227
+ scopes,
4228
+ expiresAt,
4229
+ });
4230
+ const d = db();
4231
+ const record = await d.apiKey.create({
4232
+ data: {
4233
+ name: body.name.trim(),
4234
+ keyHash,
4235
+ keyPrefix,
4236
+ userId: auth.session.userId,
4237
+ scopes: scopes,
4238
+ ipRestrictions: body.ipRestrictions?.length ? body.ipRestrictions : null,
4239
+ expiresAt: expiresAt ?? null,
4240
+ },
4241
+ select: {
4242
+ id: true,
4243
+ name: true,
4244
+ keyPrefix: true,
4245
+ scopes: true,
4246
+ ipRestrictions: true,
4247
+ expiresAt: true,
4248
+ createdAt: true,
4249
+ },
4250
+ });
4251
+ await logEvent({
4252
+ event: 'api_key_created',
4253
+ userId: auth.session.userId,
4254
+ ipAddress: clientIp(request),
4255
+ userAgent: request.headers.get('user-agent') ?? undefined,
4256
+ details: { apiKeyId: record.id, name: record.name, scopes },
4257
+ });
4258
+ // The raw `key` is the only thing the caller will ever see; we never
4259
+ // store it in plaintext. Document this in the response shape.
4260
+ return json({ data: { ...record, key } }, 201);
4261
+ }
4262
+ catch (err) {
4263
+ return internalError(err, 'api-keys/create');
4264
+ }
4265
+ });
4266
+ router.delete('/api-keys/:id', async (request, params) => {
4267
+ try {
4268
+ const auth = await requireAuth(request);
4269
+ if (auth.error)
4270
+ return auth.error;
4271
+ const adminErr = requireAdminScope(auth.session);
4272
+ if (adminErr)
4273
+ return adminErr;
4274
+ // Same reauth gate as creation — revocation is irreversible and
4275
+ // affects every dependent client.
4276
+ if (!auth.session.apiKey) {
4277
+ const reauthErr = await requirePasswordReauth(request, auth.session.userId);
4278
+ if (reauthErr)
4279
+ return reauthErr;
4280
+ }
4281
+ const d = db();
4282
+ const existing = await d.apiKey.findUnique({ where: { id: params.id } });
4283
+ if (!existing)
4284
+ return errorResponse('API key not found', 404);
4285
+ await d.apiKey.update({
4286
+ where: { id: params.id },
4287
+ data: { revokedAt: new Date() },
4288
+ });
4289
+ await logEvent({
4290
+ event: 'api_key_revoked',
4291
+ userId: auth.session.userId,
4292
+ ipAddress: clientIp(request),
4293
+ userAgent: request.headers.get('user-agent') ?? undefined,
4294
+ details: { apiKeyId: existing.id, name: existing.name },
4295
+ });
4296
+ return json({ data: { revoked: true } });
4297
+ }
4298
+ catch (err) {
4299
+ return internalError(err, 'api-keys/revoke');
4300
+ }
4301
+ });
4302
+ // ---------------------------------------------------------------------------
4303
+ // Users route
4304
+ // ---------------------------------------------------------------------------
4305
+ router.get('/users', async (request) => {
4306
+ try {
4307
+ const auth = await requireAuth(request);
4308
+ if (auth.error)
4309
+ return auth.error;
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 });
4323
+ }
4324
+ catch (err) {
4325
+ return internalError(err);
4326
+ }
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
+ });
4346
+ // Lightweight directory lookup used by the comment mention picker.
4347
+ // Any authenticated user can query this — mentions are a
4348
+ // collaboration primitive, not an admin tool. Returns ONLY the
4349
+ // minimal subset needed to render a picker row (id, name, email);
4350
+ // never expose role, hashed-password, or audit metadata here.
4351
+ //
4352
+ // The matcher is intentionally simple: case-insensitive `contains`
4353
+ // against name + email, capped at 10 results. The picker UI is a
4354
+ // dropdown — anything beyond 10 rows is unusable and would only
4355
+ // serve as a directory-enumeration attack vector. Inactive /
4356
+ // unapproved users are filtered out so the picker can't show
4357
+ // someone the actor can't actually collaborate with (the mention
4358
+ // would create a comment_mention notification that no one ever
4359
+ // reads).
4360
+ router.get('/users/search', async (request) => {
4361
+ try {
4362
+ const auth = await requireAuth(request);
4363
+ if (auth.error)
4364
+ return auth.error;
4365
+ const url = new URL(request.url);
4366
+ const qRaw = (url.searchParams.get('q') ?? '').trim();
4367
+ // Hard floor on query length: avoids dumping the entire user
4368
+ // table when a UI sends an empty `q` by mistake, and avoids
4369
+ // making the endpoint a generic directory browser.
4370
+ if (qRaw.length === 0) {
4371
+ return json({ data: [] });
4372
+ }
4373
+ // Hard ceiling on query length: 64 chars is more than any name
4374
+ // or email prefix needs and prevents pathological regex / LIKE
4375
+ // workloads from a hostile client.
4376
+ const q = qRaw.slice(0, 64);
4377
+ // Mention only people the actor can actually collaborate
4378
+ // with — inactive accounts can't be reached, and unapproved
4379
+ // accounts haven't passed admin gating yet. Either user would
4380
+ // produce a `comment_mention` notification that goes nowhere
4381
+ // (PR #159 Copilot finding — the previous query only filtered
4382
+ // `isActive` even though the route comment promised both).
4383
+ const users = await db().user.findMany({
4384
+ where: {
4385
+ isActive: true,
4386
+ isApproved: true,
4387
+ OR: [
4388
+ { name: { contains: q, mode: 'insensitive' } },
4389
+ { email: { contains: q, mode: 'insensitive' } },
4390
+ ],
4391
+ },
4392
+ select: { id: true, name: true, email: true },
4393
+ orderBy: { name: 'asc' },
4394
+ take: 10,
4395
+ });
4396
+ return json({ data: users });
4397
+ }
4398
+ catch (err) {
4399
+ return internalError(err, 'users search');
4400
+ }
4401
+ });
4402
+ router.post('/users/invite', async (request) => {
4403
+ try {
4404
+ const auth = await requireAuth(request);
4405
+ if (auth.error)
4406
+ return auth.error;
4407
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4408
+ if (roleErr)
4409
+ return roleErr;
4410
+ if (!(await checkRateLimitAsync(inviteLimiter, `invite:${auth.session.userId}`))) {
4411
+ return errorResponse('Too many invitations. Please try again later.', 429);
4412
+ }
4413
+ const d = db();
4414
+ if (!hasModel(d, 'user'))
4415
+ return modelNotAvailable('user');
4416
+ if (!hasModel(d, 'invite'))
4417
+ return modelNotAvailable('invite');
4418
+ const body = (await request.json());
4419
+ if (!body.email)
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
+ }
4431
+ const cmsConfig = getActuateConfig();
4432
+ const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? new URL(request.url).origin;
4433
+ const inviter = await d.user.findUnique({
4434
+ where: { id: auth.session.userId },
4435
+ select: { name: true },
4436
+ });
4437
+ const result = await createInvite(d, {
4438
+ email: body.email,
4439
+ role: body.role ?? 'VIEWER',
4440
+ message: body.message,
4441
+ expiresInDays: body.expiresInDays,
4442
+ }, {
4443
+ siteUrl,
4444
+ adminPath: cmsConfig?.admin?.path ?? '/admin',
4445
+ invitedByName: inviter?.name ?? undefined,
4446
+ invitedByUserId: auth.session.userId,
4447
+ siteName: cmsConfig?.admin?.branding?.name ??
4448
+ undefined,
4449
+ });
4450
+ if (!result.success) {
4451
+ return errorResponse(result.error ?? 'Could not create invitation', 400);
4452
+ }
4453
+ await logEvent({
4454
+ event: 'user_invited',
4455
+ userId: auth.session.userId,
4456
+ details: {
4457
+ targetEmail: result.invite?.email,
4458
+ role: result.invite?.role,
4459
+ inviteId: result.invite?.id,
4460
+ emailed: result.emailed,
4461
+ },
4462
+ });
4463
+ return json({
4464
+ data: {
4465
+ success: true,
4466
+ invite: result.invite,
4467
+ emailed: result.emailed,
4468
+ inviteUrl: result.inviteUrl,
4469
+ },
4470
+ });
4471
+ }
4472
+ catch (err) {
4473
+ return internalError(err, 'user invite');
4474
+ }
4475
+ });
4476
+ router.delete('/users/:id', async (request, params) => {
4477
+ try {
4478
+ const auth = await requireAuth(request);
4479
+ if (auth.error)
4480
+ return auth.error;
4481
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4482
+ if (roleErr)
4483
+ return roleErr;
4484
+ if (auth.session.userId === params.id) {
4485
+ return errorResponse('Cannot revoke your own access', 400);
4486
+ }
4487
+ const user = await db().user.findUnique({ where: { id: params.id } });
4488
+ if (!user)
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
+ }
4502
+ await db().user.update({
4503
+ where: { id: params.id },
4504
+ data: { isActive: false, suspendedAt: new Date() },
4505
+ });
4506
+ await db().session.updateMany({
4507
+ where: { userId: params.id },
4508
+ data: { revokedAt: new Date() },
4509
+ });
4510
+ await logEvent({
4511
+ event: 'user_deactivated',
4512
+ userId: auth.session.userId,
4513
+ details: { targetUserId: params.id, targetEmail: user.email },
4514
+ });
4515
+ return json({ data: { success: true } });
4516
+ }
4517
+ catch (err) {
4518
+ return internalError(err);
4519
+ }
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) => {
4104
5058
  try {
4105
5059
  const auth = await requireAuth(request);
4106
5060
  if (auth.error)
4107
5061
  return auth.error;
4108
- const adminErr = requireAdminScope(auth.session);
4109
- if (adminErr)
4110
- return adminErr;
5062
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
5063
+ if (roleErr)
5064
+ return roleErr;
4111
5065
  const d = db();
4112
- if (!hasModel(d, 'apiKey'))
4113
- return json({ data: [] });
4114
- const keys = await d.apiKey.findMany({
4115
- orderBy: { createdAt: 'desc' },
4116
- select: {
4117
- id: true,
4118
- name: true,
4119
- keyPrefix: true,
4120
- scopes: true,
4121
- ipRestrictions: true,
4122
- expiresAt: true,
4123
- lastUsedAt: true,
4124
- revokedAt: true,
4125
- createdAt: true,
4126
- user: { select: { id: true, name: true, email: true } },
4127
- },
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,
4128
5072
  });
4129
- return json({ data: keys });
5073
+ return json({ data: invites });
4130
5074
  }
4131
5075
  catch (err) {
4132
- return internalError(err, 'api-keys/list');
5076
+ return internalError(err, 'list invites');
4133
5077
  }
4134
5078
  });
4135
- router.post('/api-keys', async (request) => {
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) => {
4136
5089
  try {
4137
5090
  const auth = await requireAuth(request);
4138
5091
  if (auth.error)
4139
5092
  return auth.error;
4140
- const adminErr = requireAdminScope(auth.session);
4141
- if (adminErr)
4142
- return adminErr;
4143
- // Issuing a new credential is a sensitive action — require reauth for
4144
- // session-authenticated callers. API-key-authenticated requests are
4145
- // already proving possession of a long-lived credential.
4146
- if (!auth.session.apiKey) {
4147
- const reauthErr = await requirePasswordReauth(request, auth.session.userId);
4148
- if (reauthErr)
4149
- return reauthErr;
4150
- }
4151
- const body = (await request.json());
4152
- if (!body.name || typeof body.name !== 'string' || body.name.trim().length === 0) {
4153
- return errorResponse('name is required', 400);
4154
- }
4155
- if (body.name.length > 100) {
4156
- return errorResponse('name must be 100 characters or fewer', 400);
4157
- }
4158
- const scopes = body.scopes ?? {};
4159
- // Sanity check the scope shape so we don't store garbage that fails
4160
- // every authorization check at runtime.
4161
- if (!scopes.admin &&
4162
- !scopes.media &&
4163
- !scopes.pageBuilder &&
4164
- (!scopes.collections || scopes.collections.length === 0) &&
4165
- (!scopes.globals || scopes.globals.length === 0)) {
4166
- return errorResponse('scopes must grant at least one capability (admin, media, pageBuilder, collections, or globals)', 400);
4167
- }
4168
- let expiresAt;
4169
- if (body.expiresAt) {
4170
- const parsed = new Date(body.expiresAt);
4171
- if (isNaN(parsed.getTime()))
4172
- return errorResponse('Invalid expiresAt date', 400);
4173
- if (parsed.getTime() < Date.now())
4174
- return errorResponse('expiresAt must be in the future', 400);
4175
- expiresAt = parsed;
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);
4176
5098
  }
4177
- const { key, keyHash, keyPrefix } = await generateApiKey({
4178
- prefix: 'act_sk',
4179
- scopes,
4180
- expiresAt,
4181
- });
4182
5099
  const d = db();
4183
- const record = await d.apiKey.create({
4184
- data: {
4185
- name: body.name.trim(),
4186
- keyHash,
4187
- keyPrefix,
4188
- userId: auth.session.userId,
4189
- scopes: scopes,
4190
- ipRestrictions: body.ipRestrictions?.length ? body.ipRestrictions : null,
4191
- expiresAt: expiresAt ?? null,
4192
- },
4193
- select: {
4194
- id: true,
4195
- name: true,
4196
- keyPrefix: true,
4197
- scopes: true,
4198
- ipRestrictions: true,
4199
- expiresAt: true,
4200
- createdAt: true,
4201
- },
4202
- });
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
+ }
4203
5106
  await logEvent({
4204
- event: 'api_key_created',
5107
+ event: 'invite_resent',
4205
5108
  userId: auth.session.userId,
4206
- ipAddress: clientIp(request),
4207
- userAgent: request.headers.get('user-agent') ?? undefined,
4208
- details: { apiKeyId: record.id, name: record.name, scopes },
5109
+ details: { inviteId: params.id, emailed: result.emailed },
5110
+ });
5111
+ return json({
5112
+ data: { success: true, emailed: result.emailed, inviteUrl: result.inviteUrl },
4209
5113
  });
4210
- // The raw `key` is the only thing the caller will ever see; we never
4211
- // store it in plaintext. Document this in the response shape.
4212
- return json({ data: { ...record, key } }, 201);
4213
5114
  }
4214
5115
  catch (err) {
4215
- return internalError(err, 'api-keys/create');
5116
+ return internalError(err, 'resend invite');
4216
5117
  }
4217
5118
  });
4218
- router.delete('/api-keys/:id', async (request, params) => {
5119
+ router.post('/invites/:id/cancel', async (request, params) => {
4219
5120
  try {
4220
5121
  const auth = await requireAuth(request);
4221
5122
  if (auth.error)
4222
5123
  return auth.error;
4223
- const adminErr = requireAdminScope(auth.session);
4224
- if (adminErr)
4225
- return adminErr;
4226
- // Same reauth gate as creation — revocation is irreversible and
4227
- // affects every dependent client.
4228
- if (!auth.session.apiKey) {
4229
- const reauthErr = await requirePasswordReauth(request, auth.session.userId);
4230
- if (reauthErr)
4231
- return reauthErr;
4232
- }
5124
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
5125
+ if (roleErr)
5126
+ return roleErr;
4233
5127
  const d = db();
4234
- const existing = await d.apiKey.findUnique({ where: { id: params.id } });
4235
- if (!existing)
4236
- return errorResponse('API key not found', 404);
4237
- await d.apiKey.update({
4238
- where: { id: params.id },
4239
- data: { revokedAt: new Date() },
4240
- });
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
+ }
4241
5134
  await logEvent({
4242
- event: 'api_key_revoked',
5135
+ event: 'invite_cancelled',
4243
5136
  userId: auth.session.userId,
4244
- ipAddress: clientIp(request),
4245
- userAgent: request.headers.get('user-agent') ?? undefined,
4246
- details: { apiKeyId: existing.id, name: existing.name },
5137
+ details: { inviteId: params.id },
4247
5138
  });
4248
- return json({ data: { revoked: true } });
5139
+ return json({ data: { success: true } });
4249
5140
  }
4250
5141
  catch (err) {
4251
- return internalError(err, 'api-keys/revoke');
5142
+ return internalError(err, 'cancel invite');
4252
5143
  }
4253
5144
  });
4254
- // ---------------------------------------------------------------------------
4255
- // Users route
4256
- // ---------------------------------------------------------------------------
4257
- router.get('/users', async (request) => {
5145
+ router.patch('/invites/:id', async (request, params) => {
4258
5146
  try {
4259
5147
  const auth = await requireAuth(request);
4260
5148
  if (auth.error)
4261
5149
  return auth.error;
4262
- if (auth.session.role !== 'ADMIN') {
4263
- return errorResponse('Forbidden', 403);
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 } });
4264
5171
  }
4265
- const users = await db().user.findMany({
4266
- select: { id: true, email: true, name: true, role: true, isActive: true, createdAt: true },
4267
- });
4268
- return json({ data: users });
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);
4269
5185
  }
4270
5186
  catch (err) {
4271
- return internalError(err);
5187
+ return internalError(err, 'update invite');
4272
5188
  }
4273
5189
  });
4274
- // Lightweight directory lookup used by the comment mention picker.
4275
- // Any authenticated user can query this — mentions are a
4276
- // collaboration primitive, not an admin tool. Returns ONLY the
4277
- // minimal subset needed to render a picker row (id, name, email);
4278
- // never expose role, hashed-password, or audit metadata here.
4279
- //
4280
- // The matcher is intentionally simple: case-insensitive `contains`
4281
- // against name + email, capped at 10 results. The picker UI is a
4282
- // dropdown — anything beyond 10 rows is unusable and would only
4283
- // serve as a directory-enumeration attack vector. Inactive /
4284
- // unapproved users are filtered out so the picker can't show
4285
- // someone the actor can't actually collaborate with (the mention
4286
- // would create a comment_mention notification that no one ever
4287
- // reads).
4288
- router.get('/users/search', async (request) => {
5190
+ // ---------------------------------------------------------------------------
5191
+ // Access review routes
5192
+ // ---------------------------------------------------------------------------
5193
+ router.get('/access-review', async (request) => {
4289
5194
  try {
4290
5195
  const auth = await requireAuth(request);
4291
5196
  if (auth.error)
4292
5197
  return auth.error;
4293
- const url = new URL(request.url);
4294
- const qRaw = (url.searchParams.get('q') ?? '').trim();
4295
- // Hard floor on query length: avoids dumping the entire user
4296
- // table when a UI sends an empty `q` by mistake, and avoids
4297
- // making the endpoint a generic directory browser.
4298
- if (qRaw.length === 0) {
4299
- return json({ data: [] });
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');
4300
5214
  }
4301
- // Hard ceiling on query length: 64 chars is more than any name
4302
- // or email prefix needs and prevents pathological regex / LIKE
4303
- // workloads from a hostile client.
4304
- const q = qRaw.slice(0, 64);
4305
- // Mention only people the actor can actually collaborate
4306
- // with — inactive accounts can't be reached, and unapproved
4307
- // accounts haven't passed admin gating yet. Either user would
4308
- // produce a `comment_mention` notification that goes nowhere
4309
- // (PR #159 Copilot finding — the previous query only filtered
4310
- // `isActive` even though the route comment promised both).
4311
- const users = await db().user.findMany({
4312
- where: {
4313
- isActive: true,
4314
- isApproved: true,
4315
- OR: [
4316
- { name: { contains: q, mode: 'insensitive' } },
4317
- { email: { contains: q, mode: 'insensitive' } },
4318
- ],
4319
- },
4320
- select: { id: true, name: true, email: true },
4321
- orderBy: { name: 'asc' },
4322
- take: 10,
5215
+ catch {
5216
+ dismissedIds = [];
5217
+ }
5218
+ const recommendations = await computeAccessReview(d, {
5219
+ ...teamOptionsFor(auth.session),
5220
+ dismissedIds,
4323
5221
  });
4324
- return json({ data: users });
5222
+ return json({ data: recommendations });
4325
5223
  }
4326
5224
  catch (err) {
4327
- return internalError(err, 'users search');
5225
+ return internalError(err, 'access review');
4328
5226
  }
4329
5227
  });
4330
- router.delete('/users/:id', async (request, params) => {
5228
+ router.post('/access-review/:id/dismiss', async (request, params) => {
4331
5229
  try {
4332
5230
  const auth = await requireAuth(request);
4333
5231
  if (auth.error)
@@ -4335,29 +5233,15 @@ export function registerCMSRoutes(router) {
4335
5233
  const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4336
5234
  if (roleErr)
4337
5235
  return roleErr;
4338
- if (auth.session.userId === params.id) {
4339
- return errorResponse('Cannot delete your own account', 400);
4340
- }
4341
- const user = await db().user.findUnique({ where: { id: params.id } });
4342
- if (!user)
4343
- return errorResponse('User not found', 404);
4344
- await db().user.update({
4345
- where: { id: params.id },
4346
- data: { isActive: false },
4347
- });
4348
- await db().session.updateMany({
4349
- where: { userId: params.id },
4350
- data: { revokedAt: new Date() },
4351
- });
4352
5236
  await logEvent({
4353
- event: 'user_deactivated',
5237
+ event: 'access_review_dismissed',
4354
5238
  userId: auth.session.userId,
4355
- details: { targetUserId: params.id, targetEmail: user.email },
5239
+ details: { recommendationId: params.id },
4356
5240
  });
4357
5241
  return json({ data: { success: true } });
4358
5242
  }
4359
5243
  catch (err) {
4360
- return internalError(err);
5244
+ return internalError(err, 'dismiss access review');
4361
5245
  }
4362
5246
  });
4363
5247
  // ---------------------------------------------------------------------------
@@ -9053,6 +9937,44 @@ export function registerCMSRoutes(router) {
9053
9937
  return internalError(err, 'security GET');
9054
9938
  }
9055
9939
  });
9940
+ // Read-only AI status. The dashboard cannot configure AI — it is wired up
9941
+ // server-side via `aiPlugin({ provider, apiKey })` reading environment
9942
+ // variables. This endpoint just reports whether the plugin is installed and
9943
+ // a provider is configured so the admin UI can show an honest status instead
9944
+ // of a decorative key field. Never returns secret values.
9945
+ router.get('/ai/status', async (request) => {
9946
+ try {
9947
+ const auth = await requireAuth(request);
9948
+ if (auth.error)
9949
+ return auth.error;
9950
+ const adminErr = requireAdminScope(auth.session);
9951
+ if (adminErr)
9952
+ return adminErr;
9953
+ let installed = false;
9954
+ let configured = false;
9955
+ try {
9956
+ const aiMod = await importAIPlugin();
9957
+ installed = true;
9958
+ if (typeof aiMod?.getAIProvider === 'function') {
9959
+ configured = aiMod.getAIProvider() != null;
9960
+ }
9961
+ }
9962
+ catch {
9963
+ installed = false;
9964
+ }
9965
+ // Best-effort provider hint from the environment. We only expose which
9966
+ // provider env var is present, never the key value itself.
9967
+ const provider = process.env.ANTHROPIC_API_KEY
9968
+ ? 'anthropic'
9969
+ : process.env.OPENAI_API_KEY
9970
+ ? 'openai'
9971
+ : null;
9972
+ return json({ data: { installed, configured, provider } });
9973
+ }
9974
+ catch (err) {
9975
+ return internalError(err, 'ai status');
9976
+ }
9977
+ });
9056
9978
  router.put('/security', async (request) => {
9057
9979
  try {
9058
9980
  const auth = await requireAuth(request);