@valentinkolb/cloud 0.1.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 (196) hide show
  1. package/package.json +69 -0
  2. package/public/logo.svg +1 -0
  3. package/scripts/build.ts +113 -0
  4. package/scripts/preload.ts +73 -0
  5. package/src/_internal/define-app.ts +399 -0
  6. package/src/_internal/heartbeat.ts +33 -0
  7. package/src/_internal/registry.ts +100 -0
  8. package/src/_internal/runtime-context.ts +38 -0
  9. package/src/api/accounts-entities.ts +134 -0
  10. package/src/api/admin-lifecycle.ts +210 -0
  11. package/src/api/auth/schemas.ts +28 -0
  12. package/src/api/auth.ts +230 -0
  13. package/src/api/index.ts +66 -0
  14. package/src/api/me.ts +206 -0
  15. package/src/api/search/schemas.ts +43 -0
  16. package/src/api/search.ts +130 -0
  17. package/src/clients/core.ts +19 -0
  18. package/src/config/env.ts +23 -0
  19. package/src/config/index.ts +6 -0
  20. package/src/config/ssr.ts +58 -0
  21. package/src/contracts/app.ts +140 -0
  22. package/src/contracts/index.ts +5 -0
  23. package/src/contracts/profile.ts +67 -0
  24. package/src/contracts/registry.ts +50 -0
  25. package/src/contracts/settings-types.ts +84 -0
  26. package/src/contracts/shared.ts +258 -0
  27. package/src/contracts/widgets.ts +121 -0
  28. package/src/index.ts +6 -0
  29. package/src/server/api/index.ts +1 -0
  30. package/src/server/api/respond.ts +55 -0
  31. package/src/server/api-client.ts +54 -0
  32. package/src/server/app-context.ts +39 -0
  33. package/src/server/index.ts +62 -0
  34. package/src/server/middleware/auth.ts +168 -0
  35. package/src/server/middleware/index.ts +7 -0
  36. package/src/server/middleware/middleware.ts +47 -0
  37. package/src/server/middleware/openapi.ts +126 -0
  38. package/src/server/middleware/rate-limit.ts +126 -0
  39. package/src/server/middleware/request-logger.ts +41 -0
  40. package/src/server/middleware/validator.ts +35 -0
  41. package/src/server/services/access.ts +294 -0
  42. package/src/server/services/freeipa/client.ts +100 -0
  43. package/src/server/services/freeipa/index.ts +9 -0
  44. package/src/server/services/freeipa/session.ts +78 -0
  45. package/src/server/services/freeipa/tls.ts +48 -0
  46. package/src/server/services/freeipa/util.ts +60 -0
  47. package/src/server/services/geo.ts +154 -0
  48. package/src/server/services/index.ts +28 -0
  49. package/src/server/services/services.ts +13 -0
  50. package/src/services/account-lifecycle/audit.ts +41 -0
  51. package/src/services/account-lifecycle/index.ts +907 -0
  52. package/src/services/account-lifecycle/scheduler.ts +347 -0
  53. package/src/services/account-model.ts +21 -0
  54. package/src/services/accounts/app.ts +966 -0
  55. package/src/services/accounts/authz.ts +22 -0
  56. package/src/services/accounts/base-group.ts +11 -0
  57. package/src/services/accounts/base-user.ts +45 -0
  58. package/src/services/accounts/entities.ts +529 -0
  59. package/src/services/accounts/group-sql.ts +106 -0
  60. package/src/services/accounts/groups.ts +246 -0
  61. package/src/services/accounts/index.ts +14 -0
  62. package/src/services/accounts/ipa-data.ts +64 -0
  63. package/src/services/accounts/lifecycle.ts +2 -0
  64. package/src/services/accounts/local-groups.ts +491 -0
  65. package/src/services/accounts/model.ts +135 -0
  66. package/src/services/accounts/switching.ts +117 -0
  67. package/src/services/accounts/users.ts +714 -0
  68. package/src/services/auth-flows/index.ts +6 -0
  69. package/src/services/auth-flows/ipa.ts +128 -0
  70. package/src/services/auth-flows/magic-link.ts +119 -0
  71. package/src/services/freeipa-config.ts +89 -0
  72. package/src/services/index.ts +46 -0
  73. package/src/services/ipa/auth.ts +122 -0
  74. package/src/services/ipa/groups.ts +684 -0
  75. package/src/services/ipa/guard.ts +17 -0
  76. package/src/services/ipa/index.ts +17 -0
  77. package/src/services/ipa/profile.ts +90 -0
  78. package/src/services/ipa/search.ts +154 -0
  79. package/src/services/ipa/sync.ts +740 -0
  80. package/src/services/ipa/users.ts +794 -0
  81. package/src/services/logging/index.ts +294 -0
  82. package/src/services/notifications/email.ts +123 -0
  83. package/src/services/notifications/index.ts +413 -0
  84. package/src/services/postgres.ts +51 -0
  85. package/src/services/providers/index.ts +27 -0
  86. package/src/services/providers/local/auth.ts +13 -0
  87. package/src/services/providers/local/index.ts +4 -0
  88. package/src/services/providers/local/users.ts +255 -0
  89. package/src/services/session/index.ts +137 -0
  90. package/src/services/settings/api.ts +61 -0
  91. package/src/services/settings/app.ts +101 -0
  92. package/src/services/settings/crypto.ts +69 -0
  93. package/src/services/settings/defaults.ts +824 -0
  94. package/src/services/settings/index.ts +203 -0
  95. package/src/services/settings/namespace.ts +9 -0
  96. package/src/services/settings/snapshot.ts +49 -0
  97. package/src/services/settings/store.ts +179 -0
  98. package/src/services/settings/templates.ts +10 -0
  99. package/src/services/weather/forecast.ts +287 -0
  100. package/src/services/weather/geo.ts +110 -0
  101. package/src/services/weather/index.ts +99 -0
  102. package/src/services/weather/location.ts +24 -0
  103. package/src/services/weather/locations.ts +125 -0
  104. package/src/services/weather/migrate.ts +22 -0
  105. package/src/services/weather/types.ts +61 -0
  106. package/src/services/weather/ui.ts +50 -0
  107. package/src/shared/account-display.ts +17 -0
  108. package/src/shared/account-session.ts +15 -0
  109. package/src/shared/icons.ts +109 -0
  110. package/src/shared/index.ts +10 -0
  111. package/src/shared/markdown/client.ts +130 -0
  112. package/src/shared/markdown/extensions/code.ts +58 -0
  113. package/src/shared/markdown/extensions/images.ts +43 -0
  114. package/src/shared/markdown/extensions/info-blocks.ts +93 -0
  115. package/src/shared/markdown/extensions/katex.ts +120 -0
  116. package/src/shared/markdown/extensions/links.ts +34 -0
  117. package/src/shared/markdown/extensions/tables.ts +88 -0
  118. package/src/shared/markdown/extensions/task-list.ts +53 -0
  119. package/src/shared/markdown/index.ts +97 -0
  120. package/src/shared/markdown/shared.ts +36 -0
  121. package/src/ssr/AdminLayout.tsx +42 -0
  122. package/src/ssr/AdminSidebar.tsx +95 -0
  123. package/src/ssr/Footer.island.tsx +62 -0
  124. package/src/ssr/GlobalSearchDialog.tsx +389 -0
  125. package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
  126. package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
  127. package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
  128. package/src/ssr/Layout.tsx +326 -0
  129. package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
  130. package/src/ssr/NavMenu.island.tsx +108 -0
  131. package/src/ssr/ThemeToggleRail.island.tsx +27 -0
  132. package/src/ssr/index.ts +5 -0
  133. package/src/ssr/islands/SearchBar.island.tsx +77 -0
  134. package/src/ssr/islands/index.ts +1 -0
  135. package/src/ssr/runtime.ts +22 -0
  136. package/src/styles/base-popover.css +28 -0
  137. package/src/styles/effects.css +65 -0
  138. package/src/styles/global.css +133 -0
  139. package/src/styles/input.css +54 -0
  140. package/src/styles/tokens.css +35 -0
  141. package/src/styles/utilities-buttons.css +125 -0
  142. package/src/styles/utilities-feedback.css +65 -0
  143. package/src/styles/utilities-layout.css +122 -0
  144. package/src/styles/utilities-navigation.css +196 -0
  145. package/src/types/ambient.d.ts +8 -0
  146. package/src/ui/admin-settings.tsx +148 -0
  147. package/src/ui/dialog-core.ts +146 -0
  148. package/src/ui/filter/FilterChip.tsx +196 -0
  149. package/src/ui/filter/index.ts +2 -0
  150. package/src/ui/index.ts +19 -0
  151. package/src/ui/input/Checkbox.tsx +55 -0
  152. package/src/ui/input/ColorInput.tsx +122 -0
  153. package/src/ui/input/DateTimeInput.tsx +86 -0
  154. package/src/ui/input/ImageInput.tsx +170 -0
  155. package/src/ui/input/NumberInput.tsx +113 -0
  156. package/src/ui/input/PinInput.tsx +169 -0
  157. package/src/ui/input/SegmentedControl.tsx +99 -0
  158. package/src/ui/input/Select.tsx +288 -0
  159. package/src/ui/input/SelectChip.tsx +61 -0
  160. package/src/ui/input/Slider.tsx +118 -0
  161. package/src/ui/input/Switch.tsx +62 -0
  162. package/src/ui/input/TagsInput.tsx +115 -0
  163. package/src/ui/input/TextInput.tsx +160 -0
  164. package/src/ui/input/index.ts +13 -0
  165. package/src/ui/input/types.ts +42 -0
  166. package/src/ui/input/util.tsx +105 -0
  167. package/src/ui/ipa/Avatar.tsx +28 -0
  168. package/src/ui/ipa/GroupView.tsx +36 -0
  169. package/src/ui/ipa/LoginBtn.tsx +16 -0
  170. package/src/ui/ipa/UserView.tsx +58 -0
  171. package/src/ui/ipa/index.ts +4 -0
  172. package/src/ui/misc/ContextMenu.tsx +211 -0
  173. package/src/ui/misc/CopyButton.tsx +28 -0
  174. package/src/ui/misc/Dropdown.tsx +194 -0
  175. package/src/ui/misc/EntitySearch.tsx +213 -0
  176. package/src/ui/misc/Lightbox.tsx +194 -0
  177. package/src/ui/misc/LinkCard.tsx +34 -0
  178. package/src/ui/misc/LogEntriesTable.tsx +61 -0
  179. package/src/ui/misc/MarkdownView.tsx +65 -0
  180. package/src/ui/misc/Pagination.tsx +51 -0
  181. package/src/ui/misc/PermissionEditor.tsx +379 -0
  182. package/src/ui/misc/ProgressBar.tsx +47 -0
  183. package/src/ui/misc/RemoveBtn.tsx +27 -0
  184. package/src/ui/misc/StatCell.tsx +90 -0
  185. package/src/ui/misc/index.ts +18 -0
  186. package/src/ui/navigation.ts +32 -0
  187. package/src/ui/prompts.tsx +854 -0
  188. package/src/ui/sidebar.tsx +468 -0
  189. package/src/ui/widgets/Widget.tsx +62 -0
  190. package/src/ui/widgets/WidgetCard.tsx +19 -0
  191. package/src/ui/widgets/WidgetHero.tsx +39 -0
  192. package/src/ui/widgets/WidgetList.tsx +84 -0
  193. package/src/ui/widgets/WidgetPills.tsx +68 -0
  194. package/src/ui/widgets/WidgetStat.tsx +67 -0
  195. package/src/ui/widgets/WidgetStatus.tsx +62 -0
  196. package/src/ui/widgets/index.ts +9 -0
