@k2b/cloud 0.24.0 → 0.26.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 (53) hide show
  1. package/package.json +3 -2
  2. package/src/_internal/capabilities.ts +12 -0
  3. package/src/_internal/registry.ts +1 -0
  4. package/src/access/GroupCoverage.tsx +175 -0
  5. package/src/access/PermissionEditor.tsx +134 -93
  6. package/src/access/managers.ts +32 -0
  7. package/src/access/messages.ts +36 -0
  8. package/src/ai/approval-routes.ts +5 -5
  9. package/src/ai/capabilities.ts +34 -12
  10. package/src/ai/chat/blocks.tsx +86 -179
  11. package/src/ai/chat/builtin-tools.tsx +57 -30
  12. package/src/ai/chat/live-turn.browser-harness.tsx +37 -0
  13. package/src/ai/chat/message-actions.tsx +6 -2
  14. package/src/ai/chat/message-utils.ts +17 -14
  15. package/src/ai/chat/messages.ts +324 -2
  16. package/src/ai/chat/presentation.tsx +257 -103
  17. package/src/ai/chat/tool-groups.ts +55 -35
  18. package/src/ai/chat/turn-layout.ts +141 -0
  19. package/src/ai/chat/turn-view.tsx +609 -0
  20. package/src/ai/client/projection.ts +37 -6
  21. package/src/ai/executor.ts +38 -5
  22. package/src/ai/memory-learning.ts +7 -12
  23. package/src/ai/memory-workflow-evidence.ts +10 -2
  24. package/src/ai/migrate.ts +45 -2
  25. package/src/ai/model-access.ts +4 -2
  26. package/src/ai/prefs.ts +19 -5
  27. package/src/ai/projects.ts +5 -2
  28. package/src/ai/protocol.ts +20 -4
  29. package/src/ai/provider-fetch.ts +67 -15
  30. package/src/ai/provider-retry.ts +105 -0
  31. package/src/ai/quota-provider.ts +14 -5
  32. package/src/ai/skills.ts +5 -2
  33. package/src/ai/store.ts +108 -3
  34. package/src/ai/stream.ts +2 -0
  35. package/src/ai/timeline.ts +9 -11
  36. package/src/ai/turn-timing.ts +31 -3
  37. package/src/ai/types.ts +12 -2
  38. package/src/browser/FileChooser.tsx +600 -0
  39. package/src/browser/choose-files.browser-harness.tsx +32 -0
  40. package/src/browser/choose-files.tsx +70 -0
  41. package/src/browser/file-chooser-messages.ts +86 -0
  42. package/src/browser/file-providers.ts +217 -0
  43. package/src/browser/files.tsx +23 -0
  44. package/src/cli/admin/account-administration.ts +2 -2
  45. package/src/contracts/registry.ts +2 -0
  46. package/src/server/index.ts +1 -0
  47. package/src/server/services/access.ts +44 -11
  48. package/src/server/services/index.ts +1 -0
  49. package/src/shared/app-presentation.ts +10 -2
  50. package/src/ssr/platform-messages.ts +2 -2
  51. package/src/styles/effects.css +69 -0
  52. package/src/styles/file-chooser.css +169 -0
  53. package/src/styles/global.css +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.24.0",
