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