@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
@@ -0,0 +1,40 @@
1
+ import { type JSX, Show } from "solid-js";
2
+
3
+ export type PlaceholderAlign = "center" | "left";
4
+ export type PlaceholderSurface = "none" | "paper";
5
+
6
+ export type PlaceholderProps = {
7
+ title?: JSX.Element;
8
+ description?: JSX.Element;
9
+ children?: JSX.Element;
10
+ icon?: string;
11
+ action?: JSX.Element;
12
+ align?: PlaceholderAlign;
13
+ surface?: PlaceholderSurface;
14
+ class?: string;
15
+ };
16
+
17
+ const alignClass = (align: PlaceholderAlign) => (align === "left" ? "items-start text-left" : "items-center text-center");
18
+
19
+ export default function Placeholder(props: PlaceholderProps) {
20
+ const align = () => props.align ?? "center";
21
+ const description = () => props.description ?? props.children;
22
+ const surfaceClass = () => (props.surface === "paper" ? "paper" : "");
23
+
24
+ return (
25
+ <div class={`${surfaceClass()} flex flex-col ${alignClass(align())} gap-1 px-3 py-6 text-xs text-dimmed ${props.class ?? ""}`}>
26
+ <Show when={props.icon}>
27
+ <i class={`${props.icon} text-base text-zinc-400 dark:text-zinc-500`} aria-hidden="true" />
28
+ </Show>
29
+ <Show when={props.title}>
30
+ <p class="text-xs font-medium text-secondary">{props.title}</p>
31
+ </Show>
32
+ <Show when={description()}>
33
+ <p class="max-w-sm text-xs text-dimmed">{description()}</p>
34
+ </Show>
35
+ <Show when={props.action}>
36
+ <div class="mt-2">{props.action}</div>
37
+ </Show>
38
+ </div>
39
+ );
40
+ }
@@ -38,7 +38,7 @@ export default function ProgressBar(props: ProgressBarProps) {
38
38
 
39
39
  return (
40
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)}`}>
41
+ <div class={`flex-1 min-w-0 rounded-full overflow-hidden bg-zinc-200 dark:bg-zinc-700 [box-shadow:var(--theme-recess)] ${heightClass(props.size)}`}>
42
42
  <div class={`h-full transition-all duration-200 ${toneClass(props.tone)}`} style={`width: ${percent()}%`} />
43
43
  </div>
44
44
  {props.showValue ? <span class="shrink-0 tabular-nums text-[11px] text-dimmed">{percent()}%</span> : null}
@@ -0,0 +1,260 @@
1
+ import { dates } from "@valentinkolb/stdlib";
2
+ import { mutation as mutations } from "@valentinkolb/stdlib/solid";
3
+ import { createEffect, createSignal, For, Show } from "solid-js";
4
+ import type { PermissionLevel, ServiceAccountCredential } from "../../contracts/shared";
5
+ import { DateTimePicker } from "../input/DatePicker";
6
+ import SelectInput from "../input/Select";
7
+ import TextInput from "../input/TextInput";
8
+ import { prompts } from "../prompts";
9
+ import CopyButton from "./CopyButton";
10
+ import Placeholder from "./Placeholder";
11
+
12
+ type GrantablePermission = Exclude<PermissionLevel, "none">;
13
+
14
+ export type ResourceApiKey = ServiceAccountCredential & {
15
+ permission: PermissionLevel;
16
+ };
17
+
18
+ export type ResourceApiKeyPermissionOption = {
19
+ value: GrantablePermission;
20
+ label: string;
21
+ description: string;
22
+ icon?: string;
23
+ };
24
+
25
+ type CreateResourceApiKeyInput = {
26
+ name: string;
27
+ expiresAt: string | null;
28
+ permission: GrantablePermission;
29
+ };
30
+
31
+ export type ResourceApiKeysProps = {
32
+ title?: string;
33
+ description?: string;
34
+ initialKeys: ResourceApiKey[];
35
+ permissionOptions?: ResourceApiKeyPermissionOption[];
36
+ createKey: (input: CreateResourceApiKeyInput) => Promise<{ credential: ResourceApiKey; token: string }>;
37
+ revokeKey: (credentialId: string) => Promise<void>;
38
+ };
39
+
40
+ const DEFAULT_PERMISSIONS: ResourceApiKeyPermissionOption[] = [
41
+ { value: "read", label: "Read", description: "Read this resource through the app API.", icon: "ti ti-eye" },
42
+ { value: "write", label: "Write", description: "Read and update this resource through the app API.", icon: "ti ti-pencil" },
43
+ { value: "admin", label: "Admin", description: "Manage this resource through the app API.", icon: "ti ti-shield" },
44
+ ];
45
+
46
+ const presetDate = (days: number) => new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
47
+ const hasInstantOffset = (value: string) => /[T\s].*([zZ]|[+-]\d{2}:?\d{2})$/.test(value);
48
+ const toInstant = (value: string | null): string | null => {
49
+ if (!value) return null;
50
+ if (hasInstantOffset(value)) return value;
51
+ const date = new Date(value);
52
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
53
+ };
54
+
55
+ const permissionLabel = (permission: PermissionLevel, options: ResourceApiKeyPermissionOption[]) =>
56
+ permission === "none" ? "No access" : (options.find((option) => option.value === permission)?.label ?? permission);
57
+
58
+ function TokenDialog(props: { token: string }) {
59
+ return (
60
+ <div class="flex flex-col gap-4">
61
+ <div class="info-block-warning text-xs">Copy this API key now. It is shown once and cannot be recovered later.</div>
62
+ <div class="rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-800 dark:bg-zinc-900">
63
+ <code class="block break-all font-mono text-xs text-primary">{props.token}</code>
64
+ </div>
65
+ <div class="flex justify-end">
66
+ <CopyButton text={props.token} label="Copy key" class="btn-primary btn-sm" />
67
+ </div>
68
+ </div>
69
+ );
70
+ }
71
+
72
+ function CreateResourceApiKeyDialog(props: {
73
+ permissionOptions: ResourceApiKeyPermissionOption[];
74
+ close: (value: CreateResourceApiKeyInput | null) => void;
75
+ }) {
76
+ const [name, setName] = createSignal("");
77
+ const [permission, setPermission] = createSignal<GrantablePermission>(props.permissionOptions[0]?.value ?? "read");
78
+ const [expiresAt, setExpiresAt] = createSignal<string | null>(presetDate(90));
79
+ const [error, setError] = createSignal<string | undefined>();
80
+ const selectOptions = () =>
81
+ props.permissionOptions.map((option) => ({
82
+ id: option.value,
83
+ label: option.label,
84
+ description: option.description,
85
+ icon: option.icon ?? "ti ti-key",
86
+ }));
87
+
88
+ const submit = () => {
89
+ const trimmedName = name().trim();
90
+ if (!trimmedName) {
91
+ setError("Name is required.");
92
+ return;
93
+ }
94
+ props.close({ name: trimmedName, permission: permission(), expiresAt: toInstant(expiresAt()) });
95
+ };
96
+
97
+ return (
98
+ <form
99
+ class="flex flex-col gap-4"
100
+ onSubmit={(event) => {
101
+ event.preventDefault();
102
+ submit();
103
+ }}
104
+ >
105
+ <TextInput
106
+ label="Name"
107
+ description="Shown in this resource so admins can identify where the key is used."
108
+ placeholder="e.g. Website embed"
109
+ icon="ti ti-tag"
110
+ value={name}
111
+ onInput={(value) => {
112
+ setName(value);
113
+ setError(undefined);
114
+ }}
115
+ error={error}
116
+ required
117
+ />
118
+ <SelectInput
119
+ label="Access"
120
+ description="Choose what this API key can do with this resource."
121
+ icon="ti ti-shield-lock"
122
+ value={permission}
123
+ onChange={(value) => setPermission(value as GrantablePermission)}
124
+ options={selectOptions()}
125
+ required
126
+ />
127
+ <DateTimePicker
128
+ label="Expires"
129
+ description="Leave empty only for long-lived integrations you actively maintain."
130
+ value={expiresAt}
131
+ onChange={setExpiresAt}
132
+ clearable
133
+ presets={[
134
+ { label: "30 days", value: presetDate(30) },
135
+ { label: "90 days", value: presetDate(90) },
136
+ { label: "1 year", value: presetDate(365) },
137
+ { label: "Never", value: null },
138
+ ]}
139
+ />
140
+ <div class="flex justify-end gap-2">
141
+ <button type="button" class="btn-secondary btn-sm" onClick={() => props.close(null)}>
142
+ Cancel
143
+ </button>
144
+ <button type="submit" class="btn-primary btn-sm">
145
+ <i class="ti ti-plus" />
146
+ Create key
147
+ </button>
148
+ </div>
149
+ </form>
150
+ );
151
+ }
152
+
153
+ export default function ResourceApiKeys(props: ResourceApiKeysProps) {
154
+ const options = () => (props.permissionOptions && props.permissionOptions.length > 0 ? props.permissionOptions : DEFAULT_PERMISSIONS);
155
+ const [keys, setKeys] = createSignal<ResourceApiKey[]>(props.initialKeys);
156
+
157
+ createEffect(() => {
158
+ setKeys(props.initialKeys);
159
+ });
160
+
161
+ const createMutation = mutations.create<{ credential: ResourceApiKey; token: string }, CreateResourceApiKeyInput>({
162
+ mutation: props.createKey,
163
+ onSuccess: async (data) => {
164
+ setKeys([data.credential, ...keys()]);
165
+ await prompts.dialog<void>(() => <TokenDialog token={data.token} />, {
166
+ title: "API key created",
167
+ icon: "ti ti-key",
168
+ size: "medium",
169
+ });
170
+ },
171
+ onError: (error) => prompts.error(error.message),
172
+ });
173
+
174
+ const revokeMutation = mutations.create<void, { id: string; name: string }, { id: string }>({
175
+ onBefore: (vars) => ({ id: vars.id }),
176
+ mutation: async (vars) => props.revokeKey(vars.id),
177
+ onSuccess: (_, ctx) => {
178
+ if (ctx?.id) setKeys(keys().filter((key) => key.id !== ctx.id));
179
+ },
180
+ onError: (error) => prompts.error(error.message),
181
+ });
182
+
183
+ const openCreate = async () => {
184
+ const result = await prompts.dialog<CreateResourceApiKeyInput | null>(
185
+ (close) => <CreateResourceApiKeyDialog permissionOptions={options()} close={close} />,
186
+ { title: "Create API key", icon: "ti ti-key", size: "medium" },
187
+ );
188
+ if (result) await createMutation.mutate(result);
189
+ };
190
+
191
+ const revoke = async (key: ResourceApiKey) => {
192
+ const confirmed = await prompts.confirm(`Revoke "${key.name}"? Integrations using this key will lose access immediately.`, {
193
+ title: "Revoke API key",
194
+ icon: "ti ti-key-off",
195
+ variant: "danger",
196
+ confirmText: "Revoke",
197
+ });
198
+ if (confirmed) await revokeMutation.mutate({ id: key.id, name: key.name });
199
+ };
200
+
201
+ return (
202
+ <section class="flex flex-col gap-4">
203
+ <div class="flex items-start justify-between gap-3">
204
+ <div>
205
+ <h3 class="flex items-center gap-1.5 text-sm font-semibold text-primary">
206
+ <i class="ti ti-key text-sm" />
207
+ {props.title ?? "API keys"}
208
+ </h3>
209
+ <p class="mt-1 text-xs text-dimmed">{props.description ?? "Resource-bound keys for app integrations."}</p>
210
+ </div>
211
+ <button type="button" class="btn-secondary btn-sm shrink-0" onClick={openCreate} disabled={createMutation.loading()}>
212
+ <i class="ti ti-plus" />
213
+ Add
214
+ </button>
215
+ </div>
216
+
217
+ <Show
218
+ when={keys().length > 0}
219
+ fallback={
220
+ <Placeholder icon="ti ti-key" class="rounded-lg border border-dashed border-zinc-200 dark:border-zinc-800">
221
+ No API keys yet.
222
+ </Placeholder>
223
+ }
224
+ >
225
+ <div class="flex flex-col divide-y divide-zinc-100 overflow-hidden rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
226
+ <For each={keys()}>
227
+ {(key) => (
228
+ <div class="flex items-center gap-3 p-3">
229
+ <div class="min-w-0 flex-1">
230
+ <div class="flex min-w-0 items-center gap-2">
231
+ <span class="truncate text-sm font-medium text-primary">{key.name}</span>
232
+ <span
233
+ class={
234
+ key.permission === "none"
235
+ ? "tag bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-200"
236
+ : "tag bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-200"
237
+ }
238
+ >
239
+ {permissionLabel(key.permission, options())}
240
+ </span>
241
+ <span class="tag bg-zinc-100 text-dimmed dark:bg-zinc-800">{key.tokenPrefix}</span>
242
+ </div>
243
+ <div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-dimmed">
244
+ <span>Created {dates.formatDate(key.createdAt)}</span>
245
+ <span>{key.expiresAt ? `Expires ${dates.formatDate(key.expiresAt)}` : "Never expires"}</span>
246
+ <span>{key.lastUsedAt ? `Used ${dates.formatDateTimeRelative(key.lastUsedAt)}` : "Never used"}</span>
247
+ </div>
248
+ </div>
249
+ <button type="button" class="btn-simple btn-sm shrink-0 text-red-600 dark:text-red-400" onClick={() => revoke(key)}>
250
+ <i class="ti ti-trash" />
251
+ Revoke
252
+ </button>
253
+ </div>
254
+ )}
255
+ </For>
256
+ </div>
257
+ </Show>
258
+ </section>
259
+ );
260
+ }
@@ -0,0 +1,150 @@
1
+ import { children, createMemo, createSignal, For, type JSX, Show } from "solid-js";
2
+
3
+ const SETTINGS_MODAL_TAB = Symbol("SettingsModal.Tab");
4
+
5
+ export type SettingsModalTabTone = "default" | "danger";
6
+
7
+ export type SettingsModalTabProps = {
8
+ id: string;
9
+ title: string;
10
+ description?: string;
11
+ icon?: string;
12
+ tone?: SettingsModalTabTone;
13
+ children: JSX.Element;
14
+ };
15
+
16
+ type SettingsModalTabDefinition = SettingsModalTabProps & {
17
+ readonly kind: typeof SETTINGS_MODAL_TAB;
18
+ };
19
+
20
+ export type SettingsModalProps = {
21
+ title: string;
22
+ subtitle?: string;
23
+ icon?: string;
24
+ defaultTab?: string;
25
+ activeTab?: string;
26
+ onTabChange?: (id: string) => void;
27
+ onClose?: () => void;
28
+ closeLabel?: string;
29
+ class?: string;
30
+ children: JSX.Element;
31
+ };
32
+
33
+ type SettingsModalComponent = ((props: SettingsModalProps) => JSX.Element) & {
34
+ Tab: (props: SettingsModalTabProps) => JSX.Element;
35
+ };
36
+
37
+ const isTabDefinition = (value: unknown): value is SettingsModalTabDefinition =>
38
+ !!value && typeof value === "object" && (value as { kind?: unknown }).kind === SETTINGS_MODAL_TAB;
39
+
40
+ const collectTabs = (value: unknown): SettingsModalTabDefinition[] => {
41
+ if (Array.isArray(value)) return value.flatMap(collectTabs);
42
+ return isTabDefinition(value) ? [value] : [];
43
+ };
44
+
45
+ const tablerIconClass = (icon: string | null | undefined, fallback: string): string => {
46
+ const value = icon?.trim() || fallback;
47
+ return value.startsWith("ti ") ? value : `ti ${value}`;
48
+ };
49
+
50
+ function SettingsModalTab(props: SettingsModalTabProps): JSX.Element {
51
+ return {
52
+ kind: SETTINGS_MODAL_TAB,
53
+ ...props,
54
+ } satisfies SettingsModalTabDefinition as unknown as JSX.Element;
55
+ }
56
+
57
+ const SettingsModal = ((props: SettingsModalProps) => {
58
+ const resolved = children(() => props.children);
59
+ const tabs = createMemo(() => collectTabs(resolved()));
60
+ const firstTabId = () => tabs()[0]?.id ?? "";
61
+ const [localActiveTab, setLocalActiveTab] = createSignal(props.defaultTab ?? firstTabId());
62
+ const activeTabId = () => props.activeTab ?? (localActiveTab() || firstTabId());
63
+ const activeTab = () => tabs().find((tab) => tab.id === activeTabId()) ?? tabs()[0] ?? null;
64
+
65
+ const selectTab = (id: string) => {
66
+ setLocalActiveTab(id);
67
+ props.onTabChange?.(id);
68
+ };
69
+
70
+ return (
71
+ <div class={`flex h-full min-h-0 flex-col gap-2 overflow-hidden ${props.class ?? ""}`}>
72
+ <section class="paper shrink-0 p-4">
73
+ <div class="flex min-h-9 items-center gap-4">
74
+ <span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-blue-500 text-white">
75
+ <i class={`${tablerIconClass(props.icon, "ti-settings")} text-sm`} />
76
+ </span>
77
+ <div class="min-w-0">
78
+ <p class="truncate font-semibold text-primary">{props.title}</p>
79
+ <Show when={props.subtitle}>
80
+ <p class="truncate text-xs text-dimmed">{props.subtitle}</p>
81
+ </Show>
82
+ </div>
83
+ <Show when={props.onClose}>
84
+ <button type="button" onClick={props.onClose} class="icon-btn ml-auto shrink-0" aria-label={props.closeLabel ?? "Close"}>
85
+ <i class="ti ti-x" />
86
+ </button>
87
+ </Show>
88
+ </div>
89
+ </section>
90
+
91
+ <div class="grid min-h-0 flex-1 gap-3 md:grid-cols-[14rem_1fr]">
92
+ <nav class="paper flex gap-1 overflow-x-auto p-2 md:min-h-0 md:flex-col md:overflow-visible" aria-label={`${props.title} sections`}>
93
+ <For each={tabs()}>
94
+ {(tab) => (
95
+ <button
96
+ type="button"
97
+ class={`flex min-w-40 shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors md:w-full md:min-w-0 ${
98
+ activeTabId() === tab.id
99
+ ? "bg-blue-50 text-blue-700 dark:bg-blue-950/30 dark:text-blue-200"
100
+ : tab.tone === "danger"
101
+ ? "text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/30"
102
+ : "text-dimmed hover:bg-zinc-50 hover:text-primary dark:hover:bg-zinc-900"
103
+ }`}
104
+ onClick={() => selectTab(tab.id)}
105
+ >
106
+ <Show when={tab.icon}>
107
+ <i class={`${tab.icon} shrink-0 text-base`} />
108
+ </Show>
109
+ <span class="min-w-0 flex-1 truncate whitespace-nowrap">{tab.title}</span>
110
+ </button>
111
+ )}
112
+ </For>
113
+ </nav>
114
+
115
+ <main class="paper min-h-0 overflow-hidden">
116
+ <Show when={activeTab()}>
117
+ {(tab) => (
118
+ <section
119
+ class={`h-full overflow-y-auto px-5 py-5 ${tab().tone === "danger" ? "rounded-lg ring-1 ring-red-200 dark:ring-red-900/50" : ""}`}
120
+ >
121
+ <div class="mb-4 flex items-start gap-3">
122
+ <span
123
+ class={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${
124
+ tab().tone === "danger"
125
+ ? "bg-red-50 text-red-600 dark:bg-red-950/40 dark:text-red-300"
126
+ : "bg-zinc-100 text-dimmed dark:bg-zinc-900"
127
+ }`}
128
+ >
129
+ <i class={`${tab().icon || "ti ti-settings"} text-sm`} />
130
+ </span>
131
+ <div class="min-w-0">
132
+ <h3 class={`section-label mb-1 ${tab().tone === "danger" ? "text-red-600 dark:text-red-300" : ""}`}>{tab().title}</h3>
133
+ <Show when={tab().description}>
134
+ <p class="text-xs text-dimmed">{tab().description}</p>
135
+ </Show>
136
+ </div>
137
+ </div>
138
+ {tab().children}
139
+ </section>
140
+ )}
141
+ </Show>
142
+ </main>
143
+ </div>
144
+ </div>
145
+ );
146
+ }) as SettingsModalComponent;
147
+
148
+ SettingsModal.Tab = SettingsModalTab;
149
+
150
+ export default SettingsModal;