@valentinkolb/cloud 0.3.1 → 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.
Files changed (194) hide show
  1. package/package.json +18 -8
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +119 -47
  4. package/src/_internal/runtime-context.ts +1 -0
  5. package/src/api/accounts-entities.ts +4 -0
  6. package/src/api/admin-core-settings.ts +98 -0
  7. package/src/api/announcements.ts +131 -0
  8. package/src/api/auth/schemas.ts +24 -0
  9. package/src/api/auth.ts +113 -10
  10. package/src/api/index.ts +15 -25
  11. package/src/api/me.ts +203 -14
  12. package/src/api/search/schemas.ts +1 -0
  13. package/src/api/search.ts +62 -8
  14. package/src/config/ssr.ts +2 -9
  15. package/src/contracts/announcements.test.ts +37 -0
  16. package/src/contracts/announcements.ts +121 -0
  17. package/src/contracts/app.ts +4 -0
  18. package/src/contracts/index.ts +3 -2
  19. package/src/contracts/registry.ts +4 -0
  20. package/src/contracts/shared.ts +108 -1
  21. package/src/desktop/index.ts +704 -0
  22. package/src/desktop/solid.tsx +938 -0
  23. package/src/server/api/index.ts +1 -1
  24. package/src/server/api/respond.ts +50 -10
  25. package/src/server/index.ts +44 -38
  26. package/src/server/middleware/auth.ts +98 -9
  27. package/src/server/middleware/index.ts +2 -1
  28. package/src/server/middleware/settings.ts +26 -0
  29. package/src/server/services/access.test.ts +197 -0
  30. package/src/server/services/access.ts +254 -6
  31. package/src/server/services/index.ts +14 -11
  32. package/src/server/services/pagination.ts +22 -0
  33. package/src/server/time.ts +45 -0
  34. package/src/services/account-lifecycle/index.ts +142 -18
  35. package/src/services/accounts/app.ts +658 -170
  36. package/src/services/accounts/authz.test.ts +77 -0
  37. package/src/services/accounts/authz.ts +22 -0
  38. package/src/services/accounts/entities.ts +84 -5
  39. package/src/services/accounts/groups.ts +30 -24
  40. package/src/services/accounts/model.test.ts +30 -0
  41. package/src/services/accounts/switching.test.ts +14 -0
  42. package/src/services/accounts/switching.ts +15 -6
  43. package/src/services/accounts/users.ts +75 -52
  44. package/src/services/announcements/index.test.ts +32 -0
  45. package/src/services/announcements/index.ts +224 -0
  46. package/src/services/audit/index.test.ts +84 -0
  47. package/src/services/audit/index.ts +431 -0
  48. package/src/services/auth-flows/index.ts +9 -2
  49. package/src/services/auth-flows/ipa.ts +0 -2
  50. package/src/services/auth-flows/magic-link.ts +3 -2
  51. package/src/services/auth-flows/password-reset.ts +284 -0
  52. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  53. package/src/services/auth-flows/proxy-return.ts +49 -0
  54. package/src/services/gateway.ts +162 -0
  55. package/src/services/index.ts +44 -2
  56. package/src/services/ipa/effective-groups.test.ts +33 -0
  57. package/src/services/ipa/effective-groups.ts +70 -0
  58. package/src/services/ipa/profile.ts +45 -3
  59. package/src/services/ipa/search.ts +3 -5
  60. package/src/services/ipa/service-account.ts +15 -0
  61. package/src/services/ipa/sync-planning.test.ts +32 -0
  62. package/src/services/ipa/sync-planning.ts +22 -0
  63. package/src/services/ipa/sync.ts +110 -38
  64. package/src/services/oauth-tokens.ts +104 -0
  65. package/src/services/postgres.ts +21 -6
  66. package/src/services/providers/local/auth.test.ts +22 -0
  67. package/src/services/providers/local/auth.ts +46 -3
  68. package/src/services/secrets.ts +10 -0
  69. package/src/services/service-account-credentials.test.ts +210 -0
  70. package/src/services/service-account-credentials.ts +715 -0
  71. package/src/services/service-accounts.ts +188 -0
  72. package/src/services/session/index.ts +7 -8
  73. package/src/services/settings/app.ts +4 -20
  74. package/src/services/settings/defaults.ts +64 -22
  75. package/src/services/settings/store.ts +47 -0
  76. package/src/services/weather/forecast.ts +40 -7
  77. package/src/services/webauthn.test.ts +36 -0
  78. package/src/services/webauthn.ts +384 -0
  79. package/src/shared/icons.ts +391 -100
  80. package/src/shared/index.ts +7 -0
  81. package/src/shared/markdown/extensions/code.ts +38 -1
  82. package/src/shared/markdown/extensions/images.ts +39 -3
  83. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  84. package/src/shared/markdown/extensions/mark.ts +48 -0
  85. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  86. package/src/shared/markdown/extensions/tables.ts +79 -58
  87. package/src/shared/markdown/formula.test.ts +1089 -0
  88. package/src/shared/markdown/formula.ts +1187 -0
  89. package/src/shared/markdown/index.ts +76 -2
  90. package/src/shared/mock-cover.ts +130 -0
  91. package/src/shared/redirect.test.ts +49 -0
  92. package/src/shared/redirect.ts +52 -0
  93. package/src/shared/theme.test.ts +24 -0
  94. package/src/shared/theme.ts +68 -0
  95. package/src/shared/time.ts +13 -0
  96. package/src/ssr/AdminLayout.tsx +7 -3
  97. package/src/ssr/AdminSidebar.tsx +115 -49
  98. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  99. package/src/ssr/Footer.island.tsx +3 -8
  100. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  101. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  102. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  103. package/src/ssr/Layout.tsx +74 -66
  104. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  105. package/src/ssr/LayoutHelp.tsx +266 -0
  106. package/src/ssr/NavMenu.island.tsx +0 -39
  107. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  108. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  109. package/src/ssr/islands/index.ts +13 -0
  110. package/src/styles/base-popover.css +5 -2
  111. package/src/styles/effects.css +87 -6
  112. package/src/styles/global.css +146 -9
  113. package/src/styles/input.css +3 -1
  114. package/src/styles/utilities-buttons.css +133 -27
  115. package/src/styles/utilities-code-display.css +67 -0
  116. package/src/styles/utilities-completion.css +223 -0
  117. package/src/styles/utilities-detail.css +73 -0
  118. package/src/styles/utilities-feedback.css +16 -15
  119. package/src/styles/utilities-layout.css +42 -2
  120. package/src/styles/utilities-markdown-editor.css +472 -0
  121. package/src/styles/utilities-navigation.css +63 -8
  122. package/src/styles/utilities-script.css +84 -0
  123. package/src/styles/utilities-table-tile.css +229 -0
  124. package/src/types/ambient.d.ts +9 -0
  125. package/src/ui/completion/behaviors.test.ts +95 -0
  126. package/src/ui/completion/behaviors.ts +205 -0
  127. package/src/ui/completion/engine.ts +368 -0
  128. package/src/ui/completion/index.ts +40 -0
  129. package/src/ui/completion/overlay.ts +92 -0
  130. package/src/ui/dialog-core.ts +173 -45
  131. package/src/ui/filter/FilterChip.tsx +42 -40
  132. package/src/ui/index.ts +11 -12
  133. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  134. package/src/ui/input/CheckboxCard.tsx +91 -0
  135. package/src/ui/input/Combobox.tsx +375 -0
  136. package/src/ui/input/DatePicker.tsx +846 -0
  137. package/src/ui/input/DateTimeInput.tsx +29 -4
  138. package/src/ui/input/FileDropzone.tsx +116 -0
  139. package/src/ui/input/IconInput.tsx +116 -0
  140. package/src/ui/input/ImageInput.tsx +19 -2
  141. package/src/ui/input/MultiSelectInput.tsx +448 -0
  142. package/src/ui/input/NumberInput.tsx +417 -61
  143. package/src/ui/input/SegmentedControl.tsx +2 -2
  144. package/src/ui/input/Select.tsx +172 -10
  145. package/src/ui/input/Slider.tsx +3 -4
  146. package/src/ui/input/Switch.tsx +3 -2
  147. package/src/ui/input/TemplateEditor.tsx +212 -0
  148. package/src/ui/input/TextInput.tsx +144 -13
  149. package/src/ui/input/index.ts +53 -8
  150. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  151. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  152. package/src/ui/input/markdown/actions.ts +233 -0
  153. package/src/ui/input/markdown/active-formats.ts +94 -0
  154. package/src/ui/input/markdown/behaviors.ts +193 -0
  155. package/src/ui/input/markdown/code-zone.ts +23 -0
  156. package/src/ui/input/markdown/highlight.ts +316 -0
  157. package/src/ui/layout.ts +22 -0
  158. package/src/ui/misc/AppOverview.tsx +105 -0
  159. package/src/ui/misc/AppWorkspace.tsx +607 -0
  160. package/src/ui/misc/Calendar.tsx +1291 -0
  161. package/src/ui/misc/Chart.tsx +162 -0
  162. package/src/ui/misc/CodeDisplay.tsx +54 -0
  163. package/src/ui/misc/ContextMenu.tsx +2 -2
  164. package/src/ui/misc/DataTable.tsx +269 -0
  165. package/src/ui/misc/DockWorkspace.tsx +425 -0
  166. package/src/ui/misc/Docs.tsx +153 -0
  167. package/src/ui/misc/Dropdown.tsx +2 -2
  168. package/src/ui/misc/EntitySearch.tsx +260 -129
  169. package/src/ui/misc/LinkCard.tsx +14 -2
  170. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  171. package/src/ui/misc/Pagination.tsx +31 -12
  172. package/src/ui/misc/PanelDialog.tsx +109 -0
  173. package/src/ui/misc/Panes.tsx +873 -0
  174. package/src/ui/misc/PermissionEditor.tsx +358 -262
  175. package/src/ui/misc/Placeholder.tsx +40 -0
  176. package/src/ui/misc/ProgressBar.tsx +1 -1
  177. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  178. package/src/ui/misc/SettingsModal.tsx +150 -0
  179. package/src/ui/misc/StatCell.tsx +182 -40
  180. package/src/ui/misc/StatGrid.tsx +149 -0
  181. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  182. package/src/ui/misc/code-highlight.ts +213 -0
  183. package/src/ui/misc/index.ts +93 -12
  184. package/src/ui/prompts.tsx +362 -312
  185. package/src/ui/toast.ts +384 -0
  186. package/src/ui/widgets/Widget.tsx +12 -4
  187. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  188. package/src/ui/ipa/GroupView.tsx +0 -36
  189. package/src/ui/ipa/LoginBtn.tsx +0 -16
  190. package/src/ui/ipa/UserView.tsx +0 -58
  191. package/src/ui/ipa/index.ts +0 -4
  192. package/src/ui/navigation.ts +0 -32
  193. package/src/ui/sidebar.tsx +0 -468
  194. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -1,5 +1,5 @@