@@ -0,0 +1,379 @@
1
+ import { createSignal, For, Show } from "solid-js";
2
+
3
+ import { prompts } from "../prompts";
4
+ import { mutation } from "@valentinkolb/stdlib/solid";
5
+ import SegmentedControl from "../input/SegmentedControl";
6
+ import EntitySearch, { type EntitySearchResult } from "./EntitySearch";
7
+ import type { AccessEntry, PermissionLevel, Principal } from "../../contracts/shared";
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
+ ];
18
+
19
+ type PermissionEditorProps = {
20
+ /** Resource ID (e.g., space ID) */
21
+ resourceId: string;
22
+ /** Initial access entries */
23
+ initialEntries: AccessEntry[];
24
+ /** Whether the current user can edit permissions */
25
+ 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 */
33
+ allowPublic?: boolean;
34
+ };
35
+
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
+ */
40
+ export default function PermissionEditor(props: PermissionEditorProps) {
41
+ const [entries, setEntries] = createSignal<AccessEntry[]>([...props.initialEntries]);
42
+ const [showAddForm, setShowAddForm] = createSignal(false);
43
+ const canEdit = () => props.canEdit !== false;
44
+ const allowPublic = () => props.allowPublic === true;
45
+
46
+ // Get existing user IDs and group IDs to exclude from search
47
+ const existingUserIds = () =>
48
+ entries()
49
+ .filter((e) => e.principal.type === "user")
50
+ .map((e) => (e.principal as { type: "user"; userId: string }).userId);
51
+
52
+ const existingGroupIds = () =>
53
+ entries()
54
+ .filter((e) => e.principal.type === "group")
55
+ .map((e) => (e.principal as { type: "group"; groupId: string }).groupId);
56
+
57
+ const hasAuthenticatedEntry = () => entries().some((entry) => entry.principal.type === "authenticated");
58
+ const hasPublicEntry = () => entries().some((entry) => entry.principal.type === "public");
59
+
60
+ // Grant access mutation
61
+ const grantMut = mutation.create({
62
+ mutation: async (data: { principal: Principal; permission: PermissionLevel }) => {
63
+ return props.grantAccess(props.resourceId, data.principal, data.permission);
64
+ },
65
+ onSuccess: (newEntry) => {
66
+ setEntries([...entries(), newEntry as AccessEntry]);
67
+ setShowAddForm(false);
68
+ },
69
+ onError: (err) => prompts.error(err.message),
70
+ });
71
+
72
+ // Update permission mutation
73
+ const updateMut = mutation.create<{ accessId: string; permission: PermissionLevel }, { accessId: string; permission: PermissionLevel }>({
74
+ mutation: async (data) => {
75
+ await props.updateAccess(props.resourceId, data.accessId, data.permission);
76
+ // Return the data so we can use it in onSuccess
77
+ return data;
78
+ },
79
+ onSuccess: (result) => {
80
+ if (result) {
81
+ setEntries(entries().map((e) => (e.id === result.accessId ? { ...e, permission: result.permission } : e)));
82
+ }
83
+ },
84
+ onError: (err) => prompts.error(err.message),
85
+ });
86
+
87
+ // Revoke access mutation
88
+ const revokeMut = mutation.create<void, string>({
89
+ mutation: async (accessId: string) => {
90
+ await props.revokeAccess(props.resourceId, accessId);
91
+ },
92
+ onError: (err) => prompts.error(err.message),
93
+ });
94
+
95
+ const handleRevoke = async (entry: AccessEntry) => {
96
+ if (entries().length <= 1) {
97
+ prompts.error("Cannot remove the last access entry");
98
+ return;
99
+ }
100
+
101
+ const displayName = getEntryDisplayName(entry);
102
+ const confirmed = await prompts.confirm(`Remove access for ${displayName}?`, { title: "Remove Access", variant: "danger" });
103
+ if (confirmed) {
104
+ revokeMut.mutate(entry.id);
105
+ setEntries(entries().filter((e) => e.id !== entry.id));
106
+ }
107
+ };
108
+
109
+ const handleEntitySelect = (result: EntitySearchResult, permission: PermissionLevel) => {
110
+ const principal: Principal = result.type === "user" ? { type: "user", userId: result.id } : { type: "group", groupId: result.id };
111
+
112
+ grantMut.mutate({ principal, permission });
113
+ };
114
+
115
+ return (
116
+ <div class="flex flex-col gap-3">
117
+ {/* Existing entries */}
118
+ <div class="flex flex-col border-l-2 border-zinc-200 dark:border-zinc-700">
119
+ <For each={entries()}>
120
+ {(entry) => (
121
+ <AccessEntryRow
122
+ entry={entry}
123
+ canEdit={canEdit()}
124
+ canDelete={entries().length > 1}
125
+ onUpdatePermission={(permission) => updateMut.mutate({ accessId: entry.id, permission })}
126
+ onRevoke={() => handleRevoke(entry)}
127
+ updating={updateMut.loading()}
128
+ />
129
+ )}
130
+ </For>
131
+ </div>
132
+
133
+ {/* Add access */}
134
+ <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>
159
+ </Show>
160
+ </div>
161
+ );
162
+ }
163
+
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
+ // =============================================================================
203
+ // Access Entry Row
204
+ // =============================================================================
205
+
206
+ function AccessEntryRow(props: {
207
+ entry: AccessEntry;
208
+ canEdit: boolean;
209
+ canDelete: boolean;
210
+ onUpdatePermission: (permission: PermissionLevel) => void;
211
+ onRevoke: () => void;
212
+ updating: boolean;
213
+ }) {
214
+ const [showPermissionMenu, setShowPermissionMenu] = createSignal(false);
215
+
216
+ 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">
220
+ <i class={`ti ${getPrincipalIcon(props.entry.principal)} text-sm`} />
221
+ </div>
222
+
223
+ {/* Name */}
224
+ <div class="flex-1 min-w-0">
225
+ <span class="text-sm truncate">{getEntryDisplayName(props.entry)}</span>
226
+ <Show when={props.entry.principal.type === "public"}>
227
+ <span class="text-xs text-dimmed ml-1">(Anyone with the link)</span>
228
+ </Show>
229
+ </div>
230
+
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) => (
258
+ <button
259
+ type="button"
260
+ 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,
269
+ }}
270
+ >
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" />
275
+ </Show>
276
+ </button>
277
+ )}
278
+ </For>
279
+ </div>
280
+ </Show>
281
+ </div>
282
+
283
+ {/* Delete button */}
284
+ <Show when={props.canEdit && props.canDelete}>
285
+ <button
286
+ type="button"
287
+ 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"
289
+ >
290
+ <i class="ti ti-x text-sm" />
291
+ </button>
292
+ </Show>
293
+ </div>
294
+ );
295
+ }
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
+ }
@@ -0,0 +1,47 @@
1
+ type ProgressBarProps = {
2
+ value: number;
3
+ size?: "xs" | "sm" | "md";
4
+ tone?: "primary" | "success" | "danger";
5
+ showValue?: boolean;
6
+ class?: string;
7
+ };
8
+
9
+ const clamp = (value: number) => Math.max(0, Math.min(100, Math.round(value)));
10
+
11
+ const heightClass = (size: ProgressBarProps["size"]) => {
12
+ switch (size) {
13
+ case "xs":
14
+ return "h-1.5";
15
+ case "sm":
16
+ return "h-2";
17
+ default:
18
+ return "h-2.5";
19
+ }
20
+ };
21
+
22
+ const toneClass = (tone: ProgressBarProps["tone"]) => {
23
+ switch (tone) {
24
+ case "success":
25
+ return "bg-green-500";
26
+ case "danger":
27
+ return "bg-red-500";
28
+ default:
29
+ return "bg-blue-500";
30
+ }
31
+ };
32
+
33
+ /**
34
+ * Generic percentage progress bar for upload/job-like UI flows.
35
+ */
36
+ export default function ProgressBar(props: ProgressBarProps) {
37
+ const percent = () => clamp(props.value);
38
+
39
+ return (
40
+ <div class={`flex items-center gap-2 ${props.class ?? ""}`}>
41
+ <div class={`flex-1 min-w-0 rounded-full overflow-hidden bg-zinc-200 dark:bg-zinc-700 ${heightClass(props.size)}`}>
42
+ <div class={`h-full transition-all duration-200 ${toneClass(props.tone)}`} style={`width: ${percent()}%`} />
43
+ </div>
44
+ {props.showValue ? <span class="shrink-0 tabular-nums text-[11px] text-dimmed">{percent()}%</span> : null}
45
+ </div>
46
+ );
47
+ }
@@ -0,0 +1,27 @@
1
+ type RemoveBtnProps = {
2
+ ariaLabel: string;
3
+ onClick: () => void;
4
+ loading?: boolean;
5
+ disabled?: boolean;
6
+ };
7
+
8
+ export default function RemoveBtn(props: RemoveBtnProps) {
9
+ return (
10
+ <button
11
+ type="button"
12
+ onClick={props.onClick}
13
+ disabled={props.disabled || props.loading}
14
+ class="p-1 shrink-0 transition-colors disabled:opacity-50 group/rm"
15
+ aria-label={props.ariaLabel}
16
+ >
17
+ {props.loading ? (
18
+ <i class="ti ti-loader-2 animate-spin text-sm text-zinc-400" />
19
+ ) : (
20
+ <>
21
+ <i class="ti ti-x text-sm text-zinc-400 group-hover/rm:hidden" />
22
+ <i class="ti ti-trash text-sm text-red-500 hidden group-hover/rm:inline" />
23
+ </>
24
+ )}
25
+ </button>
26
+ );
27
+ }
@@ -0,0 +1,90 @@
1
+ import type { JSX } from "solid-js";
2
+
3
+ /**
4
+ * Single cell in a stat-card row. See `skills/cloud-app/references/frontend.md`
5
+ * § Stats and the live demos in
6
+ * `packages/ui-lab/src/frontend/UiLabShowcase.island.tsx`.
7
+ *
8
+ * Use inside a parent grid that frames the cells:
9
+ * ```tsx
10
+ * <div class="paper overflow-hidden">
11
+ * <div class="grid grid-cols-3 gap-px p-px bg-zinc-100 dark:bg-zinc-800">
12
+ * <StatCell label="Apps" value={17} sub="9 nav · 12 admin" />
13
+ * <StatCell
14
+ * label="Healthy"
15
+ * value="17/17"
16
+ * sub="all systems"
17
+ * accent={{ tone: "emerald", icon: "ti ti-check" }}
18
+ * />
19
+ * </div>
20
+ * </div>
21
+ * ```
22
+ *
23
+ * Accent rules:
24
+ * - `accent.text` set → renders an icon-and-text pill (`.tag` with bg).
25
+ * - `accent.text` omitted → renders a plain colored icon (no bg). The `.tag`
26
+ * background looks squished around a single icon, so we drop it.
27
+ * - When the accent should also colour the value (warnings, errors), pass
28
+ * `valueClass` like `text-amber-600 dark:text-amber-400`.
29
+ */
30
+ export type StatCellAccent = {
31
+ tone: "emerald" | "amber" | "red" | "blue" | "zinc";
32
+ /** Tabler icon class, e.g. `"ti ti-check"`. */
33
+ icon: string;
34
+ /** Optional pill text. If set → tag with bg. If omitted → plain colored icon. */
35
+ text?: string;
36
+ };
37
+
38
+ export type StatCellProps = {
39
+ label: string;
40
+ value: string | number;
41
+ /** Sub line under the value. Pass `" "` (non-breaking space) to keep cell heights equal when no sub exists. */
42
+ sub?: string;
43
+ /** Override the default `text-primary` value colour for warning / error / success signals. */
44
+ valueClass?: string;
45
+ accent?: StatCellAccent;
46
+ };
47
+
48
+ const ACCENT_PILL_CLASSES: Record<StatCellAccent["tone"], string> = {
49
+ emerald: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
50
+ amber: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
51
+ red: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300",
52
+ blue: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300",
53
+ zinc: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400",
54
+ };
55
+
56
+ const ACCENT_ICON_CLASSES: Record<StatCellAccent["tone"], string> = {
57
+ emerald: "text-emerald-600 dark:text-emerald-400",
58
+ amber: "text-amber-600 dark:text-amber-400",
59
+ red: "text-red-500 dark:text-red-400",
60
+ blue: "text-blue-600 dark:text-blue-400",
61
+ zinc: "text-zinc-500 dark:text-zinc-400",
62
+ };
63
+
64
+ const StatCell = (props: StatCellProps): JSX.Element => {
65
+ const valueClass = props.valueClass ?? "text-primary";
66
+ const sub = props.sub ?? " ";
67
+ return (
68
+ <div class="bg-white dark:bg-zinc-900 px-4 py-4 flex flex-col gap-0.5">
69
+ <span class="text-[10px] uppercase tracking-wider text-dimmed">{props.label}</span>
70
+ <span class={`text-xl font-bold tabular-nums ${valueClass}`}>{props.value}</span>
71
+ {props.accent ? (
72
+ <div class="flex items-center gap-1.5">
73
+ <span class="text-[10px] text-dimmed">{sub}</span>
74
+ {props.accent.text ? (
75
+ <span class={`tag ${ACCENT_PILL_CLASSES[props.accent.tone]}`}>
76
+ <i class={`${props.accent.icon} text-[9px]`} />
77
+ {props.accent.text}
78
+ </span>
79
+ ) : (
80
+ <i class={`${props.accent.icon} ${ACCENT_ICON_CLASSES[props.accent.tone]} text-[11px]`} />
81
+ )}
82
+ </div>
83
+ ) : (
84
+ <span class="text-[10px] text-dimmed">{sub}</span>
85
+ )}
86
+ </div>
87
+ );
88
+ };
89
+
90
+ export default StatCell;
@@ -0,0 +1,18 @@
1
+ export { default as Dropdown } from "./Dropdown";
2
+ export type { DropdownItem } from "./Dropdown";
3
+ export { default as LinkCard } from "./LinkCard";
4
+ export { default as ProgressBar } from "./ProgressBar";
5
+ export { Pagination } from "./Pagination";
6
+ export { default as MarkdownView } from "./MarkdownView";
7
+ export { default as PermissionEditor } from "./PermissionEditor";
8
+ export { default as EntitySearch } from "./EntitySearch";
9
+ export type { EntitySearchResult } from "./EntitySearch";
10
+ export { default as CopyButton } from "./CopyButton";
11
+ export { default as Lightbox } from "./Lightbox";
12
+ export type { LightboxImage } from "./Lightbox";
13
+ export { default as RemoveBtn } from "./RemoveBtn";
14
+ export { default as LogEntriesTable } from "./LogEntriesTable";
15
+ export type { LogTableEntry } from "./LogEntriesTable";
16
+ export { default as ContextMenu } from "./ContextMenu";
17
+ export { default as StatCell } from "./StatCell";
18
+ export type { StatCellAccent, StatCellProps } from "./StatCell";
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Browser-side navigation helpers — shared across every app's islands.
3
+ *
4
+ * Replaces the per-app `lib/navigation.ts` modules that all reimplemented the
5
+ * same handful of `window.location` wrappers. Re-exported from the `cloud/ui`
6
+ * barrel so consumers `import { navigateTo, refreshCurrentPath } from
7
+ * "@valentinkolb/cloud/ui"`.
8
+ */
9
+
10
+ /**
11
+ * Returns the canonical current URL path + query (without hash).
12
+ * Used as a deterministic refresh target after mutations — `location.reload()`
13
+ * preserves hash and forces a network revalidation we don't always want.
14
+ */
15
+ export const currentPathWithQuery = (): string => {
16
+ const url = new URL(window.location.href);
17
+ return `${url.pathname}${url.search}`;
18
+ };
19
+
20
+ /**
21
+ * Navigates to the canonical current URL. Triggers full SSR re-render.
22
+ */
23
+ export const refreshCurrentPath = (): void => {
24
+ window.location.assign(currentPathWithQuery());
25
+ };
26
+
27
+ /**
28
+ * Navigates to a target href via browser navigation (adds history entry).
29
+ */
30
+ export const navigateTo = (href: string): void => {
31
+ window.location.assign(href);
32
+ };