@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.
Files changed (93) hide show
  1. package/package.json +3 -3
  2. package/src/_internal/capabilities.ts +12 -0
  3. package/src/_internal/define-app.ts +8 -1
  4. package/src/_internal/process-identity.ts +7 -1
  5. package/src/_internal/registry-validation.ts +3 -0
  6. package/src/_internal/registry.ts +1 -0
  7. package/src/_internal/runtime-context.ts +1 -0
  8. package/src/access/GroupCoverage.tsx +175 -0
  9. package/src/access/PermissionEditor.tsx +119 -99
  10. package/src/access/messages.ts +30 -0
  11. package/src/ai/admin.ts +1 -0
  12. package/src/ai/approval-routes.ts +5 -5
  13. package/src/ai/browser-code-contracts.ts +14 -2
  14. package/src/ai/browser.ts +8 -1
  15. package/src/ai/capabilities.ts +115 -33
  16. package/src/ai/chat/blocks.tsx +86 -179
  17. package/src/ai/chat/builtin-tools.tsx +83 -48
  18. package/src/ai/chat/file-tools.tsx +4 -1
  19. package/src/ai/chat/live-turn.browser-harness.tsx +44 -0
  20. package/src/ai/chat/message-actions.tsx +6 -2
  21. package/src/ai/chat/message-utils.ts +17 -14
  22. package/src/ai/chat/messages.ts +330 -2
  23. package/src/ai/chat/presentation.tsx +278 -104
  24. package/src/ai/chat/tool-groups.ts +55 -35
  25. package/src/ai/chat/turn-layout.ts +141 -0
  26. package/src/ai/chat/turn-view.tsx +644 -0
  27. package/src/ai/client/controller.ts +60 -31
  28. package/src/ai/client/file-source.ts +20 -3
  29. package/src/ai/client/projection.ts +42 -6
  30. package/src/ai/code-mode-skill.ts +27 -27
  31. package/src/ai/code-runtime-tools.ts +10 -1
  32. package/src/ai/code-source-contracts.ts +54 -4
  33. package/src/ai/code-source-tools.ts +10 -3
  34. package/src/ai/credentials.ts +17 -3
  35. package/src/ai/data-analysis-skill.ts +2 -2
  36. package/src/ai/default-tools.ts +2 -2
  37. package/src/ai/executor.ts +177 -80
  38. package/src/ai/file-context.ts +14 -2
  39. package/src/ai/file-tools.ts +17 -3
  40. package/src/ai/files-store.ts +134 -11
  41. package/src/ai/grids-skill.ts +2 -2
  42. package/src/ai/index.ts +7 -0
  43. package/src/ai/memories.ts +14 -0
  44. package/src/ai/migrate.ts +125 -0
  45. package/src/ai/model-request-settings.ts +98 -0
  46. package/src/ai/protocol.ts +26 -4
  47. package/src/ai/provider-fetch.ts +67 -15
  48. package/src/ai/provider-retry.ts +105 -0
  49. package/src/ai/provider.ts +7 -1
  50. package/src/ai/quota-provider.ts +16 -7
  51. package/src/ai/request-headers.ts +117 -0
  52. package/src/ai/routes.ts +34 -6
  53. package/src/ai/runtime.ts +1 -1
  54. package/src/ai/settings.ts +19 -2
  55. package/src/ai/skill-seeds.ts +31 -3
  56. package/src/ai/skills.ts +26 -0
  57. package/src/ai/solid.ts +1 -1
  58. package/src/ai/store.ts +202 -59
  59. package/src/ai/stream.ts +182 -37
  60. package/src/ai/structured.ts +20 -5
  61. package/src/ai/system-prompt.ts +25 -0
  62. package/src/ai/timeline.ts +9 -11
  63. package/src/ai/tool-call-names.ts +45 -0
  64. package/src/ai/turn-policy.ts +247 -0
  65. package/src/ai/turn-timing.ts +31 -3
  66. package/src/ai/types.ts +36 -5
  67. package/src/api/admin-ai-quotas.ts +36 -1
  68. package/src/api/admin-core-settings.ts +16 -23
  69. package/src/api/admin-outgoing-mail.ts +62 -0
  70. package/src/api/index.ts +2 -0
  71. package/src/cli/admin/ai-quotas.ts +70 -1
  72. package/src/cli/admin/index.ts +6 -0
  73. package/src/cli/admin/outgoing-mail.ts +118 -0
  74. package/src/contracts/app.ts +2 -0
  75. package/src/contracts/index.ts +1 -0
  76. package/src/contracts/outgoing-mail.ts +77 -0
  77. package/src/contracts/registry.ts +4 -0
  78. package/src/services/index.ts +3 -0
  79. package/src/services/notifications/email.ts +16 -26
  80. package/src/services/outgoing-mail/index.ts +19 -0
  81. package/src/services/outgoing-mail/store.ts +286 -0
  82. package/src/services/outgoing-mail/test-send.ts +40 -0
  83. package/src/services/outgoing-mail/transport.ts +13 -0
  84. package/src/services/settings/core-settings.ts +1 -38
  85. package/src/services/settings/store.ts +5 -1
  86. package/src/shared/ai-model-request-settings.ts +21 -0
  87. package/src/shared/ai-platform-prompt.ts +1 -1
  88. package/src/shared/ai-request-options.ts +185 -0
  89. package/src/shared/app-presentation.ts +10 -2
  90. package/src/ssr/admin-navigation.ts +1 -1
  91. package/src/ssr/platform-messages.ts +2 -0
  92. package/src/ssr/workspace-navigation.ts +7 -1
  93. 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.25.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.12.1",
