@actuate-media/cms-core 0.57.0 → 0.57.1
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/stats-seo-cache.test.js +1 -1
- package/dist/__tests__/api/stats-seo-cache.test.js.map +1 -1
- package/dist/api/guarded-db.d.ts +13 -0
- package/dist/api/guarded-db.d.ts.map +1 -0
- package/dist/api/guarded-db.js +58 -0
- package/dist/api/guarded-db.js.map +1 -0
- package/dist/api/handler-factory.d.ts.map +1 -1
- package/dist/api/handler-factory.js +2 -1
- package/dist/api/handler-factory.js.map +1 -1
- package/dist/api/handlers.d.ts +5 -3
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +54 -13068
- package/dist/api/handlers.js.map +1 -1
- package/dist/api/route-helpers.d.ts +320 -0
- package/dist/api/route-helpers.d.ts.map +1 -0
- package/dist/api/route-helpers.js +1307 -0
- package/dist/api/route-helpers.js.map +1 -0
- package/dist/api/routes/ai-seo.d.ts +3 -0
- package/dist/api/routes/ai-seo.d.ts.map +1 -0
- package/dist/api/routes/ai-seo.js +377 -0
- package/dist/api/routes/ai-seo.js.map +1 -0
- package/dist/api/routes/ai-settings.d.ts +3 -0
- package/dist/api/routes/ai-settings.d.ts.map +1 -0
- package/dist/api/routes/ai-settings.js +764 -0
- package/dist/api/routes/ai-settings.js.map +1 -0
- package/dist/api/routes/auth.d.ts +3 -0
- package/dist/api/routes/auth.d.ts.map +1 -0
- package/dist/api/routes/auth.js +787 -0
- package/dist/api/routes/auth.js.map +1 -0
- package/dist/api/routes/collab.d.ts +3 -0
- package/dist/api/routes/collab.d.ts.map +1 -0
- package/dist/api/routes/collab.js +426 -0
- package/dist/api/routes/collab.js.map +1 -0
- package/dist/api/routes/dashboard.d.ts +7 -0
- package/dist/api/routes/dashboard.d.ts.map +1 -0
- package/dist/api/routes/dashboard.js +540 -0
- package/dist/api/routes/dashboard.js.map +1 -0
- package/dist/api/routes/documents.d.ts +3 -0
- package/dist/api/routes/documents.d.ts.map +1 -0
- package/dist/api/routes/documents.js +180 -0
- package/dist/api/routes/documents.js.map +1 -0
- package/dist/api/routes/folders.d.ts +3 -0
- package/dist/api/routes/folders.d.ts.map +1 -0
- package/dist/api/routes/folders.js +195 -0
- package/dist/api/routes/folders.js.map +1 -0
- package/dist/api/routes/forms.d.ts +3 -0
- package/dist/api/routes/forms.d.ts.map +1 -0
- package/dist/api/routes/forms.js +661 -0
- package/dist/api/routes/forms.js.map +1 -0
- package/dist/api/routes/globals.d.ts +3 -0
- package/dist/api/routes/globals.d.ts.map +1 -0
- package/dist/api/routes/globals.js +131 -0
- package/dist/api/routes/globals.js.map +1 -0
- package/dist/api/routes/media.d.ts +3 -0
- package/dist/api/routes/media.d.ts.map +1 -0
- package/dist/api/routes/media.js +521 -0
- package/dist/api/routes/media.js.map +1 -0
- package/dist/api/routes/meta.d.ts +3 -0
- package/dist/api/routes/meta.d.ts.map +1 -0
- package/dist/api/routes/meta.js +96 -0
- package/dist/api/routes/meta.js.map +1 -0
- package/dist/api/routes/page-templates.d.ts +3 -0
- package/dist/api/routes/page-templates.d.ts.map +1 -0
- package/dist/api/routes/page-templates.js +265 -0
- package/dist/api/routes/page-templates.js.map +1 -0
- package/dist/api/routes/presence.d.ts +3 -0
- package/dist/api/routes/presence.d.ts.map +1 -0
- package/dist/api/routes/presence.js +35 -0
- package/dist/api/routes/presence.js.map +1 -0
- package/dist/api/routes/preview.d.ts +3 -0
- package/dist/api/routes/preview.d.ts.map +1 -0
- package/dist/api/routes/preview.js +111 -0
- package/dist/api/routes/preview.js.map +1 -0
- package/dist/api/routes/redirects.d.ts +3 -0
- package/dist/api/routes/redirects.d.ts.map +1 -0
- package/dist/api/routes/redirects.js +790 -0
- package/dist/api/routes/redirects.js.map +1 -0
- package/dist/api/routes/saved-sections.d.ts +3 -0
- package/dist/api/routes/saved-sections.d.ts.map +1 -0
- package/dist/api/routes/saved-sections.js +1036 -0
- package/dist/api/routes/saved-sections.js.map +1 -0
- package/dist/api/routes/scheduling.d.ts +3 -0
- package/dist/api/routes/scheduling.d.ts.map +1 -0
- package/dist/api/routes/scheduling.js +373 -0
- package/dist/api/routes/scheduling.js.map +1 -0
- package/dist/api/routes/sections.d.ts +3 -0
- package/dist/api/routes/sections.d.ts.map +1 -0
- package/dist/api/routes/sections.js +500 -0
- package/dist/api/routes/sections.js.map +1 -0
- package/dist/api/routes/security-center.d.ts +3 -0
- package/dist/api/routes/security-center.d.ts.map +1 -0
- package/dist/api/routes/security-center.js +71 -0
- package/dist/api/routes/security-center.js.map +1 -0
- package/dist/api/routes/seo-public.d.ts +14 -0
- package/dist/api/routes/seo-public.d.ts.map +1 -0
- package/dist/api/routes/seo-public.js +930 -0
- package/dist/api/routes/seo-public.js.map +1 -0
- package/dist/api/routes/seo.d.ts +8 -0
- package/dist/api/routes/seo.d.ts.map +1 -0
- package/dist/api/routes/seo.js +848 -0
- package/dist/api/routes/seo.js.map +1 -0
- package/dist/api/routes/system.d.ts +3 -0
- package/dist/api/routes/system.d.ts.map +1 -0
- package/dist/api/routes/system.js +340 -0
- package/dist/api/routes/system.js.map +1 -0
- package/dist/api/routes/url-resolution.d.ts +3 -0
- package/dist/api/routes/url-resolution.d.ts.map +1 -0
- package/dist/api/routes/url-resolution.js +457 -0
- package/dist/api/routes/url-resolution.js.map +1 -0
- package/dist/api/routes/users.d.ts +3 -0
- package/dist/api/routes/users.d.ts.map +1 -0
- package/dist/api/routes/users.js +1162 -0
- package/dist/api/routes/users.js.map +1 -0
- package/dist/api/routes/webhooks.d.ts +3 -0
- package/dist/api/routes/webhooks.d.ts.map +1 -0
- package/dist/api/routes/webhooks.js +338 -0
- package/dist/api/routes/webhooks.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1162 @@
|
|
|
1
|
+
import { createPasswordReset } from '../../auth/reset.js';
|
|
2
|
+
import { createInvite, listInvites, resendInvite, cancelInvite, updateInviteRole, extendInvite, } from '../../auth/invites.js';
|
|
3
|
+
import { listTeamMembers, getUserStats, computeAccessReview } from '../../auth/team.js';
|
|
4
|
+
import { getUserDetail, listUserSessions, listUserActivity, getUserPermissionView, transferContentOwnership, } from '../../auth/user-admin.js';
|
|
5
|
+
import { normalizeAssignableRole, roleRank, isOwnerRole } from '../../auth/roles.js';
|
|
6
|
+
import { logEvent } from '../../security/audit.js';
|
|
7
|
+
import { getActuateConfig } from '../../config/runtime.js';
|
|
8
|
+
import { generateApiKey } from '../../security/api-key-enhanced.js';
|
|
9
|
+
import { guardedDb } from '../guarded-db.js';
|
|
10
|
+
import { json, errorResponse, internalError, hasModel, modelNotAvailable, requirePasswordReauth, requireAuth, requireAdminScope, ADMIN_ROLES, requireRole, teamOptionsFor, isEmailConfigured, inviteLimiter, checkRateLimitAsync, clientIp, } from '../route-helpers.js';
|
|
11
|
+
export function registerUserRoutes(router) {
|
|
12
|
+
const db = guardedDb;
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// API key management (admin-only). Keys are created here, hashed in the DB,
|
|
15
|
+
// and shown to the caller exactly once. They authenticate programmatic
|
|
16
|
+
// clients (AI agents, CI, integrations) against the same REST surface as a
|
|
17
|
+
// user session, but skip CSRF and use scope-based authorization.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
router.get('/api-keys', async (request) => {
|
|
20
|
+
try {
|
|
21
|
+
const auth = await requireAuth(request);
|
|
22
|
+
if (auth.error)
|
|
23
|
+
return auth.error;
|
|
24
|
+
const adminErr = requireAdminScope(auth.session);
|
|
25
|
+
if (adminErr)
|
|
26
|
+
return adminErr;
|
|
27
|
+
const d = db();
|
|
28
|
+
if (!hasModel(d, 'apiKey'))
|
|
29
|
+
return json({ data: [] });
|
|
30
|
+
const keys = await d.apiKey.findMany({
|
|
31
|
+
orderBy: { createdAt: 'desc' },
|
|
32
|
+
select: {
|
|
33
|
+
id: true,
|
|
34
|
+
name: true,
|
|
35
|
+
keyPrefix: true,
|
|
36
|
+
scopes: true,
|
|
37
|
+
ipRestrictions: true,
|
|
38
|
+
expiresAt: true,
|
|
39
|
+
lastUsedAt: true,
|
|
40
|
+
revokedAt: true,
|
|
41
|
+
createdAt: true,
|
|
42
|
+
user: { select: { id: true, name: true, email: true } },
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
return json({ data: keys });
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
return internalError(err, 'api-keys/list');
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
router.post('/api-keys', async (request) => {
|
|
52
|
+
try {
|
|
53
|
+
const auth = await requireAuth(request);
|
|
54
|
+
if (auth.error)
|
|
55
|
+
return auth.error;
|
|
56
|
+
const adminErr = requireAdminScope(auth.session);
|
|
57
|
+
if (adminErr)
|
|
58
|
+
return adminErr;
|
|
59
|
+
// Issuing a new credential is a sensitive action — require reauth for
|
|
60
|
+
// session-authenticated callers. API-key-authenticated requests are
|
|
61
|
+
// already proving possession of a long-lived credential.
|
|
62
|
+
if (!auth.session.apiKey) {
|
|
63
|
+
const reauthErr = await requirePasswordReauth(request, auth.session.userId);
|
|
64
|
+
if (reauthErr)
|
|
65
|
+
return reauthErr;
|
|
66
|
+
}
|
|
67
|
+
const body = (await request.json());
|
|
68
|
+
if (!body.name || typeof body.name !== 'string' || body.name.trim().length === 0) {
|
|
69
|
+
return errorResponse('name is required', 400);
|
|
70
|
+
}
|
|
71
|
+
if (body.name.length > 100) {
|
|
72
|
+
return errorResponse('name must be 100 characters or fewer', 400);
|
|
73
|
+
}
|
|
74
|
+
const scopes = body.scopes ?? {};
|
|
75
|
+
// Sanity check the scope shape so we don't store garbage that fails
|
|
76
|
+
// every authorization check at runtime.
|
|
77
|
+
if (!scopes.admin &&
|
|
78
|
+
!scopes.media &&
|
|
79
|
+
!scopes.pageBuilder &&
|
|
80
|
+
(!scopes.collections || scopes.collections.length === 0) &&
|
|
81
|
+
(!scopes.globals || scopes.globals.length === 0)) {
|
|
82
|
+
return errorResponse('scopes must grant at least one capability (admin, media, pageBuilder, collections, or globals)', 400);
|
|
83
|
+
}
|
|
84
|
+
let expiresAt;
|
|
85
|
+
if (body.expiresAt) {
|
|
86
|
+
const parsed = new Date(body.expiresAt);
|
|
87
|
+
if (isNaN(parsed.getTime()))
|
|
88
|
+
return errorResponse('Invalid expiresAt date', 400);
|
|
89
|
+
if (parsed.getTime() < Date.now())
|
|
90
|
+
return errorResponse('expiresAt must be in the future', 400);
|
|
91
|
+
expiresAt = parsed;
|
|
92
|
+
}
|
|
93
|
+
const { key, keyHash, keyPrefix } = await generateApiKey({
|
|
94
|
+
prefix: 'act_sk',
|
|
95
|
+
scopes,
|
|
96
|
+
expiresAt,
|
|
97
|
+
});
|
|
98
|
+
const d = db();
|
|
99
|
+
const record = await d.apiKey.create({
|
|
100
|
+
data: {
|
|
101
|
+
name: body.name.trim(),
|
|
102
|
+
keyHash,
|
|
103
|
+
keyPrefix,
|
|
104
|
+
userId: auth.session.userId,
|
|
105
|
+
scopes: scopes,
|
|
106
|
+
ipRestrictions: body.ipRestrictions?.length ? body.ipRestrictions : null,
|
|
107
|
+
expiresAt: expiresAt ?? null,
|
|
108
|
+
},
|
|
109
|
+
select: {
|
|
110
|
+
id: true,
|
|
111
|
+
name: true,
|
|
112
|
+
keyPrefix: true,
|
|
113
|
+
scopes: true,
|
|
114
|
+
ipRestrictions: true,
|
|
115
|
+
expiresAt: true,
|
|
116
|
+
createdAt: true,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
await logEvent({
|
|
120
|
+
event: 'api_key_created',
|
|
121
|
+
userId: auth.session.userId,
|
|
122
|
+
ipAddress: clientIp(request),
|
|
123
|
+
userAgent: request.headers.get('user-agent') ?? undefined,
|
|
124
|
+
details: { apiKeyId: record.id, name: record.name, scopes },
|
|
125
|
+
});
|
|
126
|
+
// The raw `key` is the only thing the caller will ever see; we never
|
|
127
|
+
// store it in plaintext. Document this in the response shape.
|
|
128
|
+
return json({ data: { ...record, key } }, 201);
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
return internalError(err, 'api-keys/create');
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
router.delete('/api-keys/:id', async (request, params) => {
|
|
135
|
+
try {
|
|
136
|
+
const auth = await requireAuth(request);
|
|
137
|
+
if (auth.error)
|
|
138
|
+
return auth.error;
|
|
139
|
+
const adminErr = requireAdminScope(auth.session);
|
|
140
|
+
if (adminErr)
|
|
141
|
+
return adminErr;
|
|
142
|
+
// Same reauth gate as creation — revocation is irreversible and
|
|
143
|
+
// affects every dependent client.
|
|
144
|
+
if (!auth.session.apiKey) {
|
|
145
|
+
const reauthErr = await requirePasswordReauth(request, auth.session.userId);
|
|
146
|
+
if (reauthErr)
|
|
147
|
+
return reauthErr;
|
|
148
|
+
}
|
|
149
|
+
const d = db();
|
|
150
|
+
const existing = await d.apiKey.findUnique({ where: { id: params.id } });
|
|
151
|
+
if (!existing)
|
|
152
|
+
return errorResponse('API key not found', 404);
|
|
153
|
+
await d.apiKey.update({
|
|
154
|
+
where: { id: params.id },
|
|
155
|
+
data: { revokedAt: new Date() },
|
|
156
|
+
});
|
|
157
|
+
await logEvent({
|
|
158
|
+
event: 'api_key_revoked',
|
|
159
|
+
userId: auth.session.userId,
|
|
160
|
+
ipAddress: clientIp(request),
|
|
161
|
+
userAgent: request.headers.get('user-agent') ?? undefined,
|
|
162
|
+
details: { apiKeyId: existing.id, name: existing.name },
|
|
163
|
+
});
|
|
164
|
+
return json({ data: { revoked: true } });
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
return internalError(err, 'api-keys/revoke');
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Users route
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
router.get('/users', async (request) => {
|
|
174
|
+
try {
|
|
175
|
+
const auth = await requireAuth(request);
|
|
176
|
+
if (auth.error)
|
|
177
|
+
return auth.error;
|
|
178
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
179
|
+
if (roleErr)
|
|
180
|
+
return roleErr;
|
|
181
|
+
const d = db();
|
|
182
|
+
if (!hasModel(d, 'user'))
|
|
183
|
+
return modelNotAvailable('user');
|
|
184
|
+
const url = new URL(request.url);
|
|
185
|
+
const members = await listTeamMembers(d, {
|
|
186
|
+
search: url.searchParams.get('search') ?? undefined,
|
|
187
|
+
role: url.searchParams.get('role') ?? undefined,
|
|
188
|
+
status: url.searchParams.get('status') ?? undefined,
|
|
189
|
+
}, teamOptionsFor(auth.session));
|
|
190
|
+
return json({ data: members });
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
return internalError(err);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
router.get('/users/stats', async (request) => {
|
|
197
|
+
try {
|
|
198
|
+
const auth = await requireAuth(request);
|
|
199
|
+
if (auth.error)
|
|
200
|
+
return auth.error;
|
|
201
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
202
|
+
if (roleErr)
|
|
203
|
+
return roleErr;
|
|
204
|
+
const d = db();
|
|
205
|
+
if (!hasModel(d, 'user'))
|
|
206
|
+
return modelNotAvailable('user');
|
|
207
|
+
const stats = await getUserStats(d, teamOptionsFor(auth.session));
|
|
208
|
+
return json({ data: { ...stats, emailConfigured: isEmailConfigured() } });
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
return internalError(err, 'user stats');
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
// Lightweight directory lookup used by the comment mention picker.
|
|
215
|
+
// Any authenticated user can query this — mentions are a
|
|
216
|
+
// collaboration primitive, not an admin tool. Returns ONLY the
|
|
217
|
+
// minimal subset needed to render a picker row (id, name, email);
|
|
218
|
+
// never expose role, hashed-password, or audit metadata here.
|
|
219
|
+
//
|
|
220
|
+
// The matcher is intentionally simple: case-insensitive `contains`
|
|
221
|
+
// against name + email, capped at 10 results. The picker UI is a
|
|
222
|
+
// dropdown — anything beyond 10 rows is unusable and would only
|
|
223
|
+
// serve as a directory-enumeration attack vector. Inactive /
|
|
224
|
+
// unapproved users are filtered out so the picker can't show
|
|
225
|
+
// someone the actor can't actually collaborate with (the mention
|
|
226
|
+
// would create a comment_mention notification that no one ever
|
|
227
|
+
// reads).
|
|
228
|
+
router.get('/users/search', async (request) => {
|
|
229
|
+
try {
|
|
230
|
+
const auth = await requireAuth(request);
|
|
231
|
+
if (auth.error)
|
|
232
|
+
return auth.error;
|
|
233
|
+
const url = new URL(request.url);
|
|
234
|
+
const qRaw = (url.searchParams.get('q') ?? '').trim();
|
|
235
|
+
// Hard floor on query length: avoids dumping the entire user
|
|
236
|
+
// table when a UI sends an empty `q` by mistake, and avoids
|
|
237
|
+
// making the endpoint a generic directory browser.
|
|
238
|
+
if (qRaw.length === 0) {
|
|
239
|
+
return json({ data: [] });
|
|
240
|
+
}
|
|
241
|
+
// Hard ceiling on query length: 64 chars is more than any name
|
|
242
|
+
// or email prefix needs and prevents pathological regex / LIKE
|
|
243
|
+
// workloads from a hostile client.
|
|
244
|
+
const q = qRaw.slice(0, 64);
|
|
245
|
+
// Mention only people the actor can actually collaborate
|
|
246
|
+
// with — inactive accounts can't be reached, and unapproved
|
|
247
|
+
// accounts haven't passed admin gating yet. Either user would
|
|
248
|
+
// produce a `comment_mention` notification that goes nowhere
|
|
249
|
+
// (PR #159 Copilot finding — the previous query only filtered
|
|
250
|
+
// `isActive` even though the route comment promised both).
|
|
251
|
+
const users = await db().user.findMany({
|
|
252
|
+
where: {
|
|
253
|
+
isActive: true,
|
|
254
|
+
isApproved: true,
|
|
255
|
+
OR: [
|
|
256
|
+
{ name: { contains: q, mode: 'insensitive' } },
|
|
257
|
+
{ email: { contains: q, mode: 'insensitive' } },
|
|
258
|
+
],
|
|
259
|
+
},
|
|
260
|
+
select: { id: true, name: true, email: true },
|
|
261
|
+
orderBy: { name: 'asc' },
|
|
262
|
+
take: 10,
|
|
263
|
+
});
|
|
264
|
+
return json({ data: users });
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
return internalError(err, 'users search');
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
router.post('/users/invite', async (request) => {
|
|
271
|
+
try {
|
|
272
|
+
const auth = await requireAuth(request);
|
|
273
|
+
if (auth.error)
|
|
274
|
+
return auth.error;
|
|
275
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
276
|
+
if (roleErr)
|
|
277
|
+
return roleErr;
|
|
278
|
+
if (!(await checkRateLimitAsync(inviteLimiter, `invite:${auth.session.userId}`))) {
|
|
279
|
+
return errorResponse('Too many invitations. Please try again later.', 429);
|
|
280
|
+
}
|
|
281
|
+
const d = db();
|
|
282
|
+
if (!hasModel(d, 'user'))
|
|
283
|
+
return modelNotAvailable('user');
|
|
284
|
+
if (!hasModel(d, 'invite'))
|
|
285
|
+
return modelNotAvailable('invite');
|
|
286
|
+
const body = (await request.json());
|
|
287
|
+
if (!body.email)
|
|
288
|
+
return errorResponse('Email is required', 400);
|
|
289
|
+
// Only Owners may invite Owners.
|
|
290
|
+
if ((body.role ?? '').toUpperCase() === 'OWNER' && auth.session.role !== 'OWNER') {
|
|
291
|
+
return errorResponse('Only an Owner can assign the Owner role.', 403);
|
|
292
|
+
}
|
|
293
|
+
const opts = teamOptionsFor(auth.session);
|
|
294
|
+
// Seat enforcement: active members + pending invites reserve seats.
|
|
295
|
+
const stats = await getUserStats(d, opts);
|
|
296
|
+
if (stats.seatsAvailable != null && stats.seatsAvailable <= 0) {
|
|
297
|
+
return errorResponse(`All ${stats.seatLimit} seats are in use. Remove a member or raise the seat limit before inviting.`, 409);
|
|
298
|
+
}
|
|
299
|
+
const cmsConfig = getActuateConfig();
|
|
300
|
+
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? new URL(request.url).origin;
|
|
301
|
+
const inviter = await d.user.findUnique({
|
|
302
|
+
where: { id: auth.session.userId },
|
|
303
|
+
select: { name: true },
|
|
304
|
+
});
|
|
305
|
+
const result = await createInvite(d, {
|
|
306
|
+
email: body.email,
|
|
307
|
+
role: body.role ?? 'VIEWER',
|
|
308
|
+
message: body.message,
|
|
309
|
+
expiresInDays: body.expiresInDays,
|
|
310
|
+
}, {
|
|
311
|
+
siteUrl,
|
|
312
|
+
adminPath: cmsConfig?.admin?.path ?? '/admin',
|
|
313
|
+
invitedByName: inviter?.name ?? undefined,
|
|
314
|
+
invitedByUserId: auth.session.userId,
|
|
315
|
+
siteName: cmsConfig?.admin?.branding?.name ??
|
|
316
|
+
undefined,
|
|
317
|
+
});
|
|
318
|
+
if (!result.success) {
|
|
319
|
+
return errorResponse(result.error ?? 'Could not create invitation', 400);
|
|
320
|
+
}
|
|
321
|
+
await logEvent({
|
|
322
|
+
event: 'user_invited',
|
|
323
|
+
userId: auth.session.userId,
|
|
324
|
+
details: {
|
|
325
|
+
targetEmail: result.invite?.email,
|
|
326
|
+
role: result.invite?.role,
|
|
327
|
+
inviteId: result.invite?.id,
|
|
328
|
+
emailed: result.emailed,
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
return json({
|
|
332
|
+
data: {
|
|
333
|
+
success: true,
|
|
334
|
+
invite: result.invite,
|
|
335
|
+
emailed: result.emailed,
|
|
336
|
+
inviteUrl: result.inviteUrl,
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
catch (err) {
|
|
341
|
+
return internalError(err, 'user invite');
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
router.delete('/users/:id', async (request, params) => {
|
|
345
|
+
try {
|
|
346
|
+
const auth = await requireAuth(request);
|
|
347
|
+
if (auth.error)
|
|
348
|
+
return auth.error;
|
|
349
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
350
|
+
if (roleErr)
|
|
351
|
+
return roleErr;
|
|
352
|
+
if (auth.session.userId === params.id) {
|
|
353
|
+
return errorResponse('Cannot revoke your own access', 400);
|
|
354
|
+
}
|
|
355
|
+
const user = await db().user.findUnique({ where: { id: params.id } });
|
|
356
|
+
if (!user)
|
|
357
|
+
return errorResponse('User not found', 404);
|
|
358
|
+
// Last-Owner protection: never deactivate the only remaining active Owner.
|
|
359
|
+
if (isOwnerRole(user.role)) {
|
|
360
|
+
if (auth.session.role !== 'OWNER') {
|
|
361
|
+
return errorResponse('Only an Owner can revoke another Owner.', 403);
|
|
362
|
+
}
|
|
363
|
+
const otherOwners = await db().user.count({
|
|
364
|
+
where: { role: 'OWNER', isActive: true, id: { not: params.id } },
|
|
365
|
+
});
|
|
366
|
+
if (otherOwners === 0) {
|
|
367
|
+
return errorResponse('Cannot revoke access for the last Owner.', 409);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
await db().user.update({
|
|
371
|
+
where: { id: params.id },
|
|
372
|
+
data: { isActive: false, suspendedAt: new Date() },
|
|
373
|
+
});
|
|
374
|
+
await db().session.updateMany({
|
|
375
|
+
where: { userId: params.id },
|
|
376
|
+
data: { revokedAt: new Date() },
|
|
377
|
+
});
|
|
378
|
+
await logEvent({
|
|
379
|
+
event: 'user_deactivated',
|
|
380
|
+
userId: auth.session.userId,
|
|
381
|
+
details: { targetUserId: params.id, targetEmail: user.email },
|
|
382
|
+
});
|
|
383
|
+
return json({ data: { success: true } });
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
return internalError(err);
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
// Change a user's role, with Owner / last-Owner / self-escalation protections.
|
|
390
|
+
router.patch('/users/:id/role', async (request, params) => {
|
|
391
|
+
try {
|
|
392
|
+
const auth = await requireAuth(request);
|
|
393
|
+
if (auth.error)
|
|
394
|
+
return auth.error;
|
|
395
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
396
|
+
if (roleErr)
|
|
397
|
+
return roleErr;
|
|
398
|
+
const d = db();
|
|
399
|
+
if (!hasModel(d, 'user'))
|
|
400
|
+
return modelNotAvailable('user');
|
|
401
|
+
const body = (await request.json());
|
|
402
|
+
const newRole = normalizeAssignableRole(body.role);
|
|
403
|
+
if (!newRole)
|
|
404
|
+
return errorResponse('Select a valid role.', 400);
|
|
405
|
+
const target = await d.user.findUnique({ where: { id: params.id } });
|
|
406
|
+
if (!target)
|
|
407
|
+
return errorResponse('User not found', 404);
|
|
408
|
+
const targetIsOwner = isOwnerRole(target.role);
|
|
409
|
+
const promotingToOwner = newRole === 'OWNER';
|
|
410
|
+
// Only an Owner may grant or remove the Owner role.
|
|
411
|
+
if ((promotingToOwner || targetIsOwner) && auth.session.role !== 'OWNER') {
|
|
412
|
+
return errorResponse('Only an Owner can assign or change the Owner role.', 403);
|
|
413
|
+
}
|
|
414
|
+
// No self-escalation.
|
|
415
|
+
if (auth.session.userId === target.id && roleRank(newRole) > roleRank(auth.session.role)) {
|
|
416
|
+
return errorResponse('You cannot raise your own role.', 403);
|
|
417
|
+
}
|
|
418
|
+
// Last-Owner protection: don't demote the only remaining active Owner.
|
|
419
|
+
if (targetIsOwner && !promotingToOwner) {
|
|
420
|
+
const otherOwners = await d.user.count({
|
|
421
|
+
where: { role: 'OWNER', isActive: true, id: { not: target.id } },
|
|
422
|
+
});
|
|
423
|
+
if (otherOwners === 0) {
|
|
424
|
+
return errorResponse('Cannot change the role of the last Owner.', 409);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (target.role === newRole) {
|
|
428
|
+
return json({ data: { success: true, role: newRole } });
|
|
429
|
+
}
|
|
430
|
+
const downgrade = roleRank(newRole) < roleRank(target.role);
|
|
431
|
+
await d.user.update({ where: { id: target.id }, data: { role: newRole } });
|
|
432
|
+
// Revoke sessions on downgrade so reduced permissions take effect at once.
|
|
433
|
+
if (downgrade) {
|
|
434
|
+
await d.session.updateMany({
|
|
435
|
+
where: { userId: target.id, revokedAt: null },
|
|
436
|
+
data: { revokedAt: new Date() },
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
await logEvent({
|
|
440
|
+
event: 'user_role_changed',
|
|
441
|
+
userId: auth.session.userId,
|
|
442
|
+
details: {
|
|
443
|
+
targetUserId: target.id,
|
|
444
|
+
targetEmail: target.email,
|
|
445
|
+
previousRole: target.role,
|
|
446
|
+
newRole,
|
|
447
|
+
sessionsRevoked: downgrade,
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
return json({ data: { success: true, role: newRole, sessionsRevoked: downgrade } });
|
|
451
|
+
}
|
|
452
|
+
catch (err) {
|
|
453
|
+
return internalError(err, 'user role change');
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
// User detail drawer routes (overview / security / sessions / activity /
|
|
458
|
+
// permissions) + per-user security actions.
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
/**
|
|
461
|
+
* Load the target user for a security action and run the guards common to all
|
|
462
|
+
* of them. Returns either the user row or an error Response. `requireOwner`
|
|
463
|
+
* is set true for actions where acting on an Owner is owner-only; `blockSelf`
|
|
464
|
+
* for actions a user must not perform on their own account.
|
|
465
|
+
*/
|
|
466
|
+
async function loadActionTarget(auth, id, { blockSelf = false } = {}) {
|
|
467
|
+
const d = db();
|
|
468
|
+
if (!hasModel(d, 'user'))
|
|
469
|
+
return { error: modelNotAvailable('user') };
|
|
470
|
+
const user = await d.user.findUnique({ where: { id } });
|
|
471
|
+
if (!user)
|
|
472
|
+
return { error: errorResponse('User not found', 404) };
|
|
473
|
+
if (blockSelf && auth.session.userId === id) {
|
|
474
|
+
return { error: errorResponse('You cannot perform this action on your own account.', 400) };
|
|
475
|
+
}
|
|
476
|
+
// Acting on an Owner is Owner-only.
|
|
477
|
+
if (isOwnerRole(user.role) && auth.session.role !== 'OWNER') {
|
|
478
|
+
return { error: errorResponse('Only an Owner can manage another Owner.', 403) };
|
|
479
|
+
}
|
|
480
|
+
return { user };
|
|
481
|
+
}
|
|
482
|
+
/** Whether deactivating/suspending/locking `userId` would remove the last Owner. */
|
|
483
|
+
async function wouldOrphanOwnership(user, userId) {
|
|
484
|
+
if (!isOwnerRole(user.role))
|
|
485
|
+
return false;
|
|
486
|
+
const otherOwners = await db().user.count({
|
|
487
|
+
where: { role: 'OWNER', isActive: true, id: { not: userId } },
|
|
488
|
+
});
|
|
489
|
+
return otherOwners === 0;
|
|
490
|
+
}
|
|
491
|
+
router.get('/users/:id', async (request, params) => {
|
|
492
|
+
try {
|
|
493
|
+
const auth = await requireAuth(request);
|
|
494
|
+
if (auth.error)
|
|
495
|
+
return auth.error;
|
|
496
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
497
|
+
if (roleErr)
|
|
498
|
+
return roleErr;
|
|
499
|
+
const d = db();
|
|
500
|
+
if (!hasModel(d, 'user'))
|
|
501
|
+
return modelNotAvailable('user');
|
|
502
|
+
const opts = teamOptionsFor(auth.session);
|
|
503
|
+
const detail = await getUserDetail(d, params.id, {
|
|
504
|
+
workspaceDomain: opts.workspaceDomain,
|
|
505
|
+
currentUserId: auth.session.userId,
|
|
506
|
+
});
|
|
507
|
+
if (!detail)
|
|
508
|
+
return errorResponse('User not found', 404);
|
|
509
|
+
return json({ data: detail });
|
|
510
|
+
}
|
|
511
|
+
catch (err) {
|
|
512
|
+
return internalError(err, 'user detail');
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
router.get('/users/:id/sessions', async (request, params) => {
|
|
516
|
+
try {
|
|
517
|
+
const auth = await requireAuth(request);
|
|
518
|
+
if (auth.error)
|
|
519
|
+
return auth.error;
|
|
520
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
521
|
+
if (roleErr)
|
|
522
|
+
return roleErr;
|
|
523
|
+
const d = db();
|
|
524
|
+
if (!hasModel(d, 'session'))
|
|
525
|
+
return modelNotAvailable('session');
|
|
526
|
+
// Mark "this device" only when an admin views their own sessions.
|
|
527
|
+
const currentSessionId = auth.session.userId === params.id ? auth.session.sessionId : undefined;
|
|
528
|
+
const sessions = await listUserSessions(d, params.id, currentSessionId);
|
|
529
|
+
return json({ data: sessions });
|
|
530
|
+
}
|
|
531
|
+
catch (err) {
|
|
532
|
+
return internalError(err, 'user sessions');
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
router.delete('/users/:id/sessions/:sessionId', async (request, params) => {
|
|
536
|
+
try {
|
|
537
|
+
const auth = await requireAuth(request);
|
|
538
|
+
if (auth.error)
|
|
539
|
+
return auth.error;
|
|
540
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
541
|
+
if (roleErr)
|
|
542
|
+
return roleErr;
|
|
543
|
+
const d = db();
|
|
544
|
+
if (!hasModel(d, 'session'))
|
|
545
|
+
return modelNotAvailable('session');
|
|
546
|
+
const target = await loadActionTarget(auth, params.id);
|
|
547
|
+
if (target.error)
|
|
548
|
+
return target.error;
|
|
549
|
+
const session = await d.session.findUnique({ where: { id: params.sessionId } });
|
|
550
|
+
if (!session || session.userId !== params.id) {
|
|
551
|
+
return errorResponse('Session not found', 404);
|
|
552
|
+
}
|
|
553
|
+
await d.session.update({
|
|
554
|
+
where: { id: params.sessionId },
|
|
555
|
+
data: { revokedAt: new Date() },
|
|
556
|
+
});
|
|
557
|
+
await logEvent({
|
|
558
|
+
event: 'session_revoked',
|
|
559
|
+
userId: auth.session.userId,
|
|
560
|
+
ipAddress: clientIp(request),
|
|
561
|
+
userAgent: request.headers.get('user-agent') ?? undefined,
|
|
562
|
+
details: { targetUserId: params.id, sessionId: params.sessionId },
|
|
563
|
+
});
|
|
564
|
+
return json({ data: { success: true } });
|
|
565
|
+
}
|
|
566
|
+
catch (err) {
|
|
567
|
+
return internalError(err, 'revoke session');
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
router.post('/users/:id/sessions/revoke-all', async (request, params) => {
|
|
571
|
+
try {
|
|
572
|
+
const auth = await requireAuth(request);
|
|
573
|
+
if (auth.error)
|
|
574
|
+
return auth.error;
|
|
575
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
576
|
+
if (roleErr)
|
|
577
|
+
return roleErr;
|
|
578
|
+
const d = db();
|
|
579
|
+
if (!hasModel(d, 'session'))
|
|
580
|
+
return modelNotAvailable('session');
|
|
581
|
+
const target = await loadActionTarget(auth, params.id);
|
|
582
|
+
if (target.error)
|
|
583
|
+
return target.error;
|
|
584
|
+
// When an admin revokes their own sessions, keep the current one so the
|
|
585
|
+
// action doesn't log them out mid-flow (the UI offers a separate path for
|
|
586
|
+
// that). For any other user, revoke everything.
|
|
587
|
+
const keepCurrent = auth.session.userId === params.id;
|
|
588
|
+
await d.session.updateMany({
|
|
589
|
+
where: {
|
|
590
|
+
userId: params.id,
|
|
591
|
+
revokedAt: null,
|
|
592
|
+
...(keepCurrent ? { id: { not: auth.session.sessionId } } : {}),
|
|
593
|
+
},
|
|
594
|
+
data: { revokedAt: new Date() },
|
|
595
|
+
});
|
|
596
|
+
await logEvent({
|
|
597
|
+
event: 'sessions_revoked_all',
|
|
598
|
+
userId: auth.session.userId,
|
|
599
|
+
ipAddress: clientIp(request),
|
|
600
|
+
userAgent: request.headers.get('user-agent') ?? undefined,
|
|
601
|
+
details: { targetUserId: params.id, keptCurrent: keepCurrent },
|
|
602
|
+
});
|
|
603
|
+
return json({ data: { success: true } });
|
|
604
|
+
}
|
|
605
|
+
catch (err) {
|
|
606
|
+
return internalError(err, 'revoke all sessions');
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
router.get('/users/:id/activity', async (request, params) => {
|
|
610
|
+
try {
|
|
611
|
+
const auth = await requireAuth(request);
|
|
612
|
+
if (auth.error)
|
|
613
|
+
return auth.error;
|
|
614
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
615
|
+
if (roleErr)
|
|
616
|
+
return roleErr;
|
|
617
|
+
const d = db();
|
|
618
|
+
if (!hasModel(d, 'auditLog'))
|
|
619
|
+
return json({ data: [] });
|
|
620
|
+
const url = new URL(request.url);
|
|
621
|
+
const limit = Number(url.searchParams.get('limit') ?? '50');
|
|
622
|
+
const events = await listUserActivity(d, params.id, Number.isFinite(limit) ? limit : 50);
|
|
623
|
+
return json({ data: events });
|
|
624
|
+
}
|
|
625
|
+
catch (err) {
|
|
626
|
+
return internalError(err, 'user activity');
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
router.get('/users/:id/permissions', async (request, params) => {
|
|
630
|
+
try {
|
|
631
|
+
const auth = await requireAuth(request);
|
|
632
|
+
if (auth.error)
|
|
633
|
+
return auth.error;
|
|
634
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
635
|
+
if (roleErr)
|
|
636
|
+
return roleErr;
|
|
637
|
+
const d = db();
|
|
638
|
+
if (!hasModel(d, 'user'))
|
|
639
|
+
return modelNotAvailable('user');
|
|
640
|
+
const user = await d.user.findUnique({
|
|
641
|
+
where: { id: params.id },
|
|
642
|
+
select: { role: true },
|
|
643
|
+
});
|
|
644
|
+
if (!user)
|
|
645
|
+
return errorResponse('User not found', 404);
|
|
646
|
+
return json({ data: getUserPermissionView(user.role) });
|
|
647
|
+
}
|
|
648
|
+
catch (err) {
|
|
649
|
+
return internalError(err, 'user permissions');
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
// Send a password reset email to the user. Admins never set or see passwords;
|
|
653
|
+
// this triggers the standard single-use, expiring, hashed-token reset flow.
|
|
654
|
+
router.post('/users/:id/password-reset', async (request, params) => {
|
|
655
|
+
try {
|
|
656
|
+
const auth = await requireAuth(request);
|
|
657
|
+
if (auth.error)
|
|
658
|
+
return auth.error;
|
|
659
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
660
|
+
if (roleErr)
|
|
661
|
+
return roleErr;
|
|
662
|
+
const target = await loadActionTarget(auth, params.id);
|
|
663
|
+
if (target.error)
|
|
664
|
+
return target.error;
|
|
665
|
+
const user = target.user;
|
|
666
|
+
// SSO/OAuth-only accounts have no local password to reset.
|
|
667
|
+
if (!user.passwordHash) {
|
|
668
|
+
return errorResponse('This account signs in with an external identity provider and has no password to reset.', 409);
|
|
669
|
+
}
|
|
670
|
+
// Resetting an Admin/Owner password is high-impact — require reauth.
|
|
671
|
+
if (isOwnerRole(user.role) || String(user.role).toUpperCase() === 'ADMIN') {
|
|
672
|
+
const reauthErr = await requirePasswordReauth(request, auth.session.userId);
|
|
673
|
+
if (reauthErr)
|
|
674
|
+
return reauthErr;
|
|
675
|
+
}
|
|
676
|
+
if (!isEmailConfigured()) {
|
|
677
|
+
return errorResponse('Email is not configured, so a reset link cannot be sent. Configure an email provider first.', 409);
|
|
678
|
+
}
|
|
679
|
+
const cmsConfig = getActuateConfig();
|
|
680
|
+
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? new URL(request.url).origin;
|
|
681
|
+
await createPasswordReset(db(), user.email, {
|
|
682
|
+
siteUrl,
|
|
683
|
+
adminPath: cmsConfig?.admin?.path ?? '/admin',
|
|
684
|
+
platform: cmsConfig?.platform,
|
|
685
|
+
});
|
|
686
|
+
await logEvent({
|
|
687
|
+
event: 'password_reset_sent',
|
|
688
|
+
userId: auth.session.userId,
|
|
689
|
+
ipAddress: clientIp(request),
|
|
690
|
+
userAgent: request.headers.get('user-agent') ?? undefined,
|
|
691
|
+
details: { targetUserId: user.id, targetEmail: user.email },
|
|
692
|
+
});
|
|
693
|
+
return json({ data: { success: true, emailed: true } });
|
|
694
|
+
}
|
|
695
|
+
catch (err) {
|
|
696
|
+
return internalError(err, 'admin password reset');
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
// Toggle "must reset password on next login". Body: { enabled: boolean }.
|
|
700
|
+
router.post('/users/:id/force-password-reset', async (request, params) => {
|
|
701
|
+
try {
|
|
702
|
+
const auth = await requireAuth(request);
|
|
703
|
+
if (auth.error)
|
|
704
|
+
return auth.error;
|
|
705
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
706
|
+
if (roleErr)
|
|
707
|
+
return roleErr;
|
|
708
|
+
const target = await loadActionTarget(auth, params.id);
|
|
709
|
+
if (target.error)
|
|
710
|
+
return target.error;
|
|
711
|
+
const body = (await request.json().catch(() => ({})));
|
|
712
|
+
const enabled = body.enabled !== false;
|
|
713
|
+
await db().user.update({
|
|
714
|
+
where: { id: params.id },
|
|
715
|
+
data: { forcePasswordReset: enabled },
|
|
716
|
+
});
|
|
717
|
+
await logEvent({
|
|
718
|
+
event: enabled ? 'force_password_reset_enabled' : 'force_password_reset_cleared',
|
|
719
|
+
userId: auth.session.userId,
|
|
720
|
+
details: { targetUserId: params.id },
|
|
721
|
+
});
|
|
722
|
+
return json({ data: { success: true, forcePasswordReset: enabled } });
|
|
723
|
+
}
|
|
724
|
+
catch (err) {
|
|
725
|
+
return internalError(err, 'force password reset');
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
// Reset MFA enrollment: clears the TOTP secret + backup codes and revokes the
|
|
729
|
+
// user's sessions. High-impact — requires reauth and protects the last Owner.
|
|
730
|
+
router.post('/users/:id/mfa-reset', async (request, params) => {
|
|
731
|
+
try {
|
|
732
|
+
const auth = await requireAuth(request);
|
|
733
|
+
if (auth.error)
|
|
734
|
+
return auth.error;
|
|
735
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
736
|
+
if (roleErr)
|
|
737
|
+
return roleErr;
|
|
738
|
+
const target = await loadActionTarget(auth, params.id);
|
|
739
|
+
if (target.error)
|
|
740
|
+
return target.error;
|
|
741
|
+
const user = target.user;
|
|
742
|
+
const reauthErr = await requirePasswordReauth(request, auth.session.userId);
|
|
743
|
+
if (reauthErr)
|
|
744
|
+
return reauthErr;
|
|
745
|
+
// Don't strip the last Owner's MFA — that would leave the most privileged
|
|
746
|
+
// account with the weakest protection and no recovery-safe path.
|
|
747
|
+
if (isOwnerRole(user.role) && (await wouldOrphanOwnership(user, user.id))) {
|
|
748
|
+
return errorResponse('Cannot reset MFA for the last Owner. Add another Owner first.', 409);
|
|
749
|
+
}
|
|
750
|
+
const d = db();
|
|
751
|
+
await d.user.update({
|
|
752
|
+
where: { id: user.id },
|
|
753
|
+
data: { totpEnabled: false, totpSecret: null, backupCodes: null },
|
|
754
|
+
});
|
|
755
|
+
if (hasModel(d, 'session')) {
|
|
756
|
+
await d.session.updateMany({
|
|
757
|
+
where: { userId: user.id, revokedAt: null },
|
|
758
|
+
data: { revokedAt: new Date() },
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
await logEvent({
|
|
762
|
+
event: 'mfa_reset',
|
|
763
|
+
userId: auth.session.userId,
|
|
764
|
+
ipAddress: clientIp(request),
|
|
765
|
+
userAgent: request.headers.get('user-agent') ?? undefined,
|
|
766
|
+
details: { targetUserId: user.id, targetEmail: user.email },
|
|
767
|
+
});
|
|
768
|
+
return json({ data: { success: true } });
|
|
769
|
+
}
|
|
770
|
+
catch (err) {
|
|
771
|
+
return internalError(err, 'mfa reset');
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
// Require the user to enrol MFA before next access. Body: { required: boolean }.
|
|
775
|
+
router.post('/users/:id/require-mfa', async (request, params) => {
|
|
776
|
+
try {
|
|
777
|
+
const auth = await requireAuth(request);
|
|
778
|
+
if (auth.error)
|
|
779
|
+
return auth.error;
|
|
780
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
781
|
+
if (roleErr)
|
|
782
|
+
return roleErr;
|
|
783
|
+
const target = await loadActionTarget(auth, params.id);
|
|
784
|
+
if (target.error)
|
|
785
|
+
return target.error;
|
|
786
|
+
const body = (await request.json().catch(() => ({})));
|
|
787
|
+
const required = body.required !== false;
|
|
788
|
+
await db().user.update({
|
|
789
|
+
where: { id: params.id },
|
|
790
|
+
data: { mfaRequired: required },
|
|
791
|
+
});
|
|
792
|
+
await logEvent({
|
|
793
|
+
event: required ? 'mfa_required' : 'mfa_requirement_cleared',
|
|
794
|
+
userId: auth.session.userId,
|
|
795
|
+
details: { targetUserId: params.id },
|
|
796
|
+
});
|
|
797
|
+
return json({ data: { success: true, mfaRequired: required } });
|
|
798
|
+
}
|
|
799
|
+
catch (err) {
|
|
800
|
+
return internalError(err, 'require mfa');
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
router.post('/users/:id/suspend', async (request, params) => {
|
|
804
|
+
try {
|
|
805
|
+
const auth = await requireAuth(request);
|
|
806
|
+
if (auth.error)
|
|
807
|
+
return auth.error;
|
|
808
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
809
|
+
if (roleErr)
|
|
810
|
+
return roleErr;
|
|
811
|
+
const target = await loadActionTarget(auth, params.id, { blockSelf: true });
|
|
812
|
+
if (target.error)
|
|
813
|
+
return target.error;
|
|
814
|
+
if (await wouldOrphanOwnership(target.user, params.id)) {
|
|
815
|
+
return errorResponse('Cannot suspend the last Owner.', 409);
|
|
816
|
+
}
|
|
817
|
+
const d = db();
|
|
818
|
+
await d.user.update({ where: { id: params.id }, data: { suspendedAt: new Date() } });
|
|
819
|
+
if (hasModel(d, 'session')) {
|
|
820
|
+
await d.session.updateMany({
|
|
821
|
+
where: { userId: params.id, revokedAt: null },
|
|
822
|
+
data: { revokedAt: new Date() },
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
await logEvent({
|
|
826
|
+
event: 'user_suspended',
|
|
827
|
+
userId: auth.session.userId,
|
|
828
|
+
details: { targetUserId: params.id, targetEmail: target.user.email },
|
|
829
|
+
});
|
|
830
|
+
return json({ data: { success: true } });
|
|
831
|
+
}
|
|
832
|
+
catch (err) {
|
|
833
|
+
return internalError(err, 'suspend user');
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
router.post('/users/:id/reactivate', async (request, params) => {
|
|
837
|
+
try {
|
|
838
|
+
const auth = await requireAuth(request);
|
|
839
|
+
if (auth.error)
|
|
840
|
+
return auth.error;
|
|
841
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
842
|
+
if (roleErr)
|
|
843
|
+
return roleErr;
|
|
844
|
+
const target = await loadActionTarget(auth, params.id);
|
|
845
|
+
if (target.error)
|
|
846
|
+
return target.error;
|
|
847
|
+
// Reactivate restores access from any non-active lifecycle state.
|
|
848
|
+
await db().user.update({
|
|
849
|
+
where: { id: params.id },
|
|
850
|
+
data: { suspendedAt: null, lockedAt: null, isActive: true, failedLoginCount: 0 },
|
|
851
|
+
});
|
|
852
|
+
await logEvent({
|
|
853
|
+
event: 'user_reactivated',
|
|
854
|
+
userId: auth.session.userId,
|
|
855
|
+
details: { targetUserId: params.id, targetEmail: target.user.email },
|
|
856
|
+
});
|
|
857
|
+
return json({ data: { success: true } });
|
|
858
|
+
}
|
|
859
|
+
catch (err) {
|
|
860
|
+
return internalError(err, 'reactivate user');
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
router.post('/users/:id/lock', async (request, params) => {
|
|
864
|
+
try {
|
|
865
|
+
const auth = await requireAuth(request);
|
|
866
|
+
if (auth.error)
|
|
867
|
+
return auth.error;
|
|
868
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
869
|
+
if (roleErr)
|
|
870
|
+
return roleErr;
|
|
871
|
+
const target = await loadActionTarget(auth, params.id, { blockSelf: true });
|
|
872
|
+
if (target.error)
|
|
873
|
+
return target.error;
|
|
874
|
+
if (await wouldOrphanOwnership(target.user, params.id)) {
|
|
875
|
+
return errorResponse('Cannot lock the last Owner.', 409);
|
|
876
|
+
}
|
|
877
|
+
const d = db();
|
|
878
|
+
await d.user.update({ where: { id: params.id }, data: { lockedAt: new Date() } });
|
|
879
|
+
if (hasModel(d, 'session')) {
|
|
880
|
+
await d.session.updateMany({
|
|
881
|
+
where: { userId: params.id, revokedAt: null },
|
|
882
|
+
data: { revokedAt: new Date() },
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
await logEvent({
|
|
886
|
+
event: 'user_locked',
|
|
887
|
+
userId: auth.session.userId,
|
|
888
|
+
details: { targetUserId: params.id, targetEmail: target.user.email },
|
|
889
|
+
});
|
|
890
|
+
return json({ data: { success: true } });
|
|
891
|
+
}
|
|
892
|
+
catch (err) {
|
|
893
|
+
return internalError(err, 'lock user');
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
router.post('/users/:id/unlock', async (request, params) => {
|
|
897
|
+
try {
|
|
898
|
+
const auth = await requireAuth(request);
|
|
899
|
+
if (auth.error)
|
|
900
|
+
return auth.error;
|
|
901
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
902
|
+
if (roleErr)
|
|
903
|
+
return roleErr;
|
|
904
|
+
const target = await loadActionTarget(auth, params.id);
|
|
905
|
+
if (target.error)
|
|
906
|
+
return target.error;
|
|
907
|
+
await db().user.update({
|
|
908
|
+
where: { id: params.id },
|
|
909
|
+
data: { lockedAt: null, failedLoginCount: 0 },
|
|
910
|
+
});
|
|
911
|
+
await logEvent({
|
|
912
|
+
event: 'user_unlocked',
|
|
913
|
+
userId: auth.session.userId,
|
|
914
|
+
details: { targetUserId: params.id, targetEmail: target.user.email },
|
|
915
|
+
});
|
|
916
|
+
return json({ data: { success: true } });
|
|
917
|
+
}
|
|
918
|
+
catch (err) {
|
|
919
|
+
return internalError(err, 'unlock user');
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
// Reassign all content owned by :id to another active user. Used before
|
|
923
|
+
// removing someone so their documents/media/templates survive with a real
|
|
924
|
+
// owner. Body: { toUserId }.
|
|
925
|
+
router.post('/users/:id/transfer-content', async (request, params) => {
|
|
926
|
+
try {
|
|
927
|
+
const auth = await requireAuth(request);
|
|
928
|
+
if (auth.error)
|
|
929
|
+
return auth.error;
|
|
930
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
931
|
+
if (roleErr)
|
|
932
|
+
return roleErr;
|
|
933
|
+
const target = await loadActionTarget(auth, params.id);
|
|
934
|
+
if (target.error)
|
|
935
|
+
return target.error;
|
|
936
|
+
const body = (await request.json().catch(() => ({})));
|
|
937
|
+
const toUserId = body.toUserId;
|
|
938
|
+
if (!toUserId)
|
|
939
|
+
return errorResponse('A recipient (toUserId) is required.', 400);
|
|
940
|
+
if (toUserId === params.id) {
|
|
941
|
+
return errorResponse('Choose a different user to receive the content.', 400);
|
|
942
|
+
}
|
|
943
|
+
const recipient = await db().user.findUnique({ where: { id: toUserId } });
|
|
944
|
+
if (!recipient)
|
|
945
|
+
return errorResponse('Recipient not found.', 404);
|
|
946
|
+
if (recipient.isActive === false || recipient.suspendedAt || recipient.lockedAt) {
|
|
947
|
+
return errorResponse('Content can only be transferred to an active user.', 409);
|
|
948
|
+
}
|
|
949
|
+
const counts = await transferContentOwnership(db(), params.id, toUserId);
|
|
950
|
+
await logEvent({
|
|
951
|
+
event: 'content_ownership_transferred',
|
|
952
|
+
userId: auth.session.userId,
|
|
953
|
+
details: {
|
|
954
|
+
fromUserId: params.id,
|
|
955
|
+
toUserId,
|
|
956
|
+
documents: counts.documents,
|
|
957
|
+
media: counts.media,
|
|
958
|
+
templates: counts.templates,
|
|
959
|
+
total: counts.total,
|
|
960
|
+
},
|
|
961
|
+
});
|
|
962
|
+
return json({ data: { success: true, ...counts } });
|
|
963
|
+
}
|
|
964
|
+
catch (err) {
|
|
965
|
+
return internalError(err, 'transfer content ownership');
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
// ---------------------------------------------------------------------------
|
|
969
|
+
// Invite management routes
|
|
970
|
+
// ---------------------------------------------------------------------------
|
|
971
|
+
router.get('/invites', async (request) => {
|
|
972
|
+
try {
|
|
973
|
+
const auth = await requireAuth(request);
|
|
974
|
+
if (auth.error)
|
|
975
|
+
return auth.error;
|
|
976
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
977
|
+
if (roleErr)
|
|
978
|
+
return roleErr;
|
|
979
|
+
const d = db();
|
|
980
|
+
if (!hasModel(d, 'invite'))
|
|
981
|
+
return modelNotAvailable('invite');
|
|
982
|
+
const url = new URL(request.url);
|
|
983
|
+
const invites = await listInvites(d, {
|
|
984
|
+
status: url.searchParams.get('status') ?? undefined,
|
|
985
|
+
search: url.searchParams.get('search') ?? undefined,
|
|
986
|
+
});
|
|
987
|
+
return json({ data: invites });
|
|
988
|
+
}
|
|
989
|
+
catch (err) {
|
|
990
|
+
return internalError(err, 'list invites');
|
|
991
|
+
}
|
|
992
|
+
});
|
|
993
|
+
function inviteConfigFor(request, inviterName) {
|
|
994
|
+
const cfg = getActuateConfig();
|
|
995
|
+
return {
|
|
996
|
+
siteUrl: process.env.NEXT_PUBLIC_SITE_URL ?? new URL(request.url).origin,
|
|
997
|
+
adminPath: cfg?.admin?.path ?? '/admin',
|
|
998
|
+
invitedByName: inviterName,
|
|
999
|
+
siteName: cfg?.admin?.branding?.name ?? undefined,
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
router.post('/invites/:id/resend', async (request, params) => {
|
|
1003
|
+
try {
|
|
1004
|
+
const auth = await requireAuth(request);
|
|
1005
|
+
if (auth.error)
|
|
1006
|
+
return auth.error;
|
|
1007
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
1008
|
+
if (roleErr)
|
|
1009
|
+
return roleErr;
|
|
1010
|
+
if (!(await checkRateLimitAsync(inviteLimiter, `invite:${auth.session.userId}`))) {
|
|
1011
|
+
return errorResponse('Too many invitations. Please try again later.', 429);
|
|
1012
|
+
}
|
|
1013
|
+
const d = db();
|
|
1014
|
+
if (!hasModel(d, 'invite'))
|
|
1015
|
+
return modelNotAvailable('invite');
|
|
1016
|
+
const result = await resendInvite(d, params.id, inviteConfigFor(request));
|
|
1017
|
+
if (!result.success) {
|
|
1018
|
+
return errorResponse(result.error ?? 'Could not resend invitation', 400);
|
|
1019
|
+
}
|
|
1020
|
+
await logEvent({
|
|
1021
|
+
event: 'invite_resent',
|
|
1022
|
+
userId: auth.session.userId,
|
|
1023
|
+
details: { inviteId: params.id, emailed: result.emailed },
|
|
1024
|
+
});
|
|
1025
|
+
return json({
|
|
1026
|
+
data: { success: true, emailed: result.emailed, inviteUrl: result.inviteUrl },
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
catch (err) {
|
|
1030
|
+
return internalError(err, 'resend invite');
|
|
1031
|
+
}
|
|
1032
|
+
});
|
|
1033
|
+
router.post('/invites/:id/cancel', async (request, params) => {
|
|
1034
|
+
try {
|
|
1035
|
+
const auth = await requireAuth(request);
|
|
1036
|
+
if (auth.error)
|
|
1037
|
+
return auth.error;
|
|
1038
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
1039
|
+
if (roleErr)
|
|
1040
|
+
return roleErr;
|
|
1041
|
+
const d = db();
|
|
1042
|
+
if (!hasModel(d, 'invite'))
|
|
1043
|
+
return modelNotAvailable('invite');
|
|
1044
|
+
const result = await cancelInvite(d, params.id);
|
|
1045
|
+
if (!result.success) {
|
|
1046
|
+
return errorResponse(result.error ?? 'Could not cancel invitation', 400);
|
|
1047
|
+
}
|
|
1048
|
+
await logEvent({
|
|
1049
|
+
event: 'invite_cancelled',
|
|
1050
|
+
userId: auth.session.userId,
|
|
1051
|
+
details: { inviteId: params.id },
|
|
1052
|
+
});
|
|
1053
|
+
return json({ data: { success: true } });
|
|
1054
|
+
}
|
|
1055
|
+
catch (err) {
|
|
1056
|
+
return internalError(err, 'cancel invite');
|
|
1057
|
+
}
|
|
1058
|
+
});
|
|
1059
|
+
router.patch('/invites/:id', async (request, params) => {
|
|
1060
|
+
try {
|
|
1061
|
+
const auth = await requireAuth(request);
|
|
1062
|
+
if (auth.error)
|
|
1063
|
+
return auth.error;
|
|
1064
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
1065
|
+
if (roleErr)
|
|
1066
|
+
return roleErr;
|
|
1067
|
+
const d = db();
|
|
1068
|
+
if (!hasModel(d, 'invite'))
|
|
1069
|
+
return modelNotAvailable('invite');
|
|
1070
|
+
const body = (await request.json());
|
|
1071
|
+
if (body.role !== undefined) {
|
|
1072
|
+
if (body.role.toUpperCase() === 'OWNER' && auth.session.role !== 'OWNER') {
|
|
1073
|
+
return errorResponse('Only an Owner can assign the Owner role.', 403);
|
|
1074
|
+
}
|
|
1075
|
+
const result = await updateInviteRole(d, params.id, body.role);
|
|
1076
|
+
if (!result.success) {
|
|
1077
|
+
return errorResponse(result.error ?? 'Could not update invitation', 400);
|
|
1078
|
+
}
|
|
1079
|
+
await logEvent({
|
|
1080
|
+
event: 'invite_role_changed',
|
|
1081
|
+
userId: auth.session.userId,
|
|
1082
|
+
details: { inviteId: params.id, newRole: result.invite?.role },
|
|
1083
|
+
});
|
|
1084
|
+
return json({ data: { success: true, invite: result.invite } });
|
|
1085
|
+
}
|
|
1086
|
+
if (body.extendDays !== undefined) {
|
|
1087
|
+
const result = await extendInvite(d, params.id, body.extendDays);
|
|
1088
|
+
if (!result.success) {
|
|
1089
|
+
return errorResponse(result.error ?? 'Could not extend invitation', 400);
|
|
1090
|
+
}
|
|
1091
|
+
await logEvent({
|
|
1092
|
+
event: 'invite_extended',
|
|
1093
|
+
userId: auth.session.userId,
|
|
1094
|
+
details: { inviteId: params.id, expiresAt: result.invite?.expiresAt },
|
|
1095
|
+
});
|
|
1096
|
+
return json({ data: { success: true, invite: result.invite } });
|
|
1097
|
+
}
|
|
1098
|
+
return errorResponse('Nothing to update', 400);
|
|
1099
|
+
}
|
|
1100
|
+
catch (err) {
|
|
1101
|
+
return internalError(err, 'update invite');
|
|
1102
|
+
}
|
|
1103
|
+
});
|
|
1104
|
+
// ---------------------------------------------------------------------------
|
|
1105
|
+
// Access review routes
|
|
1106
|
+
// ---------------------------------------------------------------------------
|
|
1107
|
+
router.get('/access-review', async (request) => {
|
|
1108
|
+
try {
|
|
1109
|
+
const auth = await requireAuth(request);
|
|
1110
|
+
if (auth.error)
|
|
1111
|
+
return auth.error;
|
|
1112
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
1113
|
+
if (roleErr)
|
|
1114
|
+
return roleErr;
|
|
1115
|
+
const d = db();
|
|
1116
|
+
if (!hasModel(d, 'user'))
|
|
1117
|
+
return modelNotAvailable('user');
|
|
1118
|
+
// Dismissals are persisted as audit events — no extra table needed.
|
|
1119
|
+
let dismissedIds = [];
|
|
1120
|
+
try {
|
|
1121
|
+
const rows = await d.auditLog.findMany({
|
|
1122
|
+
where: { event: 'access_review_dismissed' },
|
|
1123
|
+
select: { details: true },
|
|
1124
|
+
});
|
|
1125
|
+
dismissedIds = rows
|
|
1126
|
+
.map((r) => r.details?.recommendationId)
|
|
1127
|
+
.filter((x) => typeof x === 'string');
|
|
1128
|
+
}
|
|
1129
|
+
catch {
|
|
1130
|
+
dismissedIds = [];
|
|
1131
|
+
}
|
|
1132
|
+
const recommendations = await computeAccessReview(d, {
|
|
1133
|
+
...teamOptionsFor(auth.session),
|
|
1134
|
+
dismissedIds,
|
|
1135
|
+
});
|
|
1136
|
+
return json({ data: recommendations });
|
|
1137
|
+
}
|
|
1138
|
+
catch (err) {
|
|
1139
|
+
return internalError(err, 'access review');
|
|
1140
|
+
}
|
|
1141
|
+
});
|
|
1142
|
+
router.post('/access-review/:id/dismiss', async (request, params) => {
|
|
1143
|
+
try {
|
|
1144
|
+
const auth = await requireAuth(request);
|
|
1145
|
+
if (auth.error)
|
|
1146
|
+
return auth.error;
|
|
1147
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
1148
|
+
if (roleErr)
|
|
1149
|
+
return roleErr;
|
|
1150
|
+
await logEvent({
|
|
1151
|
+
event: 'access_review_dismissed',
|
|
1152
|
+
userId: auth.session.userId,
|
|
1153
|
+
details: { recommendationId: params.id },
|
|
1154
|
+
});
|
|
1155
|
+
return json({ data: { success: true } });
|
|
1156
|
+
}
|
|
1157
|
+
catch (err) {
|
|
1158
|
+
return internalError(err, 'dismiss access review');
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
//# sourceMappingURL=users.js.map
|