3
+ "version": "0.26.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": {
@@ -39,6 +39,7 @@
39
39
  "./browser/search": "./src/browser/search.ts",
40
40
  "./browser/testing": "./src/browser/testing.ts",
41
41
  "./browser/resource-picker": "./src/browser/resource-picker.tsx",
42
+ "./browser/files": "./src/browser/files.tsx",
42
43
  "./capabilities": "./src/capabilities/client.ts",
43
44
  "./capabilities/server": "./src/capabilities/server.ts",
44
45
  "./capabilities/store": "./src/capabilities/executions.ts",
@@ -99,7 +100,7 @@
99
100
  "@tailwindcss/typography": "0.5.20",
100
101
  "@k2b/nessi": "0.12.1",
101
102
  "@k2b/ssr": "0.14.0",
102
- "@k2b/ui": "0.14.0",
103
+ "@k2b/ui": "0.16.0",
103
104
  "@k2b/stdlib": "0.27.0",
104
105
  "@k2b/sync": "7.0.0",
105
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,
@@ -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;
@@ -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,12 @@
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";
9
+ import { isManagerEntry } from "./managers";
7
10
  import { accessMessages } from "./messages";
8
11
  import PrincipalPicker from "./PrincipalPicker";
9
12
  import { serviceAccountKindDisplay } from "./service-account-kind";
@@ -43,8 +46,10 @@ type PermissionEditorProps = {
43
46
  /** Update an existing entry's permission level. */
44
47
  updateAccess: (accessId: string, permission: GrantableLevel) => Promise<void>;
45
48
 
46
- /** Revoke an existing entry. The last entry IS deletable — for
47
- * hierarchical resources the parent ACL still applies. */
49
+ /** Revoke an existing entry. Any entry can be revoked except the last
50
+ * manager: once exactly one entry manages the resource (`admin` for a
51
+ * person, group, all signed-in users, or a standalone or agent service
52
+ * account), its row can neither be lowered nor removed. */
48
53
  revokeAccess: (accessId: string) => Promise<void>;
49
54
 
50
55
  /** Allow granting `public` access from this editor. */
@@ -120,9 +125,10 @@ const resolveEntryDisplay = (
120
125
  };
121
126
 
122
127
  const getEntryDisplayName = (entry: AccessEntry, t: ReturnType<typeof accessMessages.resolve>["t"], locale: string): string => {
123
- if (entry.displayName) return entry.principal.type === "group" ? groupDisplayName(entry.displayName, locale) : entry.displayName;
128
+ // Audiences always use the localized label; a server-supplied name is not translated.
124
129
  if (entry.principal.type === "authenticated") return t.allUsers;
125
130
  if (entry.principal.type === "public") return t.public;
131
+ if (entry.displayName) return entry.principal.type === "group" ? groupDisplayName(entry.displayName, locale) : entry.displayName;
126
132
  if (entry.principal.type === "user") return entry.principal.userId;
127
133
  if (entry.principal.type === "service_account") return entry.principal.serviceAccountId;
128
134
  return entry.principal.groupId;
@@ -150,7 +156,9 @@ const getPrincipalIcon = (entry: AccessEntry, t: ReturnType<typeof accessMessage
150
156
  export default function PermissionEditor(props: PermissionEditorProps) {
151
157
  const locale = useLocale();
152
158
  const t = () => accessMessages.resolve([locale()]).t;
153
- 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 })));
154
162
  const canEdit = () => props.canEdit !== false;
155
163
  const allowPublic = () => props.allowPublic === true;
156
164
  const allowAuthenticated = () => props.allowAuthenticated !== false;
@@ -174,7 +182,7 @@ export default function PermissionEditor(props: PermissionEditorProps) {
174
182
  display: { displayName: string; serviceAccountKind?: ServiceAccountKind };
175
183
  }) => props.grantAccess(data.principal, data.permission, data.display),
176
184
  onSuccess: (newEntry) => {
177
- setEntries([...entries(), newEntry as AccessEntry]);
185
+ setEntries(entries.length, { ...newEntry });
178
186
  },
179
187
  onError: (err) => prompts.error(err.message),
180
188
  });
@@ -186,7 +194,7 @@ export default function PermissionEditor(props: PermissionEditorProps) {
186
194
  },
187
195
  onSuccess: (result) => {
188
196
  if (result) {
189
- setEntries(entries().map((e) => (e.id === result.accessId ? { ...e, permission: result.permission } : e)));
197
+ setEntries((entry) => entry.id === result.accessId, "permission", result.permission);
190
198
  }
191
199
  },
192
200
  onError: (err) => prompts.error(err.message),
@@ -204,34 +212,45 @@ export default function PermissionEditor(props: PermissionEditorProps) {
204
212
  return entry.id;
205
213
  },
206
214
  onSuccess: (accessId) => {
207
- if (accessId) setEntries(entries().filter((entry) => entry.id !== accessId));
215
+ if (accessId) setEntries((current) => current.filter((entry) => entry.id !== accessId));
208
216
  },
