@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.
Files changed (194) 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 +116 -13
  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 +47 -7
  49. package/src/services/auth-flows/magic-link.ts +92 -20
  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/notifications/index.ts +82 -11
  64. package/src/services/oauth-tokens.ts +104 -0
  65. package/src/services/postgres.ts +21 -6
  66. package/src/services/providers/local/auth.test.ts +22 -0
  67. package/src/services/providers/local/auth.ts +46 -3
  68. package/src/services/secrets.ts +10 -0
  69. package/src/services/service-account-credentials.test.ts +210 -0
  70. package/src/services/service-account-credentials.ts +715 -0
  71. package/src/services/service-accounts.ts +188 -0
  72. package/src/services/session/index.ts +7 -8
  73. package/src/services/settings/app.ts +4 -20
  74. package/src/services/settings/defaults.ts +79 -22
  75. package/src/services/settings/store.ts +47 -0
  76. package/src/services/weather/forecast.ts +40 -7
  77. package/src/services/webauthn.test.ts +36 -0
  78. package/src/services/webauthn.ts +384 -0
  79. package/src/shared/icons.ts +391 -100
  80. package/src/shared/index.ts +7 -0
  81. package/src/shared/markdown/extensions/code.ts +38 -1
  82. package/src/shared/markdown/extensions/images.ts +39 -3
  83. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  84. package/src/shared/markdown/extensions/mark.ts +48 -0
  85. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  86. package/src/shared/markdown/extensions/tables.ts +79 -58
  87. package/src/shared/markdown/formula.test.ts +1089 -0
  88. package/src/shared/markdown/formula.ts +1187 -0
  89. package/src/shared/markdown/index.ts +76 -2
  90. package/src/shared/mock-cover.ts +130 -0
  91. package/src/shared/redirect.test.ts +58 -0
  92. package/src/shared/redirect.ts +56 -0
  93. package/src/shared/theme.test.ts +24 -0
  94. package/src/shared/theme.ts +68 -0
  95. package/src/shared/time.ts +13 -0
  96. package/src/ssr/AdminLayout.tsx +7 -3
  97. package/src/ssr/AdminSidebar.tsx +115 -49
  98. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  99. package/src/ssr/Footer.island.tsx +3 -8
  100. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  101. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  102. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  103. package/src/ssr/Layout.tsx +74 -66
  104. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  105. package/src/ssr/LayoutHelp.tsx +266 -0
  106. package/src/ssr/NavMenu.island.tsx +0 -39
  107. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  108. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  109. package/src/ssr/islands/index.ts +13 -0
  110. package/src/styles/base-popover.css +5 -2
  111. package/src/styles/effects.css +87 -6
  112. package/src/styles/global.css +146 -9
  113. package/src/styles/input.css +3 -1
  114. package/src/styles/utilities-buttons.css +133 -27
  115. package/src/styles/utilities-code-display.css +67 -0
  116. package/src/styles/utilities-completion.css +223 -0
  117. package/src/styles/utilities-detail.css +73 -0
  118. package/src/styles/utilities-feedback.css +16 -15
  119. package/src/styles/utilities-layout.css +42 -2
  120. package/src/styles/utilities-markdown-editor.css +472 -0
  121. package/src/styles/utilities-navigation.css +63 -8
  122. package/src/styles/utilities-script.css +84 -0
  123. package/src/styles/utilities-table-tile.css +229 -0
  124. package/src/types/ambient.d.ts +9 -0
  125. package/src/ui/completion/behaviors.test.ts +95 -0
  126. package/src/ui/completion/behaviors.ts +205 -0
  127. package/src/ui/completion/engine.ts +368 -0
  128. package/src/ui/completion/index.ts +40 -0
  129. package/src/ui/completion/overlay.ts +92 -0
  130. package/src/ui/dialog-core.ts +173 -45
  131. package/src/ui/filter/FilterChip.tsx +42 -40
  132. package/src/ui/index.ts +11 -12
  133. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  134. package/src/ui/input/CheckboxCard.tsx +91 -0
  135. package/src/ui/input/Combobox.tsx +375 -0
  136. package/src/ui/input/DatePicker.tsx +846 -0
  137. package/src/ui/input/DateTimeInput.tsx +29 -4
  138. package/src/ui/input/FileDropzone.tsx +116 -0
  139. package/src/ui/input/IconInput.tsx +116 -0
  140. package/src/ui/input/ImageInput.tsx +19 -2
  141. package/src/ui/input/MultiSelectInput.tsx +448 -0
  142. package/src/ui/input/NumberInput.tsx +417 -61
  143. package/src/ui/input/SegmentedControl.tsx +2 -2
  144. package/src/ui/input/Select.tsx +172 -10
  145. package/src/ui/input/Slider.tsx +3 -4
  146. package/src/ui/input/Switch.tsx +3 -2
  147. package/src/ui/input/TemplateEditor.tsx +212 -0
  148. package/src/ui/input/TextInput.tsx +144 -13
  149. package/src/ui/input/index.ts +53 -8
  150. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  151. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  152. package/src/ui/input/markdown/actions.ts +233 -0
  153. package/src/ui/input/markdown/active-formats.ts +94 -0
  154. package/src/ui/input/markdown/behaviors.ts +193 -0
  155. package/src/ui/input/markdown/code-zone.ts +23 -0
  156. package/src/ui/input/markdown/highlight.ts +316 -0
  157. package/src/ui/layout.ts +22 -0
  158. package/src/ui/misc/AppOverview.tsx +105 -0
  159. package/src/ui/misc/AppWorkspace.tsx +607 -0
  160. package/src/ui/misc/Calendar.tsx +1291 -0
  161. package/src/ui/misc/Chart.tsx +162 -0
  162. package/src/ui/misc/CodeDisplay.tsx +54 -0
  163. package/src/ui/misc/ContextMenu.tsx +2 -2
  164. package/src/ui/misc/DataTable.tsx +269 -0
  165. package/src/ui/misc/DockWorkspace.tsx +425 -0
  166. package/src/ui/misc/Docs.tsx +153 -0
  167. package/src/ui/misc/Dropdown.tsx +2 -2
  168. package/src/ui/misc/EntitySearch.tsx +260 -129
  169. package/src/ui/misc/LinkCard.tsx +14 -2
  170. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  171. package/src/ui/misc/Pagination.tsx +31 -12
  172. package/src/ui/misc/PanelDialog.tsx +109 -0
  173. package/src/ui/misc/Panes.tsx +873 -0
  174. package/src/ui/misc/PermissionEditor.tsx +358 -262
  175. package/src/ui/misc/Placeholder.tsx +40 -0
  176. package/src/ui/misc/ProgressBar.tsx +1 -1
  177. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  178. package/src/ui/misc/SettingsModal.tsx +150 -0
  179. package/src/ui/misc/StatCell.tsx +182 -40
  180. package/src/ui/misc/StatGrid.tsx +149 -0
  181. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  182. package/src/ui/misc/code-highlight.ts +213 -0
  183. package/src/ui/misc/index.ts +93 -12
  184. package/src/ui/prompts.tsx +362 -312
  185. package/src/ui/toast.ts +384 -0
  186. package/src/ui/widgets/Widget.tsx +12 -4
  187. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  188. package/src/ui/ipa/GroupView.tsx +0 -36
  189. package/src/ui/ipa/LoginBtn.tsx +0 -16
  190. package/src/ui/ipa/UserView.tsx +0 -58
  191. package/src/ui/ipa/index.ts +0 -4
  192. package/src/ui/navigation.ts +0 -32
  193. package/src/ui/sidebar.tsx +0 -468
  194. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -1,95 +1,184 @@