101
+ "@k2b/nessi": "0.15.0",
102
102
  "@k2b/ssr": "0.14.0",
103
- "@k2b/ui": "0.15.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 &&
@@ -276,6 +276,7 @@ export const resolveLiveCapabilityRegistryEntry = (
276
276
  endpoint,
277
277
  manifest,
278
278
  presentation,
279
+ ...(app.presentation ? { appPresentation: app.presentation } : {}),
279
280
  };
280
281
  } catch {
281
282
  return null;
@@ -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 { createSignal, For, Show } from "solid-js";
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
- const [entries, setEntries] = createSignal<AccessEntry[]>([...props.initialEntries]);
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([...entries(), newEntry as AccessEntry]);
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(entries().map((e) => (e.id === result.accessId ? { ...e, permission: result.permission } : e)));
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(entries().filter((entry) => entry.id !== accessId));
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().filter(isManagerEntry);
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
- <AccessEntryRow
229
- entry={entry}
230
- canEdit={canEdit()}
231
- disabled={busy()}
232
- lastManager={entry.id === lastManagerId()}
233
- allowed={allowed(entry.principal)}
234
- singlePicker={allowed(entry.principal).length === 1}
235
- onUpdatePermission={(permission) => {
236
- if (!busy()) void updateMut.mutate({ accessId: entry.id, permission });
237
- }}
238
- onRevoke={() => {
239
- if (!busy()) void revokeMut.mutate(entry);
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().length === 0}>
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().map((e) => e.principal)}
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 class="group/access-row flex items-center gap-2 py-1.5">
310
- <Show
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
- props.entry.principal.type === "service_account" &&
337
- props.entry.serviceAccountKind &&
338
- serviceAccountKindDisplay(props.entry.serviceAccountKind, t()).label
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
- {(label) => <span class="truncate text-xs text-dimmed">({label()})</span>}
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
- {/* Permission badge — interactive single-value picker when editable,
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
- <Show when={isInteractive()} fallback={<span class={`${badgeClass} cursor-default`}>{badgeContent}</span>}>
348
- <SelectChip
349
- aria-label={t().permissionFor({ name: displayName() })}
350
- value={() => props.entry.permission as GrantableLevel}
351
- options={props.allowed.map((option) => {
352
- const locked = props.lastManager && option.level !== "admin";
353
- return {
354
- value: option.level,
355
- label: option.label,
356
- icon: `ti ${option.icon}`,
357
- disabled: locked,
358
- description: locked ? t().lastManagerOption({ level: manageLabel() }) : undefined,
359
- };
360
- })}
361
- icon={`ti ${display().icon}`}
362
- position="bottom-left"
363
- onValueChange={(permission) => {
364
- if (permission !== props.entry.permission) props.onUpdatePermission(permission);
365
- }}
366
- />
367
- </Show>
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
- {/* Destructive row actions stay quiet until the row is engaged. They
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
- <Show when={props.canEdit}>
372
- <Tooltip.Anchor
373
- content={removeLabel()}
374
- 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"
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
- <i class="ti ti-x text-sm" />
389
- </IconButton>
390
- </Tooltip.Anchor>
391
- </Show>
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
  }