@k2b/cloud 0.25.0 → 0.27.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 +3 -3
- package/src/_internal/capabilities.ts +12 -0
- package/src/_internal/define-app.ts +8 -1
- package/src/_internal/process-identity.ts +7 -1
- package/src/_internal/registry-validation.ts +3 -0
- package/src/_internal/registry.ts +1 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/GroupCoverage.tsx +175 -0
- package/src/access/PermissionEditor.tsx +119 -99
- package/src/access/messages.ts +30 -0
- package/src/ai/admin.ts +1 -0
- package/src/ai/approval-routes.ts +5 -5
- package/src/ai/browser-code-contracts.ts +14 -2
- package/src/ai/browser.ts +8 -1
- package/src/ai/capabilities.ts +115 -33
- package/src/ai/chat/blocks.tsx +86 -179
- package/src/ai/chat/builtin-tools.tsx +83 -48
- package/src/ai/chat/file-tools.tsx +4 -1
- package/src/ai/chat/live-turn.browser-harness.tsx +44 -0
- package/src/ai/chat/message-actions.tsx +6 -2
- package/src/ai/chat/message-utils.ts +17 -14
- package/src/ai/chat/messages.ts +330 -2
- package/src/ai/chat/presentation.tsx +278 -104
- package/src/ai/chat/tool-groups.ts +55 -35
- package/src/ai/chat/turn-layout.ts +141 -0
- package/src/ai/chat/turn-view.tsx +644 -0
- package/src/ai/client/controller.ts +60 -31
- package/src/ai/client/file-source.ts +20 -3
- package/src/ai/client/projection.ts +42 -6
- package/src/ai/code-mode-skill.ts +27 -27
- package/src/ai/code-runtime-tools.ts +10 -1
- package/src/ai/code-source-contracts.ts +54 -4
- package/src/ai/code-source-tools.ts +10 -3
- package/src/ai/credentials.ts +17 -3
- package/src/ai/data-analysis-skill.ts +2 -2
- package/src/ai/default-tools.ts +2 -2
- package/src/ai/executor.ts +177 -80
- package/src/ai/file-context.ts +14 -2
- package/src/ai/file-tools.ts +17 -3
- package/src/ai/files-store.ts +134 -11
- package/src/ai/grids-skill.ts +2 -2
- package/src/ai/index.ts +7 -0
- package/src/ai/memories.ts +14 -0
- package/src/ai/migrate.ts +125 -0
- package/src/ai/model-request-settings.ts +98 -0
- package/src/ai/protocol.ts +26 -4
- package/src/ai/provider-fetch.ts +67 -15
- package/src/ai/provider-retry.ts +105 -0
- package/src/ai/provider.ts +7 -1
- package/src/ai/quota-provider.ts +16 -7
- package/src/ai/request-headers.ts +117 -0
- package/src/ai/routes.ts +34 -6
- package/src/ai/runtime.ts +1 -1
- package/src/ai/settings.ts +19 -2
- package/src/ai/skill-seeds.ts +31 -3
- package/src/ai/skills.ts +26 -0
- package/src/ai/solid.ts +1 -1
- package/src/ai/store.ts +202 -59
- package/src/ai/stream.ts +182 -37
- package/src/ai/structured.ts +20 -5
- package/src/ai/system-prompt.ts +25 -0
- package/src/ai/timeline.ts +9 -11
- package/src/ai/tool-call-names.ts +45 -0
- package/src/ai/turn-policy.ts +247 -0
- package/src/ai/turn-timing.ts +31 -3
- package/src/ai/types.ts +36 -5
- package/src/api/admin-ai-quotas.ts +36 -1
- package/src/api/admin-core-settings.ts +16 -23
- package/src/api/admin-outgoing-mail.ts +62 -0
- package/src/api/index.ts +2 -0
- package/src/cli/admin/ai-quotas.ts +70 -1
- package/src/cli/admin/index.ts +6 -0
- package/src/cli/admin/outgoing-mail.ts +118 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/outgoing-mail.ts +77 -0
- package/src/contracts/registry.ts +4 -0
- package/src/services/index.ts +3 -0
- package/src/services/notifications/email.ts +16 -26
- package/src/services/outgoing-mail/index.ts +19 -0
- package/src/services/outgoing-mail/store.ts +286 -0
- package/src/services/outgoing-mail/test-send.ts +40 -0
- package/src/services/outgoing-mail/transport.ts +13 -0
- package/src/services/settings/core-settings.ts +1 -38
- package/src/services/settings/store.ts +5 -1
- package/src/shared/ai-model-request-settings.ts +21 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/ai-request-options.ts +185 -0
- package/src/shared/app-presentation.ts +10 -2
- package/src/ssr/admin-navigation.ts +1 -1
- package/src/ssr/platform-messages.ts +2 -0
- package/src/ssr/workspace-navigation.ts +7 -1
- package/src/styles/effects.css +69 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -98,9 +98,9 @@
|
|
|
98
98
|
"@simplewebauthn/server": "14.0.3",
|
|
99
99
|
"@tabler/icons": "3.48.0",
|
|
100
100
|
"@tailwindcss/typography": "0.5.20",
|
|
101
|
-
"@k2b/nessi": "0.
|
|
101
|
+
"@k2b/nessi": "0.15.0",
|
|
102
102
|
"@k2b/ssr": "0.14.0",
|
|
103
|
-
"@k2b/ui": "0.
|
|
103
|
+
"@k2b/ui": "0.17.0",
|
|
104
104
|
"@k2b/stdlib": "0.27.0",
|
|
105
105
|
"@k2b/sync": "7.0.0",
|
|
106
106
|
"@nats-io/jetstream": "3.4.0",
|
|
@@ -786,6 +786,18 @@ const presentationOverlays = (catalog: CapabilityPresentationCatalog, requestedL
|
|
|
786
786
|
});
|
|
787
787
|
};
|
|
788
788
|
|
|
789
|
+
/** The display title of one Query or Action in the requested locale, without rebuilding the manifest. */
|
|
790
|
+
export const resolveCapabilityOperationTitle = (
|
|
791
|
+
operation: Pick<CapabilityQueryManifest, "localId" | "title">,
|
|
792
|
+
group: "queries" | "actions",
|
|
793
|
+
catalog: CapabilityPresentationCatalog | undefined,
|
|
794
|
+
requestedLocale: string,
|
|
795
|
+
): string =>
|
|
796
|
+
(catalog ? presentationOverlays(catalog, requestedLocale) : []).reduce(
|
|
797
|
+
(title, translation) => translation[group]?.[operation.localId]?.title ?? title,
|
|
798
|
+
operation.title,
|
|
799
|
+
);
|
|
800
|
+
|
|
789
801
|
/** Resolve only human presentation; stable IDs, flags, tags, aliases, and data shapes stay untouched. */
|
|
790
802
|
export const resolveCapabilityManifestPresentation = (
|
|
791
803
|
manifest: CapabilityManifest,
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
CapabilityOriginSchema,
|
|
41
41
|
} from "../contracts/capabilities";
|
|
42
42
|
import { type BoundNotificationMap, bindNotificationDefinitions, type NotificationDefinitionMap } from "../contracts/notification-types";
|
|
43
|
+
import { type PlatformPermission, PlatformPermissionSchema } from "../contracts/outgoing-mail";
|
|
43
44
|
import { isPwaPartId, PWA_AUTH_PATH, PWA_CANVAS_COLORS, PWA_MANIFEST_PATH, PWA_SHELL_APP_ID } from "../contracts/pwa";
|
|
44
45
|
import type { AppRegistryEntry } from "../contracts/registry";
|
|
45
46
|
import type { AppSettingsMap, KindToType } from "../contracts/settings-types";
|
|
@@ -201,6 +202,8 @@ export type AppOptions<S extends AppSettingsMap = {}, N extends NotificationDefi
|
|
|
201
202
|
* prefix-trie from these strings.
|
|
202
203
|
*/
|
|
203
204
|
routes: readonly string[];
|
|
205
|
+
/** Platform services requested by this application, visible to administrators. */
|
|
206
|
+
platformPermissions?: readonly PlatformPermission[];
|
|
204
207
|
/**
|
|
205
208
|
* Gateway-relative URL at which this app's OpenAPI 3.x JSON spec is
|
|
206
209
|
* served, e.g. `"/api/notebooks/openapi.json"`. Opt-in: only set this
|
|
@@ -471,6 +474,9 @@ export const defineApp = <
|
|
|
471
474
|
// ── 2. Meta ───────────────────────────────────────────────────────────
|
|
472
475
|
const baseMeta: AppMeta = {
|
|
473
476
|
id: opts.id,
|
|
477
|
+
platformPermissions: opts.platformPermissions
|
|
478
|
+
? opts.platformPermissions.map((permission) => PlatformPermissionSchema.parse(permission))
|
|
479
|
+
: undefined,
|
|
474
480
|
name: opts.name,
|
|
475
481
|
icon: opts.icon,
|
|
476
482
|
description: opts.description,
|
|
@@ -528,7 +534,7 @@ export const defineApp = <
|
|
|
528
534
|
}
|
|
529
535
|
})());
|
|
530
536
|
try {
|
|
531
|
-
bindProcessApplicationId(meta.id);
|
|
537
|
+
bindProcessApplicationId(meta.id, meta.platformPermissions);
|
|
532
538
|
cleanup.unshift(clearProcessApplicationId);
|
|
533
539
|
const startedAt = Date.now();
|
|
534
540
|
|
|
@@ -587,6 +593,7 @@ export const defineApp = <
|
|
|
587
593
|
? { href: meta.pwa.href, requiresRoles: meta.pwa.requiresRoles ? [...meta.pwa.requiresRoles] : undefined }
|
|
588
594
|
: undefined,
|
|
589
595
|
widgets: meta.widgets ? meta.widgets.map((w) => ({ ...w })) : undefined,
|
|
596
|
+
platformPermissions: meta.platformPermissions ? [...meta.platformPermissions] : undefined,
|
|
590
597
|
settingKeys: meta.settingKeys ? [...meta.settingKeys] : undefined,
|
|
591
598
|
openapi: advertiseOpenapi ? opts.openapi : undefined,
|
|
592
599
|
cliModules: cliModules.length > 0 ? cliModules : undefined,
|
|
@@ -1,15 +1,21 @@
|
|
|
1
|
+
import type { PlatformPermission } from "../contracts/outgoing-mail";
|
|
2
|
+
|
|
1
3
|
/** Identity of the application started in this process, derived from its declaration. */
|
|
2
4
|
let applicationId: string | undefined;
|
|
5
|
+
let platformPermissions: readonly PlatformPermission[] = [];
|
|
6
|
+
export const getProcessPlatformPermissions = (): readonly PlatformPermission[] => platformPermissions;
|
|
3
7
|
|
|
4
8
|
export const getProcessApplicationId = (): string | undefined => applicationId;
|
|
5
9
|
|
|
6
|
-
export const bindProcessApplicationId = (id: string): void => {
|
|
10
|
+
export const bindProcessApplicationId = (id: string, permissions: readonly PlatformPermission[] = []): void => {
|
|
7
11
|
if (applicationId !== undefined && applicationId !== id) {
|
|
8
12
|
throw new Error(`This process already hosts application "${applicationId}"`);
|
|
9
13
|
}
|
|
10
14
|
applicationId = id;
|
|
15
|
+
platformPermissions = [...permissions];
|
|
11
16
|
};
|
|
12
17
|
|
|
13
18
|
export const clearProcessApplicationId = (): void => {
|
|
14
19
|
applicationId = undefined;
|
|
20
|
+
platformPermissions = [];
|
|
15
21
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CLOUD_CLI_MODULE_NAME } from "../cli/plugin";
|
|
2
|
+
import { PlatformPermissionSchema } from "../contracts/outgoing-mail";
|
|
2
3
|
import type { AppRegistryEntry } from "../contracts/registry";
|
|
3
4
|
|
|
4
5
|
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -118,6 +119,8 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
|
|
|
118
119
|
return invalid("widgets", "an array of valid widgets");
|
|
119
120
|
}
|
|
120
121
|
}
|
|
122
|
+
if (value.platformPermissions !== undefined && !PlatformPermissionSchema.array().safeParse(value.platformPermissions).success)
|
|
123
|
+
return invalid("platformPermissions", "an array of platform permissions");
|
|
121
124
|
if (value.settingKeys !== undefined && !isStringArray(value.settingKeys)) return invalid("settingKeys", "an array of strings");
|
|
122
125
|
if (
|
|
123
126
|
value.cliModules !== undefined &&
|
|
@@ -15,6 +15,7 @@ export const buildRuntimeFromRegistry = (entries: AppRegistryEntry[], capabiliti
|
|
|
15
15
|
const searchTags = searchQueries?.flatMap((query) => query.universalSearch!.tags.flatMap((tag) => [tag.tag, ...(tag.aliases ?? [])]));
|
|
16
16
|
return {
|
|
17
17
|
id: e.id,
|
|
18
|
+
platformPermissions: e.platformPermissions ? [...e.platformPermissions] : undefined,
|
|
18
19
|
name: e.name,
|
|
19
20
|
icon: e.icon,
|
|
20
21
|
description: e.description,
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { query } from "@k2b/stdlib/solid";
|
|
2
|
+
import { Button, InlineGuidance, useLocale } from "@k2b/ui";
|
|
3
|
+
import { createSignal, createUniqueId, For, Show } from "solid-js";
|
|
4
|
+
import { CloudAvatar } from "../account/Avatar";
|
|
5
|
+
import type { BaseUser, EntityListItem, PaginationResponse } from "../contracts/shared";
|
|
6
|
+
import { accessMessages } from "./messages";
|
|
7
|
+
|
|
8
|
+
/** Members per request. Further pages load only when the viewer asks for them. */
|
|
9
|
+
const PAGE_SIZE = 20;
|
|
10
|
+
|
|
11
|
+
type MembersPage =
|
|
12
|
+
/** The directory withholds group members from this viewer (guest accounts). */
|
|
13
|
+
| { visible: false; directoryGroup: boolean }
|
|
14
|
+
| { visible: true; directoryGroup: boolean; members: BaseUser[]; total: number; page: number; hasNext: boolean };
|
|
15
|
+
|
|
16
|
+
/** Reads the Accounts directory with the viewer's own visibility: `null` when it is withheld. */
|
|
17
|
+
const listEntities = async (params: Record<string, string>, signal: AbortSignal) => {
|
|
18
|
+
const url = new URL("/api/accounts/entities", window.location.origin);
|
|
19
|
+
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
|
|
20
|
+
const response = await fetch(url, { signal, credentials: "same-origin" });
|
|
21
|
+
if (response.status === 403) return null;
|
|
22
|
+
if (!response.ok) throw new Error(response.statusText || `HTTP ${response.status}`);
|
|
23
|
+
const data: { items: EntityListItem[]; pagination: PaginationResponse } = await response.json();
|
|
24
|
+
return data;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Who currently receives access through a group grant: direct members and
|
|
29
|
+
* members of nested groups, as access resolution counts them. Loading starts
|
|
30
|
+
* when the row mounts in the browser, so the list usually opens complete
|
|
31
|
+
* instead of growing after the viewer expands it.
|
|
32
|
+
*/
|
|
33
|
+
export const createGroupCoverage = (groupId: string) => {
|
|
34
|
+
const [open, setOpen] = createSignal(false);
|
|
35
|
+
const members = query.createInfinite<string, MembersPage, number>({
|
|
36
|
+
source: () => groupId,
|
|
37
|
+
loadPage: async (id, { cursor, abortSignal }) => {
|
|
38
|
+
const page = cursor ?? 1;
|
|
39
|
+
const [list, group] = await Promise.all([
|
|
40
|
+
listEntities(
|
|
41
|
+
{ kinds: "user", member_of_group_id: id, recursive: "true", page: String(page), per_page: String(PAGE_SIZE) },
|
|
42
|
+
abortSignal,
|
|
43
|
+
),
|
|
44
|
+
// Only the first page needs the group itself, for the directory hint.
|
|
45
|
+
page === 1 ? listEntities({ kinds: "group", group_ids: id, per_page: "1" }, abortSignal) : null,
|
|
46
|
+
]);
|
|
47
|
+
const directoryGroup = group?.items.some((item) => item.kind === "group" && item.group.provider === "ipa") ?? false;
|
|
48
|
+
if (!list) return { visible: false, directoryGroup };
|
|
49
|
+
return {
|
|
50
|
+
visible: true,
|
|
51
|
+
directoryGroup,
|
|
52
|
+
members: list.items.flatMap((item) => (item.kind === "user" ? [item.user] : [])),
|
|
53
|
+
total: list.pagination.total,
|
|
54
|
+
page,
|
|
55
|
+
hasNext: list.pagination.has_next,
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
getNextCursor: (page) => (page.visible && page.hasNext ? page.page + 1 : null),
|
|
59
|
+
});
|
|
60
|
+
return { open, setOpen, panelId: createUniqueId(), members };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type GroupCoverage = ReturnType<typeof createGroupCoverage>;
|
|
64
|
+
|
|
65
|
+
const firstPage = (coverage: GroupCoverage) => coverage.members.pages()[0];
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The members toggle beside the group name. Its label never changes, so
|
|
69
|
+
* loading the members moves nothing in the row; the count leads the list.
|
|
70
|
+
*/
|
|
71
|
+
export function GroupCoverageToggle(props: { coverage: GroupCoverage; groupName: string }) {
|
|
72
|
+
const locale = useLocale();
|
|
73
|
+
const t = () => accessMessages.resolve([locale()]).t;
|
|
74
|
+
return (
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
aria-expanded={props.coverage.open()}
|
|
78
|
+
aria-controls={props.coverage.panelId}
|
|
79
|
+
// The group name tells several group rows apart.
|
|
80
|
+
aria-label={t().groupMembersOf({ name: props.groupName })}
|
|
81
|
+
onClick={() => props.coverage.setOpen(!props.coverage.open())}
|
|
82
|
+
// The size sits on the children: the scoped button normalization resets the button's own font.
|
|
83
|
+
class="focus-ui flex items-center gap-0.5 self-center rounded text-dimmed transition-colors hover:text-secondary"
|
|
84
|
+
>
|
|
85
|
+
<span class="text-xs">{t().groupMembers}</span>
|
|
86
|
+
<i
|
|
87
|
+
class="ti ti-chevron-down shrink-0 text-[10px] transition-transform motion-reduce:transition-none"
|
|
88
|
+
classList={{ "rotate-180": props.coverage.open() }}
|
|
89
|
+
aria-hidden="true"
|
|
90
|
+
/>
|
|
91
|
+
</button>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The expanded member list. It stays in the document while collapsed so `aria-controls` always resolves. */
|
|
96
|
+
export function GroupCoveragePanel(props: { coverage: GroupCoverage; groupName: string }) {
|
|
97
|
+
const locale = useLocale();
|
|
98
|
+
const t = () => accessMessages.resolve([locale()]).t;
|
|
99
|
+
const members = () => props.coverage.members;
|
|
100
|
+
const shown = () =>
|
|
101
|
+
members()
|
|
102
|
+
.pages()
|
|
103
|
+
.flatMap((page) => (page.visible ? page.members : []));
|
|
104
|
+
const total = () => {
|
|
105
|
+
const page = firstPage(props.coverage);
|
|
106
|
+
return page?.visible ? page.total : null;
|
|
107
|
+
};
|
|
108
|
+
const remaining = () => Math.max((total() ?? 0) - shown().length, 0);
|
|
109
|
+
let panel: HTMLDivElement | undefined;
|
|
110
|
+
let list: HTMLUListElement | undefined;
|
|
111
|
+
let more: HTMLButtonElement | undefined;
|
|
112
|
+
let retry: HTMLButtonElement | undefined;
|
|
113
|
+
// A loading button is disabled, which drops keyboard focus, and the buttons leave on a failure or once
|
|
114
|
+
// everything is shown. Unless the viewer has moved on, focus continues where they can act next.
|
|
115
|
+
const settleFocus = () => {
|
|
116
|
+
const active = document.activeElement;
|
|
117
|
+
if (active && active !== document.body && active.isConnected && !panel?.contains(active)) return;
|
|
118
|
+
const next = members().error() ? retry : members().hasMore() ? more : list;
|
|
119
|
+
if (next?.isConnected) next.focus();
|
|
120
|
+
};
|
|
121
|
+
const showMore = async () => {
|
|
122
|
+
await members().loadMore();
|
|
123
|
+
settleFocus();
|
|
124
|
+
};
|
|
125
|
+
const tryAgain = async () => {
|
|
126
|
+
await members().refresh();
|
|
127
|
+
settleFocus();
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
// Indented to the name column: avatar (1.75rem) plus the row gap (0.5rem).
|
|
132
|
+
<div ref={panel} id={props.coverage.panelId} hidden={!props.coverage.open()} class="flex flex-col gap-2 pb-2 pl-9">
|
|
133
|
+
<Show when={members().pages().length === 0 && !members().error()}>
|
|
134
|
+
<InlineGuidance loading>{t().groupMembersLoading}</InlineGuidance>
|
|
135
|
+
</Show>
|
|
136
|
+
<Show when={firstPage(props.coverage)?.visible === false}>
|
|
137
|
+
<InlineGuidance icon="ti ti-eye-off">{t().groupMembersHidden}</InlineGuidance>
|
|
138
|
+
</Show>
|
|
139
|
+
<Show when={total() !== null}>
|
|
140
|
+
<p class="text-sm text-dimmed">{t().groupMemberCount({ count: total() ?? 0 })}</p>
|
|
141
|
+
</Show>
|
|
142
|
+
<Show when={firstPage(props.coverage)?.directoryGroup}>
|
|
143
|
+
<InlineGuidance icon="ti ti-info-circle">{t().directoryGroupHint}</InlineGuidance>
|
|
144
|
+
</Show>
|
|
145
|
+
<Show when={shown().length > 0}>
|
|
146
|
+
<ul ref={list} tabindex="-1" aria-label={t().groupMembersOf({ name: props.groupName })} class="flex flex-col gap-1 outline-none">
|
|
147
|
+
<For each={shown()}>
|
|
148
|
+
{(user) => {
|
|
149
|
+
const name = user.displayName || user.mail || user.uid;
|
|
150
|
+
return (
|
|
151
|
+
<li class="flex min-w-0 items-center gap-2 text-sm">
|
|
152
|
+
<CloudAvatar username={name} userId={user.id} avatarHash={user.avatarHash} size="xs" class="h-6 w-6 shrink-0" />
|
|
153
|
+
<span class="truncate">{name}</span>
|
|
154
|
+
</li>
|
|
155
|
+
);
|
|
156
|
+
}}
|
|
157
|
+
</For>
|
|
158
|
+
</ul>
|
|
159
|
+
</Show>
|
|
160
|
+
<Show when={members().error()}>
|
|
161
|
+
<InlineGuidance tone="danger" icon="ti ti-alert-circle" role="alert">
|
|
162
|
+
<span>{t().groupMembersFailed}</span>{" "}
|
|
163
|
+
<Button ref={retry} variant="text" size="xs" onClick={() => void tryAgain()}>
|
|
164
|
+
{t().retry}
|
|
165
|
+
</Button>
|
|
166
|
+
</InlineGuidance>
|
|
167
|
+
</Show>
|
|
168
|
+
<Show when={members().hasMore() && !members().error()}>
|
|
169
|
+
<Button ref={more} variant="text" size="xs" class="self-start" loading={members().loadingMore()} onClick={() => void showMore()}>
|
|
170
|
+
{t().showMoreMembers({ count: Math.min(remaining(), PAGE_SIZE) })}
|
|
171
|
+
</Button>
|
|
172
|
+
</Show>
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { mutation } from "@k2b/stdlib/solid";
|
|
2
2
|
import { IconButton, Placeholder, prompts, SelectChip, Tooltip, useLocale } from "@k2b/ui";
|
|
3
|
-
import {
|
|
3
|
+
import { For, Show } from "solid-js";
|
|
4
|
+
import { createStore } from "solid-js/store";
|
|
4
5
|
import { CloudAvatar } from "../account/Avatar";
|
|
5
6
|
import type { AccessEntry, PermissionLevel, Principal, ServiceAccountKind } from "../contracts/shared";
|
|
6
7
|
import { groupDisplayName } from "../shared/account-display";
|
|
8
|
+
import { createGroupCoverage, type GroupCoverage, GroupCoveragePanel, GroupCoverageToggle } from "./GroupCoverage";
|
|
7
9
|
import { isManagerEntry } from "./managers";
|
|
8
10
|
import { accessMessages } from "./messages";
|
|
9
11
|
import PrincipalPicker from "./PrincipalPicker";
|
|
@@ -154,7 +156,9 @@ const getPrincipalIcon = (entry: AccessEntry, t: ReturnType<typeof accessMessage
|
|
|
154
156
|
export default function PermissionEditor(props: PermissionEditorProps) {
|
|
155
157
|
const locale = useLocale();
|
|
156
158
|
const t = () => accessMessages.resolve([locale()]).t;
|
|
157
|
-
|
|
159
|
+
// A store keeps each row's identity across level changes, so an expanded member list stays open. It holds
|
|
160
|
+
// copies: store writes go into the objects themselves, and the caller's entries are not the editor's to change.
|
|
161
|
+
const [entries, setEntries] = createStore<AccessEntry[]>(props.initialEntries.map((entry) => ({ ...entry })));
|
|
158
162
|
const canEdit = () => props.canEdit !== false;
|
|
159
163
|
const allowPublic = () => props.allowPublic === true;
|
|
160
164
|
const allowAuthenticated = () => props.allowAuthenticated !== false;
|
|
@@ -178,7 +182,7 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
178
182
|
display: { displayName: string; serviceAccountKind?: ServiceAccountKind };
|
|
179
183
|
}) => props.grantAccess(data.principal, data.permission, data.display),
|
|
180
184
|
onSuccess: (newEntry) => {
|
|
181
|
-
setEntries(
|
|
185
|
+
setEntries(entries.length, { ...newEntry });
|
|
182
186
|
},
|
|
183
187
|
onError: (err) => prompts.error(err.message),
|
|
184
188
|
});
|
|
@@ -190,7 +194,7 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
190
194
|
},
|
|
191
195
|
onSuccess: (result) => {
|
|
192
196
|
if (result) {
|
|
193
|
-
setEntries(
|
|
197
|
+
setEntries((entry) => entry.id === result.accessId, "permission", result.permission);
|
|
194
198
|
}
|
|
195
199
|
},
|
|
196
200
|
onError: (err) => prompts.error(err.message),
|
|
@@ -208,14 +212,14 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
208
212
|
return entry.id;
|
|
209
213
|
},
|
|
210
214
|
onSuccess: (accessId) => {
|
|
211
|
-
if (accessId) setEntries(
|
|
215
|
+
if (accessId) setEntries((current) => current.filter((entry) => entry.id !== accessId));
|
|
212
216
|
},
|
|
213
217
|
onError: (err) => prompts.error(err.message),
|
|
214
218
|
});
|
|
215
219
|
const busy = () => grantMut.loading() || updateMut.loading() || revokeMut.loading();
|
|
216
220
|
// The service refuses to remove the last manager; the row says so up front.
|
|
217
221
|
const lastManagerId = () => {
|
|
218
|
-
const managers = entries
|
|
222
|
+
const managers = entries.filter(isManagerEntry);
|
|
219
223
|
return managers.length === 1 ? managers[0]!.id : null;
|
|
220
224
|
};
|
|
221
225
|
|
|
@@ -223,25 +227,30 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
223
227
|
<div class="flex flex-col gap-3">
|
|
224
228
|
{/* Existing entries */}
|
|
225
229
|
<div class="flex flex-col gap-1">
|
|
226
|
-
<For each={entries
|
|
227
|
-
{(entry) =>
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
230
|
+
<For each={entries}>
|
|
231
|
+
{(entry) => {
|
|
232
|
+
// A row keeps its principal, so its group coverage is created once per row.
|
|
233
|
+
const coverage = entry.principal.type === "group" ? createGroupCoverage(entry.principal.groupId) : undefined;
|
|
234
|
+
return (
|
|
235
|
+
<AccessEntryRow
|
|
236
|
+
entry={entry}
|
|
237
|
+
coverage={coverage}
|
|
238
|
+
canEdit={canEdit()}
|
|
239
|
+
disabled={busy()}
|
|
240
|
+
lastManager={entry.id === lastManagerId()}
|
|
241
|
+
allowed={allowed(entry.principal)}
|
|
242
|
+
singlePicker={allowed(entry.principal).length === 1}
|
|
243
|
+
onUpdatePermission={(permission) => {
|
|
244
|
+
if (!busy()) void updateMut.mutate({ accessId: entry.id, permission });
|
|
245
|
+
}}
|
|
246
|
+
onRevoke={() => {
|
|
247
|
+
if (!busy()) void revokeMut.mutate(entry);
|
|
248
|
+
}}
|
|
249
|
+
/>
|
|
250
|
+
);
|
|
251
|
+
}}
|
|
243
252
|
</For>
|
|
244
|
-
<Show when={entries
|
|
253
|
+
<Show when={entries.length === 0}>
|
|
245
254
|
<Placeholder align="left" class="px-1 py-2" description={t().noDirectGrants} />
|
|
246
255
|
</Show>
|
|
247
256
|
</div>
|
|
@@ -251,7 +260,7 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
251
260
|
a higher level. KISS: one decision per step. */}
|
|
252
261
|
<Show when={canEdit()}>
|
|
253
262
|
<PrincipalPicker
|
|
254
|
-
existing={entries
|
|
263
|
+
existing={entries.map((e) => e.principal)}
|
|
255
264
|
allowPublic={allowPublic()}
|
|
256
265
|
allowAuthenticated={allowAuthenticated()}
|
|
257
266
|
allowServiceAccounts={props.allowServiceAccounts}
|
|
@@ -280,6 +289,8 @@ function AccessEntryRow(props: {
|
|
|
280
289
|
/** When true the per-row picker collapses to a non-interactive badge
|
|
281
290
|
* (single-level mode — there's nothing to switch to). */
|
|
282
291
|
singlePicker: boolean;
|
|
292
|
+
/** Present for group grants: who currently receives access through the group. */
|
|
293
|
+
coverage?: GroupCoverage;
|
|
283
294
|
onUpdatePermission: (permission: GrantableLevel) => void;
|
|
284
295
|
onRevoke: () => void;
|
|
285
296
|
}) {
|
|
@@ -306,89 +317,98 @@ function AccessEntryRow(props: {
|
|
|
306
317
|
);
|
|
307
318
|
|
|
308
319
|
return (
|
|
309
|
-
<div
|
|
310
|
-
<
|
|
311
|
-
when={props.entry.principal.type === "user"}
|
|
312
|
-
fallback={
|
|
313
|
-
<div class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
|
|
314
|
-
<i class={`ti ${getPrincipalIcon(props.entry, t())} text-sm`} />
|
|
315
|
-
</div>
|
|
316
|
-
}
|
|
317
|
-
>
|
|
318
|
-
<CloudAvatar
|
|
319
|
-
username={displayName()}
|
|
320
|
-
userId={props.entry.principal.type === "user" ? props.entry.principal.userId : undefined}
|
|
321
|
-
avatarHash={props.entry.avatarHash}
|
|
322
|
-
size="xs"
|
|
323
|
-
class="h-7 w-7"
|
|
324
|
-
/>
|
|
325
|
-
</Show>
|
|
326
|
-
|
|
327
|
-
{/* Display name. Name and label share one line so every row keeps its height on phones:
|
|
328
|
-
when both do not fit, each gets an equal share and the shorter one stays whole. */}
|
|
329
|
-
<div class="grid min-w-0 flex-1 auto-cols-[minmax(0,max-content)] grid-flow-col items-baseline gap-1">
|
|
330
|
-
<span class="truncate text-sm">{displayName()}</span>
|
|
331
|
-
<Show when={props.entry.principal.type === "public"}>
|
|
332
|
-
<span class="truncate text-xs text-dimmed">({t().anyoneWithLink})</span>
|
|
333
|
-
</Show>
|
|
320
|
+
<div>
|
|
321
|
+
<div class="group/access-row flex items-center gap-2 py-1.5">
|
|
334
322
|
<Show
|
|
335
|
-
when={
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
323
|
+
when={props.entry.principal.type === "user"}
|
|
324
|
+
fallback={
|
|
325
|
+
<div class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
|
|
326
|
+
<i class={`ti ${getPrincipalIcon(props.entry, t())} text-sm`} />
|
|
327
|
+
</div>
|
|
339
328
|
}
|
|
340
329
|
>
|
|
341
|
-
|
|
330
|
+
<CloudAvatar
|
|
331
|
+
username={displayName()}
|
|
332
|
+
userId={props.entry.principal.type === "user" ? props.entry.principal.userId : undefined}
|
|
333
|
+
avatarHash={props.entry.avatarHash}
|
|
334
|
+
size="xs"
|
|
335
|
+
class="h-7 w-7"
|
|
336
|
+
/>
|
|
342
337
|
</Show>
|
|
343
|
-
</div>
|
|
344
338
|
|
|
345
|
-
|
|
339
|
+
{/* Display name. Name and label share one line so every row keeps its height on phones:
|
|
340
|
+
when both do not fit, each gets an equal share and the shorter one stays whole. A group's
|
|
341
|
+
members toggle always stays whole; the name takes the rest. */}
|
|
342
|
+
<div
|
|
343
|
+
class="grid min-w-0 flex-1 auto-cols-[minmax(0,max-content)] grid-flow-col items-baseline gap-1"
|
|
344
|
+
classList={{ "grid-cols-[minmax(0,max-content)_max-content]": props.coverage !== undefined }}
|
|
345
|
+
>
|
|
346
|
+
<span class="truncate text-sm">{displayName()}</span>
|
|
347
|
+
<Show when={props.entry.principal.type === "public"}>
|
|
348
|
+
<span class="truncate text-xs text-dimmed">({t().anyoneWithLink})</span>
|
|
349
|
+
</Show>
|
|
350
|
+
<Show
|
|
351
|
+
when={
|
|
352
|
+
props.entry.principal.type === "service_account" &&
|
|
353
|
+
props.entry.serviceAccountKind &&
|
|
354
|
+
serviceAccountKindDisplay(props.entry.serviceAccountKind, t()).label
|
|
355
|
+
}
|
|
356
|
+
>
|
|
357
|
+
{(label) => <span class="truncate text-xs text-dimmed">({label()})</span>}
|
|
358
|
+
</Show>
|
|
359
|
+
<Show when={props.coverage}>{(coverage) => <GroupCoverageToggle coverage={coverage()} groupName={displayName()} />}</Show>
|
|
360
|
+
</div>
|
|
361
|
+
|
|
362
|
+
{/* Permission badge — interactive single-value picker when editable,
|
|
346
363
|
plain span otherwise. */}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
364
|
+
<Show when={isInteractive()} fallback={<span class={`${badgeClass} cursor-default`}>{badgeContent}</span>}>
|
|
365
|
+
<SelectChip
|
|
366
|
+
aria-label={t().permissionFor({ name: displayName() })}
|
|
367
|
+
value={() => props.entry.permission as GrantableLevel}
|
|
368
|
+
options={props.allowed.map((option) => {
|
|
369
|
+
const locked = props.lastManager && option.level !== "admin";
|
|
370
|
+
return {
|
|
371
|
+
value: option.level,
|
|
372
|
+
label: option.label,
|
|
373
|
+
icon: `ti ${option.icon}`,
|
|
374
|
+
disabled: locked,
|
|
375
|
+
description: locked ? t().lastManagerOption({ level: manageLabel() }) : undefined,
|
|
376
|
+
};
|
|
377
|
+
})}
|
|
378
|
+
icon={`ti ${display().icon}`}
|
|
379
|
+
position="bottom-left"
|
|
380
|
+
onValueChange={(permission) => {
|
|
381
|
+
if (permission !== props.entry.permission) props.onUpdatePermission(permission);
|
|
382
|
+
}}
|
|
383
|
+
/>
|
|
384
|
+
</Show>
|
|
368
385
|
|
|
369
|
-
|
|
386
|
+
{/* Destructive row actions stay quiet until the row is engaged. They
|
|
370
387
|
remain keyboard reachable and stay visible on touch-sized layouts. */}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
>
|
|
376
|
-
<IconButton
|
|
377
|
-
type="button"
|
|
378
|
-
label={t().remove({ name: displayName() })}
|
|
379
|
-
variant="ghost"
|
|
380
|
-
size="xs"
|
|
381
|
-
onClick={props.onRevoke}
|
|
382
|
-
disabled={props.disabled || props.lastManager}
|
|
383
|
-
aria-label={t().remove({ name: displayName() })}
|
|
384
|
-
// A disabled button takes no focus and touch has no hover, so the reason also reaches assistive technology.
|
|
385
|
-
aria-description={props.lastManager ? removeLabel() : undefined}
|
|
386
|
-
class="focus-ui flex h-7 w-7 items-center justify-center rounded text-zinc-400 transition-colors focus:opacity-100 enabled:hover:bg-red-500/[0.08] enabled:hover:text-red-600 dark:enabled:hover:text-red-400"
|
|
388
|
+
<Show when={props.canEdit}>
|
|
389
|
+
<Tooltip.Anchor
|
|
390
|
+
content={removeLabel()}
|
|
391
|
+
class="shrink-0 opacity-100 transition-opacity sm:opacity-0 sm:group-hover/access-row:opacity-100 sm:group-focus-within/access-row:opacity-100"
|
|
387
392
|
>
|
|
388
|
-
<
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
393
|
+
<IconButton
|
|
394
|
+
type="button"
|
|
395
|
+
label={t().remove({ name: displayName() })}
|
|
396
|
+
variant="ghost"
|
|
397
|
+
size="xs"
|
|
398
|
+
onClick={props.onRevoke}
|
|
399
|
+
disabled={props.disabled || props.lastManager}
|
|
400
|
+
aria-label={t().remove({ name: displayName() })}
|
|
401
|
+
// A disabled button takes no focus and touch has no hover, so the reason also reaches assistive technology.
|
|
402
|
+
aria-description={props.lastManager ? removeLabel() : undefined}
|
|
403
|
+
class="focus-ui flex h-7 w-7 items-center justify-center rounded text-zinc-400 transition-colors focus:opacity-100 enabled:hover:bg-red-500/[0.08] enabled:hover:text-red-600 dark:enabled:hover:text-red-400"
|
|
404
|
+
>
|
|
405
|
+
<i class="ti ti-x text-sm" />
|
|
406
|
+
</IconButton>
|
|
407
|
+
</Tooltip.Anchor>
|
|
408
|
+
</Show>
|
|
409
|
+
</div>
|
|
410
|
+
{/* Expanding pushes the rows below down, only on the viewer's own request. */}
|
|
411
|
+
<Show when={props.coverage}>{(coverage) => <GroupCoveragePanel coverage={coverage()} groupName={displayName()} />}</Show>
|
|
392
412
|
</div>
|
|
393
413
|
);
|
|
394
414
|
}
|