@k2b/cloud 0.35.0 → 0.37.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.35.0",
3
+ "version": "0.37.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": {
@@ -106,8 +106,8 @@
106
106
  "@tabler/icons": "3.48.0",
107
107
  "@tailwindcss/typography": "0.5.20",
108
108
  "@k2b/nessi": "0.18.0",
109
- "@k2b/ssr": "0.16.0",
110
- "@k2b/ui": "0.25.0",
109
+ "@k2b/ssr": "0.17.0",
110
+ "@k2b/ui": "0.27.0",
111
111
  "@k2b/stdlib": "0.29.0",
112
112
  "@k2b/sync": "7.0.0",
113
113
  "@nats-io/jetstream": "3.4.0",
package/scripts/README.md CHANGED
@@ -3,6 +3,7 @@
3
3
  - `build.ts` — production build for one Cloud app (`APP_ID`); used by the Dockerfiles and `bun run build` targets.
4
4
  - `preload.ts` — dev-mode preload that registers the SSR plugin and builds CSS before app code loads.
5
5
  - `build-canvas-workers.ts` — packages the isolated canvas workers (PDF pages, mobile app icons) and the native canvas for bundles that start them.
6
+ - `cli-pack.ts` — packs a standalone `cld` module with its verified manifest and skill references: `bun run packages/cloud/scripts/cli-pack.ts <module.ts> --references <dir> --out <dir>`. Reads the package name and version from the current directory; publish the output with `npm publish <dir>` or archive it with `npm pack <dir>`.
6
7
  - `app-favicon.ts` — generates app favicons from Tabler icons; imported by `build.ts` and `preload.ts`.
7
8
  - `browser-performance.ts` — bundles the web-vitals browser asset for core; imported by `build.ts` and `preload.ts`.
8
9
  - `runtime-recovery-acceptance.ts` — shared-runtime recovery acceptance on disposable Docker containers: `bun packages/cloud/scripts/runtime-recovery-acceptance.ts` (also `bun run test:runtime-recovery`).
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Pack a `cld` module as a package plugin, for authors without a Cloud
4
+ * application:
5
+ *
6
+ * bun run node_modules/@k2b/cloud/scripts/cli-pack.ts src/cli.ts --references src/cli-references --out dist/cld-plugin
7
+ *
8
+ * Writes the same verified bundle the application build writes to
9
+ * `dist/cli/<name>/` (`cli.js`, `references/`, `manifest.json`), plus a
10
+ * `package.json` whose `cld.manifest` points at the manifest. Name and version
11
+ * come from `package.json` in the current directory.
12
+ */
13
+ import { lstat, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
14
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
15
+ import { pathToFileURL } from "node:url";
16
+ import { buildCliPlugin, writeCliPlugin } from "../src/_internal/cli-plugins";
17
+ import { validateCloudCliModule } from "../src/cli/plugin";
18
+
19
+ const USAGE = "Usage: bun run node_modules/@k2b/cloud/scripts/cli-pack.ts <module.ts> --references <dir> --out <dir>";
20
+
21
+ const HELP = `${USAGE}
22
+
23
+ Packs a cld module as a package plugin: the bundled module cli.js, the skill
24
+ references (index.md plus further Markdown files), manifest.json with the
25
+ SHA-512 of every file, and a package.json with "cld": { "manifest": "manifest.json" }.
26
+ Name and version come from package.json in the current directory.
27
+
28
+ --out must be missing, empty, or an earlier pack output, which is replaced.
29
+ Ship the result with \`npm publish <out>\` or \`npm pack <out>\`.`;
30
+
31
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
32
+
33
+ const readJson = (path: string): Promise<unknown> =>
34
+ readFile(path, "utf8")
35
+ .then(JSON.parse)
36
+ .catch(() => null);
37
+
38
+ const parseArgs = (args: string[]) => {
39
+ const [module, ...rest] = args;
40
+ const flags = new Map<string, string>();
41
+ for (let index = 0; index < rest.length; index += 2) {
42
+ const [flag, value] = [rest[index]!, rest[index + 1]];
43
+ if (!["--references", "--out"].includes(flag) || !value || value.startsWith("-") || flags.has(flag)) throw new Error(USAGE);
44
+ flags.set(flag, value);
45
+ }
46
+ const references = flags.get("--references");
47
+ const out = flags.get("--out");
48
+ if (!module || module.startsWith("-") || !references || !out) throw new Error(USAGE);
49
+ return { module: resolve(module), references: resolve(references), out: resolve(out) };
50
+ };
51
+
52
+ const containsPath = (directory: string, path: string): boolean => {
53
+ const inside = relative(directory, path);
54
+ return inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside);
55
+ };
56
+
57
+ /** Empty `out` for a fresh pack; anything but an earlier pack output stays untouched. */
58
+ const prepareOut = async (out: string, inputs: string[]): Promise<void> => {
59
+ const refusal = `Refusing --out ${out}: it must be missing, empty, or an earlier pack output.`;
60
+ const output = await realpath(out).catch(() => out);
61
+ for (const input of inputs) {
62
+ const path = await realpath(input).catch(() => input);
63
+ if (containsPath(out, input) || containsPath(output, path)) throw new Error(refusal);
64
+ }
65
+ const info = await lstat(out).catch(() => null);
66
+ if (!info) return;
67
+ if (!info.isDirectory()) throw new Error(refusal);
68
+ const entries = await readdir(out);
69
+ if (entries.length === 0) return;
70
+ const previous = await readJson(join(out, "package.json"));
71
+ if (
72
+ entries.some((entry) => !["package.json", "manifest.json", "cli.js", "references"].includes(entry)) ||
73
+ !isRecord(previous) ||
74
+ !isRecord(previous.cld) ||
75
+ previous.cld.manifest !== "manifest.json"
76
+ )
77
+ throw new Error(refusal);
78
+ await rm(out, { recursive: true });
79
+ };
80
+
81
+ const pack = async (args: string[]): Promise<void> => {
82
+ if (args.includes("--help") || args.includes("-h")) {
83
+ console.log(HELP);
84
+ return;
85
+ }
86
+ const options = parseArgs(args);
87
+ const cwd = process.cwd();
88
+ const manifest = await readJson(join(cwd, "package.json"));
89
+ if (
90
+ !isRecord(manifest) ||
91
+ typeof manifest.name !== "string" ||
92
+ !manifest.name ||
93
+ typeof manifest.version !== "string" ||
94
+ !manifest.version
95
+ ) {
96
+ throw new Error(`${join(cwd, "package.json")} needs a string name and version.`);
97
+ }
98
+ const { name: packageName, version } = manifest;
99
+ await prepareOut(options.out, [cwd, options.module, options.references]);
100
+ const loaded: unknown = await import(pathToFileURL(options.module).href);
101
+ const module = validateCloudCliModule(isRecord(loaded) ? loaded.default : undefined);
102
+ const plugin = await buildCliPlugin({
103
+ appDir: cwd,
104
+ appId: packageName,
105
+ name: module.name,
106
+ declaration: { module: options.module, references: options.references },
107
+ version,
108
+ });
109
+ await writeCliPlugin(options.out, plugin);
110
+ await writeFile(
111
+ join(options.out, "package.json"),
112
+ `${JSON.stringify({ name: packageName, version, type: "module", cld: { manifest: "manifest.json" } }, null, 2)}\n`,
113
+ );
114
+ const references = plugin.manifest.files.filter((file) => file.path.startsWith("references/")).length;
115
+ console.log(
116
+ `Packed ${packageName}@${version} (module "${module.name}", ${references} reference files) to ${options.out}. Ship it with \`npm publish ${options.out}\` or \`npm pack ${options.out}\`.`,
117
+ );
118
+ };
119
+
120
+ pack(Bun.argv.slice(2)).catch((error: unknown) => {
121
+ console.error(error instanceof Error ? error.message.replace(/\s*\n\s*/g, " ") : String(error));
122
+ process.exitCode = 1;
123
+ });
@@ -0,0 +1,11 @@
1
+ export const SHELL_FOCUS_COOKIE = "cloud_shell_focus";
2
+
3
+ export const readShellFocusCookie = (cookieHeader: string | null | undefined): boolean =>
4
+ cookieHeader
5
+ ?.split(";")
6
+ .map((part) => part.trim())
7
+ .find((part) => part.startsWith(`${SHELL_FOCUS_COOKIE}=`))
8
+ ?.slice(SHELL_FOCUS_COOKIE.length + 1) === "1";
9
+
10
+ export const shellFocusCookie = (hidden: boolean, secure: boolean): string =>
11
+ `${SHELL_FOCUS_COOKIE}=${hidden ? "1" : ""}; Path=/; Max-Age=${hidden ? 31536000 : 0}; SameSite=Lax${secure ? "; Secure" : ""}`;
@@ -68,12 +68,13 @@ const firstPage = (coverage: GroupCoverage) => coverage.members.pages()[0];
68
68
  * The members toggle beside the group name. Its label never changes, so
69
69
  * loading the members moves nothing in the row; the count leads the list.
70
70
  */
71
- export function GroupCoverageToggle(props: { coverage: GroupCoverage; groupName: string }) {
71
+ export function GroupCoverageToggle(props: { coverage: GroupCoverage; groupName: string; disabled?: boolean }) {
72
72
  const locale = useLocale();
73
73
  const t = () => accessMessages.resolve([locale()]).t;
74
74
  return (
75
75
  <button
76
76
  type="button"
77
+ disabled={props.disabled}
77
78
  aria-expanded={props.coverage.open()}
78
79
  aria-controls={props.coverage.panelId}
79
80
  // The group name tells several group rows apart.
@@ -0,0 +1,46 @@
1
+ import { LocaleProvider } from "@k2b/ui";
2
+ import { render } from "solid-js/web";
3
+ import PermissionEditor from "./PermissionEditor";
4
+
5
+ const host = document.getElementById("root");
6
+ if (!host) throw new Error("Missing harness root");
7
+ render(
8
+ () => (
9
+ <LocaleProvider locale="en">
10
+ <PermissionEditor
11
+ initialEntries={["Mara Lind", "Lea Sommer", "Jonas Weber"].map((displayName, index) => ({
12
+ id: `access-${index}`,
13
+ principal: { type: "user", userId: `user-${index}` },
14
+ displayName,
15
+ permission: "read",
16
+ createdAt: "2026-10-15T07:00:00.000Z",
17
+ }))}
18
+ // Equal-width labels isolate pending-state layout from a normal change in label width.
19
+ allowedLevels={[
20
+ { level: "read", label: "Level 1" },
21
+ { level: "write", label: "Level 2" },
22
+ ]}
23
+ allowAuthenticated={false}
24
+ grantAccess={async () => {
25
+ throw new Error("Not used");
26
+ }}
27
+ updateAccess={() =>
28
+ new Promise<void>((resolve) => {
29
+ // The test waits for this mark, so it does not depend on how the editor shows a pending change.
30
+ document.documentElement.dataset.saving = "true";
31
+ document.addEventListener(
32
+ "settle-access",
33
+ () => {
34
+ delete document.documentElement.dataset.saving;
35
+ resolve();
36
+ },
37
+ { once: true },
38
+ );
39
+ })
40
+ }
41
+ revokeAccess={async () => {}}
42
+ />
43
+ </LocaleProvider>
44
+ ),
45
+ host,
46
+ );
@@ -1,14 +1,14 @@
1
1
  import { mutation } from "@k2b/stdlib/solid";
2
- import { IconButton, Placeholder, prompts, SelectChip, Tooltip, useLocale } from "@k2b/ui";
3
- import { createMemo, For, Show } from "solid-js";
4
- import { createStore } from "solid-js/store";
2
+ import { announce, IconButton, Placeholder, prompts, SelectChip, Tooltip, useLocale } from "@k2b/ui";
3
+ import { createMemo, createSignal, For, onCleanup, Show } from "solid-js";
4
+ import { createStore, reconcile } from "solid-js/store";
5
5
  import { CloudAvatar } from "../account/Avatar";
6
6
  import type { AccessEntry, PermissionLevel, Principal, ServiceAccountKind } from "../contracts/shared";
7
7
  import { groupDisplayName } from "../shared/account-display";
8
8
  import { createGroupCoverage, type GroupCoverage, GroupCoveragePanel, GroupCoverageToggle } from "./GroupCoverage";
9
9
  import { isManagerEntry } from "./managers";
10
10
  import { accessMessages } from "./messages";
11
- import PrincipalPicker from "./PrincipalPicker";
11
+ import PrincipalPicker, { principalKey } from "./PrincipalPicker";
12
12
  import { serviceAccountKindDisplay } from "./service-account-kind";
13
13
 
14
14
  // ─────────────────────────────────────────────────────────────────────────
@@ -180,6 +180,12 @@ export default function PermissionEditor(props: PermissionEditorProps) {
180
180
  // A store keeps each row's identity across level changes, so an expanded member list stays open. It holds
181
181
  // copies: store writes go into the objects themselves, and the caller's entries are not the editor's to change.
182
182
  const [entries, setEntries] = createStore<AccessEntry[]>(props.initialEntries.map((entry) => ({ ...entry })));
183
+ const [pending, setPending] = createSignal<string | null>(null);
184
+ const grantPending = () => pending() !== null;
185
+ let disposed = false;
186
+ onCleanup(() => {
187
+ disposed = true;
188
+ });
183
189
  const canEdit = () => props.canEdit !== false;
184
190
  const allowPublic = () => props.allowPublic === true;
185
191
  const allowAuthenticated = () => props.allowAuthenticated !== false;
@@ -196,22 +202,50 @@ export default function PermissionEditor(props: PermissionEditorProps) {
196
202
  }
197
203
  }
198
204
 
199
- const grantMut = mutation.create({
200
- mutation: async (data: {
201
- principal: Principal;
202
- permission: GrantableLevel;
203
- scope: string | undefined;
204
- display: { displayName: string; serviceAccountKind?: ServiceAccountKind };
205
- }) => props.grantAccess(data.principal, data.permission, data.display, data.scope),
206
- onSuccess: (newEntry) => {
207
- setEntries(entries.length, { ...newEntry });
208
- },
209
- onError: (err) => prompts.error(err.message),
210
- });
205
+ const grant = async (principal: Principal, display: { displayName: string; serviceAccountKind?: ServiceAccountKind }) => {
206
+ if (disposed || grantPending() || updateMut.loading() || revokeMut.loading()) return;
207
+ const key = principalKey(principal);
208
+ if (entries.some((entry) => principalKey(entry.principal) === key)) return;
209
+ const first = allowed(principal)[0];
210
+ if (!first) return;
211
+ const id = `pending:${key}`;
212
+ setPending(id);
213
+ setEntries(entries.length, {
214
+ id,
215
+ principal,
216
+ permission: first.level,
217
+ scope: first.scope,
218
+ createdAt: new Date().toISOString(),
219
+ ...display,
220
+ });
221
+ try {
222
+ const entry = await props.grantAccess(principal, first.level, display, first.scope);
223
+ if (disposed) return;
224
+ const index = entries.findIndex((entry) => entry.id === id);
225
+ if (index !== -1) setEntries(index, reconcile({ ...entry }));
226
+ announce(t().accessGranted({ name: display.displayName }));
227
+ } catch (err) {
228
+ if (!disposed) setEntries((current) => current.filter((entry) => entry.id !== id));
229
+ // Prompts are global, so a grant that fails after its dialog closed still says so.
230
+ if (!(typeof err === "object" && err !== null && "name" in err && err.name === "AbortError")) {
231
+ prompts.error(err instanceof Error ? err.message : String(err));
232
+ }
233
+ } finally {
234
+ if (!disposed) setPending(null);
235
+ }
236
+ };
211
237
 
212
238
  type LevelChange = { accessId: string; permission: GrantableLevel; scope: string | undefined };
239
+ // The row whose change is saving: it shows the chosen level with a spinner, the other rows wait.
240
+ const [updating, setUpdating] = createSignal<LevelChange | null>(null);
241
+ const [revoking, setRevoking] = createSignal<string | null>(null);
242
+ const updatingRow = (accessId: string) => {
243
+ const change = updating();
244
+ return change?.accessId === accessId ? change : undefined;
245
+ };
213
246
  const updateMut = mutation.create<LevelChange, LevelChange>({
214
247
  mutation: async (data) => {
248
+ setUpdating(data);
215
249
  await props.updateAccess(data.accessId, data.permission, data.scope);
216
250
  return data;
217
251
  },
@@ -221,6 +255,9 @@ export default function PermissionEditor(props: PermissionEditorProps) {
221
255
  }
222
256
  },
223
257
  onError: (err) => prompts.error(err.message),
258
+ onFinally: () => {
259
+ if (!disposed) setUpdating(null);
260
+ },
224
261
  });
225
262
 
226
263
  const revokeMut = mutation.create<string | null, AccessEntry>({
@@ -231,6 +268,7 @@ export default function PermissionEditor(props: PermissionEditorProps) {
231
268
  variant: "danger",
232
269
  });
233
270
  if (!confirmed) return null;
271
+ setRevoking(entry.id);
234
272
  await props.revokeAccess(entry.id);
235
273
  return entry.id;
236
274
  },
@@ -238,12 +276,16 @@ export default function PermissionEditor(props: PermissionEditorProps) {
238
276
  if (accessId) setEntries((current) => current.filter((entry) => entry.id !== accessId));
239
277
  },
240
278
  onError: (err) => prompts.error(err.message),
279
+ onFinally: () => {
280
+ if (!disposed) setRevoking(null);
281
+ },
241
282
  });
242
- const busy = () => grantMut.loading() || updateMut.loading() || revokeMut.loading();
283
+ const busy = () => grantPending() || updateMut.loading() || revokeMut.loading();
243
284
  // The service refuses to remove the last manager; the row says so up front.
244
285
  const lastManagerId = createMemo(() => {
245
286
  // A grant on part of the resource never manages it.
246
- const managers = (props.effectiveEntries?.(entries) ?? entries).filter((entry) => !entry.scope && isManagerEntry(entry));
287
+ const granted = entries.filter((entry) => entry.id !== pending());
288
+ const managers = (props.effectiveEntries?.(granted) ?? granted).filter((entry) => !entry.scope && isManagerEntry(entry));
247
289
  return managers.length === 1 ? managers[0]!.id : null;
248
290
  });
249
291
 
@@ -260,6 +302,9 @@ export default function PermissionEditor(props: PermissionEditorProps) {
260
302
  entry={entry}
261
303
  coverage={coverage}
262
304
  canEdit={canEdit()}
305
+ pending={entry.id === pending()}
306
+ updating={updatingRow(entry.id)}
307
+ revoking={entry.id === revoking()}
263
308
  disabled={busy()}
264
309
  lastManager={entry.id === lastManagerId()}
265
310
  allowed={allowed(entry.principal)}
@@ -288,11 +333,9 @@ export default function PermissionEditor(props: PermissionEditorProps) {
288
333
  allowPublic={allowPublic()}
289
334
  allowAuthenticated={allowAuthenticated()}
290
335
  allowServiceAccounts={props.allowServiceAccounts}
291
- disabled={busy()}
292
- onSelect={(principal, display) => {
293
- const first = allowed(principal)[0];
294
- if (first && !busy()) grantMut.mutate({ principal, permission: first.level, scope: first.scope, display });
295
- }}
336
+ readOnly={grantPending()}
337
+ disabled={updateMut.loading() || revokeMut.loading()}
338
+ onSelect={(principal, display) => void grant(principal, display)}
296
339
  />
297
340
  </Show>
298
341
  </div>
@@ -306,7 +349,14 @@ export default function PermissionEditor(props: PermissionEditorProps) {
306
349
  function AccessEntryRow(props: {
307
350
  entry: AccessEntry;
308
351
  canEdit: boolean;
352
+ /** Another change is saving: the row keeps its controls, disabled. */
309
353
  disabled: boolean;
354
+ /** This row's grant is saving. */
355
+ pending?: boolean;
356
+ /** This row's level change is saving. */
357
+ updating?: { permission: GrantableLevel; scope: string | undefined };
358
+ /** This row's removal is saving. */
359
+ revoking?: boolean;
310
360
  /** The only entry that manages the resource: it keeps its level and cannot be removed. */
311
361
  lastManager: boolean;
312
362
  allowed: ResolvedLevel[];
@@ -325,25 +375,28 @@ function AccessEntryRow(props: {
325
375
  const display = () => resolveEntryDisplay(props.entry.permission, props.entry.scope, props.allowed, t());
326
376
  const manageLabel = () => resolveEntryDisplay("admin", undefined, props.allowed, t()).label;
327
377
  const removeLabel = () => (props.lastManager ? t().lastManager({ level: manageLabel() }) : t().remove({ name: displayName() }));
328
- const isInteractive = () =>
329
- props.canEdit && !props.disabled && !props.singlePicker && props.allowed.some((option) => option.key === entryKey());
378
+ // Whether the row offers a picker depends only on what the viewer may do, never on a change in flight, so a
379
+ // saving change never swaps the picker for the badge and the rows keep their size.
380
+ const hasPicker = () => props.canEdit && !props.singlePicker && props.allowed.some((option) => option.key === entryKey());
381
+ const saving = () => props.pending || props.updating !== undefined || props.revoking === true;
382
+ // While a level change saves, the picker already shows the chosen level.
383
+ const shownKey = () => (props.updating ? levelKey(props.updating.permission, props.updating.scope) : entryKey());
384
+ const shownDisplay = () =>
385
+ props.updating ? resolveEntryDisplay(props.updating.permission, props.updating.scope, props.allowed, t()) : display();
330
386
 
331
387
  const badgeClass =
332
388
  "flex min-h-7 items-center gap-1 rounded-full border border-transparent bg-[var(--ui-surface-muted)] px-2.5 py-1 text-xs text-secondary";
333
389
 
334
390
  const badgeContent = (
335
391
  <>
336
- <i class={`ti ${display().icon}`} />
392
+ <i class={props.pending ? "ti ti-loader-2 animate-spin" : `ti ${display().icon}`} />
337
393
  <span>{display().label}</span>
338
- <Show when={isInteractive()}>
339
- <i class="ti ti-chevron-down text-[10px]" />
340
- </Show>
341
394
  </>
342
395
  );
343
396
 
344
397
  return (
345
398
  <div>
346
- <div class="group/access-row flex items-center gap-2 py-1.5">
399
+ <div class="group/access-row flex items-center gap-2 py-1.5" aria-busy={saving() ? "true" : undefined}>
347
400
  <Show
348
401
  when={props.entry.principal.type === "user"}
349
402
  fallback={
@@ -381,15 +434,19 @@ function AccessEntryRow(props: {
381
434
  >
382
435
  {(label) => <span class="truncate text-xs text-dimmed">({label()})</span>}
383
436
  </Show>
384
- <Show when={props.coverage}>{(coverage) => <GroupCoverageToggle coverage={coverage()} groupName={displayName()} />}</Show>
437
+ <Show when={props.coverage}>
438
+ {(coverage) => <GroupCoverageToggle coverage={coverage()} groupName={displayName()} disabled={props.pending} />}
439
+ </Show>
385
440
  </div>
386
441
 
387
442
  {/* Permission badge — interactive single-value picker when editable,
388
443
  plain span otherwise. */}
389
- <Show when={isInteractive()} fallback={<span class={`${badgeClass} cursor-default`}>{badgeContent}</span>}>
444
+ <Show when={hasPicker()} fallback={<span class={`${badgeClass} cursor-default`}>{badgeContent}</span>}>
390
445
  <SelectChip
391
446
  aria-label={t().permissionFor({ name: displayName() })}
392
- value={entryKey}
447
+ value={shownKey}
448
+ disabled={props.disabled}
449
+ loading={props.pending || props.updating !== undefined}
393
450
  options={props.allowed.map((option) => {
394
451
  const locked = props.lastManager && (option.level !== "admin" || option.scope !== undefined);
395
452
  return {
@@ -400,7 +457,7 @@ function AccessEntryRow(props: {
400
457
  description: locked ? t().lastManagerOption({ level: manageLabel() }) : option.description,
401
458
  };
402
459
  })}
403
- icon={`ti ${display().icon}`}
460
+ icon={`ti ${shownDisplay().icon}`}
404
461
  position="bottom-left"
405
462
  onValueChange={(key) => {
406
463
  const option = props.allowed.find((candidate) => candidate.key === key);
@@ -422,7 +479,8 @@ function AccessEntryRow(props: {
422
479
  variant="ghost"
423
480
  size="xs"
424
481
  onClick={props.onRevoke}
425
- disabled={props.disabled || props.lastManager}
482
+ disabled={props.disabled || props.pending || props.lastManager}
483
+ loading={props.revoking}
426
484
  aria-label={t().remove({ name: displayName() })}
427
485
  // A disabled button takes no focus and touch has no hover, so the reason also reaches assistive technology.
428
486
  aria-description={props.lastManager ? removeLabel() : undefined}
@@ -33,6 +33,7 @@ export default function PrincipalPicker(props: {
33
33
  allowAuthenticated?: boolean;
34
34
  allowServiceAccounts?: boolean;
35
35
  disabled?: boolean;
36
+ readOnly?: boolean;
36
37
  /** `serviceAccountKind` is set for service accounts so a new row can show the same kind as the result. */
37
38
  onSelect: (principal: Principal, display: { displayName: string; serviceAccountKind?: ServiceAccountKind }) => void;
38
39
  }) {
@@ -100,6 +101,7 @@ export default function PrincipalPicker(props: {
100
101
  return (
101
102
  <Combobox
102
103
  disabled={props.disabled}
104
+ readOnly={props.readOnly}
103
105
  placeholder={
104
106
  props.allowAuthenticated !== false || props.allowPublic
105
107
  ? props.allowServiceAccounts
@@ -14,6 +14,7 @@ export const accessMessages = i18n.define({
14
14
  publicDescription: "Anyone with the link, including people who are not signed in",
15
15
  removeAccessConfirm: ({ name }: { name: string }) => `Remove access for ${name}?`,
16
16
  removeAccess: "Remove access",
17
+ accessGranted: ({ name }: { name: string }) => `Added ${name}`,
17
18
  userBoundServiceAccount: "User-bound service account",
18
19
  resourceBoundServiceAccount: "Resource-bound service account",
19
20
  standaloneServiceAccount: "Service account",
@@ -92,6 +93,7 @@ export const accessMessages = i18n.define({
92
93
  publicDescription: "Alle mit dem Link, auch ohne Anmeldung",
93
94
  removeAccessConfirm: ({ name }) => `Zugriff für ${name} entfernen?`,
94
95
  removeAccess: "Zugriff entfernen",
96
+ accessGranted: ({ name }) => `${name} hinzugefügt`,
95
97
  userBoundServiceAccount: "Benutzergebundenes Dienstkonto",
96
98
  resourceBoundServiceAccount: "Ressourcengebundenes Dienstkonto",
97
99
  standaloneServiceAccount: "Dienstkonto",
package/src/access/ui.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * from depending on Cloud's broad legacy UI entrypoint.
7
7
  */
8
8
 
9
+ export { isManagerEntry, type ManagerCandidate } from "./managers";
9
10
  export type { AllowedLevel, GrantableLevel } from "./PermissionEditor";
10
11
  export { default as PermissionEditor } from "./PermissionEditor";
11
12
  export { default as PrincipalPicker, principalKey } from "./PrincipalPicker";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Root scheme-less Cloud resource links in sanitized Assistant answer HTML.
3
+ * Normalized links stay root-relative under /app/ or /open/ and can never change origin.
4
+ */
5
+ export function rootResourceLinks(html: string): string {
6
+ // sanitize-html emits double-quoted attributes with ", <, and > escaped in values.
7
+ return html.replace(/<a\b[^>]*>/g, (tag) => tag.replace(/(\shref=")((?:app|open)\/)/g, "$1/$2"));
8
+ }
@@ -34,6 +34,7 @@ const messages = i18n.define({
34
34
  turnErrorReason: ({ code, limitMinutes }: AiTurnError) => {
35
35
  if (code === "model_unavailable") return "The model service did not answer.";
36
36
  if (code === "provider_stopped") return "The model provider ended this answer early.";
37
+ if (code === "tool_failed") return "A step failed, and the model stopped without an answer.";
37
38
  if (code === "quota_exhausted") return "Your AI usage limit for this period is reached. You can continue once it resets.";
38
39
  if (code === "context_full") return "This chat is too long for the model. Start a new chat to continue; this one stays as it is.";
39
40
  if (code === "time_limit")
@@ -336,6 +337,7 @@ const messages = i18n.define({
336
337
  turnErrorReason: ({ code, limitMinutes }) => {
337
338
  if (code === "model_unavailable") return "Der KI-Dienst hat nicht geantwortet.";
338
339
  if (code === "provider_stopped") return "Der KI-Anbieter hat diese Antwort vorzeitig beendet.";
340
+ if (code === "tool_failed") return "Ein Schritt ist fehlgeschlagen, und das Modell hat danach nicht geantwortet.";
339
341
  if (code === "quota_exhausted")
340
342
  return "Dein KI-Kontingent für diesen Zeitraum ist aufgebraucht. Du kannst weiterarbeiten, sobald es sich erneuert.";
341
343
  if (code === "context_full")
@@ -1,4 +1,5 @@
1
1
  import { createEffect, onCleanup, onMount } from "solid-js";
2
+ import { rootResourceLinks } from "./answer-links";
2
3
  import { useAiChatActions } from "./message-actions";
3
4
 
4
5
  export function AssistantMarkdownBlock(props: { html: string }) {
@@ -41,5 +42,5 @@ export function AssistantMarkdownBlock(props: { html: string }) {
41
42
  element.addEventListener("click", handleClick);
42
43
  onCleanup(() => element.removeEventListener("click", handleClick));
43
44
  });
44
- return <div ref={element} class="assistant-markdown-block" innerHTML={props.html} />;
45
+ return <div ref={element} class="assistant-markdown-block" innerHTML={rootResourceLinks(props.html)} />;
45
46
  }