@valentinkolb/cloud 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +18 -6
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +53 -46
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +116 -13
- package/src/api/index.ts +7 -2
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +47 -7
- package/src/services/auth-flows/magic-link.ts +92 -20
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/notifications/index.ts +82 -11
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +79 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +58 -0
- package/src/shared/redirect.ts +56 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
|
@@ -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;
|