@actuate-media/cms-core 0.39.0 → 0.41.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.
- package/dist/__tests__/api/users-detail.test.d.ts +2 -0
- package/dist/__tests__/api/users-detail.test.d.ts.map +1 -0
- package/dist/__tests__/api/users-detail.test.js +401 -0
- package/dist/__tests__/api/users-detail.test.js.map +1 -0
- package/dist/__tests__/api/users-invite.test.js +80 -26
- package/dist/__tests__/api/users-invite.test.js.map +1 -1
- package/dist/__tests__/auth/invites.test.d.ts +2 -0
- package/dist/__tests__/auth/invites.test.d.ts.map +1 -0
- package/dist/__tests__/auth/invites.test.js +181 -0
- package/dist/__tests__/auth/invites.test.js.map +1 -0
- package/dist/__tests__/auth/roles.test.d.ts +2 -0
- package/dist/__tests__/auth/roles.test.d.ts.map +1 -0
- package/dist/__tests__/auth/roles.test.js +60 -0
- package/dist/__tests__/auth/roles.test.js.map +1 -0
- package/dist/__tests__/auth/team.test.d.ts +2 -0
- package/dist/__tests__/auth/team.test.d.ts.map +1 -0
- package/dist/__tests__/auth/team.test.js +139 -0
- package/dist/__tests__/auth/team.test.js.map +1 -0
- package/dist/__tests__/auth/user-admin.test.d.ts +2 -0
- package/dist/__tests__/auth/user-admin.test.d.ts.map +1 -0
- package/dist/__tests__/auth/user-admin.test.js +198 -0
- package/dist/__tests__/auth/user-admin.test.js.map +1 -0
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +883 -16
- package/dist/api/handlers.js.map +1 -1
- package/dist/auth/index.d.ts +4 -2
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +2 -1
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/invites.d.ts +71 -0
- package/dist/auth/invites.d.ts.map +1 -0
- package/dist/auth/invites.js +279 -0
- package/dist/auth/invites.js.map +1 -0
- package/dist/auth/reset.d.ts.map +1 -1
- package/dist/auth/reset.js +27 -4
- package/dist/auth/reset.js.map +1 -1
- package/dist/auth/roles.d.ts +51 -0
- package/dist/auth/roles.d.ts.map +1 -0
- package/dist/auth/roles.js +159 -0
- package/dist/auth/roles.js.map +1 -0
- package/dist/auth/team.d.ts +86 -0
- package/dist/auth/team.d.ts.map +1 -0
- package/dist/auth/team.js +270 -0
- package/dist/auth/team.js.map +1 -0
- package/dist/auth/user-admin.d.ts +118 -0
- package/dist/auth/user-admin.d.ts.map +1 -0
- package/dist/auth/user-admin.js +292 -0
- package/dist/auth/user-admin.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +6 -1
- package/prisma/migrations/0011_user_roles_owner_viewer/migration.sql +6 -0
- package/prisma/migrations/0012_invites_and_user_lifecycle/migration.sql +40 -0
- package/prisma/schema.prisma +43 -0
- package/dist/__tests__/auth/invite.test.d.ts +0 -2
- package/dist/__tests__/auth/invite.test.d.ts.map +0 -1
- package/dist/__tests__/auth/invite.test.js +0 -151
- package/dist/__tests__/auth/invite.test.js.map +0 -1
- package/dist/auth/invite.d.ts +0 -37
- package/dist/auth/invite.d.ts.map +0 -1
- package/dist/auth/invite.js +0 -104
- package/dist/auth/invite.js.map +0 -1
package/dist/api/handlers.js
CHANGED
|
@@ -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 {
|
|
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, transferContentOwnership, } 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
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
const
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
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
|
|
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.
|
|
4376
|
-
role: result.
|
|
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
|
-
|
|
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
|
|
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,778 @@ 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
|
+
// Reassign all content owned by :id to another active user. Used before
|
|
5055
|
+
// removing someone so their documents/media/templates survive with a real
|
|
5056
|
+
// owner. Body: { toUserId }.
|
|
5057
|
+
router.post('/users/:id/transfer-content', async (request, params) => {
|
|
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 target = await loadActionTarget(auth, params.id);
|
|
5066
|
+
if (target.error)
|
|
5067
|
+
return target.error;
|
|
5068
|
+
const body = (await request.json().catch(() => ({})));
|
|
5069
|
+
const toUserId = body.toUserId;
|
|
5070
|
+
if (!toUserId)
|
|
5071
|
+
return errorResponse('A recipient (toUserId) is required.', 400);
|
|
5072
|
+
if (toUserId === params.id) {
|
|
5073
|
+
return errorResponse('Choose a different user to receive the content.', 400);
|
|
5074
|
+
}
|
|
5075
|
+
const recipient = await db().user.findUnique({ where: { id: toUserId } });
|
|
5076
|
+
if (!recipient)
|
|
5077
|
+
return errorResponse('Recipient not found.', 404);
|
|
5078
|
+
if (recipient.isActive === false || recipient.suspendedAt || recipient.lockedAt) {
|
|
5079
|
+
return errorResponse('Content can only be transferred to an active user.', 409);
|
|
5080
|
+
}
|
|
5081
|
+
const counts = await transferContentOwnership(db(), params.id, toUserId);
|
|
5082
|
+
await logEvent({
|
|
5083
|
+
event: 'content_ownership_transferred',
|
|
5084
|
+
userId: auth.session.userId,
|
|
5085
|
+
details: {
|
|
5086
|
+
fromUserId: params.id,
|
|
5087
|
+
toUserId,
|
|
5088
|
+
documents: counts.documents,
|
|
5089
|
+
media: counts.media,
|
|
5090
|
+
templates: counts.templates,
|
|
5091
|
+
total: counts.total,
|
|
5092
|
+
},
|
|
5093
|
+
});
|
|
5094
|
+
return json({ data: { success: true, ...counts } });
|
|
5095
|
+
}
|
|
5096
|
+
catch (err) {
|
|
5097
|
+
return internalError(err, 'transfer content ownership');
|
|
5098
|
+
}
|
|
5099
|
+
});
|
|
5100
|
+
// ---------------------------------------------------------------------------
|
|
5101
|
+
// Invite management routes
|
|
5102
|
+
// ---------------------------------------------------------------------------
|
|
5103
|
+
router.get('/invites', async (request) => {
|
|
5104
|
+
try {
|
|
5105
|
+
const auth = await requireAuth(request);
|
|
5106
|
+
if (auth.error)
|
|
5107
|
+
return auth.error;
|
|
5108
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
5109
|
+
if (roleErr)
|
|
5110
|
+
return roleErr;
|
|
5111
|
+
const d = db();
|
|
5112
|
+
if (!hasModel(d, 'invite'))
|
|
5113
|
+
return modelNotAvailable('invite');
|
|
5114
|
+
const url = new URL(request.url);
|
|
5115
|
+
const invites = await listInvites(d, {
|
|
5116
|
+
status: url.searchParams.get('status') ?? undefined,
|
|
5117
|
+
search: url.searchParams.get('search') ?? undefined,
|
|
5118
|
+
});
|
|
5119
|
+
return json({ data: invites });
|
|
5120
|
+
}
|
|
5121
|
+
catch (err) {
|
|
5122
|
+
return internalError(err, 'list invites');
|
|
5123
|
+
}
|
|
5124
|
+
});
|
|
5125
|
+
function inviteConfigFor(request, inviterName) {
|
|
5126
|
+
const cfg = getActuateConfig();
|
|
5127
|
+
return {
|
|
5128
|
+
siteUrl: process.env.NEXT_PUBLIC_SITE_URL ?? new URL(request.url).origin,
|
|
5129
|
+
adminPath: cfg?.admin?.path ?? '/admin',
|
|
5130
|
+
invitedByName: inviterName,
|
|
5131
|
+
siteName: cfg?.admin?.branding?.name ?? undefined,
|
|
5132
|
+
};
|
|
5133
|
+
}
|
|
5134
|
+
router.post('/invites/:id/resend', async (request, params) => {
|
|
5135
|
+
try {
|
|
5136
|
+
const auth = await requireAuth(request);
|
|
5137
|
+
if (auth.error)
|
|
5138
|
+
return auth.error;
|
|
5139
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
5140
|
+
if (roleErr)
|
|
5141
|
+
return roleErr;
|
|
5142
|
+
if (!(await checkRateLimitAsync(inviteLimiter, `invite:${auth.session.userId}`))) {
|
|
5143
|
+
return errorResponse('Too many invitations. Please try again later.', 429);
|
|
5144
|
+
}
|
|
5145
|
+
const d = db();
|
|
5146
|
+
if (!hasModel(d, 'invite'))
|
|
5147
|
+
return modelNotAvailable('invite');
|
|
5148
|
+
const result = await resendInvite(d, params.id, inviteConfigFor(request));
|
|
5149
|
+
if (!result.success) {
|
|
5150
|
+
return errorResponse(result.error ?? 'Could not resend invitation', 400);
|
|
5151
|
+
}
|
|
5152
|
+
await logEvent({
|
|
5153
|
+
event: 'invite_resent',
|
|
5154
|
+
userId: auth.session.userId,
|
|
5155
|
+
details: { inviteId: params.id, emailed: result.emailed },
|
|
5156
|
+
});
|
|
5157
|
+
return json({
|
|
5158
|
+
data: { success: true, emailed: result.emailed, inviteUrl: result.inviteUrl },
|
|
5159
|
+
});
|
|
5160
|
+
}
|
|
5161
|
+
catch (err) {
|
|
5162
|
+
return internalError(err, 'resend invite');
|
|
5163
|
+
}
|
|
5164
|
+
});
|
|
5165
|
+
router.post('/invites/:id/cancel', async (request, params) => {
|
|
5166
|
+
try {
|
|
5167
|
+
const auth = await requireAuth(request);
|
|
5168
|
+
if (auth.error)
|
|
5169
|
+
return auth.error;
|
|
5170
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
5171
|
+
if (roleErr)
|
|
5172
|
+
return roleErr;
|
|
5173
|
+
const d = db();
|
|
5174
|
+
if (!hasModel(d, 'invite'))
|
|
5175
|
+
return modelNotAvailable('invite');
|
|
5176
|
+
const result = await cancelInvite(d, params.id);
|
|
5177
|
+
if (!result.success) {
|
|
5178
|
+
return errorResponse(result.error ?? 'Could not cancel invitation', 400);
|
|
5179
|
+
}
|
|
5180
|
+
await logEvent({
|
|
5181
|
+
event: 'invite_cancelled',
|
|
5182
|
+
userId: auth.session.userId,
|
|
5183
|
+
details: { inviteId: params.id },
|
|
5184
|
+
});
|
|
5185
|
+
return json({ data: { success: true } });
|
|
5186
|
+
}
|
|
5187
|
+
catch (err) {
|
|
5188
|
+
return internalError(err, 'cancel invite');
|
|
5189
|
+
}
|
|
5190
|
+
});
|
|
5191
|
+
router.patch('/invites/:id', async (request, params) => {
|
|
5192
|
+
try {
|
|
5193
|
+
const auth = await requireAuth(request);
|
|
5194
|
+
if (auth.error)
|
|
5195
|
+
return auth.error;
|
|
5196
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
5197
|
+
if (roleErr)
|
|
5198
|
+
return roleErr;
|
|
5199
|
+
const d = db();
|
|
5200
|
+
if (!hasModel(d, 'invite'))
|
|
5201
|
+
return modelNotAvailable('invite');
|
|
5202
|
+
const body = (await request.json());
|
|
5203
|
+
if (body.role !== undefined) {
|
|
5204
|
+
if (body.role.toUpperCase() === 'OWNER' && auth.session.role !== 'OWNER') {
|
|
5205
|
+
return errorResponse('Only an Owner can assign the Owner role.', 403);
|
|
5206
|
+
}
|
|
5207
|
+
const result = await updateInviteRole(d, params.id, body.role);
|
|
5208
|
+
if (!result.success) {
|
|
5209
|
+
return errorResponse(result.error ?? 'Could not update invitation', 400);
|
|
5210
|
+
}
|
|
5211
|
+
await logEvent({
|
|
5212
|
+
event: 'invite_role_changed',
|
|
5213
|
+
userId: auth.session.userId,
|
|
5214
|
+
details: { inviteId: params.id, newRole: result.invite?.role },
|
|
5215
|
+
});
|
|
5216
|
+
return json({ data: { success: true, invite: result.invite } });
|
|
5217
|
+
}
|
|
5218
|
+
if (body.extendDays !== undefined) {
|
|
5219
|
+
const result = await extendInvite(d, params.id, body.extendDays);
|
|
5220
|
+
if (!result.success) {
|
|
5221
|
+
return errorResponse(result.error ?? 'Could not extend invitation', 400);
|
|
5222
|
+
}
|
|
5223
|
+
await logEvent({
|
|
5224
|
+
event: 'invite_extended',
|
|
5225
|
+
userId: auth.session.userId,
|
|
5226
|
+
details: { inviteId: params.id, expiresAt: result.invite?.expiresAt },
|
|
5227
|
+
});
|
|
5228
|
+
return json({ data: { success: true, invite: result.invite } });
|
|
5229
|
+
}
|
|
5230
|
+
return errorResponse('Nothing to update', 400);
|
|
5231
|
+
}
|
|
5232
|
+
catch (err) {
|
|
5233
|
+
return internalError(err, 'update invite');
|
|
5234
|
+
}
|
|
5235
|
+
});
|
|
5236
|
+
// ---------------------------------------------------------------------------
|
|
5237
|
+
// Access review routes
|
|
5238
|
+
// ---------------------------------------------------------------------------
|
|
5239
|
+
router.get('/access-review', async (request) => {
|
|
5240
|
+
try {
|
|
5241
|
+
const auth = await requireAuth(request);
|
|
5242
|
+
if (auth.error)
|
|
5243
|
+
return auth.error;
|
|
5244
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
5245
|
+
if (roleErr)
|
|
5246
|
+
return roleErr;
|
|
5247
|
+
const d = db();
|
|
5248
|
+
if (!hasModel(d, 'user'))
|
|
5249
|
+
return modelNotAvailable('user');
|
|
5250
|
+
// Dismissals are persisted as audit events — no extra table needed.
|
|
5251
|
+
let dismissedIds = [];
|
|
5252
|
+
try {
|
|
5253
|
+
const rows = await d.auditLog.findMany({
|
|
5254
|
+
where: { event: 'access_review_dismissed' },
|
|
5255
|
+
select: { details: true },
|
|
5256
|
+
});
|
|
5257
|
+
dismissedIds = rows
|
|
5258
|
+
.map((r) => r.details?.recommendationId)
|
|
5259
|
+
.filter((x) => typeof x === 'string');
|
|
5260
|
+
}
|
|
5261
|
+
catch {
|
|
5262
|
+
dismissedIds = [];
|
|
5263
|
+
}
|
|
5264
|
+
const recommendations = await computeAccessReview(d, {
|
|
5265
|
+
...teamOptionsFor(auth.session),
|
|
5266
|
+
dismissedIds,
|
|
5267
|
+
});
|
|
5268
|
+
return json({ data: recommendations });
|
|
5269
|
+
}
|
|
5270
|
+
catch (err) {
|
|
5271
|
+
return internalError(err, 'access review');
|
|
5272
|
+
}
|
|
5273
|
+
});
|
|
5274
|
+
router.post('/access-review/:id/dismiss', async (request, params) => {
|
|
5275
|
+
try {
|
|
5276
|
+
const auth = await requireAuth(request);
|
|
5277
|
+
if (auth.error)
|
|
5278
|
+
return auth.error;
|
|
5279
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
5280
|
+
if (roleErr)
|
|
5281
|
+
return roleErr;
|
|
5282
|
+
await logEvent({
|
|
5283
|
+
event: 'access_review_dismissed',
|
|
5284
|
+
userId: auth.session.userId,
|
|
5285
|
+
details: { recommendationId: params.id },
|
|
5286
|
+
});
|
|
5287
|
+
return json({ data: { success: true } });
|
|
5288
|
+
}
|
|
5289
|
+
catch (err) {
|
|
5290
|
+
return internalError(err, 'dismiss access review');
|
|
5291
|
+
}
|
|
5292
|
+
});
|
|
4426
5293
|
// ---------------------------------------------------------------------------
|
|
4427
5294
|
// Forms routes
|
|
4428
5295
|
// ---------------------------------------------------------------------------
|