@valentinkolb/cloud 0.4.0 → 0.5.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/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 +116 -13
- 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 +47 -7
- package/src/services/auth-flows/magic-link.ts +92 -20
- 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/notifications/index.ts +82 -11
- 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 +79 -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 +58 -0
- package/src/shared/redirect.ts +56 -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
|
@@ -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 (
|
|
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
|
|
15
|
+
export { freeipa } from "./freeipa";
|
|
16
|
+
export type { GeoPlace, GeoService } from "./geo";
|
|
16
17
|
|
|
17
18
|
export { geo, geoService } from "./geo";
|
|
18
|
-
export
|
|
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 {
|
|
23
|
-
|
|
24
|
-
export type {
|
|
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;
|