1
- import { sql } from "bun";
2
1
  import { err, fail, ok, type Result } from "@valentinkolb/stdlib";
2
+ import { sql } from "bun";
3
3
 
4
4
  // ==========================
5
5
  // Permission Levels
@@ -18,14 +18,19 @@ export const hasPermission = (userLevel: PermissionLevel, requiredLevel: Permiss
18
18
  // Principal Types
19
19
  // ==========================
20
20
 
21
- export type PrincipalType = "user" | "group" | "authenticated" | "public";
21
+ export type PrincipalType = "user" | "group" | "service_account" | "authenticated" | "public";
22
22
 
23
23
  export type Principal =
24
24
  | { type: "user"; userId: string }
25
25
  | { type: "group"; groupId: string }
26
+ | { type: "service_account"; serviceAccountId: string }
26
27
  | { type: "authenticated" }
27
28
  | { type: "public" };
28
29
 
30
+ export type AccessSubject =
31
+ | { type: "user"; userId: string; delegatedByServiceAccountId?: string | null }
32
+ | { type: "service_account"; serviceAccountId: string };
33
+
29
34
  // ==========================
30
35
  // Access Entry Types
31
36
  // ==========================
@@ -39,15 +44,43 @@ export type AccessEntry = {
39
44
  displayName?: string;
40
45
  };
41
46
 
47
+ export type AccessUserSource =
48
+ | { type: "direct" }
49
+ | {
50
+ type: "group";
51
+ /** Top-level group from the access grant, not the nested membership group. */
52
+ groupId: string;
53
+ groupName: string;
54
+ };
55
+
56
+ export type AccessUser = {
57
+ id: string;
58
+ uid: string;
59
+ displayName: string;
60
+ permission: Exclude<PermissionLevel, "none">;
61
+ source: AccessUserSource;
62
+ };
63
+
42
64
  type DbAccess = {
43
65
  id: string;
44
66
  user_id: string | null;
45
67
  group_id: string | null;
68
+ service_account_id: string | null;
46
69
  authenticated_only: boolean;
47
70
  permission: PermissionLevel;
48
71
  created_at: Date;
49
72
  };
50
73
 
74
+ type DbAccessUser = {
75
+ id: string;
76
+ uid: string;
77
+ display_name: string;
78
+ permission: Exclude<PermissionLevel, "none">;
79
+ direct: boolean;
80
+ source_group_id: string | null;
81
+ source_group_name: string | null;
82
+ };
83
+
51
84
  // ==========================
52
85
  // Helper Functions
53
86
  // ==========================
@@ -60,12 +93,24 @@ const toPgUuidArray = (values: string[] | null | undefined): string => {
60
93
  return `{${values.join(",")}}`;
61
94
  };
62
95
 
96
+ const uniqueIds = (values: string[] | null | undefined): string[] => [...new Set((values ?? []).filter(Boolean))];
97
+
98
+ const escapeLikePattern = (value: string): string => value.replace(/[\\%_]/g, (match) => `\\${match}`);
99
+
100
+ const PERMISSION_RANK: Record<PermissionLevel, number> = {
101
+ none: 1,
102
+ read: 2,
103
+ write: 3,
104
+ admin: 4,
105
+ };
106
+
63
107
  /**
64
108
  * Builds a typed access principal from one database access row.
65
109
  */
66
110
  const principalFromDb = (row: DbAccess): Principal => {
67
111
  if (row.user_id) return { type: "user", userId: row.user_id };
68
112
  if (row.group_id) return { type: "group", groupId: row.group_id };
113
+ if (row.service_account_id) return { type: "service_account", serviceAccountId: row.service_account_id };
69
114
  if (row.authenticated_only) return { type: "authenticated" };
70
115
  return { type: "public" };
71
116
  };
@@ -93,6 +138,7 @@ export const createAccess = async (params: { principal: Principal; permission: P
93
138
 
94
139
  let userId: string | null = null;
95
140
  let groupId: string | null = null;
141
+ let serviceAccountId: string | null = null;
96
142
  let authenticatedOnly = false;
97
143
 
98
144
  if (principal.type === "user") {
@@ -113,14 +159,22 @@ export const createAccess = async (params: { principal: Principal; permission: P
113
159
  if (!group) {
114
160
  return fail(err.notFound("Group"));
115
161
  }
162
+ } else if (principal.type === "service_account") {
163
+ serviceAccountId = principal.serviceAccountId;
164
+ const [serviceAccount] = await sql<{ id: string }[]>`
165
+ SELECT id FROM auth.service_accounts WHERE id = ${serviceAccountId}::uuid AND status = 'active'
166
+ `;
167
+ if (!serviceAccount) {
168
+ return fail(err.notFound("Service account"));
169
+ }
116
170
  } else if (principal.type === "authenticated") {
117
171
  authenticatedOnly = true;
118
172
  }
119
173
  // public: user/group null, authenticated_only false
120
174
 
121
175
  const [row] = await sql<{ id: string }[]>`
122
- INSERT INTO auth.access (user_id, group_id, authenticated_only, permission)
123
- VALUES (${userId}::uuid, ${groupId}::uuid, ${authenticatedOnly}, ${permission}::auth.permission_level)
176
+ INSERT INTO auth.access (user_id, group_id, service_account_id, authenticated_only, permission)
177
+ VALUES (${userId}::uuid, ${groupId}::uuid, ${serviceAccountId}::uuid, ${authenticatedOnly}, ${permission}::auth.permission_level)
124
178
  RETURNING id
125
179
  `;
126
180
 
@@ -136,7 +190,7 @@ export const createAccess = async (params: { principal: Principal; permission: P
136
190
  */
137
191
  export const getAccess = async (params: { id: string }): Promise<AccessEntry | null> => {
138
192
  const [row] = await sql<DbAccess[]>`
139
- SELECT id, user_id, group_id, authenticated_only, permission, created_at
193
+ SELECT id, user_id, group_id, service_account_id, authenticated_only, permission, created_at
140
194
  FROM auth.access
141
195
  WHERE id = ${params.id}::uuid
142
196
  `;
@@ -206,10 +260,12 @@ export const getEffectivePermission = async (params: {
206
260
  accessIds: string[];
207
261
  userId: string | null;
208
262
  userGroups: string[];
263
+ serviceAccountId?: string | null;
209
264
  }): Promise<PermissionLevel> => {
210
265
  const accessIds = params.accessIds ?? [];
211
266
  const userId = params.userId;
212
267
  const userGroups = params.userGroups ?? [];
268
+ const serviceAccountId = params.serviceAccountId ?? null;
213
269
 
214
270
  if (accessIds.length === 0) return "none";
215
271
 
@@ -221,8 +277,15 @@ export const getEffectivePermission = async (params: {
221
277
  AND (
222
278
  user_id = ${userId}::uuid
223
279
  OR group_id = ANY(${toPgUuidArray(userGroups)}::uuid[])
280
+ OR service_account_id = ${serviceAccountId}::uuid
224
281
  OR (${userId}::uuid IS NOT NULL AND authenticated_only = true)
225
- OR (user_id IS NULL AND group_id IS NULL AND authenticated_only = false)
282
+ OR (
283
+ ${serviceAccountId}::uuid IS NULL
284
+ AND user_id IS NULL
285
+ AND group_id IS NULL
286
+ AND service_account_id IS NULL
287
+ AND authenticated_only = false
288
+ )
226
289
  )
227
290
  ORDER BY
228
291
  CASE permission
@@ -237,6 +300,172 @@ export const getEffectivePermission = async (params: {
237
300
  return rows[0]?.permission ?? "none";
238
301
  };
239
302
 
303
+ /**
304
+ * Lists concrete users reachable from auth.access entries.
305
+ *
306
+ * Apps stay responsible for collecting the relevant access entry IDs from their
307
+ * own junction tables. This helper expands direct user grants and recursive
308
+ * group grants only. It intentionally does not expand public or
309
+ * authenticated-only grants into "all users", because those scopes are not
310
+ * bounded, predictable assignee/member lists.
311
+ */
312
+ export const listUsersWithAccess = async (params: {
313
+ accessIds: string[];
314
+ search?: string;
315
+ userIds?: string[];
316
+ excludeUserIds?: string[];
317
+ minimumPermission?: Exclude<PermissionLevel, "none">;
318
+ limit?: number;
319
+ }): Promise<AccessUser[]> => {
320
+ const accessIds = uniqueIds(params.accessIds);
321
+ if (accessIds.length === 0) return [];
322
+
323
+ const requestedUserIds = uniqueIds(params.userIds);
324
+ const excludeUserIds = uniqueIds(params.excludeUserIds);
325
+ const query = params.search?.trim().toLowerCase();
326
+ const pattern = query ? `%${escapeLikePattern(query)}%` : null;
327
+ const minimumRank = PERMISSION_RANK[params.minimumPermission ?? "read"];
328
+ const defaultLimit = requestedUserIds.length > 0 ? requestedUserIds.length : 20;
329
+ const limit = Math.min(Math.max(params.limit ?? defaultLimit, 1), 500);
330
+ const userFilter = requestedUserIds.length > 0 ? sql`AND id = ANY(${toPgUuidArray(requestedUserIds)}::uuid[])` : sql``;
331
+
332
+ const rows = await sql<DbAccessUser[]>`
333
+ WITH RECURSIVE
334
+ root_groups(root_group_id, root_group_name, group_id, group_ids, permission, permission_rank) AS (
335
+ SELECT
336
+ a.group_id,
337
+ COALESCE(NULLIF(g.name, ''), g.cn),
338
+ a.group_id,
339
+ ARRAY[a.group_id]::uuid[],
340
+ a.permission,
341
+ CASE a.permission
342
+ WHEN 'admin' THEN 4
343
+ WHEN 'write' THEN 3
344
+ WHEN 'read' THEN 2
345
+ ELSE 1
346
+ END
347
+ FROM auth.access a
348
+ JOIN auth.groups g ON g.id = a.group_id
349
+ WHERE a.id = ANY(${toPgUuidArray(accessIds)}::uuid[])
350
+ AND a.group_id IS NOT NULL
351
+ AND CASE a.permission
352
+ WHEN 'admin' THEN 4
353
+ WHEN 'write' THEN 3
354
+ WHEN 'read' THEN 2
355
+ ELSE 1
356
+ END >= ${minimumRank}
357
+
358
+ UNION ALL
359
+
360
+ SELECT
361
+ rg.root_group_id,
362
+ rg.root_group_name,
363
+ gg.child_group_id,
364
+ rg.group_ids || gg.child_group_id,
365
+ rg.permission,
366
+ rg.permission_rank
367
+ FROM auth.group_groups_v2 gg
368
+ JOIN root_groups rg ON rg.group_id = gg.parent_group_id
369
+ WHERE NOT gg.child_group_id = ANY(rg.group_ids)
370
+ ),
371
+ candidate_users AS (
372
+ SELECT
373
+ u.id,
374
+ u.uid,
375
+ COALESCE(NULLIF(u.display_name, ''), u.uid, u.id::text) AS display_name,
376
+ TRUE AS direct,
377
+ NULL::uuid AS source_group_id,
378
+ NULL::text AS source_group_name,
379
+ a.permission,
380
+ CASE a.permission
381
+ WHEN 'admin' THEN 4
382
+ WHEN 'write' THEN 3
383
+ WHEN 'read' THEN 2
384
+ ELSE 1
385
+ END AS permission_rank
386
+ FROM auth.access a
387
+ JOIN auth.users u ON u.id = a.user_id
388
+ WHERE a.id = ANY(${toPgUuidArray(accessIds)}::uuid[])
389
+ AND a.user_id IS NOT NULL
390
+ AND CASE a.permission
391
+ WHEN 'admin' THEN 4
392
+ WHEN 'write' THEN 3
393
+ WHEN 'read' THEN 2
394
+ ELSE 1
395
+ END >= ${minimumRank}
396
+
397
+ UNION ALL
398
+
399
+ SELECT
400
+ u.id,
401
+ u.uid,
402
+ COALESCE(NULLIF(u.display_name, ''), u.uid, u.id::text) AS display_name,
403
+ FALSE AS direct,
404
+ rg.root_group_id AS source_group_id,
405
+ rg.root_group_name AS source_group_name,
406
+ rg.permission,
407
+ rg.permission_rank
408
+ FROM root_groups rg
409
+ JOIN auth.user_groups_v2 ug ON ug.group_id = rg.group_id
410
+ JOIN auth.users u ON u.id = ug.user_id
411
+ ),
412
+ access_users AS (
413
+ SELECT
414
+ id,
415
+ uid,
416
+ display_name,
417
+ CASE MAX(permission_rank)
418
+ WHEN 4 THEN 'admin'
419
+ WHEN 3 THEN 'write'
420
+ ELSE 'read'
421
+ END AS permission,
422
+ BOOL_OR(direct) AS direct,
423
+ (
424
+ ARRAY_AGG(source_group_id ORDER BY permission_rank DESC, source_group_name)
425
+ FILTER (WHERE NOT direct AND source_group_id IS NOT NULL)
426
+ )[1] AS source_group_id,
427
+ (
428
+ ARRAY_AGG(source_group_name ORDER BY permission_rank DESC, source_group_name)
429
+ FILTER (WHERE NOT direct AND source_group_name IS NOT NULL)
430
+ )[1] AS source_group_name,
431
+ COALESCE(
432
+ STRING_AGG(source_group_name, ' ')
433
+ FILTER (WHERE NOT direct AND source_group_name IS NOT NULL),
434
+ ''
435
+ ) AS group_names
436
+ FROM candidate_users
437
+ GROUP BY id, uid, display_name
438
+ )
439
+ SELECT id, uid, display_name, permission, direct, source_group_id, source_group_name
440
+ FROM access_users
441
+ WHERE id <> ALL(${toPgUuidArray(excludeUserIds)}::uuid[])
442
+ AND (direct OR (source_group_id IS NOT NULL AND source_group_name IS NOT NULL))
443
+ ${userFilter}
444
+ AND (
445
+ ${pattern}::text IS NULL
446
+ OR LOWER(display_name) LIKE ${pattern} ESCAPE '\\'
447
+ OR LOWER(uid) LIKE ${pattern} ESCAPE '\\'
448
+ OR LOWER(group_names) LIKE ${pattern} ESCAPE '\\'
449
+ )
450
+ ORDER BY LOWER(display_name), id
451
+ LIMIT ${limit}
452
+ `;
453
+
454
+ return rows.map((row) => ({
455
+ id: row.id,
456
+ uid: row.uid,
457
+ displayName: row.display_name,
458
+ permission: row.permission,
459
+ source: row.direct
460
+ ? { type: "direct" }
461
+ : {
462
+ type: "group",
463
+ groupId: row.source_group_id as string,
464
+ groupName: row.source_group_name as string,
465
+ },
466
+ }));
467
+ };
468
+
240
469
  /**
241
470
  * Resolve display names for access entries.
242
471
  * Populates the displayName field based on principal type.
@@ -248,6 +477,10 @@ export const resolveDisplayNames = async (entries: AccessEntry[]): Promise<Acces
248
477
  .filter((e) => e.principal.type === "group")
249
478
  .map((e) => (e.principal as { type: "group"; groupId: string }).groupId);
250
479
 
480
+ const serviceAccountIds = entries
481
+ .filter((e) => e.principal.type === "service_account")
482
+ .map((e) => (e.principal as { type: "service_account"; serviceAccountId: string }).serviceAccountId);
483
+
251
484
  // Fetch user display names
252
485
  const userNames = new Map<string, string>();
253
486
  if (userIds.length > 0) {
@@ -273,6 +506,18 @@ export const resolveDisplayNames = async (entries: AccessEntry[]): Promise<Acces
273
506
  }
274
507
  }
275
508
 
509
+ const serviceAccountNames = new Map<string, string>();
510
+ if (serviceAccountIds.length > 0) {
511
+ const serviceAccounts = await sql<{ id: string; name: string }[]>`
512
+ SELECT id, name
513
+ FROM auth.service_accounts
514
+ WHERE id = ANY(${toPgUuidArray(serviceAccountIds)}::uuid[])
515
+ `;
516
+ for (const serviceAccount of serviceAccounts) {
517
+ serviceAccountNames.set(serviceAccount.id, serviceAccount.name);
518
+ }
519
+ }
520
+
276
521
  return entries.map((entry) => {
277
522
  let displayName: string;
278
523
  switch (entry.principal.type) {
@@ -282,6 +527,9 @@ export const resolveDisplayNames = async (entries: AccessEntry[]): Promise<Acces
282
527
  case "group":
283
528
  displayName = groupNames.get(entry.principal.groupId) ?? "Unknown Group";
284
529
  break;
530
+ case "service_account":
531
+ displayName = serviceAccountNames.get(entry.principal.serviceAccountId) ?? "Unknown Service Account";
532
+ break;
285
533
  case "authenticated":
286
534
  displayName = "All users (incl. guests)";
287
535
  break;
@@ -1,27 +1,30 @@
1
1
  // Cloud-specific server services
2
- export { services } from "./services";
3
- export { freeipa } from "./freeipa";
4
2
 
3
+ export type { AccessEntry, AccessSubject, AccessUser, AccessUserSource, PermissionLevel, Principal, PrincipalType, ResourceAccessAdapter } from "./access";
5
4
  export {
6
- PERMISSION_LEVELS,
7
- hasPermission,
8
5
  createAccess,
9
- getAccess,
10
- updateAccess,
11
6
  deleteAccess,
7
+ getAccess,
12
8
  getEffectivePermission,
9
+ hasPermission,
10
+ listUsersWithAccess,
11
+ PERMISSION_LEVELS,
13
12
  resolveDisplayNames,
13
+ updateAccess,
14
14
  } from "./access";
15
- export type { AccessEntry, PermissionLevel, PrincipalType, Principal, ResourceAccessAdapter } from "./access";
15
+ export { freeipa } from "./freeipa";
16
+ export type { GeoPlace, GeoService } from "./geo";
16
17
 
17
18
  export { geo, geoService } from "./geo";
18
- export type { GeoService, GeoPlace } from "./geo";
19
+ export { paginateItems } from "./pagination";
20
+ export { services } from "./services";
19
21
 
20
22
  // Re-export from stdlib for backward compatibility
21
23
  // Prefer importing directly from @valentinkolb/stdlib
22
- import { svg as _svg, password as _password } from "@valentinkolb/stdlib";
23
- export { ok, okMany, fail, err, unwrap, paginate, tryCatch, isServiceError, crypto, svg, password } from "@valentinkolb/stdlib";
24
- export type { Result, Paginated, PageParams, ServiceError, ServiceErrorCode } from "@valentinkolb/stdlib";
24
+ import { password as _password, svg as _svg } from "@valentinkolb/stdlib";
25
+
26
+ export type { PageParams, Paginated, Result, ServiceError, ServiceErrorCode } from "@valentinkolb/stdlib";
27
+ export { crypto, err, fail, isServiceError, ok, okMany, paginate, password, svg, tryCatch, unwrap } from "@valentinkolb/stdlib";
25
28
 
26
29
  // Compat aliases for old API names
27
30
  export const images = { generateFallback: _svg.generateAvatar, parseWebpDataUrl: _svg.parseWebpDataUrl };
@@ -0,0 +1,22 @@
1
+ import { type PageParams, type Paginated, paginate } from "@valentinkolb/stdlib";
2
+
3
+ export const paginateItems = <T>(items: T[], pagination?: PageParams): Paginated<T> => {
4
+ if (!pagination) {
5
+ return {
6
+ items,
7
+ page: 1,
8
+ perPage: items.length,
9
+ total: items.length,
10
+ hasNext: false,
11
+ };
12
+ }
13
+
14
+ const { page, perPage, offset } = paginate(pagination);
15
+ return {
16
+ items: items.slice(offset, offset + perPage),
17
+ page,
18
+ perPage,
19
+ total: items.length,
20
+ hasNext: page * perPage < items.length,
21
+ };
22
+ };
@@ -0,0 +1,45 @@
1
+ import type { DateContext } from "@valentinkolb/stdlib";
2
+ import { normalizeTimeZone, TIMEZONE_COOKIE } from "../shared/time";
3
+
4
+ export { TIMEZONE_COOKIE };
5
+
6
+ type TimeContext = {
7
+ get(key: "settings"): Record<string, any> | undefined;
8
+ req: { raw: { headers: Headers } };
9
+ };
10
+
11
+ const readCookie = (headers: Headers, name: string): string | undefined => {
12
+ const cookie = headers.get("Cookie");
13
+ if (!cookie) return undefined;
14
+
15
+ for (const part of cookie.split(";")) {
16
+ const [rawName, ...rawValue] = part.trim().split("=");
17
+ if (rawName !== name) continue;
18
+ const value = rawValue.join("=");
19
+ try {
20
+ return decodeURIComponent(value);
21
+ } catch {
22
+ return value;
23
+ }
24
+ }
25
+
26
+ return undefined;
27
+ };
28
+
29
+ export const getTimeZone = (c: TimeContext): string => {
30
+ const settingsTimeZone = c.get("settings")?.app?.timezone;
31
+ const fallback = normalizeTimeZone(typeof settingsTimeZone === "string" ? settingsTimeZone : undefined, "UTC");
32
+ return normalizeTimeZone(readCookie(c.req.raw.headers, TIMEZONE_COOKIE), fallback);
33
+ };
34
+
35
+ export const getDateConfig = (c: TimeContext): DateContext => ({
36
+ timeZone: getTimeZone(c),
37
+ locale: "en",
38
+ firstDayOfWeek: 1,
39
+ });
40
+
41
+ export const time = {
42
+ TIMEZONE_COOKIE,
43
+ getTimeZone,
44
+ getDateConfig,
45
+ } as const;