@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.
Files changed (193) hide show
  1. package/package.json +18 -6
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +53 -46
  4. package/src/api/accounts-entities.ts +4 -0
  5. package/src/api/admin-core-settings.ts +98 -0
  6. package/src/api/announcements.ts +131 -0
  7. package/src/api/auth/schemas.ts +24 -0
  8. package/src/api/auth.ts +113 -10
  9. package/src/api/index.ts +7 -2
  10. package/src/api/me.ts +203 -14
  11. package/src/api/search/schemas.ts +1 -0
  12. package/src/api/search.ts +62 -8
  13. package/src/config/ssr.ts +2 -9
  14. package/src/contracts/announcements.test.ts +37 -0
  15. package/src/contracts/announcements.ts +121 -0
  16. package/src/contracts/app.ts +2 -0
  17. package/src/contracts/index.ts +3 -2
  18. package/src/contracts/registry.ts +2 -0
  19. package/src/contracts/shared.ts +108 -1
  20. package/src/desktop/index.ts +704 -0
  21. package/src/desktop/solid.tsx +938 -0
  22. package/src/server/api/index.ts +1 -1
  23. package/src/server/api/respond.ts +50 -10
  24. package/src/server/index.ts +44 -38
  25. package/src/server/middleware/auth.ts +98 -9
  26. package/src/server/middleware/index.ts +2 -1
  27. package/src/server/middleware/settings.ts +26 -0
  28. package/src/server/services/access.test.ts +197 -0
  29. package/src/server/services/access.ts +254 -6
  30. package/src/server/services/index.ts +14 -11
  31. package/src/server/services/pagination.ts +22 -0
  32. package/src/server/time.ts +45 -0
  33. package/src/services/account-lifecycle/index.ts +142 -18
  34. package/src/services/accounts/app.ts +658 -170
  35. package/src/services/accounts/authz.test.ts +77 -0
  36. package/src/services/accounts/authz.ts +22 -0
  37. package/src/services/accounts/entities.ts +84 -5
  38. package/src/services/accounts/groups.ts +30 -24
  39. package/src/services/accounts/model.test.ts +30 -0
  40. package/src/services/accounts/switching.test.ts +14 -0
  41. package/src/services/accounts/switching.ts +15 -6
  42. package/src/services/accounts/users.ts +75 -52
  43. package/src/services/announcements/index.test.ts +32 -0
  44. package/src/services/announcements/index.ts +224 -0
  45. package/src/services/audit/index.test.ts +84 -0
  46. package/src/services/audit/index.ts +431 -0
  47. package/src/services/auth-flows/index.ts +9 -2
  48. package/src/services/auth-flows/ipa.ts +0 -2
  49. package/src/services/auth-flows/magic-link.ts +3 -2
  50. package/src/services/auth-flows/password-reset.ts +284 -0
  51. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  52. package/src/services/auth-flows/proxy-return.ts +49 -0
  53. package/src/services/gateway.ts +162 -0
  54. package/src/services/index.ts +44 -2
  55. package/src/services/ipa/effective-groups.test.ts +33 -0
  56. package/src/services/ipa/effective-groups.ts +70 -0
  57. package/src/services/ipa/profile.ts +45 -3
  58. package/src/services/ipa/search.ts +3 -5
  59. package/src/services/ipa/service-account.ts +15 -0
  60. package/src/services/ipa/sync-planning.test.ts +32 -0
  61. package/src/services/ipa/sync-planning.ts +22 -0
  62. package/src/services/ipa/sync.ts +110 -38
  63. package/src/services/oauth-tokens.ts +104 -0
  64. package/src/services/postgres.ts +21 -6
  65. package/src/services/providers/local/auth.test.ts +22 -0
  66. package/src/services/providers/local/auth.ts +46 -3
  67. package/src/services/secrets.ts +10 -0
  68. package/src/services/service-account-credentials.test.ts +210 -0
  69. package/src/services/service-account-credentials.ts +715 -0
  70. package/src/services/service-accounts.ts +188 -0
  71. package/src/services/session/index.ts +7 -8
  72. package/src/services/settings/app.ts +4 -20
  73. package/src/services/settings/defaults.ts +64 -22
  74. package/src/services/settings/store.ts +47 -0
  75. package/src/services/weather/forecast.ts +40 -7
  76. package/src/services/webauthn.test.ts +36 -0
  77. package/src/services/webauthn.ts +384 -0
  78. package/src/shared/icons.ts +391 -100
  79. package/src/shared/index.ts +7 -0
  80. package/src/shared/markdown/extensions/code.ts +38 -1
  81. package/src/shared/markdown/extensions/images.ts +39 -3
  82. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  83. package/src/shared/markdown/extensions/mark.ts +48 -0
  84. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  85. package/src/shared/markdown/extensions/tables.ts +79 -58
  86. package/src/shared/markdown/formula.test.ts +1089 -0
  87. package/src/shared/markdown/formula.ts +1187 -0
  88. package/src/shared/markdown/index.ts +76 -2
  89. package/src/shared/mock-cover.ts +130 -0
  90. package/src/shared/redirect.test.ts +49 -0
  91. package/src/shared/redirect.ts +52 -0
  92. package/src/shared/theme.test.ts +24 -0
  93. package/src/shared/theme.ts +68 -0
  94. package/src/shared/time.ts +13 -0
  95. package/src/ssr/AdminLayout.tsx +7 -3
  96. package/src/ssr/AdminSidebar.tsx +115 -49
  97. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  98. package/src/ssr/Footer.island.tsx +3 -8
  99. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  100. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  101. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  102. package/src/ssr/Layout.tsx +74 -66
  103. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  104. package/src/ssr/LayoutHelp.tsx +266 -0
  105. package/src/ssr/NavMenu.island.tsx +0 -39
  106. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  107. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  108. package/src/ssr/islands/index.ts +13 -0
  109. package/src/styles/base-popover.css +5 -2
  110. package/src/styles/effects.css +87 -6
  111. package/src/styles/global.css +146 -9
  112. package/src/styles/input.css +3 -1
  113. package/src/styles/utilities-buttons.css +133 -27
  114. package/src/styles/utilities-code-display.css +67 -0
  115. package/src/styles/utilities-completion.css +223 -0
  116. package/src/styles/utilities-detail.css +73 -0
  117. package/src/styles/utilities-feedback.css +16 -15
  118. package/src/styles/utilities-layout.css +42 -2
  119. package/src/styles/utilities-markdown-editor.css +472 -0
  120. package/src/styles/utilities-navigation.css +63 -8
  121. package/src/styles/utilities-script.css +84 -0
  122. package/src/styles/utilities-table-tile.css +229 -0
  123. package/src/types/ambient.d.ts +9 -0
  124. package/src/ui/completion/behaviors.test.ts +95 -0
  125. package/src/ui/completion/behaviors.ts +205 -0
  126. package/src/ui/completion/engine.ts +368 -0
  127. package/src/ui/completion/index.ts +40 -0
  128. package/src/ui/completion/overlay.ts +92 -0
  129. package/src/ui/dialog-core.ts +173 -45
  130. package/src/ui/filter/FilterChip.tsx +42 -40
  131. package/src/ui/index.ts +11 -12
  132. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  133. package/src/ui/input/CheckboxCard.tsx +91 -0
  134. package/src/ui/input/Combobox.tsx +375 -0
  135. package/src/ui/input/DatePicker.tsx +846 -0
  136. package/src/ui/input/DateTimeInput.tsx +29 -4
  137. package/src/ui/input/FileDropzone.tsx +116 -0
  138. package/src/ui/input/IconInput.tsx +116 -0
  139. package/src/ui/input/ImageInput.tsx +19 -2
  140. package/src/ui/input/MultiSelectInput.tsx +448 -0
  141. package/src/ui/input/NumberInput.tsx +417 -61
  142. package/src/ui/input/SegmentedControl.tsx +2 -2
  143. package/src/ui/input/Select.tsx +172 -10
  144. package/src/ui/input/Slider.tsx +3 -4
  145. package/src/ui/input/Switch.tsx +3 -2
  146. package/src/ui/input/TemplateEditor.tsx +212 -0
  147. package/src/ui/input/TextInput.tsx +144 -13
  148. package/src/ui/input/index.ts +53 -8
  149. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  150. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  151. package/src/ui/input/markdown/actions.ts +233 -0
  152. package/src/ui/input/markdown/active-formats.ts +94 -0
  153. package/src/ui/input/markdown/behaviors.ts +193 -0
  154. package/src/ui/input/markdown/code-zone.ts +23 -0
  155. package/src/ui/input/markdown/highlight.ts +316 -0
  156. package/src/ui/layout.ts +22 -0
  157. package/src/ui/misc/AppOverview.tsx +105 -0
  158. package/src/ui/misc/AppWorkspace.tsx +607 -0
  159. package/src/ui/misc/Calendar.tsx +1291 -0
  160. package/src/ui/misc/Chart.tsx +162 -0
  161. package/src/ui/misc/CodeDisplay.tsx +54 -0
  162. package/src/ui/misc/ContextMenu.tsx +2 -2
  163. package/src/ui/misc/DataTable.tsx +269 -0
  164. package/src/ui/misc/DockWorkspace.tsx +425 -0
  165. package/src/ui/misc/Docs.tsx +153 -0
  166. package/src/ui/misc/Dropdown.tsx +2 -2
  167. package/src/ui/misc/EntitySearch.tsx +260 -129
  168. package/src/ui/misc/LinkCard.tsx +14 -2
  169. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  170. package/src/ui/misc/Pagination.tsx +31 -12
  171. package/src/ui/misc/PanelDialog.tsx +109 -0
  172. package/src/ui/misc/Panes.tsx +873 -0
  173. package/src/ui/misc/PermissionEditor.tsx +358 -262
  174. package/src/ui/misc/Placeholder.tsx +40 -0
  175. package/src/ui/misc/ProgressBar.tsx +1 -1
  176. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  177. package/src/ui/misc/SettingsModal.tsx +150 -0
  178. package/src/ui/misc/StatCell.tsx +182 -40
  179. package/src/ui/misc/StatGrid.tsx +149 -0
  180. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  181. package/src/ui/misc/code-highlight.ts +213 -0
  182. package/src/ui/misc/index.ts +93 -12
  183. package/src/ui/prompts.tsx +362 -312
  184. package/src/ui/toast.ts +384 -0
  185. package/src/ui/widgets/Widget.tsx +12 -4
  186. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  187. package/src/ui/ipa/GroupView.tsx +0 -36
  188. package/src/ui/ipa/LoginBtn.tsx +0 -16
  189. package/src/ui/ipa/UserView.tsx +0 -58
  190. package/src/ui/ipa/index.ts +0 -4
  191. package/src/ui/navigation.ts +0 -32
  192. package/src/ui/sidebar.tsx +0 -468
  193. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -1,79 +1,197 @@