1
- import { createSignal, For, Show } from "solid-js";
2
1
  import { timed } from "@valentinkolb/stdlib/solid";
2
+ import { createSignal, For, Show } from "solid-js";
3
3
  import TextInput from "../input/TextInput";
4
4
 
5
- type UserResult = {
5
+ /**
6
+ * Discriminated principal returned to `onSelect`. Field names match the
7
+ * platform `Principal` contract (`userId`/`groupId`) so callers can pass
8
+ * `onSelect={grant}` directly via structural typing — the extra display
9
+ * fields (uid, displayName, etc.) are silently ignored by `Principal`-
10
+ * typed callbacks.
11
+ */
12
+ export type EntitySearchPrincipal =
13
+ | {
14
+ type: "user";
15
+ userId: string;
16
+ uid: string;
17
+ displayName: string;
18
+ mail: string | null;
19
+ provider: "ipa" | "local";
20
+ }
21
+ | {
22
+ type: "group";
23
+ groupId: string;
24
+ provider: "ipa" | "local";
25
+ name: string;
26
+ description: string | null;
27
+ }
28
+ | {
29
+ type: "service_account";
30
+ serviceAccountId: string;
31
+ name: string;
32
+ kind: "user_delegated" | "resource_bound";
33
+ appId: string | null;
34
+ resourceType: string | null;
35
+ resourceId: string | null;
36
+ }
37
+ | { type: "authenticated" }
38
+ | { type: "public" };
39
+
40
+ type EntitySearchProps = {
41
+ // ── Include flags (one per principal type, all default false) ────────
42
+ /** Surface real user accounts in the result list. */
43
+ includeUsers?: boolean;
44
+ /** Surface real groups in the result list. */
45
+ includeGroups?: boolean;
46
+ /** Surface service accounts in the result list. Off by default. */
47
+ includeServiceAccounts?: boolean;
48
+ /** Inject a synthetic "All authenticated users" row at the top. */
49
+ includeAuthenticated?: boolean;
50
+ /** Inject a synthetic "Public" row at the top. */
51
+ includePublic?: boolean;
52
+
53
+ // ── Exclude filters (apply only when the related kind is included) ──
54
+ excludeUserIds?: string[];
55
+ excludeGroupIds?: string[];
56
+ excludeServiceAccountIds?: string[];
57
+
58
+ /** Provider filter — applies uniformly to BOTH users and groups.
59
+ * Whitelist semantics: `["local"]` shows only local accounts,
60
+ * `["ipa"]` only IPA. Empty / both / undefined → no filter.
61
+ * Backend accepts a single provider; this client only sends the
62
+ * filter when the array has exactly one entry, since both-allowed
63
+ * is identical to no-filter. */
64
+ providers?: ("ipa" | "local")[];
65
+
66
+ /** Restrict users to those who are members of at least one of these
67
+ * groups. User-side qualifier — only meaningful when
68
+ * `includeUsers` is true. */
69
+ onlyMembersOf?: string[];
70
+
71
+ // ── Output ──────────────────────────────────────────────────────────
72
+ onSelect: (principal: EntitySearchPrincipal) => void;
73
+
74
+ // ── Cosmetics ───────────────────────────────────────────────────────
75
+ placeholder?: string;
76
+ /** Override the result-list height. Default `h-48`. */
77
+ resultsHeightClass?: string;
78
+ /** Disables every "+" button in the result list while a parent-side
79
+ * mutation is in flight. */
80
+ disabled?: boolean;
81
+ };
82
+
83
+ // Backend `/api/accounts/entities` shape (subset we consume here).
84
+ type ApiUser = {
6
85
  id: string;
7
86
  uid: string;
8
87
  displayName: string;
9
88
  mail: string | null;
89
+ provider: "ipa" | "local";
10
90
  };
11
-
12
- type GroupResult = {
91
+ type ApiGroup = {
13
92
  id: string;
14
93
  provider: "ipa" | "local";
15
94
  name: string;
16
95
  description: string | null;
17
96
  };
18
-
19
- export type EntitySearchResult =
20
- | { type: "user"; id: string; displayName: string; mail: string | null }
21
- | { type: "group"; id: string; provider: "ipa" | "local"; name: string; description: string | null };
22
-
23
- type EntitySearchProps = {
24
- apiBaseUrl?: string;
25
- groupProvider?: "ipa" | "local";
26
- searchUsers?: boolean;
27
- searchGroups?: boolean;
28
- excludeUserIds?: string[];
29
- excludeGroupIds?: string[];
30
- onSelect: (result: EntitySearchResult) => void;
31
- placeholder?: string;
32
- adding?: boolean;
33
- userMemberOfGroupIds?: string[];
34
- resultsHeightClass?: string;
97
+ type ApiServiceAccount = {
98
+ id: string;
99
+ name: string;
100
+ kind: "user_delegated" | "resource_bound";
101
+ appId: string | null;
102
+ resourceType: string | null;
103
+ resourceId: string | null;
35
104
  };
36
105
 
37
106
  const EntitySearch = (props: EntitySearchProps) => {
38
107
  const [search, setSearch] = createSignal("");
39
- const [users, setUsers] = createSignal<UserResult[]>([]);
40
- const [groups, setGroups] = createSignal<GroupResult[]>([]);
108
+ const [users, setUsers] = createSignal<ApiUser[]>([]);
109
+ const [groups, setGroups] = createSignal<ApiGroup[]>([]);
110
+ const [serviceAccounts, setServiceAccounts] = createSignal<ApiServiceAccount[]>([]);
41
111
  const [loading, setLoading] = createSignal(false);
42
- const [addingId, setAddingId] = createSignal<string | null>(null);
112
+
113
+ // Defensive dev-warning: at least one principal kind must be enabled,
114
+ // otherwise the component is decorative-only and the caller probably
115
+ // forgot a flag.
116
+ if (!props.includeUsers && !props.includeGroups && !props.includeServiceAccounts && !props.includeAuthenticated && !props.includePublic) {
117
+ if (typeof console !== "undefined") {
118
+ console.warn(
119
+ "[EntitySearch] No `includeUsers / includeGroups / includeServiceAccounts / includeAuthenticated / includePublic` flag is set — the search will never produce a result.",
120
+ );
121
+ }
122
+ }
43
123
 
44
124
  const doSearch = async (q: string) => {
45
125
  if (q.length < 2) {
46
126
  setUsers([]);
47
127
  setGroups([]);
128
+ setServiceAccounts([]);
129
+ return;
130
+ }
131
+
132
+ const kinds = [
133
+ ...(props.includeUsers ? ["user"] : []),
134
+ ...(props.includeGroups ? ["group"] : []),
135
+ ...(props.includeServiceAccounts ? ["service_account"] : []),
136
+ ];
137
+ if (kinds.length === 0) {
138
+ // Special-principals-only mode (e.g. `includeAuthenticated`).
139
+ // Nothing to fetch from the backend; the synthetic rows render
140
+ // unconditionally.
141
+ setUsers([]);
142
+ setGroups([]);
143
+ setServiceAccounts([]);
48
144
  return;
49
145
  }
50
146
 
51
147
  setLoading(true);
52
148
  try {
53
- const url = new URL(`${props.apiBaseUrl ?? "/api/accounts"}/entities`, window.location.origin);
54
- const kinds = [
55
- ...(props.searchUsers !== false ? ["user"] : []),
56
- ...(props.searchGroups ? ["group"] : []),
57
- ];
58
- if (kinds.length === 0) {
59
- setUsers([]);
60
- setGroups([]);
61
- return;
62
- }
63
-
149
+ const url = new URL("/api/accounts/entities", window.location.origin);
64
150
  url.searchParams.set("search", q);
65
151
  url.searchParams.set("kinds", kinds.join(","));
66
152
  url.searchParams.set("per_page", "10");
67
153
 
68
- if (props.excludeUserIds && props.excludeUserIds.length > 0) {
154
+ if (props.excludeUserIds?.length) {
69
155
  url.searchParams.set("exclude_user_ids", props.excludeUserIds.join(","));
70
156
  }
71
-
72
- if (props.excludeGroupIds && props.excludeGroupIds.length > 0) {
157
+ if (props.excludeGroupIds?.length) {
73
158
  url.searchParams.set("exclude_group_ids", props.excludeGroupIds.join(","));
74
159
  }
75
-
76
- if (props.userMemberOfGroupIds && props.userMemberOfGroupIds.length > 0) {
77
- url.searchParams.set("user_member_of_group_ids", props.userMemberOfGroupIds.join(","));
160
+ if (props.excludeServiceAccountIds?.length) {
161
+ url.searchParams.set("exclude_service_account_ids", props.excludeServiceAccountIds.join(","));
78
162
  }
79
-
80
- if (props.groupProvider) {
81
- url.searchParams.set("provider", props.groupProvider);
163
+ if (props.onlyMembersOf?.length) {
164
+ url.searchParams.set("user_member_of_group_ids", props.onlyMembersOf.join(","));
165
+ }
166
+ // Provider whitelist — only meaningful when restricting to a single
167
+ // provider (both-allowed = no filter).
168
+ if (props.providers?.length === 1) {
169
+ url.searchParams.set("provider", props.providers[0]!);
82
170
  }
83
171
 
84
- const res = await fetch(url.toString(), {
85
- credentials: "same-origin",
86
- });
87
-
172
+ const res = await fetch(url.toString(), { credentials: "same-origin" });
88
173
  if (res.ok) {
89
174
  const data = await res.json();
90
- const items = data.items ?? [];
91
- setUsers(items.filter((item: { kind: string }) => item.kind === "user").map((item: { user: UserResult }) => item.user));
92
- setGroups(items.filter((item: { kind: string }) => item.kind === "group").map((item: { group: GroupResult }) => item.group));
175
+ const items: { kind: "user" | "group" | "service_account"; user?: ApiUser; group?: ApiGroup; serviceAccount?: ApiServiceAccount }[] =
176
+ data.items ?? [];
177
+ setUsers(items.filter((item) => item.kind === "user" && item.user).map((item) => item.user!));
178
+ setGroups(items.filter((item) => item.kind === "group" && item.group).map((item) => item.group!));
179
+ setServiceAccounts(
180
+ items.filter((item) => item.kind === "service_account" && item.serviceAccount).map((item) => item.serviceAccount!),
181
+ );
93
182
  }
94
183
  } finally {
95
184
  setLoading(false);
@@ -103,14 +192,15 @@ const EntitySearch = (props: EntitySearchProps) => {
103
192
  debouncedSearch(value);
104
193
  };
105
194
 
106
- const handleSelect = (result: EntitySearchResult) => {
107
- setAddingId(result.id);
108
- props.onSelect(result);
109
- setAddingId(null);
110
- };
111
-
112
195
  const resultsHeightClass = () => props.resultsHeightClass ?? "h-48";
113
196
 
197
+ // Synthetic principals show whenever the flag is on — they're a
198
+ // standing offer, not gated on the search query. They render at the
199
+ // top of the list above real entities.
200
+ const showSynthetic = () => props.includeAuthenticated || props.includePublic;
201
+ const hasRealResults = () => users().length > 0 || groups().length > 0 || serviceAccounts().length > 0;
202
+ const hasAnyResults = () => showSynthetic() || hasRealResults();
203
+
114
204
  return (
115
205
  <div class="flex flex-col gap-3">
116
206
  <TextInput icon="ti ti-search" placeholder={props.placeholder ?? "Search..."} value={() => search()} onInput={handleInput} />
@@ -122,92 +212,133 @@ const EntitySearch = (props: EntitySearchProps) => {
122
212
  </div>
123
213
  </Show>
124
214
 
125
- <Show when={!loading() && search().length >= 2 && users().length === 0 && groups().length === 0}>
126
- <p class="flex items-center justify-center gap-1.5 py-8 text-xs text-dimmed">
127
- <i class="ti ti-search-off text-sm" />
128
- No results found
129
- </p>
130
- </Show>
131
-
132
- <Show when={!loading() && search().length < 2}>
133
- <p class="flex items-center justify-center gap-1.5 py-8 text-xs text-dimmed">
134
- <i class="ti ti-search text-sm" />
135
- Type at least 2 characters
136
- </p>
137
- </Show>
138
-
139
- <Show when={!loading() && (users().length > 0 || groups().length > 0)}>
215
+ <Show when={!loading() && hasAnyResults()}>
140
216
  <div class="flex flex-col gap-1">
217
+ {/* Synthetic principals — always available when their flag
218
+ is on, irrespective of the search query. */}
219
+ <Show when={props.includeAuthenticated}>
220
+ <ResultRow
221
+ icon="ti-lock-open-2"
222
+ title="All users (incl. guests)"
223
+ subtitle="Anyone signed in to the cloud"
224
+ disabled={props.disabled}
225
+ onSelect={() => props.onSelect({ type: "authenticated" })}
226
+ />
227
+ </Show>
228
+ <Show when={props.includePublic}>
229
+ <ResultRow
230
+ icon="ti-world"
231
+ title="Public"
232
+ subtitle="Anyone with the link, even unauthenticated"
233
+ disabled={props.disabled}
234
+ onSelect={() => props.onSelect({ type: "public" })}
235
+ />
236
+ </Show>
237
+
238
+ {/* Real entities — only after a search query. */}
141
239
  <For each={users()}>
142
240
  {(user) => (
143
- <div class="flex items-center gap-3 rounded p-2 hover:bg-zinc-100 dark:hover:bg-zinc-800">
144
- <div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-xs text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
145
- <i class="ti ti-user text-sm" />
146
- </div>
147
- <div class="min-w-0 flex-1">
148
- <div class="truncate text-sm font-medium">{user.displayName}</div>
149
- <div class="truncate text-xs text-dimmed">
150
- {user.uid}
151
- {user.mail && ` · ${user.mail}`}
152
- </div>
153
- </div>
154
- <button
155
- type="button"
156
- onClick={() =>
157
- handleSelect({
158
- type: "user",
159
- id: user.id,
160
- displayName: user.displayName,
161
- mail: user.mail,
162
- })
163
- }
164
- disabled={addingId() !== null || props.adding}
165
- class="rounded p-2 text-emerald-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 disabled:opacity-50 dark:hover:bg-emerald-900/20"
166
- aria-label={`Add ${user.displayName}`}
167
- >
168
- <i class={addingId() === user.id ? "ti ti-loader-2 animate-spin" : "ti ti-plus"} />
169
- </button>
170
- </div>
241
+ <ResultRow
242
+ icon="ti-user"
243
+ title={user.displayName}
244
+ subtitle={user.mail ? `${user.uid} · ${user.mail}` : user.uid}
245
+ disabled={props.disabled}
246
+ onSelect={() =>
247
+ props.onSelect({
248
+ type: "user",
249
+ userId: user.id,
250
+ uid: user.uid,
251
+ displayName: user.displayName,
252
+ mail: user.mail,
253
+ provider: user.provider,
254
+ })
255
+ }
256
+ />
171
257
  )}
172
258
  </For>
173
-
174
259
  <For each={groups()}>
175
260
  {(group) => (
176
- <div class="flex items-center gap-3 rounded p-2 hover:bg-zinc-100 dark:hover:bg-zinc-800">
177
- <div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-xs text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
178
- <i class="ti ti-users-group text-sm" />
179
- </div>
180
- <div class="min-w-0 flex-1">
181
- <div class="truncate text-sm font-medium">{group.name}</div>
182
- <Show when={group.description}>
183
- <div class="truncate text-xs text-dimmed">{group.description}</div>
184
- </Show>
185
- </div>
186
- <button
187
- type="button"
188
- onClick={() =>
189
- handleSelect({
190
- type: "group",
191
- id: group.id,
192
- provider: group.provider,
193
- name: group.name,
194
- description: group.description,
195
- })
196
- }
197
- disabled={addingId() !== null || props.adding}
198
- class="rounded p-2 text-emerald-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 disabled:opacity-50 dark:hover:bg-emerald-900/20"
199
- aria-label={`Add ${group.name}`}
200
- >
201
- <i class={addingId() === group.id ? "ti ti-loader-2 animate-spin" : "ti ti-plus"} />
202
- </button>
203
- </div>
261
+ <ResultRow
262
+ icon="ti-users-group"
263
+ title={group.name}
264
+ subtitle={group.description ?? undefined}
265
+ disabled={props.disabled}
266
+ onSelect={() =>
267
+ props.onSelect({
268
+ type: "group",
269
+ groupId: group.id,
270
+ provider: group.provider,
271
+ name: group.name,
272
+ description: group.description,
273
+ })
274
+ }
275
+ />
276
+ )}
277
+ </For>
278
+ <For each={serviceAccounts()}>
279
+ {(serviceAccount) => (
280
+ <ResultRow
281
+ icon="ti-key"
282
+ title={serviceAccount.name}
283
+ subtitle={
284
+ serviceAccount.kind === "user_delegated"
285
+ ? "User-bound service account"
286
+ : [serviceAccount.appId, serviceAccount.resourceType, serviceAccount.resourceId].filter(Boolean).join(" · ")
287
+ }
288
+ disabled={props.disabled}
289
+ onSelect={() =>
290
+ props.onSelect({
291
+ type: "service_account",
292
+ serviceAccountId: serviceAccount.id,
293
+ name: serviceAccount.name,
294
+ kind: serviceAccount.kind,
295
+ appId: serviceAccount.appId,
296
+ resourceType: serviceAccount.resourceType,
297
+ resourceId: serviceAccount.resourceId,
298
+ })
299
+ }
300
+ />
204
301
  )}
205
302
  </For>
206
303
  </div>
207
304
  </Show>
305
+
306
+ <Show when={!loading() && !hasAnyResults() && search().length >= 2}>
307
+ <p class="flex items-center justify-center gap-1.5 py-8 text-xs text-dimmed">
308
+ <i class="ti ti-search-off text-sm" />
309
+ No results found
310
+ </p>
311
+ </Show>
312
+
313
+ <Show when={!loading() && !hasAnyResults() && search().length < 2}>
314
+ <p class="flex items-center justify-center gap-1.5 py-8 text-xs text-dimmed">
315
+ <i class="ti ti-search text-sm" />
316
+ Type at least 2 characters
317
+ </p>
318
+ </Show>
208
319
  </div>
209
320
  </div>
210
321
  );
211
322
  };
212
323
 
324
+ const ResultRow = (props: { icon: string; title: string; subtitle?: string; disabled?: boolean; onSelect: () => void }) => (
325
+ <button
326
+ type="button"
327
+ onClick={props.onSelect}
328
+ disabled={props.disabled}
329
+ class="flex items-center gap-3 rounded p-2 text-left hover:bg-zinc-100 disabled:opacity-50 dark:hover:bg-zinc-800"
330
+ >
331
+ <div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-xs text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
332
+ <i class={`ti ${props.icon} text-sm`} />
333
+ </div>
334
+ <div class="min-w-0 flex-1">
335
+ <div class="truncate text-sm font-medium">{props.title}</div>
336
+ <Show when={props.subtitle}>
337
+ <div class="truncate text-xs text-dimmed">{props.subtitle}</div>
338
+ </Show>
339
+ </div>
340
+ <i class="ti ti-plus text-emerald-500" />
341
+ </button>
342
+ );
343
+
213
344
  export default EntitySearch;
@@ -18,9 +18,21 @@ const colorClasses = {
18
18
  rose: "bg-rose-100 dark:bg-rose-900/50 text-rose-600 dark:text-rose-400",
19
19
  };
20
20
 
21
+ const hoverClasses = {
22
+ blue: "dark:hover:bg-blue-950/25",
23
+ emerald: "dark:hover:bg-emerald-950/25",
24
+ violet: "dark:hover:bg-violet-950/25",
25
+ orange: "dark:hover:bg-orange-950/25",
26
+ red: "dark:hover:bg-red-950/25",
27
+ amber: "dark:hover:bg-amber-950/25",
28
+ zinc: "dark:hover:bg-zinc-800/40",
29
+ cyan: "dark:hover:bg-cyan-950/25",
30
+ rose: "dark:hover:bg-rose-950/25",
31
+ };
32
+
21
33
  export default function LinkCard(props: LinkCardProps) {
22
34
  return (
23
- <a href={props.href} class="paper p-4 flex items-center gap-4 hover:paper-highlighted transition-all">
35
+ <a href={props.href} class={`paper group p-4 flex items-center gap-4 transition-all hover:paper-highlighted ${hoverClasses[props.color]}`}>
24
36
  <div class={`flex items-center justify-center h-10 w-10 shrink-0 rounded ${colorClasses[props.color]}`}>
25
37
  <i class={`${props.icon} text-xl`} />
26
38
  </div>
@@ -28,7 +40,7 @@ export default function LinkCard(props: LinkCardProps) {
28
40
  <span class="text-sm font-semibold text-primary block">{props.title}</span>
29
41
  <p class="text-xs text-dimmed truncate">{props.description}</p>
30
42
  </div>
31
- <i class="ti ti-chevron-right text-dimmed" />
43
+ <i class="ti ti-chevron-right text-dimmed transition-transform group-hover:translate-x-0.5 group-hover:text-blue-600 dark:group-hover:text-blue-400" />
32
44
  </a>
33
45
  );
34
46
  }
@@ -1,4 +1,7 @@
1
1
  import { dates } from "@valentinkolb/stdlib";
2
+ import { Show } from "solid-js";
3
+ import DataTable, { type DataTableColumn } from "./DataTable";
4
+ import Placeholder from "./Placeholder";
2
5
 
3
6
  export type LogTableEntry = {
4
7
  id: number | string;
@@ -22,40 +25,40 @@ const levelIcon: Record<string, { icon: string; color: string; label: string }>
22
25
  };
23
26
 
24
27
  export default function LogEntriesTable(props: Props) {
25
- if (props.entries.length === 0) {
26
- return <div class="py-8 text-center text-xs text-dimmed">{props.emptyMessage ?? "No log entries found."}</div>;
27
- }
28
+ const columns = (): DataTableColumn<LogTableEntry>[] => [
29
+ { id: "level", header: "Level", value: (entry) => entry.level },
30
+ { id: "source", header: `Source (${props.entries.length})`, value: (entry) => entry.source },
31
+ { id: "message", header: "Message", value: (entry) => entry.message },
32
+ { id: "time", header: "Time", value: (entry) => entry.createdAt, cellClass: "whitespace-nowrap" },
33
+ ];
28
34
 
29
35
  return (
30
- <div class="overflow-x-auto">
31
- <table class="w-full text-xs">
32
- <thead>
33
- <tr class="border-b border-zinc-100 dark:border-zinc-800">
34
- <th class="px-3 py-2 text-left font-medium text-dimmed">Level</th>
35
- <th class="px-3 py-2 text-left font-medium text-dimmed">Source ({props.entries.length})</th>
36
- <th class="px-3 py-2 text-left font-medium text-dimmed">Message</th>
37
- <th class="px-3 py-2 text-left font-medium text-dimmed">Time</th>
38
- </tr>
39
- </thead>
40
- <tbody>
41
- {props.entries.map((entry) => {
42
- const level = levelIcon[entry.level] ?? levelIcon.debug!;
36
+ <Show
37
+ when={props.entries.length > 0}
38
+ fallback={<Placeholder surface="paper">{props.emptyMessage ?? "No log entries found."}</Placeholder>}
39
+ >
40
+ <DataTable
41
+ rows={props.entries}
42
+ columns={columns()}
43
+ getRowId={(entry) => String(entry.id)}
44
+ hoverRows
45
+ class="paper overflow-x-auto"
46
+ renderCell={({ row, col }) => {
47
+ if (col.id === "level") {
48
+ const level = levelIcon[row.level] ?? levelIcon.debug!;
43
49
  return (
44
- <tr class="border-b border-zinc-50 last:border-0 hover:bg-zinc-50 dark:border-zinc-800/50 dark:hover:bg-zinc-800/30">
45
- <td class="px-3 py-1.5 whitespace-nowrap">
46
- <span class={`inline-flex items-center gap-1.5 ${level.color}`}>
47
- <i class={`${level.icon} text-sm`} />
48
- <span>{level.label}</span>
49
- </span>
50
- </td>
51
- <td class="px-3 py-1.5 whitespace-nowrap text-secondary">{entry.source}</td>
52
- <td class="px-3 py-1.5 text-primary truncate max-w-[30rem]" title={entry.message}>{entry.message}</td>
53
- <td class="whitespace-nowrap px-3 py-1.5 text-dimmed">{dates.formatDateTime(entry.createdAt)}</td>
54
- </tr>
50
+ <span class={`inline-flex items-center gap-1.5 whitespace-nowrap ${level.color}`}>
51
+ <i class={`${level.icon} text-sm`} />
52
+ <span>{level.label}</span>
53
+ </span>
55
54
  );
56
- })}
57
- </tbody>
58
- </table>
59
- </div>
55
+ }
56
+ if (col.id === "source") return <span class="whitespace-nowrap text-secondary">{row.source}</span>;
57
+ if (col.id === "message") return <span title={row.message}>{row.message}</span>;
58
+ if (col.id === "time") return <span class="text-dimmed">{dates.formatDateTime(row.createdAt)}</span>;
59
+ return "";
60
+ }}
61
+ />
62
+ </Show>
60
63
  );
61
64
  }
@@ -1,9 +1,11 @@
1
1
  import type { JSX } from "solid-js";
2
+ import { Link, type LinkNavigateEvent } from "@valentinkolb/ssr/nav";
2
3
 
3
4
  type PaginationProps = {
4
5
  currentPage: number;
5
6
  totalPages: number;
6
7
  baseUrl: string;
8
+ onNavigate?: (event: LinkNavigateEvent) => void | Promise<void>;
7
9
  };
8
10
 
9
11
  /**
@@ -31,18 +33,35 @@ export const Pagination = (props: PaginationProps): null | JSX.Element => {
31
33
  ...
32
34
  </span>
33
35
  )}
34
- <a
35
- href={`${props.baseUrl}${page}`}
36
- class={`flex h-7 w-7 items-center justify-center rounded-lg text-xs tabular-nums transition-colors ${
37
- isActive
38
- ? "border-blue-500/35 bg-blue-50 text-blue-700 font-medium dark:border-blue-400/40 dark:bg-blue-950/40 dark:text-blue-200"
39
- : "text-dimmed hover:text-primary hover:bg-zinc-100 dark:hover:bg-zinc-800"
40
- }`}
41
- aria-current={isActive ? "page" : undefined}
42
- aria-label={`Page ${page}`}
43
- >
44
- {page}
45
- </a>
36
+ {props.onNavigate ? (
37
+ <Link
38
+ href={`${props.baseUrl}${page}`}
39
+ scroll="top"
40
+ onNavigate={props.onNavigate}
41
+ class={`flex h-7 w-7 items-center justify-center rounded-lg text-xs tabular-nums transition-colors ${
42
+ isActive
43
+ ? "border-blue-500/35 bg-blue-50 text-blue-700 font-medium dark:border-blue-400/40 dark:bg-blue-950/40 dark:text-blue-200"
44
+ : "text-dimmed hover:text-primary hover:bg-zinc-100 dark:hover:bg-zinc-800"
45
+ }`}
46
+ aria-current={isActive ? "page" : undefined}
47
+ aria-label={`Page ${page}`}
48
+ >
49
+ {page}
50
+ </Link>
51
+ ) : (
52
+ <a
53
+ href={`${props.baseUrl}${page}`}
54
+ class={`flex h-7 w-7 items-center justify-center rounded-lg text-xs tabular-nums transition-colors ${
55
+ isActive
56
+ ? "border-blue-500/35 bg-blue-50 text-blue-700 font-medium dark:border-blue-400/40 dark:bg-blue-950/40 dark:text-blue-200"
57
+ : "text-dimmed hover:text-primary hover:bg-zinc-100 dark:hover:bg-zinc-800"
58
+ }`}
59
+ aria-current={isActive ? "page" : undefined}
60
+ aria-label={`Page ${page}`}
61
+ >
62
+ {page}
63
+ </a>
64
+ )}
46
65
  </>
47
66
  );
48
67
  })}