@anchrd/intel-ui 0.31.0 → 0.32.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": "@anchrd/intel-ui",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -33,7 +33,7 @@
33
33
  "typecheck": "tsc --noEmit"
34
34
  },
35
35
  "dependencies": {
36
- "@anchrd/intel-contract": "^0.19.0",
36
+ "@anchrd/intel-contract": "^0.20.0",
37
37
  "@blocknote/core": "^0.52.1",
38
38
  "@blocknote/react": "^0.52.1",
39
39
  "@blocknote/shadcn": "^0.52.1",
@@ -255,14 +255,25 @@ export function AccessSummary({
255
255
  export function ResourceAccessSummary({
256
256
  resourceId,
257
257
  ownerId,
258
+ kind = "node",
258
259
  }: {
259
260
  resourceId: string;
260
261
  ownerId: string;
262
+ // Which side of the tree the id belongs to (#530). A flow is not a node, so it is a different
263
+ // route — but the same answer, which is why one component draws both.
264
+ kind?: "node" | "flow";
261
265
  }) {
262
266
  const { data } = useIntelRouterContext();
263
267
  const grants = useQuery({
264
- queryKey: ["effective-node-grants", resourceId],
265
- queryFn: () => data.listEffectiveAccess(resourceId),
268
+ // ⚠️ Both kinds share the `effective-node-grants` prefix on purpose: a share mutation
269
+ // invalidates that prefix, and a flow grant changes what a folder summary above it shows just
270
+ // as a folder grant changes what the flow below it shows. Two prefixes would mean one of the
271
+ // two keeps drawing an access picture that is no longer in force.
272
+ queryKey: ["effective-node-grants", kind, resourceId],
273
+ queryFn: () =>
274
+ kind === "flow"
275
+ ? data.listEffectiveFlowAccess(resourceId)
276
+ : data.listEffectiveAccess(resourceId),
266
277
  retry: false,
267
278
  // Time cannot change this answer locally. Share mutations invalidate the whole effective prefix
268
279
  // because changing one folder also changes every descendant summary already on screen.
@@ -55,8 +55,10 @@ import {
55
55
  import {
56
56
  ResourceAccessList,
57
57
  ResourceGrantList,
58
+ RevokeFlowGrantInput,
58
59
  RevokeGrantInput,
59
60
  RevokeGrantResult,
61
+ ShareFlowInput,
60
62
  ShareInput,
61
63
  ShareResult,
62
64
  } from "@anchrd/intel-contract/share";
@@ -384,6 +386,30 @@ export function createIntelDataProvider(
384
386
  { method: "POST", body: JSON.stringify(parsed) },
385
387
  );
386
388
  },
389
+ async listFlowGrants(flowId) {
390
+ return await request(`/flows/${encodeURIComponent(flowId)}/grants`, ResourceGrantList);
391
+ },
392
+ async listEffectiveFlowAccess(flowId) {
393
+ return await request(
394
+ `/flows/${encodeURIComponent(flowId)}/effective-access`,
395
+ ResourceAccessList,
396
+ );
397
+ },
398
+ async shareFlow(input) {
399
+ const parsed = ShareFlowInput.parse(input);
400
+ return await request(`/flows/${encodeURIComponent(parsed.flowId)}/grants`, ShareResult, {
401
+ method: "POST",
402
+ body: JSON.stringify(parsed),
403
+ });
404
+ },
405
+ async revokeFlowGrant(input) {
406
+ const parsed = RevokeFlowGrantInput.parse(input);
407
+ return await request(
408
+ `/flows/${encodeURIComponent(parsed.flowId)}/grants/${encodeURIComponent(parsed.grantId)}/revoke`,
409
+ RevokeGrantResult,
410
+ { method: "POST", body: JSON.stringify(parsed) },
411
+ );
412
+ },
387
413
  listFlows,
388
414
  async getFlow(flowId) {
389
415
  return await request(`/flows/${encodeURIComponent(flowId)}`, FlowDocument);
@@ -54,8 +54,10 @@ import type {
54
54
  } from "@anchrd/intel-contract/node";
55
55
  import type {
56
56
  ResourceGrantList,
57
+ RevokeFlowGrantInput,
57
58
  RevokeGrantInput,
58
59
  RevokeGrantResult,
60
+ ShareFlowInput,
59
61
  ShareInput,
60
62
  ShareResult,
61
63
  } from "@anchrd/intel-contract/share";
@@ -147,6 +149,16 @@ export interface IntelDataProvider {
147
149
  // the new principal still cannot. A warning, never a refusal (ADR-0004 §4).
148
150
  shareNode(input: ShareInput): Promise<ShareResult>;
149
151
  revokeGrant(input: RevokeGrantInput): Promise<RevokeGrantResult>;
152
+ // The same four for one flow (#530). Separate calls rather than a widened `resourceId`, because
153
+ // the id names a different table on the other side and the routes are separate there too.
154
+ listFlowGrants(flowId: string): Promise<ResourceGrantList>;
155
+ listEffectiveFlowAccess(
156
+ flowId: string,
157
+ ): Promise<import("@anchrd/intel-contract/share").ResourceAccessList>;
158
+ // ⚠️ `ShareResult.unrunnable` is only ever filled in here: a grant on one flow does not reach the
159
+ // flows it calls, and this is where that gets said.
160
+ shareFlow(input: ShareFlowInput): Promise<ShareResult>;
161
+ revokeFlowGrant(input: RevokeFlowGrantInput): Promise<RevokeGrantResult>;
150
162
  listFlows(input?: ListFlowsInput): Promise<FlowList>;
151
163
  getFlow(flowId: string): Promise<FlowDocument>;
152
164
  // What a flow calls, read out of its graph. It answers a different question from `listTreeChildren`
@@ -421,9 +421,6 @@ function FlowsEditor() {
421
421
  // Lifted out of the bar because the canvas dismisses it too — a click on the pane is the third way
422
422
  // out, next to the trigger and Escape.
423
423
  const [paletteOpen, setPaletteOpen] = usePaletteOpen();
424
- // A flow is shared through the folder it is filed in, on the Intelligence screen (ADR-0004 §2). It
425
- // has no share action of its own — a narrower grant beside the folder's would break the guarantee
426
- // that a flow only reaches flows in its own subtree.
427
424
  const document = useQuery({
428
425
  queryKey: ["flow", selectedFlowId],
429
426
  queryFn: () => data.getFlow(selectedFlowId ?? ""),
package/src/i18n/de.json CHANGED
@@ -76,7 +76,6 @@
76
76
  "resource.archive": "Archivieren",
77
77
  "resource.export": "Export",
78
78
  "resource.import": "Import",
79
- "resource.downloadCsv": "CSV herunterladen",
80
79
  "resource.validate": "Prüfen",
81
80
  "resource.links": "Verweise",
82
81
  "resource.share": "Freigeben",
@@ -116,8 +115,6 @@
116
115
  "node.download": "Datei herunterladen",
117
116
  "node.attachmentHelp": "Die kanonische Datei liegt privat in Intel. Ihre KI-lesbare Projektion wird getrennt indexiert.",
118
117
  "node.table.summary": "{rows} Zeilen, {columns} Spalten",
119
- "node.table.download": "CSV herunterladen",
120
- "node.table.downloadFailed": "Die CSV konnte nicht erzeugt werden. Möglicherweise ist die Tabelle noch leer.",
121
118
  "node.table.empty": "Noch keine Zeilen. Zeilen werden von Flows angehängt.",
122
119
  "node.table.undefined": "Diese Tabelle hat noch keine Spalten.",
123
120
  "node.select": "Wähle ein Dokument oder einen Ordner, um damit zu arbeiten.",
@@ -364,5 +361,17 @@
364
361
  "flows.toolStepNeedsServer": "Dem Schritt „{label}“ fehlt noch ein Server.",
365
362
  "flows.toolFunctionsFailed": "Die Funktionsliste ließ sich nicht laden, daher ist die Auswahl möglicherweise unvollständig.",
366
363
  "flows.publishPreviewToolUnavailable": "Diesen Server erreichst du nicht, daher wird das Veröffentlichen abgelehnt.",
367
- "flows.publishPreviewToolAllow": "Eingefroren auf: {functions}"
364
+ "flows.publishPreviewToolAllow": "Eingefroren auf: {functions}",
365
+ "flow.shareUnreadable": "Dieser Flow liest Dokumente, die diese Freigabe nicht abdeckt: {titles}.",
366
+ "flow.shareUnreadableMore": "{count} weitere liegen ebenfalls außer Reichweite, und du kannst sie nicht sehen.",
367
+ "flow.shareUnreadableHidden": "{count} Dokumente, die dieser Flow liest, liegen mit dieser Freigabe außer Reichweite. Du kannst sie nicht sehen.",
368
+ "flow.shareUnreadableHint": "Nichts ist blockiert. Ein Lauf hält für sie schlicht an dieser Stelle an.",
369
+ "flow.shareUnrunnable": "Dieser Flow ruft Flows auf, die diese Freigabe nicht abdeckt: {titles}.",
370
+ "flow.shareUnrunnableMore": "{count} weitere liegen ebenfalls außer Reichweite, und du kannst sie nicht sehen.",
371
+ "flow.shareUnrunnableHidden": "{count} Flows, die dieser aufruft, liegen mit dieser Freigabe außer Reichweite. Du kannst sie nicht sehen.",
372
+ "flow.shareUnrunnableHint": "Eine Freigabe auf einem Flow reicht nur bis zu diesem Flow. Gib die anderen ebenfalls frei oder den Ordner, in dem sie liegen. Mit „Prüfen“ sehen die Beschenkten es vor dem Start selbst.",
373
+ "flow.verbHint.read": "Diesen Flow öffnen und lesen",
374
+ "flow.verbHint.write": "Diesen Flow ändern",
375
+ "flow.verbHint.execute": "Diesen Flow starten",
376
+ "flow.verbHint.share": "Anderen Zugriff darauf geben"
368
377
  }
package/src/i18n/en.json CHANGED
@@ -76,7 +76,6 @@
76
76
  "resource.archive": "Archive",
77
77
  "resource.export": "Export",
78
78
  "resource.import": "Import",
79
- "resource.downloadCsv": "Download CSV",
80
79
  "resource.validate": "Check",
81
80
  "resource.links": "Links",
82
81
  "resource.share": "Share",
@@ -116,8 +115,6 @@
116
115
  "node.download": "Download file",
117
116
  "node.attachmentHelp": "The canonical file is stored privately in Intel. Its AI-readable projection is indexed separately.",
118
117
  "node.table.summary": "{rows} rows, {columns} columns",
119
- "node.table.download": "Download CSV",
120
- "node.table.downloadFailed": "The CSV could not be created. The table may still be empty.",
121
118
  "node.table.empty": "No rows yet. Rows are appended by flows.",
122
119
  "node.table.undefined": "This table has no columns yet.",
123
120
  "node.select": "Select a document or folder to work with it.",
@@ -364,5 +361,17 @@
364
361
  "flows.toolStepNeedsServer": "The step “{label}” still needs a server.",
365
362
  "flows.toolFunctionsFailed": "The list of functions could not be loaded, so the selection may be incomplete.",
366
363
  "flows.publishPreviewToolUnavailable": "You do not reach this server, so publishing will be refused.",
367
- "flows.publishPreviewToolAllow": "Frozen to: {functions}"
364
+ "flows.publishPreviewToolAllow": "Frozen to: {functions}",
365
+ "flow.shareUnreadable": "This flow reads documents this grant does not cover: {titles}.",
366
+ "flow.shareUnreadableMore": "{count} more are out of reach too, and you cannot see them.",
367
+ "flow.shareUnreadableHidden": "{count} documents this flow reads are out of reach with this grant. You cannot see them.",
368
+ "flow.shareUnreadableHint": "Nothing is blocked. A run will simply stop at that step for them.",
369
+ "flow.shareUnrunnable": "This flow calls flows this grant does not cover: {titles}.",
370
+ "flow.shareUnrunnableMore": "{count} more are out of reach too, and you cannot see them.",
371
+ "flow.shareUnrunnableHidden": "{count} flows this one calls are out of reach with this grant. You cannot see them.",
372
+ "flow.shareUnrunnableHint": "A grant on one flow reaches that flow alone. Share those flows too, or share the folder they are filed in. Let them check with Validate before they run it.",
373
+ "flow.verbHint.read": "Open this flow and read it",
374
+ "flow.verbHint.write": "Change this flow",
375
+ "flow.verbHint.execute": "Start this flow",
376
+ "flow.verbHint.share": "Give others access to it"
368
377
  }
package/src/i18n/es.json CHANGED
@@ -76,7 +76,6 @@
76
76
  "resource.archive": "Archivar",
77
77
  "resource.export": "Exportar",
78
78
  "resource.import": "Importar",
79
- "resource.downloadCsv": "Descargar CSV",
80
79
  "resource.validate": "Comprobar",
81
80
  "resource.links": "Enlaces",
82
81
  "resource.share": "Compartir",
@@ -116,8 +115,6 @@
116
115
  "node.download": "Descargar el archivo",
117
116
  "node.attachmentHelp": "El archivo canónico se guarda en privado en Intel. Su proyección legible por la IA se indexa por separado.",
118
117
  "node.table.summary": "{rows} filas, {columns} columnas",
119
- "node.table.download": "Descargar CSV",
120
- "node.table.downloadFailed": "No se pudo crear el CSV. Puede que la tabla todavía esté vacía.",
121
118
  "node.table.empty": "Todavía no hay filas. Las filas las añaden los flujos.",
122
119
  "node.table.undefined": "Esta tabla todavía no tiene columnas.",
123
120
  "node.select": "Elige un documento o una carpeta para trabajar con ello.",
@@ -364,5 +361,17 @@
364
361
  "flows.toolStepNeedsServer": "Al paso «{label}» todavía le falta un servidor.",
365
362
  "flows.toolFunctionsFailed": "No se pudo cargar la lista de funciones, así que la selección puede estar incompleta.",
366
363
  "flows.publishPreviewToolUnavailable": "No alcanzas este servidor, así que la publicación será rechazada.",
367
- "flows.publishPreviewToolAllow": "Congelado en: {functions}"
364
+ "flows.publishPreviewToolAllow": "Congelado en: {functions}",
365
+ "flow.shareUnreadable": "Este flujo lee documentos que este acceso no cubre: {titles}.",
366
+ "flow.shareUnreadableMore": "{count} más también quedan fuera de alcance, y no puedes verlos.",
367
+ "flow.shareUnreadableHidden": "{count} documentos que lee este flujo quedan fuera de alcance con este acceso. No puedes verlos.",
368
+ "flow.shareUnreadableHint": "No se bloquea nada. Una ejecución simplemente se detendrá en ese paso para esa persona.",
369
+ "flow.shareUnrunnable": "Este flujo llama a flujos que este acceso no cubre: {titles}.",
370
+ "flow.shareUnrunnableMore": "{count} más también quedan fuera de alcance, y no puedes verlos.",
371
+ "flow.shareUnrunnableHidden": "{count} flujos a los que llama este quedan fuera de alcance con este acceso. No puedes verlos.",
372
+ "flow.shareUnrunnableHint": "Un acceso sobre un flujo llega solo a ese flujo. Comparte también esos flujos, o la carpeta donde están. Con «Validar» lo verán ellos mismos antes de ejecutarlo.",
373
+ "flow.verbHint.read": "Abrir este flujo y leerlo",
374
+ "flow.verbHint.write": "Cambiar este flujo",
375
+ "flow.verbHint.execute": "Iniciar este flujo",
376
+ "flow.verbHint.share": "Dar acceso a otras personas"
368
377
  }
@@ -32,8 +32,10 @@ export function NodeTablePanel({ node }: { node: Node }) {
32
32
  query lives; the screen would otherwise have to load a table it does not show.
33
33
 
34
34
  ⚠️ The CSV export used to stand here too, as a button WITH TEXT beside two icon buttons.
35
- Since #454 it is an entry in the title line's three-dot menu (`resource-menu.tsx`) where
36
- the bundle export sits as well, because both do the same: hand out the whole thing. */}
35
+ #454 moved it into the title line's three-dot menu, and #532 dropped it there: the bundle
36
+ export beside it already writes the table into the zip as `<title>.csv` (`bundle.ts`), so
37
+ the two entries handed out the same bytes. Taking a table home is `Export`, here and
38
+ nowhere else. */}
37
39
  <ActionSlot name="title-meta">
38
40
  {table.data
39
41
  ? i18n.t("node.table.summary", {
@@ -6,7 +6,6 @@ import { useNavigate, useRouterState } from "@tanstack/react-router";
6
6
  import {
7
7
  Archive,
8
8
  CornerLeftUp,
9
- Download,
10
9
  Ellipsis,
11
10
  FileArchive,
12
11
  FolderDown,
@@ -49,9 +48,9 @@ import { useDateTime } from "@/time/time-context.tsx";
49
48
  * row menu could do is reached here — including moving, which is why this component owns the folder
50
49
  * picker rather than being handed one.
51
50
  *
52
- * ⚠️ An entry that does not apply is absent, never disabled: a folder has no retrieval mode, a flow
53
- * is shared through the folder it is filed in rather than on its own (ADR-0004 §2). A greyed-out row
54
- * still promises something is there.
51
+ * ⚠️ An entry that does not apply is absent, never disabled: a folder has no retrieval mode and no
52
+ * links, a flow has no version history of the node kind. A greyed-out row still promises something
53
+ * is there. Sharing applies to all of them since #530.
55
54
  */
56
55
  export type ResourceTarget = { type: "node"; node: Node } | { type: "flow"; flow: Flow };
57
56
 
@@ -83,13 +82,14 @@ function titleOf(target: ResourceTarget): string {
83
82
  /**
84
83
  * Which sentence explains a verb.
85
84
  *
86
- * ⚠️ One sentence per verb, and that is only true again since Agents were parked (#388). "Run"
87
- * used to mean two different things starting the flows filed in a folder, and using an agent
88
- * through its chat, its schedules and its MCP address so #143 gave the agent its own wording.
89
- * With one meaning left, a per-kind branch would be a fork nothing takes.
85
+ * ⚠️ The hint is about the SUBJECT as much as the verb, and the branch came back with #530. "See
86
+ * everything in here" is right for a folder and wrong for one flow, and a hint describing the wrong
87
+ * reach is worse than none it is the sentence somebody reads INSTEAD of thinking about what they
88
+ * are granting. The wording that used to fork here was the agent's (#143), parked with it (#388);
89
+ * this is a different fork, on the subject rather than on a second meaning of one word.
90
90
  */
91
- export function verbHintKey(verb: ResourceVerb): string {
92
- return `node.verbHint.${verb}`;
91
+ export function verbHintKey(verb: ResourceVerb, subject: ResourceTarget["type"] = "node"): string {
92
+ return `${subject}.verbHint.${verb}`;
93
93
  }
94
94
 
95
95
  // ⚠️ The same reading `moveErrorKey` does for a move, for the changes this menu makes. A conflict is
@@ -271,37 +271,6 @@ export function ResourceMenu({
271
271
  },
272
272
  });
273
273
 
274
- // #454: a table's CSV export — an ENTRY, not a button of its own. It was the only control WITH
275
- // TEXT beside two icon buttons in the title line, and it was needed no more often than what sits
276
- // in here anyway; it was merely wider. Intel has made the same decision twice already (#363 for
277
- // status and archive, #346 for the import).
278
- //
279
- // ⚠️ It lives HERE and no longer in `NodeTablePanel`: there it hung off the table query in order
280
- // to prevent an empty export. The condition survives the move without a second query — the
281
- // canonical content IS the CSV, and if it is empty nothing is downloaded, it is refused instead.
282
- // A file of zero bytes is the worse answer.
283
- const downloadCsv = useMutation({
284
- mutationFn: async () => {
285
- if (target.type !== "node") throw new Error("csv export is a node's");
286
- const document_ = await data.getNode(target.node.id);
287
- const content = document_.content ?? "";
288
- if (content === "") throw new Error("nothing to export yet");
289
- return new Blob([content], { type: "text/csv;charset=utf-8" });
290
- },
291
- onSuccess: (blob) => {
292
- const url = URL.createObjectURL(blob);
293
- const anchor = document.createElement("a");
294
- anchor.href = url;
295
- // No doubled `.csv` when the title already carries the extension — unchanged from #40.
296
- anchor.download = title.toLowerCase().endsWith(".csv") ? title : `${title}.csv`;
297
- document.body.append(anchor);
298
- anchor.click();
299
- anchor.remove();
300
- setTimeout(() => URL.revokeObjectURL(url), 0);
301
- },
302
- });
303
- const isTable = target.type === "node" && target.node.kind === "table";
304
-
305
274
  const failure = rename.isError
306
275
  ? null // the rename dialog words its own refusal, beside the field that caused it
307
276
  : archive.isError
@@ -340,19 +309,21 @@ export function ResourceMenu({
340
309
  </DropdownMenuItem>
341
310
  {/* Every kind exports: a folder takes its subtree along, everything else is a bundle of
342
311
  one (#136). The entry sits with rename and move because it acts on the whole thing,
343
- not on its content. */}
312
+ not on its content.
313
+
314
+ ⚠️ A table has NO second entry beside this one (#532). It used to carry "Download CSV"
315
+ here, and for a table WITH a header both handed out the same bytes: `bundle.ts` writes
316
+ such a node into the zip as `<title>.csv`. Two ways to one file are two strings, two
317
+ catalogs and two tests — the zip costs one step more and is the whole difference.
318
+
319
+ ⚠️ The one case where they differed is the table that has no header yet: `downloadCsv`
320
+ refused it out loud, `tableCsv` joins no segments and writes a file of zero bytes
321
+ (#534). That is the export's gap on every kind it can hit, not something this entry
322
+ was covering — which is why it is fixed there and not by keeping a second entry. */}
344
323
  <DropdownMenuItem onSelect={() => exportBundle.mutate()}>
345
324
  <FolderDown aria-hidden="true" />
346
325
  {i18n.t("resource.export")}
347
326
  </DropdownMenuItem>
348
- {/* Only a table has a CSV — and it stands beside the bundle export because both do the
349
- same thing: hand out the whole thing, not a part of its content. */}
350
- {isTable ? (
351
- <DropdownMenuItem onSelect={() => downloadCsv.mutate()}>
352
- <Download aria-hidden="true" />
353
- {i18n.t("resource.downloadCsv")}
354
- </DropdownMenuItem>
355
- ) : null}
356
327
  {/* The other half of the same round trip, next to it rather than in the tree's plus
357
328
  (#346): one word in the menu, both sources under it. */}
358
329
  {importable ? (
@@ -387,15 +358,12 @@ export function ResourceMenu({
387
358
  {i18n.t("resource.links")}
388
359
  </DropdownMenuItem>
389
360
  ) : null}
390
- {/* A flow has no share of its own: it is reached through the folder it is filed in, and a
391
- narrower grant beside the folder's would break the rule that a flow only calls flows in
392
- its own subtree (ADR-0004 §2). */}
393
- {node ? (
394
- <DropdownMenuItem onSelect={() => setSharing(true)}>
395
- <Share2 aria-hidden="true" />
396
- {i18n.t("resource.share")}
397
- </DropdownMenuItem>
398
- ) : null}
361
+ {/* Every kind of resource is shared here, a flow included since #530. What a grant on one
362
+ flow does NOT reach is said in the dialog rather than refused here. */}
363
+ <DropdownMenuItem onSelect={() => setSharing(true)}>
364
+ <Share2 aria-hidden="true" />
365
+ {i18n.t("resource.share")}
366
+ </DropdownMenuItem>
399
367
  {(node === null || node.kind !== "folder") && (
400
368
  <DropdownMenuItem onSelect={() => setVersionsOpen(true)}>
401
369
  <History aria-hidden="true" />
@@ -419,9 +387,6 @@ export function ResourceMenu({
419
387
  {/* Its own sentence, not `resourceErrorKey`'s: nothing was changed, something failed to
420
388
  arrive, and "the change was not saved" would send the reader looking for a change. */}
421
389
  {exportBundle.isError ? <MenuFailure>{i18n.t("resource.exportFailed")}</MenuFailure> : null}
422
- {downloadCsv.isError ? (
423
- <MenuFailure>{i18n.t("node.table.downloadFailed")}</MenuFailure>
424
- ) : null}
425
390
  {/* The import's own sentence, and its own pickers — only where the entry exists, because two
426
391
  file inputs on every document's menu would be two elements nothing can ever open. */}
427
392
  {bundleImport.isError ? <MenuFailure>{i18n.t("tree.importFailed")}</MenuFailure> : null}
@@ -490,7 +455,7 @@ export function ResourceMenu({
490
455
  submit={(next) => rename.mutate(next)}
491
456
  />
492
457
  ) : null}
493
- {sharing && node ? <SharePanel node={node} close={() => setSharing(false)} /> : null}
458
+ {sharing ? <SharePanel target={target} close={() => setSharing(false)} /> : null}
494
459
  {linksOpen && node ? <NodeLinksPanel node={node} close={() => setLinksOpen(false)} /> : null}
495
460
  {versionsOpen ? (
496
461
  <VersionHistory target={target} close={() => setVersionsOpen(false)} />
@@ -688,10 +653,10 @@ function NodeLinksPanel({ node, close }: { node: Node; close(): void }) {
688
653
  );
689
654
  }
690
655
 
691
- // The one share dialog in the product. It sits on the folder that holds the documents and the
692
- // flows, which is where a permission decision belongs (ADR-0004 §2). Which verbs it offers is the
693
- // server's answer, not this component's: `execute` never appears on a document, because a document
694
- // has nothing to run.
656
+ // The one share dialog in the product, for every kind of resource that can be shared a folder, a
657
+ // document, a table, and since #530 a flow on its own. Which verbs it offers is the server's
658
+ // answer, not this component's: `execute` never appears on a document, because a document has
659
+ // nothing to run, and all four appear on a flow.
695
660
  /**
696
661
  * Which of the three principals the dialog can SET (#431).
697
662
  *
@@ -709,7 +674,18 @@ function NodeLinksPanel({ node, close }: { node: Node; close(): void }) {
709
674
  */
710
675
  type SharePrincipalKind = "email" | "organization";
711
676
 
712
- function SharePanel({ node, close }: { node: Node; close(): void }) {
677
+ // What the grant just made does not cover, in the two directions it can fail to (#530): documents
678
+ // the grantee cannot read, and flows the grantee cannot start. Both are `null` until an answer
679
+ // arrives, so "no warning yet" and "nothing to warn about" stay distinguishable.
680
+ type ShareWarnings = { unreadable: UnreadableNodes; unrunnable: UnreadableNodes } | null;
681
+
682
+ // Whether there is anything to say. A warning with neither a title nor a count is silence, and
683
+ // drawing an empty box for it would make every ordinary grant look like it had a caveat.
684
+ function withheld(value: UnreadableNodes): boolean {
685
+ return value.titles.length > 0 || value.hidden > 0;
686
+ }
687
+
688
+ function SharePanel({ target, close }: { target: ResourceTarget; close(): void }) {
713
689
  const { data } = useIntelRouterContext();
714
690
  const i18n = useI18n();
715
691
  const queryClient = useQueryClient();
@@ -718,31 +694,50 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
718
694
  const [verbs, setVerbs] = useState<ResourceVerb[]>(["read"]);
719
695
  // What the grant just made does not cover. It survives the form being cleared, because it is the
720
696
  // answer to the question the user just asked and they need a moment to read it.
721
- const [unreadable, setUnreadable] = useState<UnreadableNodes | null>(null);
697
+ const [warnings, setWarnings] = useState<ShareWarnings>(null);
698
+ const isFlow = target.type === "flow";
699
+ const resourceId = idOf(target);
722
700
  const grants = useQuery({
723
- queryKey: ["node-grants", node.id],
724
- queryFn: () => data.listGrants(node.id),
701
+ queryKey: ["resource-grants", target.type, resourceId],
702
+ queryFn: () => (isFlow ? data.listFlowGrants(resourceId) : data.listGrants(resourceId)),
725
703
  });
704
+ // ⚠️ Both queries are invalidated by every mutation below, and the flow key is under the same
705
+ // effective prefix as the node key: a grant on a flow changes what the summary of the folder
706
+ // above it shows, and the other way round.
707
+ const invalidate = async () => {
708
+ await Promise.all([
709
+ queryClient.invalidateQueries({ queryKey: ["resource-grants", target.type, resourceId] }),
710
+ queryClient.invalidateQueries({ queryKey: ["effective-node-grants"] }),
711
+ ]);
712
+ };
726
713
  const share = useMutation({
727
714
  // One request per verb, because one grant is one verb. The keys differ so a retry of the whole
728
715
  // form replays each verb on its own rather than collapsing them into one.
729
716
  mutationFn: async () => {
730
- let last: UnreadableNodes | null = null;
717
+ let last: ShareWarnings = null;
731
718
  for (const verb of verbs) {
719
+ const principal =
720
+ principalKind === "organization"
721
+ ? ({ type: "organization" } as const)
722
+ : ({ type: "email", email } as const);
732
723
  // The last verb's answer is the one kept: every request describes the access in force after
733
724
  // it, so the newest is the only one still true.
734
- last = (
735
- await data.shareNode({
736
- resourceId: node.id,
737
- principal:
738
- principalKind === "organization"
739
- ? { type: "organization" }
740
- : { type: "email", email },
741
- verb,
742
- expiresAt: null,
743
- idempotencyKey: crypto.randomUUID(),
744
- })
745
- ).unreadable;
725
+ const result = isFlow
726
+ ? await data.shareFlow({
727
+ flowId: resourceId,
728
+ principal,
729
+ verb,
730
+ expiresAt: null,
731
+ idempotencyKey: crypto.randomUUID(),
732
+ })
733
+ : await data.shareNode({
734
+ resourceId,
735
+ principal,
736
+ verb,
737
+ expiresAt: null,
738
+ idempotencyKey: crypto.randomUUID(),
739
+ });
740
+ last = { unreadable: result.unreadable, unrunnable: result.unrunnable };
746
741
  }
747
742
  return last;
748
743
  },
@@ -752,32 +747,28 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
752
747
  // until the review of #431 caught it, and "everyone in the organization" is the one setting
753
748
  // that must never be the quiet default for the NEXT grant somebody makes in the same dialog.
754
749
  setPrincipalKind("email");
755
- setUnreadable(result);
750
+ setWarnings(result);
756
751
  },
757
752
  // ⚠️ `onSettled`, not `onSuccess`. One request per verb means a run can end halfway: three verbs
758
753
  // granted, the fourth refused. On `onSuccess` the list would then still show the state from
759
754
  // before, and the user would read a permission picture that is not the one in force. Of all the
760
755
  // things to be silently wrong about, access is the worst.
761
- onSettled: async () => {
762
- await Promise.all([
763
- queryClient.invalidateQueries({ queryKey: ["node-grants", node.id] }),
764
- queryClient.invalidateQueries({ queryKey: ["effective-node-grants"] }),
765
- ]);
766
- },
756
+ onSettled: invalidate,
767
757
  });
768
758
  const revoke = useMutation({
769
759
  mutationFn: (grantId: string) =>
770
- data.revokeGrant({
771
- resourceId: node.id,
772
- grantId,
773
- idempotencyKey: crypto.randomUUID(),
774
- }),
775
- onSettled: async () => {
776
- await Promise.all([
777
- queryClient.invalidateQueries({ queryKey: ["node-grants", node.id] }),
778
- queryClient.invalidateQueries({ queryKey: ["effective-node-grants"] }),
779
- ]);
780
- },
760
+ isFlow
761
+ ? data.revokeFlowGrant({
762
+ flowId: resourceId,
763
+ grantId,
764
+ idempotencyKey: crypto.randomUUID(),
765
+ })
766
+ : data.revokeGrant({
767
+ resourceId,
768
+ grantId,
769
+ idempotencyKey: crypto.randomUUID(),
770
+ }),
771
+ onSettled: invalidate,
781
772
  });
782
773
 
783
774
  /**
@@ -817,19 +808,45 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
817
808
  {/* ⚠️ `status`, not `alert`, and beside the grant rather than in place of it: the access was
818
809
  given. A node reference across the folder edge is a possible failure, not a way around
819
810
  permissions, so nothing here blocks anything (ADR-0004 §4). */}
820
- {unreadable && (unreadable.titles.length > 0 || unreadable.hidden > 0) && (
811
+ {warnings && withheld(warnings.unreadable) && (
821
812
  <div role="status" className="mb-4 rounded-md border bg-muted p-3 text-sm">
822
- {unreadable.titles.length > 0 ? (
813
+ {warnings.unreadable.titles.length > 0 ? (
823
814
  <p>
824
- {i18n.t("node.shareUnreadable", { titles: unreadable.titles.join(", ") })}
825
- {unreadable.hidden > 0
826
- ? ` ${i18n.t("node.shareUnreadableMore", { count: unreadable.hidden })}`
815
+ {i18n.t(`${target.type}.shareUnreadable`, {
816
+ titles: warnings.unreadable.titles.join(", "),
817
+ })}
818
+ {warnings.unreadable.hidden > 0
819
+ ? ` ${i18n.t(`${target.type}.shareUnreadableMore`, { count: warnings.unreadable.hidden })}`
827
820
  : ""}
828
821
  </p>
829
822
  ) : (
830
- <p>{i18n.t("node.shareUnreadableHidden", { count: unreadable.hidden })}</p>
823
+ <p>
824
+ {i18n.t(`${target.type}.shareUnreadableHidden`, {
825
+ count: warnings.unreadable.hidden,
826
+ })}
827
+ </p>
828
+ )}
829
+ <p className="mt-1 text-xs text-muted-foreground">
830
+ {i18n.t(`${target.type}.shareUnreadableHint`)}
831
+ </p>
832
+ </div>
833
+ )}
834
+ {/* The half a folder grant never needed: a grant on ONE flow stops at that flow, so the flows
835
+ it calls are somebody else's to grant. `status` and not `alert` for the same reason as
836
+ above — the access was given, and this says what it does not reach (#530). */}
837
+ {warnings && withheld(warnings.unrunnable) && (
838
+ <div role="status" className="mb-4 rounded-md border bg-muted p-3 text-sm">
839
+ {warnings.unrunnable.titles.length > 0 ? (
840
+ <p>
841
+ {i18n.t("flow.shareUnrunnable", { titles: warnings.unrunnable.titles.join(", ") })}
842
+ {warnings.unrunnable.hidden > 0
843
+ ? ` ${i18n.t("flow.shareUnrunnableMore", { count: warnings.unrunnable.hidden })}`
844
+ : ""}
845
+ </p>
846
+ ) : (
847
+ <p>{i18n.t("flow.shareUnrunnableHidden", { count: warnings.unrunnable.hidden })}</p>
831
848
  )}
832
- <p className="mt-1 text-xs text-muted-foreground">{i18n.t("node.shareUnreadableHint")}</p>
849
+ <p className="mt-1 text-xs text-muted-foreground">{i18n.t("flow.shareUnrunnableHint")}</p>
833
850
  </div>
834
851
  )}
835
852
  {grants.data && grants.data.items.length > 0 ? (
@@ -922,7 +939,13 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
922
939
  It is a warning and not a block, deliberately: the library is a documented, wanted
923
940
  shape, MCP has always been able to make one, and refusing it here would put a person
924
941
  back below a model on the same tree — the very asymmetry this ticket removed. */}
925
- {verbs.includes("execute") ? (
942
+ {/* ⚠️ Only on a node, and that is not an oversight. What makes a folder a library is
943
+ `execute` for the organization ON THE FOLDER: `callReach` reads `node_grants` along
944
+ the callee's ancestors and nothing else, so the same grant on a flow itself makes no
945
+ library and reaches no caller outside its subtree. Showing this line there would
946
+ warn about a consequence that cannot happen — and about a refusal on the way back
947
+ that would never come (#530). */}
948
+ {!isFlow && verbs.includes("execute") ? (
926
949
  <p role="note" className="font-medium text-destructive">
927
950
  {i18n.t("node.shareLibraryWarning")}
928
951
  </p>
@@ -946,7 +969,9 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
946
969
  className="size-4 rounded border outline-none focus-visible:ring-2 focus-visible:ring-ring"
947
970
  />
948
971
  <span>{i18n.t(`node.verb.${verb}`)}</span>
949
- <span className="text-xs text-muted-foreground">{i18n.t(verbHintKey(verb))}</span>
972
+ <span className="text-xs text-muted-foreground">
973
+ {i18n.t(verbHintKey(verb, target.type))}
974
+ </span>
950
975
  </label>
951
976
  ))}
952
977
  </fieldset>
@@ -98,6 +98,15 @@ export function TitleRow({
98
98
  {target.type === "node" && target.node.kind === "folder" ? (
99
99
  <ResourceAccessSummary resourceId={target.node.id} ownerId={target.node.ownerId} />
100
100
  ) : null}
101
+ {/* A flow carries its own grants since #530, so who reaches it is worth showing where
102
+ it stands — the same question the folder above already answers here. */}
103
+ {target.type === "flow" ? (
104
+ <ResourceAccessSummary
105
+ kind="flow"
106
+ resourceId={target.flow.id}
107
+ ownerId={target.flow.ownerId}
108
+ />
109
+ ) : null}
101
110
  <span data-slot="title-meta" className="text-sm text-muted-foreground" />
102
111
  </div>
103
112
  {description ? <DescriptionDisclosure key={targetId} description={description} /> : null}