1
- import { createSignal, For, Show } from "solid-js";
2
-
3
- import { prompts } from "../prompts";
4
1
  import { mutation } from "@valentinkolb/stdlib/solid";
5
- import SegmentedControl from "../input/SegmentedControl";
6
- import EntitySearch, { type EntitySearchResult } from "./EntitySearch";
2
+ import { createSignal, For, Show } from "solid-js";
7
3
  import type { AccessEntry, PermissionLevel, Principal } from "../../contracts/shared";
4
+ import Combobox, { type ComboboxOption } from "../input/Combobox";
5
+ import { prompts } from "../prompts";
6
+ import Dropdown from "./Dropdown";
7
+ import Placeholder from "./Placeholder";
8
8
 
9
- const PERMISSION_OPTIONS: {
10
- value: PermissionLevel;
11
- label: string;
12
- icon: string;
13
- }[] = [
14
- { value: "read", label: "Read", icon: "ti-eye" },
15
- { value: "write", label: "Write", icon: "ti-pencil" },
16
- { value: "admin", label: "Admin", icon: "ti-shield" },
17
- ];
9
+ // ─────────────────────────────────────────────────────────────────────────
10
+ // Public API
11
+ // ─────────────────────────────────────────────────────────────────────────
12
+
13
+ /** The three grantable permission levels — `"none"` exists in the
14
+ * contract for resolution semantics but is never directly granted. */
15
+ export type GrantableLevel = Exclude<PermissionLevel, "none">;
16
+
17
+ /** Either a bare level (uses the default View / Edit / Manage label and
18
+ * icon) or an object with per-context overrides. */
19
+ export type AllowedLevel = GrantableLevel | { level: GrantableLevel; label?: string; icon?: string };
18
20
 