209
217
  onError: (err) => prompts.error(err.message),
210
218
  });
211
219
  const busy = () => grantMut.loading() || updateMut.loading() || revokeMut.loading();
220
+ // The service refuses to remove the last manager; the row says so up front.
221
+ const lastManagerId = () => {
222
+ const managers = entries.filter(isManagerEntry);
223
+ return managers.length === 1 ? managers[0]!.id : null;
224
+ };
212
225
 
213
226
  return (
214
227
  <div class="flex flex-col gap-3">
215
228
  {/* Existing entries */}
216
229
  <div class="flex flex-col gap-1">
217
- <For each={entries()}>
218
- {(entry) => (
219
- <AccessEntryRow
220
- entry={entry}
221
- canEdit={canEdit()}
222
- disabled={busy()}
223
- allowed={allowed(entry.principal)}
224
- singlePicker={allowed(entry.principal).length === 1}
225
- onUpdatePermission={(permission) => {
226
- if (!busy()) void updateMut.mutate({ accessId: entry.id, permission });
227
- }}
228
- onRevoke={() => {
229
- if (!busy()) void revokeMut.mutate(entry);
230
- }}
231
- />
232
- )}
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
+ }}
233
252
  </For>
234
- <Show when={entries().length === 0}>
253
+ <Show when={entries.length === 0}>
235
254
  <Placeholder align="left" class="px-1 py-2" description={t().noDirectGrants} />
236
255
  </Show>
237
256
  </div>
@@ -241,7 +260,7 @@ export default function PermissionEditor(props: PermissionEditorProps) {
241
260
  a higher level. KISS: one decision per step. */}
242
261
  <Show when={canEdit()}>
243
262
  <PrincipalPicker
244
- existing={entries().map((e) => e.principal)}
263
+ existing={entries.map((e) => e.principal)}
245
264
  allowPublic={allowPublic()}
246
265
  allowAuthenticated={allowAuthenticated()}
247
266
  allowServiceAccounts={props.allowServiceAccounts}
