@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.
- package/dist/__tests__/api/ai-status.test.d.ts +2 -0
- package/dist/__tests__/api/ai-status.test.d.ts.map +1 -0
- package/dist/__tests__/api/ai-status.test.js +110 -0
- package/dist/__tests__/api/ai-status.test.js.map +1 -0
- 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 +349 -0
- package/dist/__tests__/api/users-detail.test.js.map +1 -0
- package/dist/__tests__/api/users-invite.test.d.ts +2 -0
- package/dist/__tests__/api/users-invite.test.d.ts.map +1 -0
- package/dist/__tests__/api/users-invite.test.js +221 -0
- package/dist/__tests__/api/users-invite.test.js.map +1 -0
- 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/reset.test.js +52 -1
- package/dist/__tests__/auth/reset.test.js.map +1 -1
- 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 +162 -0
- package/dist/__tests__/auth/user-admin.test.js.map +1 -0
- package/dist/__tests__/email/sender.test.d.ts +2 -0
- package/dist/__tests__/email/sender.test.d.ts.map +1 -0
- package/dist/__tests__/email/sender.test.js +33 -0
- package/dist/__tests__/email/sender.test.js.map +1 -0
- package/dist/__tests__/middleware.test.js +30 -0
- package/dist/__tests__/middleware.test.js.map +1 -1
- package/dist/__tests__/setup/index.test.js +37 -2
- package/dist/__tests__/setup/index.test.js.map +1 -1
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +1116 -194
- package/dist/api/handlers.js.map +1 -1
- package/dist/auth/index.d.ts +4 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +2 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/invite-email.d.ts +12 -0
- package/dist/auth/invite-email.d.ts.map +1 -0
- package/dist/auth/invite-email.js +57 -0
- package/dist/auth/invite-email.js.map +1 -0
- 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 +6 -0
- package/dist/auth/reset.d.ts.map +1 -1
- package/dist/auth/reset.js +38 -7
- 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 +91 -0
- package/dist/auth/user-admin.d.ts.map +1 -0
- package/dist/auth/user-admin.js +250 -0
- package/dist/auth/user-admin.js.map +1 -0
- package/dist/email/sender.d.ts +35 -0
- package/dist/email/sender.d.ts.map +1 -0
- package/dist/email/sender.js +25 -0
- package/dist/email/sender.js.map +1 -0
- package/dist/forms/notify.d.ts.map +1 -1
- package/dist/forms/notify.js +3 -2
- package/dist/forms/notify.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +60 -1
- package/dist/middleware.js.map +1 -1
- package/dist/setup/index.d.ts +14 -0
- package/dist/setup/index.d.ts.map +1 -1
- package/dist/setup/index.js +29 -3
- package/dist/setup/index.js.map +1 -1
- package/package.json +11 -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/api/handlers.js
CHANGED
|
@@ -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 {
|
|
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
|
|
4109
|
-
if (
|
|
4110
|
-
return
|
|
5062
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
5063
|
+
if (roleErr)
|
|
5064
|
+
return roleErr;
|
|
4111
5065
|
const d = db();
|
|
4112
|
-
if (!hasModel(d, '
|
|
4113
|
-
return
|
|
4114
|
-
const
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
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:
|
|
5073
|
+
return json({ data: invites });
|
|
4130
5074
|
}
|
|
4131
5075
|
catch (err) {
|
|
4132
|
-
return internalError(err, '
|
|
5076
|
+
return internalError(err, 'list invites');
|
|
4133
5077
|
}
|
|
4134
5078
|
});
|
|
4135
|
-
|
|
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
|
|
4141
|
-
if (
|
|
4142
|
-
return
|
|
4143
|
-
|
|
4144
|
-
|
|
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
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
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: '
|
|
5107
|
+
event: 'invite_resent',
|
|
4205
5108
|
userId: auth.session.userId,
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
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, '
|
|
5116
|
+
return internalError(err, 'resend invite');
|
|
4216
5117
|
}
|
|
4217
5118
|
});
|
|
4218
|
-
router.
|
|
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
|
|
4224
|
-
if (
|
|
4225
|
-
return
|
|
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
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
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: '
|
|
5135
|
+
event: 'invite_cancelled',
|
|
4243
5136
|
userId: auth.session.userId,
|
|
4244
|
-
|
|
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: {
|
|
5139
|
+
return json({ data: { success: true } });
|
|
4249
5140
|
}
|
|
4250
5141
|
catch (err) {
|
|
4251
|
-
return internalError(err, '
|
|
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
|
-
|
|
4263
|
-
|
|
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
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
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
|
-
//
|
|
4275
|
-
//
|
|
4276
|
-
//
|
|
4277
|
-
|
|
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
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
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
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
const
|
|
4305
|
-
|
|
4306
|
-
|
|
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:
|
|
5222
|
+
return json({ data: recommendations });
|
|
4325
5223
|
}
|
|
4326
5224
|
catch (err) {
|
|
4327
|
-
return internalError(err, '
|
|
5225
|
+
return internalError(err, 'access review');
|
|
4328
5226
|
}
|
|
4329
5227
|
});
|
|
4330
|
-
router.
|
|
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: '
|
|
5237
|
+
event: 'access_review_dismissed',
|
|
4354
5238
|
userId: auth.session.userId,
|
|
4355
|
-
details: {
|
|
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);
|