19
21
  type PermissionEditorProps = {
20
- /** Resource ID (e.g., space ID) */
21
- resourceId: string;
22
- /** Initial access entries */
22
+ /** Initial access entries caller stays the source of truth for
23
+ * what's stored on the resource; the editor only mutates locally
24
+ * on optimistic update. */
23
25
  initialEntries: AccessEntry[];
24
- /** Whether the current user can edit permissions */
26
+
27
+ /** Whether the current user can edit permissions. When `false`, the
28
+ * editor renders the entries read-only — no row dropdowns, no
29
+ * delete buttons, no add form. */
25
30
  canEdit?: boolean;
26
- /** Grant access for this resource */
27
- grantAccess: (resourceId: string, principal: Principal, permission: PermissionLevel) => Promise<AccessEntry>;
28
- /** Update access permission for this resource */
29
- updateAccess: (resourceId: string, accessId: string, permission: PermissionLevel) => Promise<void>;
30
- /** Revoke access for this resource */
31
- revokeAccess: (resourceId: string, accessId: string) => Promise<void>;
32
- /** Allow creating public access entries from this editor */
31
+
32
+ /** Grant access. The caller closes over the resource id. */
33
+ grantAccess: (principal: Principal, permission: GrantableLevel) => Promise<AccessEntry>;
34
+
35
+ /** Update an existing entry's permission level. */
36
+ updateAccess: (accessId: string, permission: GrantableLevel) => Promise<void>;
37
+
38
+ /** Revoke an existing entry. The last entry IS deletable — for
39
+ * hierarchical resources the parent ACL still applies. */
40
+ revokeAccess: (accessId: string) => Promise<void>;
41
+
42
+ /** Allow granting `public` access from this editor. The
43
+ * authenticated principal is always allowed (no flag needed). */
33
44
  allowPublic?: boolean;
45
+
46
+ /** Allow granting service accounts. Off by default so ordinary
47
+ * permission pickers stay user/group focused. */
48
+ allowServiceAccounts?: boolean;
49
+
50
+ /** Which levels the UI offers — and what they're called. Bare strings
51
+ * use the default labels (View / Edit / Manage). Objects override
52
+ * label and/or icon for per-context vocabulary (e.g. forms call
53
+ * write "Use", views call read "View"). When undefined, all three
54
+ * are offered with default labels. New entries are granted
55
+ * `allowedLevels[0]` on pick — the user upgrades via the row pill
56
+ * afterwards. */
57
+ allowedLevels?: AllowedLevel[];
58
+ };
59
+
60
+ // ─────────────────────────────────────────────────────────────────────────
61
+ // Defaults & helpers
62
+ // ─────────────────────────────────────────────────────────────────────────
63
+
64
+ const DEFAULT_LABELS: Record<PermissionLevel, { label: string; icon: string }> = {
65
+ read: { label: "View", icon: "ti-eye" },
66
+ write: { label: "Edit", icon: "ti-pencil" },
67
+ admin: { label: "Manage", icon: "ti-shield" },
68
+ // Defensive — never granted by this editor, but renders correctly if
69
+ // a legacy entry has permission === "none".
70
+ none: { label: "No access", icon: "ti-ban" },
71
+ };
72
+
73
+ type ResolvedLevel = {
74
+ level: GrantableLevel;
75
+ label: string;
76
+ icon: string;
77
+ };
78
+
79
+ /** Resolve the AllowedLevel union into a flat shape the renderer can
80
+ * loop over. Falls back to the default View / Edit / Manage list when
81
+ * no override is given. */
82
+ const resolveAllowedLevels = (allowed: AllowedLevel[] | undefined): ResolvedLevel[] => {
83
+ const list = allowed && allowed.length > 0 ? allowed : (["read", "write", "admin"] as GrantableLevel[]);
84
+ return list.map((entry) => {
85
+ const level = typeof entry === "string" ? entry : entry.level;
86
+ const override = typeof entry === "string" ? null : entry;
87
+ const def = DEFAULT_LABELS[level];
88
+ return {
89
+ level,
90
+ label: override?.label ?? def.label,
91
+ icon: override?.icon ?? def.icon,
92
+ };
93
+ });
94
+ };
95
+
96
+ /** Resolve a stored entry's permission to a renderable {label,icon},
97
+ * preferring the caller's allowedLevels override and falling back to
98
+ * the platform defaults. Tolerates "none" / unknown legacy values. */
99
+ const resolveEntryDisplay = (permission: PermissionLevel, allowed: ResolvedLevel[]): { label: string; icon: string } => {
100
+ const fromAllowed = allowed.find((a) => a.level === permission);
101
+ if (fromAllowed) return fromAllowed;
102
+ return DEFAULT_LABELS[permission] ?? DEFAULT_LABELS.none;
103
+ };
104
+
105
+ const getEntryDisplayName = (entry: AccessEntry): string => {
106
+ if (entry.displayName) return entry.displayName;
107
+ if (entry.principal.type === "authenticated") return "All users (incl. guests)";
108
+ if (entry.principal.type === "public") return "Public";
109
+ if (entry.principal.type === "user") return entry.principal.userId;
110
+ if (entry.principal.type === "service_account") return entry.principal.serviceAccountId;
111
+ return entry.principal.groupId;
112
+ };
113
+
114
+ const getPrincipalIcon = (principal: Principal): string => {
115
+ switch (principal.type) {
116
+ case "user":
117
+ return "ti-user";
118
+ case "group":
119
+ return "ti-users-group";
120
+ case "service_account":
121
+ return "ti-key";
122
+ case "authenticated":
123
+ return "ti-lock-open-2";
124
+ case "public":
125
+ return "ti-world";
126
+ }
127
+ };
128
+
129
+ const getPermissionColor = (level: PermissionLevel): string => {
130
+ switch (level) {
131
+ case "read":
132
+ return "text-blue-500";
133
+ case "write":
134
+ return "text-amber-500";
135
+ case "admin":
136
+ return "text-purple-500";
137
+ default:
138
+ return "text-zinc-500";
139
+ }
34
140
  };