@@ -264,10 +283,14 @@ function AccessEntryRow(props: {
264
283
  entry: AccessEntry;
265
284
  canEdit: boolean;
266
285
  disabled: boolean;
286
+ /** The only entry that manages the resource: it keeps its level and cannot be removed. */
287
+ lastManager: boolean;
267
288
  allowed: ResolvedLevel[];
268
289
  /** When true the per-row picker collapses to a non-interactive badge
269
290
  * (single-level mode — there's nothing to switch to). */
270
291
  singlePicker: boolean;
292
+ /** Present for group grants: who currently receives access through the group. */
293
+ coverage?: GroupCoverage;
271
294
  onUpdatePermission: (permission: GrantableLevel) => void;
272
295
  onRevoke: () => void;
273
296
  }) {
@@ -275,6 +298,8 @@ function AccessEntryRow(props: {
275
298
  const t = () => accessMessages.resolve([locale()]).t;
276
299
  const displayName = () => getEntryDisplayName(props.entry, t(), locale());
277
300
  const display = () => resolveEntryDisplay(props.entry.permission, props.allowed, t());
301
+ const manageLabel = () => resolveEntryDisplay("admin", props.allowed, t()).label;
302
+ const removeLabel = () => (props.lastManager ? t().lastManager({ level: manageLabel() }) : t().remove({ name: displayName() }));
278
303
  const isInteractive = () =>
279
304
  props.canEdit && !props.disabled && !props.singlePicker && props.allowed.some((option) => option.level === props.entry.permission);
280
305
 
@@ -292,82 +317,98 @@ function AccessEntryRow(props: {
292
317
  );
293
318
 
294
319
  return (
295
- <div class="group/access-row flex items-center gap-2 py-1.5">
296
- <Show
297
- when={props.entry.principal.type === "user"}
298
- fallback={
299
- <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">
300
- <i class={`ti ${getPrincipalIcon(props.entry, t())} text-sm`} />
301
- </div>
302
- }
303
- >
304
- <CloudAvatar
305
- username={displayName()}
306
- userId={props.entry.principal.type === "user" ? props.entry.principal.userId : undefined}
307
- avatarHash={props.entry.avatarHash}
308
- size="xs"
309
- class="h-7 w-7"
310
- />
311
- </Show>
312
-
313
- {/* Display name. Name and label share one line so every row keeps its height on phones:
314
- when both do not fit, each gets an equal share and the shorter one stays whole. */}
315
- <div class="grid min-w-0 flex-1 auto-cols-[minmax(0,max-content)] grid-flow-col items-baseline gap-1">
316
- <span class="truncate text-sm">{displayName()}</span>
317
- <Show when={props.entry.principal.type === "public"}>
318
- <span class="truncate text-xs text-dimmed">({t().anyoneWithLink})</span>
319
- </Show>
320
+ <div>
321
+ <div class="group/access-row flex items-center gap-2 py-1.5">
320
322
  <Show
321
- when={
322
- props.entry.principal.type === "service_account" &&
323
- props.entry.serviceAccountKind &&
324
- 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>
325
328
  }
326
329
  >
327
- {(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
+ />
328
337
  </Show>
329
- </div>
330
338
 
331
- {/* 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,
332
363
  plain span otherwise. */}
333
- <Show when={isInteractive()} fallback={<span class={`${badgeClass} cursor-default`}>{badgeContent}</span>}>
334
- <SelectChip
335
- aria-label={t().permissionFor({ name: displayName() })}
336
- value={() => props.entry.permission as GrantableLevel}
337
- options={props.allowed.map((option) => ({
338
- value: option.level,
339
- label: option.label,
340
- icon: `ti ${option.icon}`,
341
- }))}
342
- icon={`ti ${display().icon}`}
343
- position="bottom-left"
344
- onValueChange={(permission) => {
345
- if (permission !== props.entry.permission) props.onUpdatePermission(permission);
346
- }}
347
- />
348
- </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>
349
385
 
350
- {/* Destructive row actions stay quiet until the row is engaged. They
386
+ {/* Destructive row actions stay quiet until the row is engaged. They
351
387
  remain keyboard reachable and stay visible on touch-sized layouts. */}
352
- <Show when={props.canEdit}>
353
- <Tooltip.Anchor
354
- content={t().remove({ name: displayName() })}
355
- 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"
356
- >
357
- <IconButton
358
- type="button"
359
- label={t().remove({ name: displayName() })}
360
- variant="ghost"
361
- size="xs"
362
- onClick={props.onRevoke}
363
- disabled={props.disabled}
364
- aria-label={t().remove({ name: displayName() })}
365
- class="focus-ui flex h-7 w-7 items-center justify-center rounded text-zinc-400 transition-colors hover:bg-red-500/[0.08] hover:text-red-600 focus:opacity-100 dark: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"
366
392
  >
367
- <i class="ti ti-x text-sm" />
368
- </IconButton>
369
- </Tooltip.Anchor>
370
- </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>
371
412
  </div>
372
413
  );
373
414
  }
@@ -0,0 +1,32 @@
1
+ import type { PermissionLevel, Principal, ServiceAccountKind } from "../contracts/shared";
2
+
3
+ /** The fields of an access entry that decide whether it manages its resource. */
4
+ export type ManagerCandidate = {
5
+ principal: Principal;
6
+ permission: PermissionLevel;
7
+ serviceAccountKind?: ServiceAccountKind | null;
8
+ };
9
+
10
+ /**
11
+ * Whether an entry keeps its resource manageable: `admin` for a user, a group,
12
+ * all signed-in users, or a standalone or agent service account. Public access
13
+ * never counts, and neither do resource-bound or user-delegated keys: they
14
+ * disappear with their key or their user without passing the resource's rules.
15
+ */
16
+ export const isManagerEntry = (entry: ManagerCandidate): boolean => {
17
+ if (entry.permission !== "admin") return false;
18
+ switch (entry.principal.type) {
19
+ case "user":
20
+ case "group":
21
+ case "authenticated":
22
+ return true;
23
+ case "service_account":
24
+ return entry.serviceAccountKind === "standalone" || entry.serviceAccountKind === "agent";
25
+ case "public":
26
+ return false;
27
+ }
28
+ };
29
+
30
+ /** Whether a change from `before` to `after` takes a resource from at least one manager to none. */
31
+ export const removesLastManager = (before: readonly ManagerCandidate[], after: readonly ManagerCandidate[]): boolean =>
32
+ before.some(isManagerEntry) && !after.some(isManagerEntry);
@@ -26,6 +26,24 @@ export const accessMessages = i18n.define({
26
26
  anyoneWithLink: "Anyone with the link",
27
27
  permissionFor: ({ name }: { name: string }) => `Permission for ${name}`,
28
28
  remove: ({ name }: { name: string }) => `Remove ${name}`,
29
+ lastManager: ({ level }: { level: string }) =>
30
+ `The last entry with “${level}” access can't be lowered or removed. Give another person or group “${level}” access first.`,
31
+ lastManagerOption: ({ level }: { level: string }) => `Available once another person or group has “${level}” access`,
32
+ groupMembers: "Members",
33
+ groupMembersOf: ({ name }: { name: string }) => `Members of ${name}`,
34
+ groupMemberCount: ({ count }: { count: number }) =>
35
+ count === 0
36
+ ? "No one receives access through this group right now."
37
+ : count === 1
38
+ ? "1 person receives access through this group right now."
39
+ : `${count.toLocaleString("en")} people receive access through this group right now.`,
40
+ groupMembersLoading: "Loading members…",
41
+ groupMembersHidden: "Your account can't see who belongs to this group.",
42
+ groupMembersFailed: "Members couldn't be loaded.",
43
+ retry: "Try again",
44
+ showMoreMembers: ({ count }: { count: number }) => `Show ${count.toLocaleString("en")} more`,
45
+ directoryGroupHint:
46
+ "This group comes from the directory. Local accounts such as guests can't be members, so give them access directly.",
29
47
  read: "Read",
30
48
  readDescription: "Read this resource through the app API.",
31
49
  write: "Write",
@@ -86,6 +104,24 @@ export const accessMessages = i18n.define({
86
104
  anyoneWithLink: "Alle mit dem Link",
87
105
  permissionFor: ({ name }) => `Berechtigung für ${name}`,
88
106
  remove: ({ name }) => `${name} entfernen`,
107
+ lastManager: ({ level }) =>
108
+ `Der letzte Eintrag mit Zugriff „${level}“ kann nicht herabgestuft oder entfernt werden. Gib zuerst einer anderen Person oder Gruppe Zugriff „${level}“.`,
109
+ lastManagerOption: ({ level }) => `Möglich, sobald eine weitere Person oder Gruppe Zugriff „${level}“ hat`,
110
+ groupMembers: "Mitglieder",
111
+ groupMembersOf: ({ name }) => `Mitglieder von ${name}`,
112
+ groupMemberCount: ({ count }) =>
113
+ count === 0
114
+ ? "Über diese Gruppe erhält gerade niemand Zugriff."
115
+ : count === 1
116
+ ? "Über diese Gruppe erhält gerade 1 Person Zugriff."
117
+ : `Über diese Gruppe erhalten gerade ${count.toLocaleString("de")} Personen Zugriff.`,
118
+ groupMembersLoading: "Mitglieder werden geladen…",
119
+ groupMembersHidden: "Dein Konto kann nicht sehen, wer zu dieser Gruppe gehört.",
120
+ groupMembersFailed: "Die Mitglieder konnten nicht geladen werden.",
121
+ retry: "Erneut versuchen",
122
+ showMoreMembers: ({ count }) => `${count.toLocaleString("de")} weitere anzeigen`,
123
+ directoryGroupHint:
124
+ "Diese Gruppe stammt aus dem Verzeichnis. Lokale Konten wie Gäste können nicht Mitglied sein; gib ihnen direkt Zugriff.",
89
125
  read: "Lesen",
90
126
  readDescription: "Diese Ressource über die App-API lesen.",
91
127
  write: "Schreiben",