@valentinkolb/cloud 0.4.0 → 0.5.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/package.json +18 -6
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +53 -46
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +113 -10
- package/src/api/index.ts +7 -2
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +0 -2
- package/src/services/auth-flows/magic-link.ts +3 -2
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +64 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +49 -0
- package/src/shared/redirect.ts +52 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
|
@@ -5,6 +5,7 @@ import { renderTemplate } from "../settings/templates";
|
|
|
5
5
|
import { session } from "../session";
|
|
6
6
|
import { freeipa } from "../../server/services";
|
|
7
7
|
import { providers } from "../providers";
|
|
8
|
+
import { getServiceIpaSession } from "../ipa/service-account";
|
|
8
9
|
import { transitionIpaUserToLocal } from "./switching";
|
|
9
10
|
import {
|
|
10
11
|
canPersistStoredAdmin,
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
recursiveGroupNamesSubquery,
|
|
26
27
|
} from "./group-sql";
|
|
27
28
|
import { getFreeIpaConfig } from "../freeipa-config";
|
|
29
|
+
import { createAuthLoginUrl } from "../../shared/redirect";
|
|
28
30
|
import type {
|
|
29
31
|
BaseUser,
|
|
30
32
|
MutationResult,
|
|
@@ -82,7 +84,7 @@ const sendMagicLinkEmail = async (email: string): Promise<void> => {
|
|
|
82
84
|
const token = await providers.local.auth.createMagicLinkToken({ email, ttlSeconds: 300 });
|
|
83
85
|
const rawAppUrl = await settings.get<string>("app.url");
|
|
84
86
|
const appUrl = rawAppUrl.startsWith("http") ? rawAppUrl : `https://${rawAppUrl}`;
|
|
85
|
-
const magicLink =
|
|
87
|
+
const magicLink = createAuthLoginUrl(appUrl, { token });
|
|
86
88
|
const appName = await settings.get<string>("app.name");
|
|
87
89
|
const template = await settings.get<string>("mail.magic_link_login");
|
|
88
90
|
|
|
@@ -113,17 +115,21 @@ const buildUser = (row: DbRow, groupsAdmin: string[]): User => {
|
|
|
113
115
|
const memberofGroupIds = (row.member_group_ids as string[]) ?? [];
|
|
114
116
|
const manages = (row.manages as string[]) ?? [];
|
|
115
117
|
const managesGroupIds = (row.manages_group_ids as string[]) ?? [];
|
|
118
|
+
const effectiveAdmin =
|
|
119
|
+
row.effective_admin !== undefined
|
|
120
|
+
? Boolean(row.effective_admin)
|
|
121
|
+
: resolveEffectiveAdminState({
|
|
122
|
+
provider,
|
|
123
|
+
storedAdmin: Boolean(row.admin),
|
|
124
|
+
memberofGroup,
|
|
125
|
+
groupsAdmin,
|
|
126
|
+
});
|
|
116
127
|
const roles = buildRoles({
|
|
117
128
|
provider,
|
|
118
129
|
profile,
|
|
119
130
|
memberofGroup,
|
|
120
131
|
manages,
|
|
121
|
-
admin:
|
|
122
|
-
provider,
|
|
123
|
-
storedAdmin: Boolean(row.admin),
|
|
124
|
-
memberofGroup,
|
|
125
|
-
groupsAdmin,
|
|
126
|
-
}),
|
|
132
|
+
admin: effectiveAdmin,
|
|
127
133
|
});
|
|
128
134
|
const common = {
|
|
129
135
|
id: row.id as string,
|
|
@@ -160,9 +166,19 @@ const buildUser = (row: DbRow, groupsAdmin: string[]): User => {
|
|
|
160
166
|
export const get = async (params: { id: string } | { uid: string }): Promise<User | null> => {
|
|
161
167
|
const whereClause = "id" in params ? sql`u.id = ${params.id}` : sql`u.uid = ${params.uid}`;
|
|
162
168
|
const userIdExpr = "id" in params ? sql`${params.id}` : sql`u.id`;
|
|
169
|
+
const { groupsAdmin } = await getFreeIpaConfig();
|
|
163
170
|
const rows = await sql<DbRow[]>`
|
|
164
171
|
SELECT u.*,
|
|
165
172
|
${userIpaDataColumns},
|
|
173
|
+
CASE
|
|
174
|
+
WHEN u.provider = 'local' THEN u.admin
|
|
175
|
+
ELSE EXISTS(
|
|
176
|
+
SELECT 1
|
|
177
|
+
FROM auth.ipa_user_effective_groups eg
|
|
178
|
+
WHERE eg.user_id = u.id
|
|
179
|
+
AND eg.group_name = ANY(${toPgTextArray(groupsAdmin)}::text[])
|
|
180
|
+
)
|
|
181
|
+
END AS effective_admin,
|
|
166
182
|
COALESCE(ARRAY(
|
|
167
183
|
SELECT g.name
|
|
168
184
|
FROM auth.user_groups_v2 ug
|
|
@@ -188,7 +204,6 @@ export const get = async (params: { id: string } | { uid: string }): Promise<Use
|
|
|
188
204
|
WHERE ${whereClause}
|
|
189
205
|
`;
|
|
190
206
|
if (rows.length === 0) return null;
|
|
191
|
-
const { groupsAdmin } = await getFreeIpaConfig();
|
|
192
207
|
return buildUser(rows[0]!, groupsAdmin);
|
|
193
208
|
};
|
|
194
209
|
|
|
@@ -203,13 +218,22 @@ export const getMinimal = async (params: { id: string } | { uid: string }): Prom
|
|
|
203
218
|
return buildUserMutationTarget(rows[0]!);
|
|
204
219
|
};
|
|
205
220
|
|
|
206
|
-
/**
|
|
207
|
-
* Minimal user lookup by UID. Returns id + roles WITHOUT group-derived roles.
|
|
208
|
-
* IPA admin status from group membership is NOT resolved here.
|
|
209
|
-
* Use the full `get()` for authorization decisions that depend on group-derived admin.
|
|
210
|
-
*/
|
|
211
221
|
export const getByUid = async (params: { uid: string }): Promise<{ id: string; roles: Role[] } | null> => {
|
|
212
|
-
const
|
|
222
|
+
const { groupsAdmin } = await getFreeIpaConfig();
|
|
223
|
+
const rows = await sql<DbRow[]>`
|
|
224
|
+
SELECT u.id, u.provider, u.profile, u.admin,
|
|
225
|
+
CASE
|
|
226
|
+
WHEN u.provider = 'local' THEN u.admin
|
|
227
|
+
ELSE EXISTS(
|
|
228
|
+
SELECT 1
|
|
229
|
+
FROM auth.ipa_user_effective_groups eg
|
|
230
|
+
WHERE eg.user_id = u.id
|
|
231
|
+
AND eg.group_name = ANY(${toPgTextArray(groupsAdmin)}::text[])
|
|
232
|
+
)
|
|
233
|
+
END AS effective_admin
|
|
234
|
+
FROM auth.users u
|
|
235
|
+
WHERE u.uid = ${params.uid}
|
|
236
|
+
`;
|
|
213
237
|
if (rows.length === 0) return null;
|
|
214
238
|
const { provider, profile } = resolveProviderProfile(rows[0]!);
|
|
215
239
|
const roles = buildRoles({
|
|
@@ -217,10 +241,7 @@ export const getByUid = async (params: { uid: string }): Promise<{ id: string; r
|
|
|
217
241
|
profile,
|
|
218
242
|
memberofGroup: [],
|
|
219
243
|
manages: [],
|
|
220
|
-
admin:
|
|
221
|
-
provider,
|
|
222
|
-
storedAdmin: Boolean(rows[0]!.admin),
|
|
223
|
-
}),
|
|
244
|
+
admin: Boolean(rows[0]!.effective_admin),
|
|
224
245
|
});
|
|
225
246
|
return { id: rows[0]!.id as string, roles };
|
|
226
247
|
};
|
|
@@ -281,11 +302,9 @@ export const list = async (params: {
|
|
|
281
302
|
WHEN u.provider = 'local' THEN u.admin
|
|
282
303
|
ELSE EXISTS(
|
|
283
304
|
SELECT 1
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
AND g_admin.provider = 'ipa'
|
|
288
|
-
AND g_admin.name = ANY(${toPgTextArray(groupsAdmin)}::text[])
|
|
305
|
+
FROM auth.ipa_user_effective_groups eg
|
|
306
|
+
WHERE eg.user_id = u.id
|
|
307
|
+
AND eg.group_name = ANY(${toPgTextArray(groupsAdmin)}::text[])
|
|
289
308
|
)
|
|
290
309
|
END AS effective_admin
|
|
291
310
|
FROM auth.users u
|
|
@@ -384,7 +403,6 @@ export const getManagedGroupIds = async (params: { id: string; recursive?: boole
|
|
|
384
403
|
};
|
|
385
404
|
|
|
386
405
|
export const demoteToGuest = async (params: {
|
|
387
|
-
ipaSession?: string | null;
|
|
388
406
|
id: string;
|
|
389
407
|
actor: { userId: string; uid: string };
|
|
390
408
|
}): Promise<MutationResult<void>> => {
|
|
@@ -393,19 +411,17 @@ export const demoteToGuest = async (params: {
|
|
|
393
411
|
if (user.provider !== "ipa") {
|
|
394
412
|
return { ok: false, error: "Only IPA-backed accounts can be demoted to local guests", status: 400 };
|
|
395
413
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
414
|
+
const serviceSession = await getServiceIpaSession();
|
|
415
|
+
if (!serviceSession.ok) return serviceSession;
|
|
399
416
|
|
|
400
417
|
return providers.ipa.users.demoteToGuest({
|
|
401
|
-
ipaSession:
|
|
418
|
+
ipaSession: serviceSession.data,
|
|
402
419
|
id: params.id,
|
|
403
420
|
actor: params.actor,
|
|
404
421
|
});
|
|
405
422
|
};
|
|
406
423
|
|
|
407
424
|
export const create = async (params: {
|
|
408
|
-
ipaSession?: string | null;
|
|
409
425
|
data: CreateUserData;
|
|
410
426
|
}): Promise<MutationResult<{ user: User; temporaryPassword?: string }>> => {
|
|
411
427
|
if (params.data.provider === "local" && params.data.admin && !canPersistStoredAdmin("local", params.data.profile)) {
|
|
@@ -436,12 +452,11 @@ export const create = async (params: {
|
|
|
436
452
|
return { ok: true, data: { user } };
|
|
437
453
|
}
|
|
438
454
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
}
|
|
455
|
+
const serviceSession = await getServiceIpaSession();
|
|
456
|
+
if (!serviceSession.ok) return serviceSession;
|
|
442
457
|
|
|
443
458
|
const created = await providers.ipa.users.create({
|
|
444
|
-
ipaSession:
|
|
459
|
+
ipaSession: serviceSession.data,
|
|
445
460
|
profile: params.data.profile,
|
|
446
461
|
accountExpires,
|
|
447
462
|
data: {
|
|
@@ -466,7 +481,6 @@ export const create = async (params: {
|
|
|
466
481
|
};
|
|
467
482
|
|
|
468
483
|
export const update = async (params: {
|
|
469
|
-
ipaSession?: string | null;
|
|
470
484
|
id: string;
|
|
471
485
|
data: UpdateUserData;
|
|
472
486
|
}): Promise<MutationResult<void>> => {
|
|
@@ -474,9 +488,10 @@ export const update = async (params: {
|
|
|
474
488
|
if (!user) return { ok: false, error: "User not found", status: 404 };
|
|
475
489
|
|
|
476
490
|
if (user.provider === "ipa") {
|
|
477
|
-
|
|
491
|
+
const serviceSession = await getServiceIpaSession();
|
|
492
|
+
if (!serviceSession.ok) return serviceSession;
|
|
478
493
|
return providers.ipa.users.update({
|
|
479
|
-
ipaSession:
|
|
494
|
+
ipaSession: serviceSession.data,
|
|
480
495
|
id: params.id,
|
|
481
496
|
data: params.data,
|
|
482
497
|
});
|
|
@@ -528,17 +543,27 @@ export const setAdmin = async (params: {
|
|
|
528
543
|
};
|
|
529
544
|
|
|
530
545
|
export const setExpiry = async (params: {
|
|
531
|
-
|
|
546
|
+
actor?: { userId: string; uid: string; roles: string[] };
|
|
532
547
|
id: string;
|
|
533
548
|
expiryDate: string | null;
|
|
534
549
|
}): Promise<MutationResult<void>> => {
|
|
535
550
|
const user = await getMinimal({ id: params.id });
|
|
536
551
|
if (!user) return { ok: false, error: "User not found", status: 404 };
|
|
537
552
|
|
|
553
|
+
const selfTarget = params.actor?.userId === params.id;
|
|
554
|
+
if (selfTarget && !params.actor?.roles.includes("admin")) {
|
|
555
|
+
return { ok: false, error: "Only admins can change their own account expiry.", status: 403 };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Explicit account-expiry management is allowed to target the acting admin
|
|
559
|
+
// as well. Automatic self-extension remains handled separately by the
|
|
560
|
+
// account-lifecycle service and must not turn non-expiring accounts back into
|
|
561
|
+
// expiring ones implicitly.
|
|
538
562
|
if (user.provider === "ipa") {
|
|
539
|
-
|
|
563
|
+
const serviceSession = await getServiceIpaSession();
|
|
564
|
+
if (!serviceSession.ok) return serviceSession;
|
|
540
565
|
return providers.ipa.users.setExpiry({
|
|
541
|
-
ipaSession:
|
|
566
|
+
ipaSession: serviceSession.data,
|
|
542
567
|
id: params.id,
|
|
543
568
|
expiryDate: params.expiryDate,
|
|
544
569
|
});
|
|
@@ -590,14 +615,13 @@ export const createLoginToken = async (params: {
|
|
|
590
615
|
ok: true,
|
|
591
616
|
data: {
|
|
592
617
|
token,
|
|
593
|
-
magicLink:
|
|
618
|
+
magicLink: createAuthLoginUrl(appUrl, { token }),
|
|
594
619
|
expiresInSeconds,
|
|
595
620
|
},
|
|
596
621
|
};
|
|
597
622
|
};
|
|
598
623
|
|
|
599
624
|
export const resetPassword = async (params: {
|
|
600
|
-
ipaSession?: string | null;
|
|
601
625
|
id: string;
|
|
602
626
|
}): Promise<MutationResult<{ password: string }>> => {
|
|
603
627
|
const user = await getMinimal({ id: params.id });
|
|
@@ -605,16 +629,16 @@ export const resetPassword = async (params: {
|
|
|
605
629
|
if (user.provider !== "ipa") {
|
|
606
630
|
return { ok: false, error: "Password resets are only available for IPA-backed accounts", status: 400 };
|
|
607
631
|
}
|
|
608
|
-
|
|
632
|
+
const serviceSession = await getServiceIpaSession();
|
|
633
|
+
if (!serviceSession.ok) return serviceSession;
|
|
609
634
|
|
|
610
635
|
return providers.ipa.users.resetPassword({
|
|
611
|
-
ipaSession:
|
|
636
|
+
ipaSession: serviceSession.data,
|
|
612
637
|
id: params.id,
|
|
613
638
|
});
|
|
614
639
|
};
|
|
615
640
|
|
|
616
641
|
export const switchProvider = async (params: {
|
|
617
|
-
ipaSession?: string | null;
|
|
618
642
|
id: string;
|
|
619
643
|
provider: UserProvider;
|
|
620
644
|
}): Promise<MutationResult<void>> => {
|
|
@@ -634,9 +658,8 @@ export const switchProvider = async (params: {
|
|
|
634
658
|
return { ok: false, error: "FreeIPA is disabled.", status: 400 };
|
|
635
659
|
}
|
|
636
660
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
}
|
|
661
|
+
const serviceSession = await getServiceIpaSession();
|
|
662
|
+
if (!serviceSession.ok) return serviceSession;
|
|
640
663
|
|
|
641
664
|
if (params.provider === "ipa") {
|
|
642
665
|
if (!user.mail) {
|
|
@@ -644,7 +667,7 @@ export const switchProvider = async (params: {
|
|
|
644
667
|
}
|
|
645
668
|
|
|
646
669
|
const result = await providers.ipa.users.create({
|
|
647
|
-
ipaSession:
|
|
670
|
+
ipaSession: serviceSession.data,
|
|
648
671
|
profile: currentProfile,
|
|
649
672
|
accountExpires: currentExpiry,
|
|
650
673
|
data: {
|
|
@@ -661,7 +684,7 @@ export const switchProvider = async (params: {
|
|
|
661
684
|
|
|
662
685
|
const response = await freeipa.client.call({
|
|
663
686
|
url: freeIpaConfig.url,
|
|
664
|
-
ipaSession:
|
|
687
|
+
ipaSession: serviceSession.data,
|
|
665
688
|
method: "user_del",
|
|
666
689
|
args: [user.uid],
|
|
667
690
|
options: {},
|
|
@@ -691,7 +714,6 @@ export const switchProvider = async (params: {
|
|
|
691
714
|
};
|
|
692
715
|
|
|
693
716
|
export const remove = async (params: {
|
|
694
|
-
ipaSession?: string | null;
|
|
695
717
|
id: string;
|
|
696
718
|
actor: { userId: string; uid: string };
|
|
697
719
|
}): Promise<MutationResult<void>> => {
|
|
@@ -699,9 +721,10 @@ export const remove = async (params: {
|
|
|
699
721
|
if (!user) return { ok: false, error: "User not found", status: 404 };
|
|
700
722
|
|
|
701
723
|
if (user.provider === "ipa") {
|
|
702
|
-
|
|
724
|
+
const serviceSession = await getServiceIpaSession();
|
|
725
|
+
if (!serviceSession.ok) return serviceSession;
|
|
703
726
|
return providers.ipa.users.remove({
|
|
704
|
-
ipaSession:
|
|
727
|
+
ipaSession: serviceSession.data,
|
|
705
728
|
id: params.id,
|
|
706
729
|
actor: params.actor,
|
|
707
730
|
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { AnnouncementEntry } from "../../contracts/announcements";
|
|
3
|
+
import { selectVisibleForState } from "./index";
|
|
4
|
+
|
|
5
|
+
const entry = (version: number, kind: AnnouncementEntry["kind"]): AnnouncementEntry => ({
|
|
6
|
+
id: crypto.randomUUID(),
|
|
7
|
+
version,
|
|
8
|
+
kind,
|
|
9
|
+
title: `${kind} ${version}`,
|
|
10
|
+
body: `**Body ${version}**`,
|
|
11
|
+
tone: "info",
|
|
12
|
+
publishedAt: new Date("2026-06-09T12:00:00.000Z").toISOString(),
|
|
13
|
+
expiresAt: null,
|
|
14
|
+
createdAt: new Date("2026-06-09T12:00:00.000Z").toISOString(),
|
|
15
|
+
updatedAt: new Date("2026-06-09T12:00:00.000Z").toISOString(),
|
|
16
|
+
createdBy: null,
|
|
17
|
+
updatedBy: null,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("selectVisibleForState", () => {
|
|
21
|
+
test("returns unseen announcements and undismissed banners", () => {
|
|
22
|
+
const result = selectVisibleForState([entry(4, "announcement"), entry(3, "announcement"), entry(2, "banner"), entry(1, "banner")], {
|
|
23
|
+
seenAnnouncementVersion: 3,
|
|
24
|
+
dismissedBannerVersions: [1],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(result.announcements.map((item) => item.version)).toEqual([4]);
|
|
28
|
+
expect(result.banners.map((item) => item.version)).toEqual([2]);
|
|
29
|
+
expect(result.latestAnnouncementVersion).toBe(4);
|
|
30
|
+
expect(result.announcements[0]?.bodyHtml).toContain("<strong>Body 4</strong>");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { err, fail, ok, type Result } from "@valentinkolb/stdlib";
|
|
2
|
+
import { sql } from "bun";
|
|
3
|
+
import type {
|
|
4
|
+
AnnouncementCookieState,
|
|
5
|
+
AnnouncementDisplayEntry,
|
|
6
|
+
AnnouncementEntry,
|
|
7
|
+
CreateAnnouncement,
|
|
8
|
+
UpdateAnnouncement,
|
|
9
|
+
} from "../../contracts/announcements";
|
|
10
|
+
import { markdown } from "../../shared/markdown";
|
|
11
|
+
import { logger } from "../logging";
|
|
12
|
+
|
|
13
|
+
const log = logger("announcements");
|
|
14
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
15
|
+
|
|
16
|
+
type AnnouncementRow = {
|
|
17
|
+
id: string;
|
|
18
|
+
version: number;
|
|
19
|
+
kind: "announcement" | "banner";
|
|
20
|
+
title: string;
|
|
21
|
+
body: string;
|
|
22
|
+
tone: "info" | "success" | "warning" | "danger";
|
|
23
|
+
published_at: Date;
|
|
24
|
+
expires_at: Date | null;
|
|
25
|
+
created_at: Date;
|
|
26
|
+
updated_at: Date;
|
|
27
|
+
created_by: string | null;
|
|
28
|
+
updated_by: string | null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type ListAdminConfig = {
|
|
32
|
+
filter?: {
|
|
33
|
+
kind?: "announcement" | "banner";
|
|
34
|
+
query?: string;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const mapRow = (row: AnnouncementRow): AnnouncementEntry => ({
|
|
39
|
+
id: row.id,
|
|
40
|
+
version: row.version,
|
|
41
|
+
kind: row.kind,
|
|
42
|
+
title: row.title,
|
|
43
|
+
body: row.body,
|
|
44
|
+
tone: row.tone,
|
|
45
|
+
publishedAt: row.published_at.toISOString(),
|
|
46
|
+
expiresAt: row.expires_at?.toISOString() ?? null,
|
|
47
|
+
createdAt: row.created_at.toISOString(),
|
|
48
|
+
updatedAt: row.updated_at.toISOString(),
|
|
49
|
+
createdBy: row.created_by,
|
|
50
|
+
updatedBy: row.updated_by,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const renderAnnouncement = (entry: AnnouncementEntry): AnnouncementDisplayEntry => {
|
|
54
|
+
const { body, ...rest } = entry;
|
|
55
|
+
return { ...rest, bodyHtml: markdown.renderSync(body) };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const validateDates = (input: Pick<CreateAnnouncement | UpdateAnnouncement, "publishedAt" | "expiresAt">) => {
|
|
59
|
+
const publishedAt = input.publishedAt ? new Date(input.publishedAt) : null;
|
|
60
|
+
const expiresAt = input.expiresAt ? new Date(input.expiresAt) : null;
|
|
61
|
+
if (publishedAt && Number.isNaN(publishedAt.getTime())) return fail(err.badInput("Invalid publish date."));
|
|
62
|
+
if (expiresAt && Number.isNaN(expiresAt.getTime())) return fail(err.badInput("Invalid expiry date."));
|
|
63
|
+
if (publishedAt && expiresAt && expiresAt.getTime() <= publishedAt.getTime()) {
|
|
64
|
+
return fail(err.badInput("Expiry date must be after publish date."));
|
|
65
|
+
}
|
|
66
|
+
return ok({ publishedAt, expiresAt });
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const listAdmin = async (config: ListAdminConfig = {}): Promise<AnnouncementEntry[]> => {
|
|
70
|
+
const kind = config.filter?.kind;
|
|
71
|
+
const query = config.filter?.query?.trim();
|
|
72
|
+
const search = query ? `%${query.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_")}%` : null;
|
|
73
|
+
|
|
74
|
+
const rows = await sql<AnnouncementRow[]>`
|
|
75
|
+
SELECT id, version, kind, title, body, tone, published_at, expires_at,
|
|
76
|
+
created_at, updated_at, created_by, updated_by
|
|
77
|
+
FROM announcements.entries
|
|
78
|
+
WHERE (${kind ?? null}::text IS NULL OR kind = ${kind ?? null})
|
|
79
|
+
AND (
|
|
80
|
+
${search}::text IS NULL
|
|
81
|
+
OR title ILIKE ${search} ESCAPE '\\'
|
|
82
|
+
OR body ILIKE ${search} ESCAPE '\\'
|
|
83
|
+
)
|
|
84
|
+
ORDER BY published_at DESC, version DESC
|
|
85
|
+
`;
|
|
86
|
+
return rows.map(mapRow);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const get = async (params: { id: string }): Promise<AnnouncementEntry | null> => {
|
|
90
|
+
if (!UUID_PATTERN.test(params.id)) return null;
|
|
91
|
+
const [row] = await sql<AnnouncementRow[]>`
|
|
92
|
+
SELECT id, version, kind, title, body, tone, published_at, expires_at,
|
|
93
|
+
created_at, updated_at, created_by, updated_by
|
|
94
|
+
FROM announcements.entries
|
|
95
|
+
WHERE id = ${params.id}::uuid
|
|
96
|
+
`;
|
|
97
|
+
return row ? mapRow(row) : null;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const create = async (params: { data: CreateAnnouncement; actorId: string }): Promise<Result<AnnouncementEntry>> => {
|
|
101
|
+
const dateResult = validateDates(params.data);
|
|
102
|
+
if (!dateResult.ok) return dateResult;
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const [row] = await sql<AnnouncementRow[]>`
|
|
106
|
+
INSERT INTO announcements.entries (
|
|
107
|
+
kind, title, body, tone, published_at, expires_at, created_by, updated_by
|
|
108
|
+
)
|
|
109
|
+
VALUES (
|
|
110
|
+
${params.data.kind},
|
|
111
|
+
${params.data.title},
|
|
112
|
+
${params.data.body},
|
|
113
|
+
${params.data.tone},
|
|
114
|
+
COALESCE(${params.data.publishedAt ?? null}::timestamptz, now()),
|
|
115
|
+
${params.data.expiresAt ?? null}::timestamptz,
|
|
116
|
+
${params.actorId}::uuid,
|
|
117
|
+
${params.actorId}::uuid
|
|
118
|
+
)
|
|
119
|
+
RETURNING id, version, kind, title, body, tone, published_at, expires_at,
|
|
120
|
+
created_at, updated_at, created_by, updated_by
|
|
121
|
+
`;
|
|
122
|
+
return row ? ok(mapRow(row)) : fail(err.internal("Failed to create announcement."));
|
|
123
|
+
} catch (error) {
|
|
124
|
+
log.error("Failed to create announcement", { error: error instanceof Error ? error.message : String(error) });
|
|
125
|
+
return fail(err.internal("Failed to create announcement."));
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const update = async (params: { id: string; data: UpdateAnnouncement; actorId: string }): Promise<Result<AnnouncementEntry>> => {
|
|
130
|
+
if (!UUID_PATTERN.test(params.id)) return fail(err.notFound("Announcement"));
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const existing = await get({ id: params.id });
|
|
134
|
+
if (!existing) return fail(err.notFound("Announcement"));
|
|
135
|
+
const dateResult = validateDates({
|
|
136
|
+
publishedAt: params.data.publishedAt ?? existing.publishedAt,
|
|
137
|
+
expiresAt: "expiresAt" in params.data ? params.data.expiresAt : existing.expiresAt,
|
|
138
|
+
});
|
|
139
|
+
if (!dateResult.ok) return dateResult;
|
|
140
|
+
|
|
141
|
+
const [row] = await sql<AnnouncementRow[]>`
|
|
142
|
+
UPDATE announcements.entries
|
|
143
|
+
SET
|
|
144
|
+
kind = COALESCE(${params.data.kind ?? null}, kind),
|
|
145
|
+
title = COALESCE(${params.data.title ?? null}, title),
|
|
146
|
+
body = COALESCE(${params.data.body ?? null}, body),
|
|
147
|
+
tone = COALESCE(${params.data.tone ?? null}, tone),
|
|
148
|
+
published_at = COALESCE(${params.data.publishedAt ?? null}::timestamptz, published_at),
|
|
149
|
+
expires_at = CASE
|
|
150
|
+
WHEN ${"expiresAt" in params.data} THEN ${params.data.expiresAt ?? null}::timestamptz
|
|
151
|
+
ELSE expires_at
|
|
152
|
+
END,
|
|
153
|
+
updated_at = now(),
|
|
154
|
+
updated_by = ${params.actorId}::uuid
|
|
155
|
+
WHERE id = ${params.id}::uuid
|
|
156
|
+
RETURNING id, version, kind, title, body, tone, published_at, expires_at,
|
|
157
|
+
created_at, updated_at, created_by, updated_by
|
|
158
|
+
`;
|
|
159
|
+
return row ? ok(mapRow(row)) : fail(err.notFound("Announcement"));
|
|
160
|
+
} catch (error) {
|
|
161
|
+
log.error("Failed to update announcement", { id: params.id, error: error instanceof Error ? error.message : String(error) });
|
|
162
|
+
return fail(err.internal("Failed to update announcement."));
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const remove = async (params: { id: string }): Promise<Result<void>> => {
|
|
167
|
+
if (!UUID_PATTERN.test(params.id)) return fail(err.notFound("Announcement"));
|
|
168
|
+
const result = await sql`DELETE FROM announcements.entries WHERE id = ${params.id}::uuid`;
|
|
169
|
+
return result.count > 0 ? ok() : fail(err.notFound("Announcement"));
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const listActive = async (params: { now?: Date } = {}): Promise<AnnouncementEntry[]> => {
|
|
173
|
+
const now = params.now ?? new Date();
|
|
174
|
+
const rows = await sql<AnnouncementRow[]>`
|
|
175
|
+
SELECT id, version, kind, title, body, tone, published_at, expires_at,
|
|
176
|
+
created_at, updated_at, created_by, updated_by
|
|
177
|
+
FROM announcements.entries
|
|
178
|
+
WHERE published_at <= ${now}
|
|
179
|
+
AND (expires_at IS NULL OR expires_at > ${now})
|
|
180
|
+
ORDER BY version DESC
|
|
181
|
+
`;
|
|
182
|
+
return rows.map(mapRow);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export const selectVisibleForState = (
|
|
186
|
+
entries: AnnouncementEntry[],
|
|
187
|
+
state: AnnouncementCookieState,
|
|
188
|
+
): { banners: AnnouncementDisplayEntry[]; announcements: AnnouncementDisplayEntry[]; latestAnnouncementVersion: number } => {
|
|
189
|
+
const dismissedBanners = new Set(state.dismissedBannerVersions);
|
|
190
|
+
const activeAnnouncements = entries.filter((entry) => entry.kind === "announcement");
|
|
191
|
+
const latestAnnouncementVersion = activeAnnouncements.reduce((max, entry) => Math.max(max, entry.version), state.seenAnnouncementVersion);
|
|
192
|
+
return {
|
|
193
|
+
banners: entries
|
|
194
|
+
.filter((entry) => entry.kind === "banner" && !dismissedBanners.has(entry.version))
|
|
195
|
+
.sort((a, b) => b.version - a.version)
|
|
196
|
+
.map(renderAnnouncement),
|
|
197
|
+
announcements: activeAnnouncements
|
|
198
|
+
.filter((entry) => entry.version > state.seenAnnouncementVersion)
|
|
199
|
+
.sort((a, b) => b.version - a.version)
|
|
200
|
+
.map(renderAnnouncement),
|
|
201
|
+
latestAnnouncementVersion,
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const activeForState = async (params: { state: AnnouncementCookieState; now?: Date }) =>
|
|
206
|
+
selectVisibleForState(await listActive({ now: params.now }), params.state);
|
|
207
|
+
|
|
208
|
+
export const announcements = {
|
|
209
|
+
admin: {
|
|
210
|
+
list: listAdmin,
|
|
211
|
+
get,
|
|
212
|
+
create,
|
|
213
|
+
update,
|
|
214
|
+
remove,
|
|
215
|
+
},
|
|
216
|
+
active: {
|
|
217
|
+
list: listActive,
|
|
218
|
+
forState: activeForState,
|
|
219
|
+
selectForState: selectVisibleForState,
|
|
220
|
+
},
|
|
221
|
+
render: renderAnnouncement,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
export type AnnouncementsService = typeof announcements;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { describe, expect, test } from "bun:test";
|
|
3
|
+
import { audit, sanitizeAuditMetadata, sanitizeAuditText } from "./index";
|
|
4
|
+
|
|
5
|
+
describe("sanitizeAuditMetadata", () => {
|
|
6
|
+
test("redacts sensitive nested metadata keys", () => {
|
|
7
|
+
expect(
|
|
8
|
+
sanitizeAuditMetadata({
|
|
9
|
+
changedFields: ["mail"],
|
|
10
|
+
password: "secret",
|
|
11
|
+
nested: {
|
|
12
|
+
apiToken: "token",
|
|
13
|
+
ipaSession: "cookie",
|
|
14
|
+
safe: "value",
|
|
15
|
+
},
|
|
16
|
+
}),
|
|
17
|
+
).toEqual({
|
|
18
|
+
changedFields: ["mail"],
|
|
19
|
+
password: "[REDACTED]",
|
|
20
|
+
nested: {
|
|
21
|
+
apiToken: "[REDACTED]",
|
|
22
|
+
ipaSession: "[REDACTED]",
|
|
23
|
+
safe: "value",
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("truncates large values and arrays", () => {
|
|
29
|
+
const sanitized = sanitizeAuditMetadata({
|
|
30
|
+
text: "x".repeat(510),
|
|
31
|
+
values: Array.from({ length: 52 }, (_, index) => index),
|
|
32
|
+
}) as Record<string, unknown>;
|
|
33
|
+
|
|
34
|
+
expect(sanitized.text).toBe(`${"x".repeat(500)}...`);
|
|
35
|
+
expect(sanitized.values).toEqual([...Array.from({ length: 50 }, (_, index) => index), "[2 more]"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("redacts sensitive reason and error text", () => {
|
|
39
|
+
expect(sanitizeAuditText("IPA session required to update IPA-backed users")).toBe("[REDACTED]");
|
|
40
|
+
expect(sanitizeAuditText("Current password is incorrect.")).toBe("[REDACTED]");
|
|
41
|
+
expect(sanitizeAuditText("Access denied")).toBe("Access denied");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("lists only safe actor-owned self-service activity", async () => {
|
|
45
|
+
const userId = crypto.randomUUID();
|
|
46
|
+
const otherUserId = crypto.randomUUID();
|
|
47
|
+
const requestId = `self-service-activity-${crypto.randomUUID()}`;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
await audit.record({
|
|
51
|
+
action: "service_account_credential.create",
|
|
52
|
+
outcome: "allowed",
|
|
53
|
+
actor: { userId, uid: "current-user", provider: "local" },
|
|
54
|
+
target: { type: "service_account_credential", id: crypto.randomUUID(), label: "Test key" },
|
|
55
|
+
requestId,
|
|
56
|
+
});
|
|
57
|
+
await audit.record({
|
|
58
|
+
action: "service_account_credential.create",
|
|
59
|
+
outcome: "allowed",
|
|
60
|
+
actor: { userId: otherUserId, uid: "other-user", provider: "local" },
|
|
61
|
+
target: { type: "service_account_credential", id: crypto.randomUUID(), label: "Other key" },
|
|
62
|
+
requestId,
|
|
63
|
+
});
|
|
64
|
+
await audit.record({
|
|
65
|
+
action: "accounts.user.set_expiry",
|
|
66
|
+
outcome: "allowed",
|
|
67
|
+
actor: { userId: otherUserId, uid: "admin", provider: "local" },
|
|
68
|
+
target: { type: "user", id: userId, label: "current-user" },
|
|
69
|
+
requestId,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const page = await audit.listSelfServiceActivity({ userId, days: 30, pagination: { page: 1, perPage: 20 } });
|
|
73
|
+
|
|
74
|
+
expect(page.total).toBe(1);
|
|
75
|
+
expect(page.items[0]).toMatchObject({
|
|
76
|
+
action: "service_account_credential.create",
|
|
77
|
+
label: "API key created",
|
|
78
|
+
context: "Test key",
|
|
79
|
+
});
|
|
80
|
+
} finally {
|
|
81
|
+
await sql`DELETE FROM audit.events WHERE request_id = ${requestId}`;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|