35
141
 
36
- /**
37
- * Permission Editor component for managing access control.
38
- * Can be used in dialogs or pages to manage who has access to a resource.
39
- */
142
+ // Backend `/api/accounts/entities` shape (the subset we consume).
143
+ type ApiEntity =
144
+ | { kind: "user"; user: { id: string; uid: string; displayName: string; mail: string | null } }
145
+ | { kind: "group"; group: { id: string; name: string; description: string | null } }
146
+ | { kind: "service_account"; serviceAccount: { id: string; name: string; kind: "user_delegated" | "resource_bound"; appId: string | null; resourceType: string | null; resourceId: string | null } };
147
+
148
+ // ─────────────────────────────────────────────────────────────────────────
149
+ // PermissionEditor
150
+ // ─────────────────────────────────────────────────────────────────────────
151
+
40
152
  export default function PermissionEditor(props: PermissionEditorProps) {
41
153
  const [entries, setEntries] = createSignal<AccessEntry[]>([...props.initialEntries]);
42
- const [showAddForm, setShowAddForm] = createSignal(false);
43
154
  const canEdit = () => props.canEdit !== false;
44
155
  const allowPublic = () => props.allowPublic === true;
156
+ const allowed = () => resolveAllowedLevels(props.allowedLevels);
157
+ const isSinglePicker = () => allowed().length === 1;
158
+
159
+ // Defensive dev-warning: an empty allowedLevels array makes the editor
160
+ // unable to grant anything.
161
+ if (props.allowedLevels && props.allowedLevels.length === 0) {
162
+ if (typeof console !== "undefined") {
163
+ console.warn(
164
+ "[PermissionEditor] `allowedLevels=[]` — the editor cannot grant any permission. Pass at least one level or omit the prop for the default View / Edit / Manage set.",
165
+ );
166
+ }
167
+ }
45
168
 
46
- // Get existing user IDs and group IDs to exclude from search
47
169
  const existingUserIds = () =>
48
170
  entries()
49
171
  .filter((e) => e.principal.type === "user")
50
172
  .map((e) => (e.principal as { type: "user"; userId: string }).userId);
51
-
52
173
  const existingGroupIds = () =>
53
174
  entries()
54
175
  .filter((e) => e.principal.type === "group")
55
176
  .map((e) => (e.principal as { type: "group"; groupId: string }).groupId);
56
-
177
+ const existingServiceAccountIds = () =>
178
+ entries()
179
+ .filter((e) => e.principal.type === "service_account")
180
+ .map((e) => (e.principal as { type: "service_account"; serviceAccountId: string }).serviceAccountId);
57
181
  const hasAuthenticatedEntry = () => entries().some((entry) => entry.principal.type === "authenticated");
58
182
  const hasPublicEntry = () => entries().some((entry) => entry.principal.type === "public");
59
183
 
60
- // Grant access mutation
61
184
  const grantMut = mutation.create({
62
- mutation: async (data: { principal: Principal; permission: PermissionLevel }) => {
63
- return props.grantAccess(props.resourceId, data.principal, data.permission);
64
- },
185
+ mutation: async (data: { principal: Principal; permission: GrantableLevel }) => props.grantAccess(data.principal, data.permission),
65
186
  onSuccess: (newEntry) => {
66
187
  setEntries([...entries(), newEntry as AccessEntry]);
67
- setShowAddForm(false);
68
188
  },
69
189
  onError: (err) => prompts.error(err.message),
70
190
  });
71
191
 
72
- // Update permission mutation
73
- const updateMut = mutation.create<{ accessId: string; permission: PermissionLevel }, { accessId: string; permission: PermissionLevel }>({
192
+ const updateMut = mutation.create<{ accessId: string; permission: GrantableLevel }, { accessId: string; permission: GrantableLevel }>({
74
193
  mutation: async (data) => {
75
- await props.updateAccess(props.resourceId, data.accessId, data.permission);
76
- // Return the data so we can use it in onSuccess
194
+ await props.updateAccess(data.accessId, data.permission);
77
195
  return data;
78
196
  },
79
197
  onSuccess: (result) => {
@@ -84,20 +202,14 @@ export default function PermissionEditor(props: PermissionEditorProps) {
84
202
  onError: (err) => prompts.error(err.message),
85
203
  });
86
204
 
87
- // Revoke access mutation
88
205
  const revokeMut = mutation.create<void, string>({
89
206
  mutation: async (accessId: string) => {
90
- await props.revokeAccess(props.resourceId, accessId);
207
+ await props.revokeAccess(accessId);
91
208
  },
92
209
  onError: (err) => prompts.error(err.message),
93
210
  });
94
211
 
95
212
  const handleRevoke = async (entry: AccessEntry) => {
96
- if (entries().length <= 1) {
97
- prompts.error("Cannot remove the last access entry");
98
- return;
99
- }
100
-
101
213
  const displayName = getEntryDisplayName(entry);
102
214
  const confirmed = await prompts.confirm(`Remove access for ${displayName}?`, { title: "Remove Access", variant: "danger" });
103
215
  if (confirmed) {
@@ -106,186 +218,254 @@ export default function PermissionEditor(props: PermissionEditorProps) {
106
218
  }
107
219
  };
108
220
 
109
- const handleEntitySelect = (result: EntitySearchResult, permission: PermissionLevel) => {
110
- const principal: Principal = result.type === "user" ? { type: "user", userId: result.id } : { type: "group", groupId: result.id };
221
+ // ── Combobox add-flow ─────────────────────────────────────────────────
222
+ // The Combobox is a fire-and-forget input: type pick granted at the
223
+ // lowest allowed level. The `principalsByOptId` map carries the original
224
+ // discriminated principal across the ComboboxOption boundary so onSelect
225
+ // can route it to grantAccess without re-parsing prefixed ids.
226
+ let principalsByOptId = new Map<string, Principal>();
227
+
228
+ const fetchPrincipals = async (q: string, signal: AbortSignal): Promise<ComboboxOption[]> => {
229
+ const map = new Map<string, Principal>();
230
+ const opts: ComboboxOption[] = [];
231
+
232
+ // Synthetic principals — only when allowed AND not already granted.
233
+ // Placed first so they're visible immediately on focus, before any
234
+ // typing kicks off a backend request.
235
+ if (!hasAuthenticatedEntry()) {
236
+ map.set("auth", { type: "authenticated" });
237
+ opts.push({
238
+ id: "auth",
239
+ label: "All users (incl. guests)",
240
+ description: "Anyone signed in to the cloud",
241
+ icon: "ti-lock-open-2",
242
+ });
243
+ }
244
+ if (allowPublic() && !hasPublicEntry()) {
245
+ map.set("public", { type: "public" });
246
+ opts.push({
247
+ id: "public",
248
+ label: "Public",
249
+ description: "Anyone with the link, even unauthenticated",
250
+ icon: "ti-world",
251
+ });
252
+ }
111
253
 
112
- grantMut.mutate({ principal, permission });
254
+ // Real entities require a query — avoid a wide listing on every focus.
255
+ if (q.length >= 2) {
256
+ const url = new URL("/api/accounts/entities", window.location.origin);
257
+ url.searchParams.set("search", q);
258
+ url.searchParams.set("kinds", props.allowServiceAccounts ? "user,group,service_account" : "user,group");
259
+ url.searchParams.set("per_page", "10");
260
+ const userIds = existingUserIds();
261
+ if (userIds.length) url.searchParams.set("exclude_user_ids", userIds.join(","));
262
+ const groupIds = existingGroupIds();
263
+ if (groupIds.length) url.searchParams.set("exclude_group_ids", groupIds.join(","));
264
+ const serviceAccountIds = existingServiceAccountIds();
265
+ if (serviceAccountIds.length) url.searchParams.set("exclude_service_account_ids", serviceAccountIds.join(","));
266
+
267
+ const res = await fetch(url.toString(), { credentials: "same-origin", signal });
268
+ if (res.ok) {
269
+ const data = (await res.json()) as { items?: ApiEntity[] };
270
+ for (const item of data.items ?? []) {
271
+ if (item.kind === "user") {
272
+ const id = `u:${item.user.id}`;
273
+ map.set(id, { type: "user", userId: item.user.id });
274
+ opts.push({
275
+ id,
276
+ label: item.user.displayName,
277
+ description: item.user.mail ?? item.user.uid,
278
+ icon: "ti-user",
279
+ });
280
+ } else if (item.kind === "group") {
281
+ const id = `g:${item.group.id}`;
282
+ map.set(id, { type: "group", groupId: item.group.id });
283
+ opts.push({
284
+ id,
285
+ label: item.group.name,
286
+ description: item.group.description ?? undefined,
287
+ icon: "ti-users-group",
288
+ });
289
+ } else if (item.kind === "service_account") {
290
+ const id = `sa:${item.serviceAccount.id}`;
291
+ map.set(id, { type: "service_account", serviceAccountId: item.serviceAccount.id });
292
+ opts.push({
293
+ id,
294
+ label: item.serviceAccount.name,
295
+ description:
296
+ item.serviceAccount.kind === "user_delegated"
297
+ ? "User-bound service account"
298
+ : [item.serviceAccount.appId, item.serviceAccount.resourceType, item.serviceAccount.resourceId].filter(Boolean).join(" · "),
299
+ icon: "ti-key",
300
+ });
301
+ }
302
+ }
303
+ }
304
+ }
305
+
306
+ principalsByOptId = map;
307
+ return opts;
308
+ };
309
+
310
+ const handleSelect = (option: ComboboxOption) => {
311
+ const principal = principalsByOptId.get(option.id);
312
+ if (!principal) return;
313
+ const firstLevel = allowed()[0]?.level;
314
+ if (!firstLevel) return; // dev-warned above; bail silently
315
+ grantMut.mutate({ principal, permission: firstLevel });
113
316
  };
114
317
 
115
318
  return (
116
319
  <div class="flex flex-col gap-3">
117
320
  {/* Existing entries */}
118
- <div class="flex flex-col border-l-2 border-zinc-200 dark:border-zinc-700">
321
+ <div class="flex flex-col gap-1">
119
322
  <For each={entries()}>
120
323
  {(entry) => (
121
324
  <AccessEntryRow
122
325
  entry={entry}
123
326
  canEdit={canEdit()}
124
- canDelete={entries().length > 1}
327
+ allowed={allowed()}
328
+ singlePicker={isSinglePicker()}
125
329
  onUpdatePermission={(permission) => updateMut.mutate({ accessId: entry.id, permission })}
126
330
  onRevoke={() => handleRevoke(entry)}
127
- updating={updateMut.loading()}
128
331
  />
129
332
  )}
130
333
  </For>
334
+ <Show when={entries().length === 0}>
335
+ <Placeholder align="left" class="px-1 py-2">
336
+ No direct grants yet.
337
+ </Placeholder>
338
+ </Show>
131
339
  </div>
132
340
 
133
- {/* Add access */}
341
+ {/* Add access — single Combobox, granted at the lowest allowed
342
+ level on pick. The user upgrades via the row pill if they want
343
+ a higher level. KISS: one decision per step. */}
134
344
  <Show when={canEdit()}>
135
- <Show
136
- when={showAddForm()}
137
- fallback={
138
- <button
139
- type="button"
140
- onClick={() => setShowAddForm(true)}
141
- class="flex items-center gap-2 text-sm text-dimmed hover:text-primary transition-colors"
142
- >
143
- <i class="ti ti-plus" />
144
- <span>Add access</span>
145
- </button>
146
- }
147
- >
148
- <AddAccessForm
149
- existingUserIds={existingUserIds()}
150
- existingGroupIds={existingGroupIds()}
151
- onSelectEntity={handleEntitySelect}
152
- onSelectPrincipal={(principal, permission) => grantMut.mutate({ principal, permission })}
153
- onCancel={() => setShowAddForm(false)}
154
- loading={grantMut.loading()}
155
- showAuthenticated={!hasAuthenticatedEntry()}
156
- showPublic={allowPublic() && !hasPublicEntry()}
157
- />
158
- </Show>
345
+ <Combobox
346
+ placeholder={props.allowServiceAccounts ? "Add user, group, service account or audience..." : "Add user, group or audience..."}
347
+ fetchData={fetchPrincipals}
348
+ onSelect={handleSelect}
349
+ disabled={grantMut.loading()}
350
+ />
159
351
  </Show>
160
352
  </div>
161
353
  );
162
354
  }
163
355
 
164
- // =============================================================================
165
- // Helper Functions
166
- // =============================================================================
167
-
168
- function getEntryDisplayName(entry: AccessEntry): string {
169
- if (entry.displayName) return entry.displayName;
170
- if (entry.principal.type === "authenticated") return "All users (incl. guests)";
171
- if (entry.principal.type === "public") return "Public";
172
- if (entry.principal.type === "user") return entry.principal.userId;
173
- return entry.principal.groupId;
174
- }
175
-
176
- function getPrincipalIcon(principal: Principal): string {
177
- switch (principal.type) {
178
- case "user":
179
- return "ti-user";
180
- case "group":
181
- return "ti-users-group";
182
- case "authenticated":
183
- return "ti-lock-open-2";
184
- case "public":
185
- return "ti-world";
186
- }
187
- }
188
-
189
- function getPermissionColor(level: PermissionLevel): string {
190
- switch (level) {
191
- case "read":
192
- return "text-blue-500";
193
- case "write":
194
- return "text-amber-500";
195
- case "admin":
196
- return "text-purple-500";
197
- default:
198
- return "text-zinc-500";
199
- }
200
- }
201
-
202
- // =============================================================================
356
+ // ─────────────────────────────────────────────────────────────────────────
203
357
  // Access Entry Row
204
- // =============================================================================
358
+ // ─────────────────────────────────────────────────────────────────────────
205
359
 
206
360
  function AccessEntryRow(props: {
207
361
  entry: AccessEntry;
208
362
  canEdit: boolean;
209
- canDelete: boolean;
210
- onUpdatePermission: (permission: PermissionLevel) => void;
363
+ allowed: ResolvedLevel[];
364
+ /** When true the per-row picker collapses to a non-interactive badge
365
+ * (single-level mode — there's nothing to switch to). */
366
+ singlePicker: boolean;
367
+ onUpdatePermission: (permission: GrantableLevel) => void;
211
368
  onRevoke: () => void;
212
- updating: boolean;
213
369
  }) {
214
- const [showPermissionMenu, setShowPermissionMenu] = createSignal(false);
370
+ const display = () => resolveEntryDisplay(props.entry.permission, props.allowed);
371
+ const isInteractive = () => props.canEdit && !props.singlePicker;
372
+
373
+ const badgeClass = () => `flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border ${getPermissionColor(props.entry.permission)}`;
374
+ const badgeBorderList = () => ({
375
+ "border-blue-200 dark:border-blue-900": props.entry.permission === "read",
376
+ "border-amber-200 dark:border-amber-900": props.entry.permission === "write",
377
+ "border-purple-200 dark:border-purple-900": props.entry.permission === "admin",
378
+ "border-zinc-200 dark:border-zinc-700":
379
+ props.entry.permission !== "read" && props.entry.permission !== "write" && props.entry.permission !== "admin",
380
+ });
381
+
382
+ const badgeContent = (
383
+ <>
384
+ <i class={`ti ${display().icon}`} />
385
+ <span>{display().label}</span>
386
+ <Show when={isInteractive()}>
387
+ <i class="ti ti-chevron-down text-[10px]" />
388
+ </Show>
389
+ </>
390
+ );
215
391
 
216
392
  return (
217
- <div class="group/entry pl-3 py-1.5 flex items-center gap-2">
218
- {/* Icon */}
219
- <div class="flex shrink-0 items-center justify-center rounded-full bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 h-7 w-7">
393
+ <div class="flex items-center gap-2 py-1.5">
394
+ {/* Principal icon */}
395
+ <div class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
220
396
  <i class={`ti ${getPrincipalIcon(props.entry.principal)} text-sm`} />
221
397
  </div>
222
398
 
223
- {/* Name */}
224
- <div class="flex-1 min-w-0">
225
- <span class="text-sm truncate">{getEntryDisplayName(props.entry)}</span>
399
+ {/* Display name */}
400
+ <div class="min-w-0 flex-1">
401
+ <span class="truncate text-sm">{getEntryDisplayName(props.entry)}</span>
226
402
  <Show when={props.entry.principal.type === "public"}>
227
- <span class="text-xs text-dimmed ml-1">(Anyone with the link)</span>
403
+ <span class="ml-1 text-xs text-dimmed">(Anyone with the link)</span>
228
404
  </Show>
229
405
  </div>
230
406
 
231
- {/* Permission badge / selector */}
232
- <div class="relative">
233
- <button
234
- type="button"
235
- onClick={() => props.canEdit && setShowPermissionMenu(!showPermissionMenu())}
236
- disabled={!props.canEdit}
237
- class={`flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border transition-colors ${getPermissionColor(
238
- props.entry.permission,
239
- )} ${props.canEdit ? "cursor-pointer hover:bg-zinc-100 dark:hover:bg-zinc-800" : "cursor-default"}`}
240
- classList={{
241
- "border-blue-200 dark:border-blue-900": props.entry.permission === "read",
242
- "border-amber-200 dark:border-amber-900": props.entry.permission === "write",
243
- "border-purple-200 dark:border-purple-900": props.entry.permission === "admin",
244
- }}
245
- >
246
- <i class={`ti ${PERMISSION_OPTIONS.find((o) => o.value === props.entry.permission)?.icon}`} />
247
- <span class="capitalize">{props.entry.permission}</span>
248
- <Show when={props.canEdit}>
249
- <i class="ti ti-chevron-down text-[10px]" />
250
- </Show>
251
- </button>
252
-
253
- {/* Permission dropdown */}
254
- <Show when={showPermissionMenu()}>
255
- <div class="absolute right-0 top-full mt-1 z-10 popup py-1 min-w-30">
256
- <For each={PERMISSION_OPTIONS}>
257
- {(option) => (
407
+ {/* Permission badge interactive Dropdown when editable, plain
408
+ span otherwise. */}
409
+ <Show
410
+ when={isInteractive()}
411
+ fallback={
412
+ <span class={`${badgeClass()} cursor-default`} classList={badgeBorderList()}>
413
+ {badgeContent}
414
+ </span>
415
+ }
416
+ >
417
+ <Dropdown
418
+ trigger={
419
+ <button
420
+ type="button"
421
+ class={`${badgeClass()} cursor-pointer transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800`}
422
+ classList={badgeBorderList()}
423
+ >
424
+ {badgeContent}
425
+ </button>
426
+ }
427
+ position="bottom-left"
428
+ width="10rem"
429
+ // Custom `element` items rather than action items — lets us
430
+ // color-tint each row by its level (matching the row pill
431
+ // colors), prefix icons correctly with the `ti ` base class
432
+ // (Dropdown's action.icon expected the full class but we
433
+ // store just `ti-eye` etc.), and mark the currently-active
434
+ // level with a checkmark.
435
+ elements={props.allowed.map((option) => ({
436
+ element: (close) => {
437
+ const isCurrent = () => option.level === props.entry.permission;
438
+ return (
258
439
  <button
259
440
  type="button"
260
441
  onClick={() => {
261
- if (option.value !== props.entry.permission) {
262
- props.onUpdatePermission(option.value);
263
- }
264
- setShowPermissionMenu(false);
265
- }}
266
- class="w-full px-3 py-1.5 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-2"
267
- classList={{
268
- "bg-zinc-50 dark:bg-zinc-700/50": option.value === props.entry.permission,
442
+ if (!isCurrent()) props.onUpdatePermission(option.level);
443
+ close();
269
444
  }}
445
+ class="flex w-full items-center gap-2 px-3 py-1.5 text-sm transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800"
446
+ classList={{ "bg-zinc-50 dark:bg-zinc-700/50": isCurrent() }}
270
447
  >
271
- <i class={`ti ${option.icon} ${getPermissionColor(option.value)}`} />
272
- <span>{option.label}</span>
273
- <Show when={option.value === props.entry.permission}>
274
- <i class="ti ti-check ml-auto text-green-500" />
448
+ <i class={`ti ${option.icon} ${getPermissionColor(option.level)}`} />
449
+ <span class="flex-1 text-left">{option.label}</span>
450
+ <Show when={isCurrent()}>
451
+ <i class="ti ti-check text-emerald-500" />
275
452
  </Show>
276
453
  </button>
277
- )}
278
- </For>
279
- </div>
280
- </Show>
281
- </div>
454
+ );
455
+ },
456
+ }))}
457
+ />
458
+ </Show>
282
459
 
283
- {/* Delete button */}
284
- <Show when={props.canEdit && props.canDelete}>
460
+ {/* Delete button — always visible when editable, dimmed by default
461
+ (no longer hover-only). The last entry IS deletable; parent ACL
462
+ covers the gap. */}
463
+ <Show when={props.canEdit}>
285
464
  <button
286
465
  type="button"
287
466
  onClick={props.onRevoke}
288
- class="p-1 w-6 h-6 flex items-center justify-center text-dimmed hover:text-red-500 opacity-0 group-hover/entry:opacity-100 transition-opacity"
467
+ aria-label={`Remove ${getEntryDisplayName(props.entry)}`}
468
+ class="flex h-6 w-6 items-center justify-center text-zinc-400 transition-colors hover:text-red-500"
289
469
  >
290
470
  <i class="ti ti-x text-sm" />
291
471
  </button>
@@ -293,87 +473,3 @@ function AccessEntryRow(props: {
293
473
  </div>
294
474
  );
295
475
  }
296
-
297
- // =============================================================================
298
- // Add Access Form
299
- // =============================================================================
300
-
301
- function AddAccessForm(props: {
302
- existingUserIds: string[];
303
- existingGroupIds: string[];
304
- onSelectEntity: (result: EntitySearchResult, permission: PermissionLevel) => void;
305
- onSelectPrincipal: (principal: Principal, permission: PermissionLevel) => void;
306
- onCancel: () => void;
307
- loading: boolean;
308
- showAuthenticated: boolean;
309
- showPublic: boolean;
310
- }) {
311
- const [permission, setPermission] = createSignal<PermissionLevel>("read");
312
- const hasTwoPrincipalButtons = () => props.showAuthenticated && props.showPublic;
313
- const permissionOptions = PERMISSION_OPTIONS.map((option) => ({
314
- value: option.value,
315
- label: option.label,
316
- icon: `ti ${option.icon}`,
317
- }));
318
-
319
- return (
320
- <div class="paper p-3 flex flex-col gap-2">
321
- {/* Permission level selector */}
322
- <div class="flex flex-col gap-1">
323
- <p class="text-xs text-secondary">Permission Level</p>
324
- <SegmentedControl options={permissionOptions} value={permission} onChange={setPermission} disabled={props.loading} />
325
- </div>
326
-
327
- {/* User/Group search */}
328
- <EntitySearch
329
- apiBaseUrl="/api/accounts"
330
- searchUsers
331
- searchGroups
332
- excludeUserIds={props.existingUserIds}
333
- excludeGroupIds={props.existingGroupIds}
334
- onSelect={(result) => props.onSelectEntity(result, permission())}
335
- placeholder="Search users or groups..."
336
- adding={props.loading}
337
- resultsHeightClass="max-h-36 min-h-20"
338
- />
339
-
340
- <Show when={props.showAuthenticated || props.showPublic}>
341
- <div
342
- class="grid gap-2"
343
- classList={{
344
- "grid-cols-2": hasTwoPrincipalButtons(),
345
- "grid-cols-1": !hasTwoPrincipalButtons(),
346
- }}
347
- >
348
- <Show when={props.showAuthenticated}>
349
- <button
350
- type="button"
351
- onClick={() => props.onSelectPrincipal({ type: "authenticated" }, permission())}
352
- disabled={props.loading}
353
- class="btn-simple btn-sm w-full justify-center"
354
- >
355
- <i class="ti ti-lock-open-2" />
356
- All users (incl. guests)
357
- </button>
358
- </Show>
359
- <Show when={props.showPublic}>
360
- <button
361
- type="button"
362
- onClick={() => props.onSelectPrincipal({ type: "public" }, permission())}
363
- disabled={props.loading}
364
- class="btn-secondary btn-sm w-full justify-center"
365
- >
366
- <i class="ti ti-world" />
367
- Allow public
368
- </button>
369
- </Show>
370
- </div>
371
- </Show>
372
-
373
- {/* Cancel button */}
374
- <button type="button" onClick={props.onCancel} class="text-xs text-dimmed hover:text-primary self-end">
375
- Cancel
376
- </button>
377
- </div>
378
- );
379
- }