@valentinkolb/cloud 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +69 -0
- package/public/logo.svg +1 -0
- package/scripts/build.ts +113 -0
- package/scripts/preload.ts +73 -0
- package/src/_internal/define-app.ts +399 -0
- package/src/_internal/heartbeat.ts +33 -0
- package/src/_internal/registry.ts +100 -0
- package/src/_internal/runtime-context.ts +38 -0
- package/src/api/accounts-entities.ts +134 -0
- package/src/api/admin-lifecycle.ts +210 -0
- package/src/api/auth/schemas.ts +28 -0
- package/src/api/auth.ts +230 -0
- package/src/api/index.ts +66 -0
- package/src/api/me.ts +206 -0
- package/src/api/search/schemas.ts +43 -0
- package/src/api/search.ts +130 -0
- package/src/clients/core.ts +19 -0
- package/src/config/env.ts +23 -0
- package/src/config/index.ts +6 -0
- package/src/config/ssr.ts +58 -0
- package/src/contracts/app.ts +140 -0
- package/src/contracts/index.ts +5 -0
- package/src/contracts/profile.ts +67 -0
- package/src/contracts/registry.ts +50 -0
- package/src/contracts/settings-types.ts +84 -0
- package/src/contracts/shared.ts +258 -0
- package/src/contracts/widgets.ts +121 -0
- package/src/index.ts +6 -0
- package/src/server/api/index.ts +1 -0
- package/src/server/api/respond.ts +55 -0
- package/src/server/api-client.ts +54 -0
- package/src/server/app-context.ts +39 -0
- package/src/server/index.ts +62 -0
- package/src/server/middleware/auth.ts +168 -0
- package/src/server/middleware/index.ts +7 -0
- package/src/server/middleware/middleware.ts +47 -0
- package/src/server/middleware/openapi.ts +126 -0
- package/src/server/middleware/rate-limit.ts +126 -0
- package/src/server/middleware/request-logger.ts +41 -0
- package/src/server/middleware/validator.ts +35 -0
- package/src/server/services/access.ts +294 -0
- package/src/server/services/freeipa/client.ts +100 -0
- package/src/server/services/freeipa/index.ts +9 -0
- package/src/server/services/freeipa/session.ts +78 -0
- package/src/server/services/freeipa/tls.ts +48 -0
- package/src/server/services/freeipa/util.ts +60 -0
- package/src/server/services/geo.ts +154 -0
- package/src/server/services/index.ts +28 -0
- package/src/server/services/services.ts +13 -0
- package/src/services/account-lifecycle/audit.ts +41 -0
- package/src/services/account-lifecycle/index.ts +907 -0
- package/src/services/account-lifecycle/scheduler.ts +347 -0
- package/src/services/account-model.ts +21 -0
- package/src/services/accounts/app.ts +966 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/base-group.ts +11 -0
- package/src/services/accounts/base-user.ts +45 -0
- package/src/services/accounts/entities.ts +529 -0
- package/src/services/accounts/group-sql.ts +106 -0
- package/src/services/accounts/groups.ts +246 -0
- package/src/services/accounts/index.ts +14 -0
- package/src/services/accounts/ipa-data.ts +64 -0
- package/src/services/accounts/lifecycle.ts +2 -0
- package/src/services/accounts/local-groups.ts +491 -0
- package/src/services/accounts/model.ts +135 -0
- package/src/services/accounts/switching.ts +117 -0
- package/src/services/accounts/users.ts +714 -0
- package/src/services/auth-flows/index.ts +6 -0
- package/src/services/auth-flows/ipa.ts +128 -0
- package/src/services/auth-flows/magic-link.ts +119 -0
- package/src/services/freeipa-config.ts +89 -0
- package/src/services/index.ts +46 -0
- package/src/services/ipa/auth.ts +122 -0
- package/src/services/ipa/groups.ts +684 -0
- package/src/services/ipa/guard.ts +17 -0
- package/src/services/ipa/index.ts +17 -0
- package/src/services/ipa/profile.ts +90 -0
- package/src/services/ipa/search.ts +154 -0
- package/src/services/ipa/sync.ts +740 -0
- package/src/services/ipa/users.ts +794 -0
- package/src/services/logging/index.ts +294 -0
- package/src/services/notifications/email.ts +123 -0
- package/src/services/notifications/index.ts +413 -0
- package/src/services/postgres.ts +51 -0
- package/src/services/providers/index.ts +27 -0
- package/src/services/providers/local/auth.ts +13 -0
- package/src/services/providers/local/index.ts +4 -0
- package/src/services/providers/local/users.ts +255 -0
- package/src/services/session/index.ts +137 -0
- package/src/services/settings/api.ts +61 -0
- package/src/services/settings/app.ts +101 -0
- package/src/services/settings/crypto.ts +69 -0
- package/src/services/settings/defaults.ts +824 -0
- package/src/services/settings/index.ts +203 -0
- package/src/services/settings/namespace.ts +9 -0
- package/src/services/settings/snapshot.ts +49 -0
- package/src/services/settings/store.ts +179 -0
- package/src/services/settings/templates.ts +10 -0
- package/src/services/weather/forecast.ts +287 -0
- package/src/services/weather/geo.ts +110 -0
- package/src/services/weather/index.ts +99 -0
- package/src/services/weather/location.ts +24 -0
- package/src/services/weather/locations.ts +125 -0
- package/src/services/weather/migrate.ts +22 -0
- package/src/services/weather/types.ts +61 -0
- package/src/services/weather/ui.ts +50 -0
- package/src/shared/account-display.ts +17 -0
- package/src/shared/account-session.ts +15 -0
- package/src/shared/icons.ts +109 -0
- package/src/shared/index.ts +10 -0
- package/src/shared/markdown/client.ts +130 -0
- package/src/shared/markdown/extensions/code.ts +58 -0
- package/src/shared/markdown/extensions/images.ts +43 -0
- package/src/shared/markdown/extensions/info-blocks.ts +93 -0
- package/src/shared/markdown/extensions/katex.ts +120 -0
- package/src/shared/markdown/extensions/links.ts +34 -0
- package/src/shared/markdown/extensions/tables.ts +88 -0
- package/src/shared/markdown/extensions/task-list.ts +53 -0
- package/src/shared/markdown/index.ts +97 -0
- package/src/shared/markdown/shared.ts +36 -0
- package/src/ssr/AdminLayout.tsx +42 -0
- package/src/ssr/AdminSidebar.tsx +95 -0
- package/src/ssr/Footer.island.tsx +62 -0
- package/src/ssr/GlobalSearchDialog.tsx +389 -0
- package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
- package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
- package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
- package/src/ssr/Layout.tsx +326 -0
- package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
- package/src/ssr/NavMenu.island.tsx +108 -0
- package/src/ssr/ThemeToggleRail.island.tsx +27 -0
- package/src/ssr/index.ts +5 -0
- package/src/ssr/islands/SearchBar.island.tsx +77 -0
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/runtime.ts +22 -0
- package/src/styles/base-popover.css +28 -0
- package/src/styles/effects.css +65 -0
- package/src/styles/global.css +133 -0
- package/src/styles/input.css +54 -0
- package/src/styles/tokens.css +35 -0
- package/src/styles/utilities-buttons.css +125 -0
- package/src/styles/utilities-feedback.css +65 -0
- package/src/styles/utilities-layout.css +122 -0
- package/src/styles/utilities-navigation.css +196 -0
- package/src/types/ambient.d.ts +8 -0
- package/src/ui/admin-settings.tsx +148 -0
- package/src/ui/dialog-core.ts +146 -0
- package/src/ui/filter/FilterChip.tsx +196 -0
- package/src/ui/filter/index.ts +2 -0
- package/src/ui/index.ts +19 -0
- package/src/ui/input/Checkbox.tsx +55 -0
- package/src/ui/input/ColorInput.tsx +122 -0
- package/src/ui/input/DateTimeInput.tsx +86 -0
- package/src/ui/input/ImageInput.tsx +170 -0
- package/src/ui/input/NumberInput.tsx +113 -0
- package/src/ui/input/PinInput.tsx +169 -0
- package/src/ui/input/SegmentedControl.tsx +99 -0
- package/src/ui/input/Select.tsx +288 -0
- package/src/ui/input/SelectChip.tsx +61 -0
- package/src/ui/input/Slider.tsx +118 -0
- package/src/ui/input/Switch.tsx +62 -0
- package/src/ui/input/TagsInput.tsx +115 -0
- package/src/ui/input/TextInput.tsx +160 -0
- package/src/ui/input/index.ts +13 -0
- package/src/ui/input/types.ts +42 -0
- package/src/ui/input/util.tsx +105 -0
- package/src/ui/ipa/Avatar.tsx +28 -0
- package/src/ui/ipa/GroupView.tsx +36 -0
- package/src/ui/ipa/LoginBtn.tsx +16 -0
- package/src/ui/ipa/UserView.tsx +58 -0
- package/src/ui/ipa/index.ts +4 -0
- package/src/ui/misc/ContextMenu.tsx +211 -0
- package/src/ui/misc/CopyButton.tsx +28 -0
- package/src/ui/misc/Dropdown.tsx +194 -0
- package/src/ui/misc/EntitySearch.tsx +213 -0
- package/src/ui/misc/Lightbox.tsx +194 -0
- package/src/ui/misc/LinkCard.tsx +34 -0
- package/src/ui/misc/LogEntriesTable.tsx +61 -0
- package/src/ui/misc/MarkdownView.tsx +65 -0
- package/src/ui/misc/Pagination.tsx +51 -0
- package/src/ui/misc/PermissionEditor.tsx +379 -0
- package/src/ui/misc/ProgressBar.tsx +47 -0
- package/src/ui/misc/RemoveBtn.tsx +27 -0
- package/src/ui/misc/StatCell.tsx +90 -0
- package/src/ui/misc/index.ts +18 -0
- package/src/ui/navigation.ts +32 -0
- package/src/ui/prompts.tsx +854 -0
- package/src/ui/sidebar.tsx +468 -0
- package/src/ui/widgets/Widget.tsx +62 -0
- package/src/ui/widgets/WidgetCard.tsx +19 -0
- package/src/ui/widgets/WidgetHero.tsx +39 -0
- package/src/ui/widgets/WidgetList.tsx +84 -0
- package/src/ui/widgets/WidgetPills.tsx +68 -0
- package/src/ui/widgets/WidgetStat.tsx +67 -0
- package/src/ui/widgets/WidgetStatus.tsx +62 -0
- package/src/ui/widgets/index.ts +9 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { createSignal, For, Show } from "solid-js";
|
|
2
|
+
|
|
3
|
+
import { prompts } from "../prompts";
|
|
4
|
+
import { mutation } from "@valentinkolb/stdlib/solid";
|
|
5
|
+
import SegmentedControl from "../input/SegmentedControl";
|
|
6
|
+
import EntitySearch, { type EntitySearchResult } from "./EntitySearch";
|
|
7
|
+
import type { AccessEntry, PermissionLevel, Principal } from "../../contracts/shared";
|
|
8
|
+
|
|
9
|
+
const PERMISSION_OPTIONS: {
|
|
10
|
+
value: PermissionLevel;
|
|
11
|
+
label: string;
|
|
12
|
+
icon: string;
|
|
13
|
+
}[] = [
|
|
14
|
+
{ value: "read", label: "Read", icon: "ti-eye" },
|
|
15
|
+
{ value: "write", label: "Write", icon: "ti-pencil" },
|
|
16
|
+
{ value: "admin", label: "Admin", icon: "ti-shield" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
type PermissionEditorProps = {
|
|
20
|
+
/** Resource ID (e.g., space ID) */
|
|
21
|
+
resourceId: string;
|
|
22
|
+
/** Initial access entries */
|
|
23
|
+
initialEntries: AccessEntry[];
|
|
24
|
+
/** Whether the current user can edit permissions */
|
|
25
|
+
canEdit?: boolean;
|
|
26
|
+
/** Grant access for this resource */
|
|
27
|
+
grantAccess: (resourceId: string, principal: Principal, permission: PermissionLevel) => Promise<AccessEntry>;
|
|
28
|
+
/** Update access permission for this resource */
|
|
29
|
+
updateAccess: (resourceId: string, accessId: string, permission: PermissionLevel) => Promise<void>;
|
|
30
|
+
/** Revoke access for this resource */
|
|
31
|
+
revokeAccess: (resourceId: string, accessId: string) => Promise<void>;
|
|
32
|
+
/** Allow creating public access entries from this editor */
|
|
33
|
+
allowPublic?: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Permission Editor component for managing access control.
|
|
38
|
+
* Can be used in dialogs or pages to manage who has access to a resource.
|
|
39
|
+
*/
|
|
40
|
+
export default function PermissionEditor(props: PermissionEditorProps) {
|
|
41
|
+
const [entries, setEntries] = createSignal<AccessEntry[]>([...props.initialEntries]);
|
|
42
|
+
const [showAddForm, setShowAddForm] = createSignal(false);
|
|
43
|
+
const canEdit = () => props.canEdit !== false;
|
|
44
|
+
const allowPublic = () => props.allowPublic === true;
|
|
45
|
+
|
|
46
|
+
// Get existing user IDs and group IDs to exclude from search
|
|
47
|
+
const existingUserIds = () =>
|
|
48
|
+
entries()
|
|
49
|
+
.filter((e) => e.principal.type === "user")
|
|
50
|
+
.map((e) => (e.principal as { type: "user"; userId: string }).userId);
|
|
51
|
+
|
|
52
|
+
const existingGroupIds = () =>
|
|
53
|
+
entries()
|
|
54
|
+
.filter((e) => e.principal.type === "group")
|
|
55
|
+
.map((e) => (e.principal as { type: "group"; groupId: string }).groupId);
|
|
56
|
+
|
|
57
|
+
const hasAuthenticatedEntry = () => entries().some((entry) => entry.principal.type === "authenticated");
|
|
58
|
+
const hasPublicEntry = () => entries().some((entry) => entry.principal.type === "public");
|
|
59
|
+
|
|
60
|
+
// Grant access mutation
|
|
61
|
+
const grantMut = mutation.create({
|
|
62
|
+
mutation: async (data: { principal: Principal; permission: PermissionLevel }) => {
|
|
63
|
+
return props.grantAccess(props.resourceId, data.principal, data.permission);
|
|
64
|
+
},
|
|
65
|
+
onSuccess: (newEntry) => {
|
|
66
|
+
setEntries([...entries(), newEntry as AccessEntry]);
|
|
67
|
+
setShowAddForm(false);
|
|
68
|
+
},
|
|
69
|
+
onError: (err) => prompts.error(err.message),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Update permission mutation
|
|
73
|
+
const updateMut = mutation.create<{ accessId: string; permission: PermissionLevel }, { accessId: string; permission: PermissionLevel }>({
|
|
74
|
+
mutation: async (data) => {
|
|
75
|
+
await props.updateAccess(props.resourceId, data.accessId, data.permission);
|
|
76
|
+
// Return the data so we can use it in onSuccess
|
|
77
|
+
return data;
|
|
78
|
+
},
|
|
79
|
+
onSuccess: (result) => {
|
|
80
|
+
if (result) {
|
|
81
|
+
setEntries(entries().map((e) => (e.id === result.accessId ? { ...e, permission: result.permission } : e)));
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
onError: (err) => prompts.error(err.message),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Revoke access mutation
|
|
88
|
+
const revokeMut = mutation.create<void, string>({
|
|
89
|
+
mutation: async (accessId: string) => {
|
|
90
|
+
await props.revokeAccess(props.resourceId, accessId);
|
|
91
|
+
},
|
|
92
|
+
onError: (err) => prompts.error(err.message),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const handleRevoke = async (entry: AccessEntry) => {
|
|
96
|
+
if (entries().length <= 1) {
|
|
97
|
+
prompts.error("Cannot remove the last access entry");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const displayName = getEntryDisplayName(entry);
|
|
102
|
+
const confirmed = await prompts.confirm(`Remove access for ${displayName}?`, { title: "Remove Access", variant: "danger" });
|
|
103
|
+
if (confirmed) {
|
|
104
|
+
revokeMut.mutate(entry.id);
|
|
105
|
+
setEntries(entries().filter((e) => e.id !== entry.id));
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const handleEntitySelect = (result: EntitySearchResult, permission: PermissionLevel) => {
|
|
110
|
+
const principal: Principal = result.type === "user" ? { type: "user", userId: result.id } : { type: "group", groupId: result.id };
|
|
111
|
+
|
|
112
|
+
grantMut.mutate({ principal, permission });
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div class="flex flex-col gap-3">
|
|
117
|
+
{/* Existing entries */}
|
|
118
|
+
<div class="flex flex-col border-l-2 border-zinc-200 dark:border-zinc-700">
|
|
119
|
+
<For each={entries()}>
|
|
120
|
+
{(entry) => (
|
|
121
|
+
<AccessEntryRow
|
|
122
|
+
entry={entry}
|
|
123
|
+
canEdit={canEdit()}
|
|
124
|
+
canDelete={entries().length > 1}
|
|
125
|
+
onUpdatePermission={(permission) => updateMut.mutate({ accessId: entry.id, permission })}
|
|
126
|
+
onRevoke={() => handleRevoke(entry)}
|
|
127
|
+
updating={updateMut.loading()}
|
|
128
|
+
/>
|
|
129
|
+
)}
|
|
130
|
+
</For>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{/* Add access */}
|
|
134
|
+
<Show when={canEdit()}>
|
|
135
|
+
<Show
|
|
136
|
+
when={showAddForm()}
|
|
137
|
+
fallback={
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
onClick={() => setShowAddForm(true)}
|
|
141
|
+
class="flex items-center gap-2 text-sm text-dimmed hover:text-primary transition-colors"
|
|
142
|
+
>
|
|
143
|
+
<i class="ti ti-plus" />
|
|
144
|
+
<span>Add access</span>
|
|
145
|
+
</button>
|
|
146
|
+
}
|
|
147
|
+
>
|
|
148
|
+
<AddAccessForm
|
|
149
|
+
existingUserIds={existingUserIds()}
|
|
150
|
+
existingGroupIds={existingGroupIds()}
|
|
151
|
+
onSelectEntity={handleEntitySelect}
|
|
152
|
+
onSelectPrincipal={(principal, permission) => grantMut.mutate({ principal, permission })}
|
|
153
|
+
onCancel={() => setShowAddForm(false)}
|
|
154
|
+
loading={grantMut.loading()}
|
|
155
|
+
showAuthenticated={!hasAuthenticatedEntry()}
|
|
156
|
+
showPublic={allowPublic() && !hasPublicEntry()}
|
|
157
|
+
/>
|
|
158
|
+
</Show>
|
|
159
|
+
</Show>
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// =============================================================================
|
|
165
|
+
// Helper Functions
|
|
166
|
+
// =============================================================================
|
|
167
|
+
|
|
168
|
+
function getEntryDisplayName(entry: AccessEntry): string {
|
|
169
|
+
if (entry.displayName) return entry.displayName;
|
|
170
|
+
if (entry.principal.type === "authenticated") return "All users (incl. guests)";
|
|
171
|
+
if (entry.principal.type === "public") return "Public";
|
|
172
|
+
if (entry.principal.type === "user") return entry.principal.userId;
|
|
173
|
+
return entry.principal.groupId;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function getPrincipalIcon(principal: Principal): string {
|
|
177
|
+
switch (principal.type) {
|
|
178
|
+
case "user":
|
|
179
|
+
return "ti-user";
|
|
180
|
+
case "group":
|
|
181
|
+
return "ti-users-group";
|
|
182
|
+
case "authenticated":
|
|
183
|
+
return "ti-lock-open-2";
|
|
184
|
+
case "public":
|
|
185
|
+
return "ti-world";
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function getPermissionColor(level: PermissionLevel): string {
|
|
190
|
+
switch (level) {
|
|
191
|
+
case "read":
|
|
192
|
+
return "text-blue-500";
|
|
193
|
+
case "write":
|
|
194
|
+
return "text-amber-500";
|
|
195
|
+
case "admin":
|
|
196
|
+
return "text-purple-500";
|
|
197
|
+
default:
|
|
198
|
+
return "text-zinc-500";
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// =============================================================================
|
|
203
|
+
// Access Entry Row
|
|
204
|
+
// =============================================================================
|
|
205
|
+
|
|
206
|
+
function AccessEntryRow(props: {
|
|
207
|
+
entry: AccessEntry;
|
|
208
|
+
canEdit: boolean;
|
|
209
|
+
canDelete: boolean;
|
|
210
|
+
onUpdatePermission: (permission: PermissionLevel) => void;
|
|
211
|
+
onRevoke: () => void;
|
|
212
|
+
updating: boolean;
|
|
213
|
+
}) {
|
|
214
|
+
const [showPermissionMenu, setShowPermissionMenu] = createSignal(false);
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<div class="group/entry pl-3 py-1.5 flex items-center gap-2">
|
|
218
|
+
{/* Icon */}
|
|
219
|
+
<div class="flex shrink-0 items-center justify-center rounded-full bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 h-7 w-7">
|
|
220
|
+
<i class={`ti ${getPrincipalIcon(props.entry.principal)} text-sm`} />
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
{/* Name */}
|
|
224
|
+
<div class="flex-1 min-w-0">
|
|
225
|
+
<span class="text-sm truncate">{getEntryDisplayName(props.entry)}</span>
|
|
226
|
+
<Show when={props.entry.principal.type === "public"}>
|
|
227
|
+
<span class="text-xs text-dimmed ml-1">(Anyone with the link)</span>
|
|
228
|
+
</Show>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
{/* Permission badge / selector */}
|
|
232
|
+
<div class="relative">
|
|
233
|
+
<button
|
|
234
|
+
type="button"
|
|
235
|
+
onClick={() => props.canEdit && setShowPermissionMenu(!showPermissionMenu())}
|
|
236
|
+
disabled={!props.canEdit}
|
|
237
|
+
class={`flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border transition-colors ${getPermissionColor(
|
|
238
|
+
props.entry.permission,
|
|
239
|
+
)} ${props.canEdit ? "cursor-pointer hover:bg-zinc-100 dark:hover:bg-zinc-800" : "cursor-default"}`}
|
|
240
|
+
classList={{
|
|
241
|
+
"border-blue-200 dark:border-blue-900": props.entry.permission === "read",
|
|
242
|
+
"border-amber-200 dark:border-amber-900": props.entry.permission === "write",
|
|
243
|
+
"border-purple-200 dark:border-purple-900": props.entry.permission === "admin",
|
|
244
|
+
}}
|
|
245
|
+
>
|
|
246
|
+
<i class={`ti ${PERMISSION_OPTIONS.find((o) => o.value === props.entry.permission)?.icon}`} />
|
|
247
|
+
<span class="capitalize">{props.entry.permission}</span>
|
|
248
|
+
<Show when={props.canEdit}>
|
|
249
|
+
<i class="ti ti-chevron-down text-[10px]" />
|
|
250
|
+
</Show>
|
|
251
|
+
</button>
|
|
252
|
+
|
|
253
|
+
{/* Permission dropdown */}
|
|
254
|
+
<Show when={showPermissionMenu()}>
|
|
255
|
+
<div class="absolute right-0 top-full mt-1 z-10 popup py-1 min-w-30">
|
|
256
|
+
<For each={PERMISSION_OPTIONS}>
|
|
257
|
+
{(option) => (
|
|
258
|
+
<button
|
|
259
|
+
type="button"
|
|
260
|
+
onClick={() => {
|
|
261
|
+
if (option.value !== props.entry.permission) {
|
|
262
|
+
props.onUpdatePermission(option.value);
|
|
263
|
+
}
|
|
264
|
+
setShowPermissionMenu(false);
|
|
265
|
+
}}
|
|
266
|
+
class="w-full px-3 py-1.5 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-2"
|
|
267
|
+
classList={{
|
|
268
|
+
"bg-zinc-50 dark:bg-zinc-700/50": option.value === props.entry.permission,
|
|
269
|
+
}}
|
|
270
|
+
>
|
|
271
|
+
<i class={`ti ${option.icon} ${getPermissionColor(option.value)}`} />
|
|
272
|
+
<span>{option.label}</span>
|
|
273
|
+
<Show when={option.value === props.entry.permission}>
|
|
274
|
+
<i class="ti ti-check ml-auto text-green-500" />
|
|
275
|
+
</Show>
|
|
276
|
+
</button>
|
|
277
|
+
)}
|
|
278
|
+
</For>
|
|
279
|
+
</div>
|
|
280
|
+
</Show>
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
{/* Delete button */}
|
|
284
|
+
<Show when={props.canEdit && props.canDelete}>
|
|
285
|
+
<button
|
|
286
|
+
type="button"
|
|
287
|
+
onClick={props.onRevoke}
|
|
288
|
+
class="p-1 w-6 h-6 flex items-center justify-center text-dimmed hover:text-red-500 opacity-0 group-hover/entry:opacity-100 transition-opacity"
|
|
289
|
+
>
|
|
290
|
+
<i class="ti ti-x text-sm" />
|
|
291
|
+
</button>
|
|
292
|
+
</Show>
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// =============================================================================
|
|
298
|
+
// Add Access Form
|
|
299
|
+
// =============================================================================
|
|
300
|
+
|
|
301
|
+
function AddAccessForm(props: {
|
|
302
|
+
existingUserIds: string[];
|
|
303
|
+
existingGroupIds: string[];
|
|
304
|
+
onSelectEntity: (result: EntitySearchResult, permission: PermissionLevel) => void;
|
|
305
|
+
onSelectPrincipal: (principal: Principal, permission: PermissionLevel) => void;
|
|
306
|
+
onCancel: () => void;
|
|
307
|
+
loading: boolean;
|
|
308
|
+
showAuthenticated: boolean;
|
|
309
|
+
showPublic: boolean;
|
|
310
|
+
}) {
|
|
311
|
+
const [permission, setPermission] = createSignal<PermissionLevel>("read");
|
|
312
|
+
const hasTwoPrincipalButtons = () => props.showAuthenticated && props.showPublic;
|
|
313
|
+
const permissionOptions = PERMISSION_OPTIONS.map((option) => ({
|
|
314
|
+
value: option.value,
|
|
315
|
+
label: option.label,
|
|
316
|
+
icon: `ti ${option.icon}`,
|
|
317
|
+
}));
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
<div class="paper p-3 flex flex-col gap-2">
|
|
321
|
+
{/* Permission level selector */}
|
|
322
|
+
<div class="flex flex-col gap-1">
|
|
323
|
+
<p class="text-xs text-secondary">Permission Level</p>
|
|
324
|
+
<SegmentedControl options={permissionOptions} value={permission} onChange={setPermission} disabled={props.loading} />
|
|
325
|
+
</div>
|
|
326
|
+
|
|
327
|
+
{/* User/Group search */}
|
|
328
|
+
<EntitySearch
|
|
329
|
+
apiBaseUrl="/api/accounts"
|
|
330
|
+
searchUsers
|
|
331
|
+
searchGroups
|
|
332
|
+
excludeUserIds={props.existingUserIds}
|
|
333
|
+
excludeGroupIds={props.existingGroupIds}
|
|
334
|
+
onSelect={(result) => props.onSelectEntity(result, permission())}
|
|
335
|
+
placeholder="Search users or groups..."
|
|
336
|
+
adding={props.loading}
|
|
337
|
+
resultsHeightClass="max-h-36 min-h-20"
|
|
338
|
+
/>
|
|
339
|
+
|
|
340
|
+
<Show when={props.showAuthenticated || props.showPublic}>
|
|
341
|
+
<div
|
|
342
|
+
class="grid gap-2"
|
|
343
|
+
classList={{
|
|
344
|
+
"grid-cols-2": hasTwoPrincipalButtons(),
|
|
345
|
+
"grid-cols-1": !hasTwoPrincipalButtons(),
|
|
346
|
+
}}
|
|
347
|
+
>
|
|
348
|
+
<Show when={props.showAuthenticated}>
|
|
349
|
+
<button
|
|
350
|
+
type="button"
|
|
351
|
+
onClick={() => props.onSelectPrincipal({ type: "authenticated" }, permission())}
|
|
352
|
+
disabled={props.loading}
|
|
353
|
+
class="btn-simple btn-sm w-full justify-center"
|
|
354
|
+
>
|
|
355
|
+
<i class="ti ti-lock-open-2" />
|
|
356
|
+
All users (incl. guests)
|
|
357
|
+
</button>
|
|
358
|
+
</Show>
|
|
359
|
+
<Show when={props.showPublic}>
|
|
360
|
+
<button
|
|
361
|
+
type="button"
|
|
362
|
+
onClick={() => props.onSelectPrincipal({ type: "public" }, permission())}
|
|
363
|
+
disabled={props.loading}
|
|
364
|
+
class="btn-secondary btn-sm w-full justify-center"
|
|
365
|
+
>
|
|
366
|
+
<i class="ti ti-world" />
|
|
367
|
+
Allow public
|
|
368
|
+
</button>
|
|
369
|
+
</Show>
|
|
370
|
+
</div>
|
|
371
|
+
</Show>
|
|
372
|
+
|
|
373
|
+
{/* Cancel button */}
|
|
374
|
+
<button type="button" onClick={props.onCancel} class="text-xs text-dimmed hover:text-primary self-end">
|
|
375
|
+
Cancel
|
|
376
|
+
</button>
|
|
377
|
+
</div>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
type ProgressBarProps = {
|
|
2
|
+
value: number;
|
|
3
|
+
size?: "xs" | "sm" | "md";
|
|
4
|
+
tone?: "primary" | "success" | "danger";
|
|
5
|
+
showValue?: boolean;
|
|
6
|
+
class?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const clamp = (value: number) => Math.max(0, Math.min(100, Math.round(value)));
|
|
10
|
+
|
|
11
|
+
const heightClass = (size: ProgressBarProps["size"]) => {
|
|
12
|
+
switch (size) {
|
|
13
|
+
case "xs":
|
|
14
|
+
return "h-1.5";
|
|
15
|
+
case "sm":
|
|
16
|
+
return "h-2";
|
|
17
|
+
default:
|
|
18
|
+
return "h-2.5";
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const toneClass = (tone: ProgressBarProps["tone"]) => {
|
|
23
|
+
switch (tone) {
|
|
24
|
+
case "success":
|
|
25
|
+
return "bg-green-500";
|
|
26
|
+
case "danger":
|
|
27
|
+
return "bg-red-500";
|
|
28
|
+
default:
|
|
29
|
+
return "bg-blue-500";
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Generic percentage progress bar for upload/job-like UI flows.
|
|
35
|
+
*/
|
|
36
|
+
export default function ProgressBar(props: ProgressBarProps) {
|
|
37
|
+
const percent = () => clamp(props.value);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div class={`flex items-center gap-2 ${props.class ?? ""}`}>
|
|
41
|
+
<div class={`flex-1 min-w-0 rounded-full overflow-hidden bg-zinc-200 dark:bg-zinc-700 ${heightClass(props.size)}`}>
|
|
42
|
+
<div class={`h-full transition-all duration-200 ${toneClass(props.tone)}`} style={`width: ${percent()}%`} />
|
|
43
|
+
</div>
|
|
44
|
+
{props.showValue ? <span class="shrink-0 tabular-nums text-[11px] text-dimmed">{percent()}%</span> : null}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
type RemoveBtnProps = {
|
|
2
|
+
ariaLabel: string;
|
|
3
|
+
onClick: () => void;
|
|
4
|
+
loading?: boolean;
|
|
5
|
+
disabled?: boolean;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export default function RemoveBtn(props: RemoveBtnProps) {
|
|
9
|
+
return (
|
|
10
|
+
<button
|
|
11
|
+
type="button"
|
|
12
|
+
onClick={props.onClick}
|
|
13
|
+
disabled={props.disabled || props.loading}
|
|
14
|
+
class="p-1 shrink-0 transition-colors disabled:opacity-50 group/rm"
|
|
15
|
+
aria-label={props.ariaLabel}
|
|
16
|
+
>
|
|
17
|
+
{props.loading ? (
|
|
18
|
+
<i class="ti ti-loader-2 animate-spin text-sm text-zinc-400" />
|
|
19
|
+
) : (
|
|
20
|
+
<>
|
|
21
|
+
<i class="ti ti-x text-sm text-zinc-400 group-hover/rm:hidden" />
|
|
22
|
+
<i class="ti ti-trash text-sm text-red-500 hidden group-hover/rm:inline" />
|
|
23
|
+
</>
|
|
24
|
+
)}
|
|
25
|
+
</button>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { JSX } from "solid-js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Single cell in a stat-card row. See `skills/cloud-app/references/frontend.md`
|
|
5
|
+
* § Stats and the live demos in
|
|
6
|
+
* `packages/ui-lab/src/frontend/UiLabShowcase.island.tsx`.
|
|
7
|
+
*
|
|
8
|
+
* Use inside a parent grid that frames the cells:
|
|
9
|
+
* ```tsx
|
|
10
|
+
* <div class="paper overflow-hidden">
|
|
11
|
+
* <div class="grid grid-cols-3 gap-px p-px bg-zinc-100 dark:bg-zinc-800">
|
|
12
|
+
* <StatCell label="Apps" value={17} sub="9 nav · 12 admin" />
|
|
13
|
+
* <StatCell
|
|
14
|
+
* label="Healthy"
|
|
15
|
+
* value="17/17"
|
|
16
|
+
* sub="all systems"
|
|
17
|
+
* accent={{ tone: "emerald", icon: "ti ti-check" }}
|
|
18
|
+
* />
|
|
19
|
+
* </div>
|
|
20
|
+
* </div>
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* Accent rules:
|
|
24
|
+
* - `accent.text` set → renders an icon-and-text pill (`.tag` with bg).
|
|
25
|
+
* - `accent.text` omitted → renders a plain colored icon (no bg). The `.tag`
|
|
26
|
+
* background looks squished around a single icon, so we drop it.
|
|
27
|
+
* - When the accent should also colour the value (warnings, errors), pass
|
|
28
|
+
* `valueClass` like `text-amber-600 dark:text-amber-400`.
|
|
29
|
+
*/
|
|
30
|
+
export type StatCellAccent = {
|
|
31
|
+
tone: "emerald" | "amber" | "red" | "blue" | "zinc";
|
|
32
|
+
/** Tabler icon class, e.g. `"ti ti-check"`. */
|
|
33
|
+
icon: string;
|
|
34
|
+
/** Optional pill text. If set → tag with bg. If omitted → plain colored icon. */
|
|
35
|
+
text?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type StatCellProps = {
|
|
39
|
+
label: string;
|
|
40
|
+
value: string | number;
|
|
41
|
+
/** Sub line under the value. Pass `" "` (non-breaking space) to keep cell heights equal when no sub exists. */
|
|
42
|
+
sub?: string;
|
|
43
|
+
/** Override the default `text-primary` value colour for warning / error / success signals. */
|
|
44
|
+
valueClass?: string;
|
|
45
|
+
accent?: StatCellAccent;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const ACCENT_PILL_CLASSES: Record<StatCellAccent["tone"], string> = {
|
|
49
|
+
emerald: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
|
|
50
|
+
amber: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
|
|
51
|
+
red: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300",
|
|
52
|
+
blue: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300",
|
|
53
|
+
zinc: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const ACCENT_ICON_CLASSES: Record<StatCellAccent["tone"], string> = {
|
|
57
|
+
emerald: "text-emerald-600 dark:text-emerald-400",
|
|
58
|
+
amber: "text-amber-600 dark:text-amber-400",
|
|
59
|
+
red: "text-red-500 dark:text-red-400",
|
|
60
|
+
blue: "text-blue-600 dark:text-blue-400",
|
|
61
|
+
zinc: "text-zinc-500 dark:text-zinc-400",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const StatCell = (props: StatCellProps): JSX.Element => {
|
|
65
|
+
const valueClass = props.valueClass ?? "text-primary";
|
|
66
|
+
const sub = props.sub ?? " ";
|
|
67
|
+
return (
|
|
68
|
+
<div class="bg-white dark:bg-zinc-900 px-4 py-4 flex flex-col gap-0.5">
|
|
69
|
+
<span class="text-[10px] uppercase tracking-wider text-dimmed">{props.label}</span>
|
|
70
|
+
<span class={`text-xl font-bold tabular-nums ${valueClass}`}>{props.value}</span>
|
|
71
|
+
{props.accent ? (
|
|
72
|
+
<div class="flex items-center gap-1.5">
|
|
73
|
+
<span class="text-[10px] text-dimmed">{sub}</span>
|
|
74
|
+
{props.accent.text ? (
|
|
75
|
+
<span class={`tag ${ACCENT_PILL_CLASSES[props.accent.tone]}`}>
|
|
76
|
+
<i class={`${props.accent.icon} text-[9px]`} />
|
|
77
|
+
{props.accent.text}
|
|
78
|
+
</span>
|
|
79
|
+
) : (
|
|
80
|
+
<i class={`${props.accent.icon} ${ACCENT_ICON_CLASSES[props.accent.tone]} text-[11px]`} />
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
) : (
|
|
84
|
+
<span class="text-[10px] text-dimmed">{sub}</span>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export default StatCell;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { default as Dropdown } from "./Dropdown";
|
|
2
|
+
export type { DropdownItem } from "./Dropdown";
|
|
3
|
+
export { default as LinkCard } from "./LinkCard";
|
|
4
|
+
export { default as ProgressBar } from "./ProgressBar";
|
|
5
|
+
export { Pagination } from "./Pagination";
|
|
6
|
+
export { default as MarkdownView } from "./MarkdownView";
|
|
7
|
+
export { default as PermissionEditor } from "./PermissionEditor";
|
|
8
|
+
export { default as EntitySearch } from "./EntitySearch";
|
|
9
|
+
export type { EntitySearchResult } from "./EntitySearch";
|
|
10
|
+
export { default as CopyButton } from "./CopyButton";
|
|
11
|
+
export { default as Lightbox } from "./Lightbox";
|
|
12
|
+
export type { LightboxImage } from "./Lightbox";
|
|
13
|
+
export { default as RemoveBtn } from "./RemoveBtn";
|
|
14
|
+
export { default as LogEntriesTable } from "./LogEntriesTable";
|
|
15
|
+
export type { LogTableEntry } from "./LogEntriesTable";
|
|
16
|
+
export { default as ContextMenu } from "./ContextMenu";
|
|
17
|
+
export { default as StatCell } from "./StatCell";
|
|
18
|
+
export type { StatCellAccent, StatCellProps } from "./StatCell";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side navigation helpers — shared across every app's islands.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the per-app `lib/navigation.ts` modules that all reimplemented the
|
|
5
|
+
* same handful of `window.location` wrappers. Re-exported from the `cloud/ui`
|
|
6
|
+
* barrel so consumers `import { navigateTo, refreshCurrentPath } from
|
|
7
|
+
* "@valentinkolb/cloud/ui"`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Returns the canonical current URL path + query (without hash).
|
|
12
|
+
* Used as a deterministic refresh target after mutations — `location.reload()`
|
|
13
|
+
* preserves hash and forces a network revalidation we don't always want.
|
|
14
|
+
*/
|
|
15
|
+
export const currentPathWithQuery = (): string => {
|
|
16
|
+
const url = new URL(window.location.href);
|
|
17
|
+
return `${url.pathname}${url.search}`;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Navigates to the canonical current URL. Triggers full SSR re-render.
|
|
22
|
+
*/
|
|
23
|
+
export const refreshCurrentPath = (): void => {
|
|
24
|
+
window.location.assign(currentPathWithQuery());
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Navigates to a target href via browser navigation (adds history entry).
|
|
29
|
+
*/
|
|
30
|
+
export const navigateTo = (href: string): void => {
|
|
31
|
+
window.location.assign(href);
|
|
32
|
+
};
|