@anchrd/intel-ui 0.29.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 +2 -2
- package/src/access-summary/access-summary.tsx +13 -2
- package/src/data/intel-data-provider/intel-data-provider.ts +26 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +12 -0
- package/src/flows/flows.tsx +285 -38
- package/src/i18n/de.json +29 -7
- package/src/i18n/en.json +28 -6
- package/src/i18n/es.json +28 -6
- package/src/node-table/node-table.tsx +4 -2
- package/src/resource-menu/resource-menu.tsx +136 -111
- package/src/title-row/title-row.tsx +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-ui",
|
|
3
|
-
"version": "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.
|
|
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
|
-
|
|
265
|
-
|
|
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`
|
package/src/flows/flows.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Flow, FlowGraph, FlowNode } from "@anchrd/intel-contract/flow";
|
|
2
2
|
import { flowNodeLayer } from "@anchrd/intel-contract/flow";
|
|
3
3
|
import type { Node } from "@anchrd/intel-contract/node";
|
|
4
|
+
import { serverOf } from "@anchrd/intel-contract/tool";
|
|
4
5
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
5
6
|
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
6
7
|
import {
|
|
@@ -367,9 +368,25 @@ function FlowsEditor() {
|
|
|
367
368
|
const navigate = useNavigate();
|
|
368
369
|
// The servers a tool step can name (#489). The flat function list the picker used to show is
|
|
369
370
|
// what that ticket set out to remove; which function runs is decided while the flow runs.
|
|
371
|
+
// ⚠️ The flat function list is NOT what a step is chosen from any more (#489) — it is only what
|
|
372
|
+
// an author narrows a chosen server WITH. It is asked for regardless of whether a tool step is
|
|
373
|
+
// selected, because a query that starts when a panel opens shows a spinner inside the panel.
|
|
374
|
+
const tools = useQuery({
|
|
375
|
+
queryKey: ["tools"],
|
|
376
|
+
queryFn: () => data.listTools(),
|
|
377
|
+
// ⚠️ No retry. This query only ENRICHES the panel — the server is already chosen without it —
|
|
378
|
+
// and a retry is paused while the tab is unfocused, so a deterministic failure would leave the
|
|
379
|
+
// function list empty for as long as the reader looks away, with nothing saying why (#212).
|
|
380
|
+
retry: false,
|
|
381
|
+
});
|
|
370
382
|
const toolServers = useQuery({
|
|
371
383
|
queryKey: ["tool-servers"],
|
|
372
384
|
queryFn: () => data.listToolServers(),
|
|
385
|
+
// ⚠️ No retry here either, and for a sharper reason than the list above: the panel shows
|
|
386
|
+
// NOTHING while this is pending. A deterministic 502 with an unfocused tab pauses the retry,
|
|
387
|
+
// and the author then sits in front of an empty server field with no sentence saying why —
|
|
388
|
+
// which is #212 exactly. `error` is a state this panel can speak; `pending` is not.
|
|
389
|
+
retry: false,
|
|
373
390
|
});
|
|
374
391
|
// A tree link points at a node, so the editor needs candidates. The graph is the authorized
|
|
375
392
|
// flat list of them; walking the folder tree for a picker would be the N+1 all over again.
|
|
@@ -404,9 +421,6 @@ function FlowsEditor() {
|
|
|
404
421
|
// Lifted out of the bar because the canvas dismisses it too — a click on the pane is the third way
|
|
405
422
|
// out, next to the trigger and Escape.
|
|
406
423
|
const [paletteOpen, setPaletteOpen] = usePaletteOpen();
|
|
407
|
-
// A flow is shared through the folder it is filed in, on the Intelligence screen (ADR-0004 §2). It
|
|
408
|
-
// has no share action of its own — a narrower grant beside the folder's would break the guarantee
|
|
409
|
-
// that a flow only reaches flows in its own subtree.
|
|
410
424
|
const document = useQuery({
|
|
411
425
|
queryKey: ["flow", selectedFlowId],
|
|
412
426
|
queryFn: () => data.getFlow(selectedFlowId ?? ""),
|
|
@@ -420,6 +434,13 @@ function FlowsEditor() {
|
|
|
420
434
|
// guarantee that the loaded document is both the selected flow and settled.
|
|
421
435
|
const documentReady = selectedFlowId !== null && document.data?.flow.id === selectedFlowId;
|
|
422
436
|
const canMutate = documentReady && !document.isFetching;
|
|
437
|
+
// ⚠️ A tool step without a server cannot be saved at all — `ToolServerHandle` has a minimum
|
|
438
|
+
// length. Letting the request go out anyway answers with a generic failure that names no step, so
|
|
439
|
+
// the author learns "something is wrong" instead of "this one needs a server". The refusal
|
|
440
|
+
// belongs here, before the write (#517).
|
|
441
|
+
const stepsMissingServer = nodes.filter(
|
|
442
|
+
(node) => node.data.node.kind === "tool" && node.data.node.configuration.server === "",
|
|
443
|
+
);
|
|
423
444
|
useEffect(() => {
|
|
424
445
|
if (!document.data || document.data.flow.id !== selectedFlowId) return;
|
|
425
446
|
const next = canvas(document.data.version?.graph ?? defaultGraph());
|
|
@@ -468,6 +489,7 @@ function FlowsEditor() {
|
|
|
468
489
|
dirty={dirty}
|
|
469
490
|
saving={save.isPending}
|
|
470
491
|
canMutate={canMutate}
|
|
492
|
+
incomplete={stepsMissingServer.map((node) => ({ label: node.data.node.label }))}
|
|
471
493
|
onSave={() => save.mutate()}
|
|
472
494
|
onPublish={() => setPublishing(true)}
|
|
473
495
|
/>
|
|
@@ -647,6 +669,18 @@ function FlowsEditor() {
|
|
|
647
669
|
node={selectedNode}
|
|
648
670
|
update={updateNode}
|
|
649
671
|
toolServers={toolServers.data?.items ?? []}
|
|
672
|
+
toolServersState={
|
|
673
|
+
toolServers.isPending
|
|
674
|
+
? "pending"
|
|
675
|
+
: toolServers.isError
|
|
676
|
+
? "error"
|
|
677
|
+
: toolServers.data?.portalConnected === false
|
|
678
|
+
? "disconnected"
|
|
679
|
+
: "ready"
|
|
680
|
+
}
|
|
681
|
+
toolNames={(tools.data?.items ?? []).map((entry) => entry.name)}
|
|
682
|
+
toolNamesFailed={tools.isError}
|
|
683
|
+
toolNamesPending={tools.isPending}
|
|
650
684
|
flows={(callable.data?.items ?? []).filter(
|
|
651
685
|
(entry) => entry.id !== selectedFlowId,
|
|
652
686
|
)}
|
|
@@ -707,6 +741,7 @@ function FlowTitle({
|
|
|
707
741
|
dirty,
|
|
708
742
|
saving,
|
|
709
743
|
canMutate,
|
|
744
|
+
incomplete,
|
|
710
745
|
onSave,
|
|
711
746
|
onPublish,
|
|
712
747
|
}: {
|
|
@@ -714,6 +749,9 @@ function FlowTitle({
|
|
|
714
749
|
dirty: boolean;
|
|
715
750
|
saving: boolean;
|
|
716
751
|
canMutate: boolean;
|
|
752
|
+
// Tool steps that name no server yet. They cannot be saved, so the refusal is shown here rather
|
|
753
|
+
// than fetched from the server as a message that names no step.
|
|
754
|
+
incomplete: Array<{ label: string }>;
|
|
717
755
|
onSave(): void;
|
|
718
756
|
onPublish(): void;
|
|
719
757
|
}) {
|
|
@@ -740,7 +778,16 @@ function FlowTitle({
|
|
|
740
778
|
applies EVERYWHERE stands. But it says how THIS flow is shown, and so belongs in the line
|
|
741
779
|
that names this flow. A flow is the only level with runs, and therefore the only one with
|
|
742
780
|
three views (#35). */}
|
|
743
|
-
|
|
781
|
+
{incomplete.length > 0 && (
|
|
782
|
+
<p className="mr-2 text-xs text-destructive">
|
|
783
|
+
{i18n.t("flows.toolStepNeedsServer", { label: incomplete[0]?.label ?? "" })}
|
|
784
|
+
</p>
|
|
785
|
+
)}
|
|
786
|
+
<SaveButton
|
|
787
|
+
dirty={dirty && canMutate && incomplete.length === 0}
|
|
788
|
+
saving={saving}
|
|
789
|
+
onSave={onSave}
|
|
790
|
+
/>
|
|
744
791
|
<TooltipProvider delayDuration={300}>
|
|
745
792
|
<Tooltip>
|
|
746
793
|
<TooltipTrigger asChild>
|
|
@@ -804,10 +851,34 @@ function PublishPreview({
|
|
|
804
851
|
) : (
|
|
805
852
|
<>
|
|
806
853
|
<p className="text-sm text-muted-foreground">
|
|
807
|
-
{preview.data.calls.length === 0
|
|
854
|
+
{preview.data.calls.length === 0 && preview.data.tools.length === 0
|
|
808
855
|
? i18n.t("flows.publishPreviewEmpty")
|
|
809
856
|
: i18n.t("flows.publishPreviewIntro")}
|
|
810
857
|
</p>
|
|
858
|
+
{/* ⚠️ The tool surface belongs in the preview because publishing FREEZES it: after this
|
|
859
|
+
confirmation the step is pinned to these functions and inherits nothing the provider
|
|
860
|
+
adds later. An author who is not shown it is asked to freeze something unseen (#517). */}
|
|
861
|
+
{preview.data.tools.length > 0 && (
|
|
862
|
+
<ul className="mt-4 space-y-2">
|
|
863
|
+
{preview.data.tools.map((tool) => (
|
|
864
|
+
<li key={tool.nodeId} className="rounded-md border p-3 text-sm">
|
|
865
|
+
<span className="block font-medium">{tool.nodeLabel}</span>
|
|
866
|
+
<span className="block text-xs text-muted-foreground">{tool.server}</span>
|
|
867
|
+
<span
|
|
868
|
+
className={`mt-1 block text-xs ${tool.available ? "text-muted-foreground" : "text-destructive"}`}
|
|
869
|
+
>
|
|
870
|
+
{!tool.available
|
|
871
|
+
? i18n.t("flows.publishPreviewToolUnavailable")
|
|
872
|
+
: tool.allow === null
|
|
873
|
+
? i18n.t("flows.toolAllowAll")
|
|
874
|
+
: i18n.t("flows.publishPreviewToolAllow", {
|
|
875
|
+
functions: tool.allow.join(", "),
|
|
876
|
+
})}
|
|
877
|
+
</span>
|
|
878
|
+
</li>
|
|
879
|
+
))}
|
|
880
|
+
</ul>
|
|
881
|
+
)}
|
|
811
882
|
<ul className="mt-4 space-y-2">
|
|
812
883
|
{preview.data.calls.map((call) => {
|
|
813
884
|
// ⚠️ One condition for the sentence and its colour. They were two, so a call that
|
|
@@ -860,11 +931,29 @@ function NodeInspector({
|
|
|
860
931
|
node,
|
|
861
932
|
update,
|
|
862
933
|
toolServers,
|
|
934
|
+
toolServersState,
|
|
935
|
+
toolNames,
|
|
936
|
+
toolNamesFailed,
|
|
937
|
+
toolNamesPending,
|
|
863
938
|
flows,
|
|
864
939
|
}: {
|
|
865
940
|
node: CanvasNode | null;
|
|
866
941
|
update(fn: (node: FlowNode) => FlowNode): void;
|
|
867
942
|
toolServers: Array<{ handle: string; name: string; toolCount: number }>;
|
|
943
|
+
// ⚠️ FOUR answers, not two. A list that is still loading is not one that came back empty; a
|
|
944
|
+
// failure is neither; and a portal this user never connected is not a portal that reaches
|
|
945
|
+
// nothing. Drawing them all as "no servers" is the mistake #350 records for the tools screen —
|
|
946
|
+
// and the fourth one is the worst of them: it answers a SIGN-IN problem with a sentence about
|
|
947
|
+
// reach, in a place that offers no way to sign in.
|
|
948
|
+
toolServersState: "pending" | "error" | "disconnected" | "ready";
|
|
949
|
+
toolNames: string[];
|
|
950
|
+
// Told apart from "this server offers nothing": a function list that could not be loaded must not
|
|
951
|
+
// read as a narrowing that lost its functions.
|
|
952
|
+
toolNamesFailed: boolean;
|
|
953
|
+
// ⚠️ A query that has not answered is not one that answered "nothing". Without this a step opened
|
|
954
|
+
// while the catalog is still loading shows its allowed functions as missing — the panel accusing
|
|
955
|
+
// the author of a narrowing that broke, moments before the list arrives.
|
|
956
|
+
toolNamesPending: boolean;
|
|
868
957
|
flows: Flow[];
|
|
869
958
|
}) {
|
|
870
959
|
const i18n = useI18n();
|
|
@@ -953,40 +1042,198 @@ function NodeInspector({
|
|
|
953
1042
|
</div>
|
|
954
1043
|
)}
|
|
955
1044
|
{contract.kind === "tool" && (
|
|
956
|
-
<
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1045
|
+
<ToolStepFields
|
|
1046
|
+
configuration={contract.configuration}
|
|
1047
|
+
servers={toolServers}
|
|
1048
|
+
state={toolServersState}
|
|
1049
|
+
toolNames={toolNames}
|
|
1050
|
+
toolNamesFailed={toolNamesFailed}
|
|
1051
|
+
toolNamesPending={toolNamesPending}
|
|
1052
|
+
update={update}
|
|
1053
|
+
/>
|
|
1054
|
+
)}
|
|
1055
|
+
</div>
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* What a tool step may reach: one server, and optionally only some of its functions.
|
|
1061
|
+
*
|
|
1062
|
+
* ⚠️ The server comes first and the functions are a NARROWING, not the other way round (#489). The
|
|
1063
|
+
* old field offered every function of every server in one flat list — 85 entries here, portal
|
|
1064
|
+
* management tools among them — and asked the author to pick the one call they wanted before they
|
|
1065
|
+
* knew what the step would say. Which function runs is a run-time answer; which server may be
|
|
1066
|
+
* reached is an authoring decision, and that is the one this asks for.
|
|
1067
|
+
*/
|
|
1068
|
+
export function ToolStepFields({
|
|
1069
|
+
configuration,
|
|
1070
|
+
servers,
|
|
1071
|
+
state,
|
|
1072
|
+
toolNames,
|
|
1073
|
+
toolNamesFailed,
|
|
1074
|
+
toolNamesPending,
|
|
1075
|
+
update,
|
|
1076
|
+
}: {
|
|
1077
|
+
configuration: { server: string; allow: string[] | null; fingerprint: string | null };
|
|
1078
|
+
servers: Array<{ handle: string; name: string; toolCount: number }>;
|
|
1079
|
+
state: "pending" | "error" | "disconnected" | "ready";
|
|
1080
|
+
toolNames: string[];
|
|
1081
|
+
toolNamesFailed: boolean;
|
|
1082
|
+
toolNamesPending: boolean;
|
|
1083
|
+
update(fn: (node: FlowNode) => FlowNode): void;
|
|
1084
|
+
}) {
|
|
1085
|
+
const i18n = useI18n();
|
|
1086
|
+
// ⚠️ The group's identity, not its data. Two steps on one screen with the same server would
|
|
1087
|
+
// otherwise share one radio group and unset each other — and the component is exported now, so
|
|
1088
|
+
// that invariant no longer lives in the file that guarantees it.
|
|
1089
|
+
const groupId = useId();
|
|
1090
|
+
const hintId = `${groupId}-hint`;
|
|
1091
|
+
// The one sentence this field has to say, or none. Derived once so the control and the paragraph
|
|
1092
|
+
// cannot disagree about whether there is something to point at.
|
|
1093
|
+
const hint =
|
|
1094
|
+
state === "error"
|
|
1095
|
+
? ({ key: "flows.toolServersFailed", tone: "bad" } as const)
|
|
1096
|
+
: state === "disconnected"
|
|
1097
|
+
? ({ key: "flows.toolServersDisconnected", tone: "bad" } as const)
|
|
1098
|
+
: state === "ready" && servers.length === 0
|
|
1099
|
+
? ({ key: "flows.toolServersEmpty", tone: "plain" } as const)
|
|
1100
|
+
: null;
|
|
1101
|
+
const ownFunctions = toolNames.filter(
|
|
1102
|
+
(name) => configuration.server !== "" && serverOf(name, [configuration.server]) !== null,
|
|
1103
|
+
);
|
|
1104
|
+
// ⚠️ An allowed function the catalog does not carry is DRAWN, not dropped. Rendering only what
|
|
1105
|
+
// the catalog knows would let the panel and the saved graph disagree in silence: the author reads
|
|
1106
|
+
// "narrowed to nothing" while the step still names something. The API treats the same condition
|
|
1107
|
+
// as serious enough to refuse a publish, so the editor may not swallow it.
|
|
1108
|
+
// Nothing is "missing" until the catalog has actually answered.
|
|
1109
|
+
const missing = toolNamesPending
|
|
1110
|
+
? []
|
|
1111
|
+
: (configuration.allow ?? []).filter((name) => !ownFunctions.includes(name));
|
|
1112
|
+
const functions = [...ownFunctions, ...missing];
|
|
1113
|
+
|
|
1114
|
+
function setTool(
|
|
1115
|
+
change: (current: { server: string; allow: string[] | null; fingerprint: string | null }) => {
|
|
1116
|
+
server: string;
|
|
1117
|
+
allow: string[] | null;
|
|
1118
|
+
fingerprint: string | null;
|
|
1119
|
+
},
|
|
1120
|
+
) {
|
|
1121
|
+
update((value) =>
|
|
1122
|
+
value.kind === "tool" ? { ...value, configuration: change(value.configuration) } : value,
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
return (
|
|
1127
|
+
<div className="mt-4 space-y-3">
|
|
1128
|
+
<label className="block text-sm font-medium">
|
|
1129
|
+
{i18n.t("flows.tool")}
|
|
1130
|
+
<select
|
|
1131
|
+
value={configuration.server}
|
|
1132
|
+
// ⚠️ A disabled control drops out of the tab order, so the reason has to be ANNOUNCED
|
|
1133
|
+
// rather than merely printed beside it. It points at the hint only when the hint is
|
|
1134
|
+
// actually rendered — `pending` prints nothing on purpose, and a reference to an id that
|
|
1135
|
+
// does not exist is worse than none: a screen reader reads it as a broken relation.
|
|
1136
|
+
aria-describedby={hint === null ? undefined : hintId}
|
|
1137
|
+
disabled={state !== "ready" || servers.length === 0}
|
|
1138
|
+
onChange={(event) => {
|
|
1139
|
+
// ⚠️ Read out of the event NOW, not inside the updater. The updater runs later, and by
|
|
1140
|
+
// then this controlled select has been set back to the state's value — the change would
|
|
1141
|
+
// apply to itself and nothing would move.
|
|
1142
|
+
const server = event.target.value;
|
|
1143
|
+
// Changing the server drops the narrowing AND the frozen surface. Keeping `allow` would
|
|
1144
|
+
// leave functions of the old server behind — the contract refuses that outright — and
|
|
1145
|
+
// keeping the fingerprint would claim a surface nobody has confirmed.
|
|
1146
|
+
setTool(() => ({ server, allow: null, fingerprint: null }));
|
|
1147
|
+
}}
|
|
1148
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
1149
|
+
>
|
|
1150
|
+
<option value="">{i18n.t("flows.selectTool")}</option>
|
|
1151
|
+
{servers.map((entry) => (
|
|
1152
|
+
<option key={entry.handle} value={entry.handle}>
|
|
1153
|
+
{entry.name}
|
|
1154
|
+
</option>
|
|
1155
|
+
))}
|
|
1156
|
+
</select>
|
|
1157
|
+
</label>
|
|
1158
|
+
{/* The three states of the list, told apart. Silence while loading; a reason when it failed;
|
|
1159
|
+
and "you reach none" only once that is actually known. */}
|
|
1160
|
+
{hint !== null && (
|
|
1161
|
+
<p
|
|
1162
|
+
id={hintId}
|
|
1163
|
+
className={`text-xs ${hint.tone === "bad" ? "text-destructive" : "text-muted-foreground"}`}
|
|
1164
|
+
>
|
|
1165
|
+
{i18n.t(hint.key)}
|
|
1166
|
+
</p>
|
|
1167
|
+
)}
|
|
1168
|
+
{configuration.server !== "" && (
|
|
1169
|
+
<fieldset className="space-y-2">
|
|
1170
|
+
<legend className="text-sm font-medium">{i18n.t("flows.toolFunctions")}</legend>
|
|
1171
|
+
<label className="flex items-center gap-2 text-sm">
|
|
1172
|
+
<input
|
|
1173
|
+
type="radio"
|
|
1174
|
+
name={groupId}
|
|
1175
|
+
checked={configuration.allow === null}
|
|
1176
|
+
onChange={() =>
|
|
1177
|
+
setTool((current) => ({ ...current, allow: null, fingerprint: null }))
|
|
1178
|
+
}
|
|
1179
|
+
className="size-4"
|
|
1180
|
+
/>
|
|
1181
|
+
{i18n.t("flows.toolAllowAll")}
|
|
988
1182
|
</label>
|
|
989
|
-
|
|
1183
|
+
<label className="flex items-center gap-2 text-sm">
|
|
1184
|
+
<input
|
|
1185
|
+
type="radio"
|
|
1186
|
+
name={groupId}
|
|
1187
|
+
checked={configuration.allow !== null}
|
|
1188
|
+
onChange={() => setTool((current) => ({ ...current, allow: [], fingerprint: null }))}
|
|
1189
|
+
className="size-4"
|
|
1190
|
+
/>
|
|
1191
|
+
{i18n.t("flows.toolAllowSome")}
|
|
1192
|
+
</label>
|
|
1193
|
+
{configuration.allow !== null && (
|
|
1194
|
+
<ul className="max-h-48 space-y-1 overflow-y-auto pl-6">
|
|
1195
|
+
{functions.map((name) => (
|
|
1196
|
+
<li key={name}>
|
|
1197
|
+
<label className="flex items-center gap-2 text-sm">
|
|
1198
|
+
<input
|
|
1199
|
+
type="checkbox"
|
|
1200
|
+
checked={configuration.allow?.includes(name) ?? false}
|
|
1201
|
+
onChange={(event) => {
|
|
1202
|
+
// Same reason as the select above: the checked flag is read now, the
|
|
1203
|
+
// updater runs later.
|
|
1204
|
+
const add = event.target.checked;
|
|
1205
|
+
setTool((current) => ({
|
|
1206
|
+
...current,
|
|
1207
|
+
allow: add
|
|
1208
|
+
? [...(current.allow ?? []), name]
|
|
1209
|
+
: (current.allow ?? []).filter((entry) => entry !== name),
|
|
1210
|
+
fingerprint: null,
|
|
1211
|
+
}));
|
|
1212
|
+
}}
|
|
1213
|
+
className="size-4"
|
|
1214
|
+
/>
|
|
1215
|
+
<span className="min-w-0 truncate">{name}</span>
|
|
1216
|
+
</label>
|
|
1217
|
+
</li>
|
|
1218
|
+
))}
|
|
1219
|
+
</ul>
|
|
1220
|
+
)}
|
|
1221
|
+
{/* Two sentences that only appear when they are true, and neither is the generic hint:
|
|
1222
|
+
a narrowing to nothing cannot be published, and a name the catalog does not carry is
|
|
1223
|
+
something the author has to see rather than lose. */}
|
|
1224
|
+
{configuration.allow?.length === 0 && (
|
|
1225
|
+
<p className="text-xs text-destructive">{i18n.t("flows.toolAllowNone")}</p>
|
|
1226
|
+
)}
|
|
1227
|
+
{toolNamesFailed && (
|
|
1228
|
+
<p className="text-xs text-destructive">{i18n.t("flows.toolFunctionsFailed")}</p>
|
|
1229
|
+
)}
|
|
1230
|
+
{!toolNamesFailed && missing.length > 0 && (
|
|
1231
|
+
<p className="text-xs text-destructive">
|
|
1232
|
+
{i18n.t("flows.toolAllowUnknown", { count: missing.length })}
|
|
1233
|
+
</p>
|
|
1234
|
+
)}
|
|
1235
|
+
<p className="text-xs text-muted-foreground">{i18n.t("flows.toolAllowHint")}</p>
|
|
1236
|
+
</fieldset>
|
|
990
1237
|
)}
|
|
991
1238
|
</div>
|
|
992
1239
|
);
|
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",
|
|
@@ -97,7 +96,7 @@
|
|
|
97
96
|
"archive.restoreFailed": "Es wurde nicht wiederhergestellt. Vielleicht hat jemand anderes es geändert — lade neu und versuche es erneut.",
|
|
98
97
|
"archive.purge": "{title} endgültig löschen",
|
|
99
98
|
"archive.purge.title": "Endgültig löschen?",
|
|
100
|
-
"archive.purge.body": "„{title}
|
|
99
|
+
"archive.purge.body": "„{title}“ und alles, was dazugehört — jede Version, der Inhalt und die Einträge im Suchindex — ist danach weg. Das lässt sich nicht rückgängig machen.",
|
|
101
100
|
"archive.purge.links.none": "Keine anderen Dokumente verweisen darauf.",
|
|
102
101
|
"archive.purge.links.one": "Ein anderes Dokument verweist darauf; dieser Verweis wird brechen.",
|
|
103
102
|
"archive.purge.links.many": "{count} andere Dokumente verweisen darauf; diese Verweise werden brechen.",
|
|
@@ -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.",
|
|
@@ -275,8 +272,8 @@
|
|
|
275
272
|
"flows.publishPreviewUnavailable": "Der aufgerufene Flow hat noch nichts veröffentlicht, das lässt sich nicht einfrieren.",
|
|
276
273
|
"flows.publishConfirm": "Veröffentlichen",
|
|
277
274
|
"flows.publishFailed": "Das Veröffentlichen ist fehlgeschlagen. Lade die neueste Fassung und versuche es erneut.",
|
|
278
|
-
"flows.tool": "MCP-
|
|
279
|
-
"flows.selectTool": "
|
|
275
|
+
"flows.tool": "MCP-Server",
|
|
276
|
+
"flows.selectTool": "Server auswählen",
|
|
280
277
|
"flows.operationFailed": "Der Flow-Vorgang ist fehlgeschlagen. Lade die neueste Fassung und versuche es erneut.",
|
|
281
278
|
"flows.needs": "Was dieser Flow braucht",
|
|
282
279
|
"flows.needsNodes": "Dokumente",
|
|
@@ -351,5 +348,30 @@
|
|
|
351
348
|
"common.noAccess": "Das ist für dich nicht verfügbar. Es existiert nicht, oder es ist nicht mehr für dich freigegeben.",
|
|
352
349
|
"common.noPermission": "Dir fehlt die Berechtigung, das zu sehen.",
|
|
353
350
|
"title.descriptionMore": "Mehr",
|
|
354
|
-
"title.descriptionLess": "Weniger"
|
|
351
|
+
"title.descriptionLess": "Weniger",
|
|
352
|
+
"flows.toolServersEmpty": "Über das Portal erreichst du noch keinen MCP-Server.",
|
|
353
|
+
"flows.toolServersFailed": "Die Serverliste ließ sich nicht laden, daher lässt sich gerade keiner auswählen.",
|
|
354
|
+
"flows.toolAllowAll": "Der Agent darf jede Funktion nutzen",
|
|
355
|
+
"flows.toolAllowSome": "Den Agenten auf bestimmte Funktionen hinweisen",
|
|
356
|
+
"flows.toolAllowHint": "Intel sagt, wofür ein Schritt da ist; der Agent wählt den Aufruf und erreicht das Portal mit seinen eigenen Rechten.",
|
|
357
|
+
"flows.toolFunctions": "Funktionen",
|
|
358
|
+
"flows.toolServersDisconnected": "Du bist nicht mit dem Portal verbunden, daher lässt sich kein Server wählen. Verbinde es zuerst in den Werkzeug-Einstellungen.",
|
|
359
|
+
"flows.toolAllowNone": "Es ist noch keine Funktion ausgewählt, daher lässt sich dieser Schritt nicht veröffentlichen.",
|
|
360
|
+
"flows.toolAllowUnknown": "{count} ausgewählte Funktion(en) bietet dieser Server gerade nicht an.",
|
|
361
|
+
"flows.toolStepNeedsServer": "Dem Schritt „{label}“ fehlt noch ein Server.",
|
|
362
|
+
"flows.toolFunctionsFailed": "Die Funktionsliste ließ sich nicht laden, daher ist die Auswahl möglicherweise unvollständig.",
|
|
363
|
+
"flows.publishPreviewToolUnavailable": "Diesen Server erreichst du nicht, daher wird das Veröffentlichen abgelehnt.",
|
|
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"
|
|
355
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.",
|
|
@@ -275,8 +272,8 @@
|
|
|
275
272
|
"flows.publishPreviewUnavailable": "The called flow has published nothing yet, so this cannot be frozen.",
|
|
276
273
|
"flows.publishConfirm": "Publish",
|
|
277
274
|
"flows.publishFailed": "Publishing failed. Reload the latest version and try again.",
|
|
278
|
-
"flows.tool": "MCP
|
|
279
|
-
"flows.selectTool": "Select a
|
|
275
|
+
"flows.tool": "MCP server",
|
|
276
|
+
"flows.selectTool": "Select a server",
|
|
280
277
|
"flows.operationFailed": "The flow operation failed. Reload the latest version and try again.",
|
|
281
278
|
"flows.needs": "What this flow needs",
|
|
282
279
|
"flows.needsNodes": "Documents",
|
|
@@ -351,5 +348,30 @@
|
|
|
351
348
|
"common.noAccess": "This is not available to you. It may not exist, or it may no longer be shared with you.",
|
|
352
349
|
"common.noPermission": "You do not have permission to see this.",
|
|
353
350
|
"title.descriptionMore": "More",
|
|
354
|
-
"title.descriptionLess": "Less"
|
|
351
|
+
"title.descriptionLess": "Less",
|
|
352
|
+
"flows.toolServersEmpty": "You reach no MCP server through the portal yet.",
|
|
353
|
+
"flows.toolServersFailed": "The list of servers could not be loaded, so none can be chosen right now.",
|
|
354
|
+
"flows.toolAllowAll": "Let the agent use any function",
|
|
355
|
+
"flows.toolAllowSome": "Point the agent at specific functions",
|
|
356
|
+
"flows.toolAllowHint": "Intel says what a step is for; the agent chooses the call and reaches the portal with its own permissions.",
|
|
357
|
+
"flows.toolFunctions": "Functions",
|
|
358
|
+
"flows.toolServersDisconnected": "You are not connected to the portal, so no server can be chosen. Connect it in the tool settings first.",
|
|
359
|
+
"flows.toolAllowNone": "No function is selected yet, so this step cannot be published.",
|
|
360
|
+
"flows.toolAllowUnknown": "{count} selected function(s) are not offered by this server right now.",
|
|
361
|
+
"flows.toolStepNeedsServer": "The step “{label}” still needs a server.",
|
|
362
|
+
"flows.toolFunctionsFailed": "The list of functions could not be loaded, so the selection may be incomplete.",
|
|
363
|
+
"flows.publishPreviewToolUnavailable": "You do not reach this server, so publishing will be refused.",
|
|
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"
|
|
355
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.",
|
|
@@ -275,8 +272,8 @@
|
|
|
275
272
|
"flows.publishPreviewUnavailable": "El flujo llamado todavía no ha publicado nada, así que esto no se puede congelar.",
|
|
276
273
|
"flows.publishConfirm": "Publicar",
|
|
277
274
|
"flows.publishFailed": "La publicación ha fallado. Carga la versión más reciente e inténtalo de nuevo.",
|
|
278
|
-
"flows.tool": "
|
|
279
|
-
"flows.selectTool": "
|
|
275
|
+
"flows.tool": "Servidor MCP",
|
|
276
|
+
"flows.selectTool": "Elegir un servidor",
|
|
280
277
|
"flows.operationFailed": "La operación del flujo ha fallado. Carga la versión más reciente e inténtalo de nuevo.",
|
|
281
278
|
"flows.needs": "Qué necesita este flujo",
|
|
282
279
|
"flows.needsNodes": "Documentos",
|
|
@@ -351,5 +348,30 @@
|
|
|
351
348
|
"common.noAccess": "Esto no está disponible para ti. Puede que no exista o que ya no esté compartido contigo.",
|
|
352
349
|
"common.noPermission": "No tienes permiso para ver esto.",
|
|
353
350
|
"title.descriptionMore": "Más",
|
|
354
|
-
"title.descriptionLess": "Menos"
|
|
351
|
+
"title.descriptionLess": "Menos",
|
|
352
|
+
"flows.toolServersEmpty": "Todavía no alcanzas ningún servidor MCP a través del portal.",
|
|
353
|
+
"flows.toolServersFailed": "No se pudo cargar la lista de servidores, así que ahora no se puede elegir ninguno.",
|
|
354
|
+
"flows.toolAllowAll": "El agente puede usar cualquier función",
|
|
355
|
+
"flows.toolAllowSome": "Indicar al agente funciones concretas",
|
|
356
|
+
"flows.toolAllowHint": "Intel dice para qué sirve un paso; el agente elige la llamada y accede al portal con sus propios permisos.",
|
|
357
|
+
"flows.toolFunctions": "Funciones",
|
|
358
|
+
"flows.toolServersDisconnected": "No estás conectado al portal, así que no se puede elegir ningún servidor. Conéctalo primero en la configuración de herramientas.",
|
|
359
|
+
"flows.toolAllowNone": "Todavía no hay ninguna función seleccionada, así que este paso no se puede publicar.",
|
|
360
|
+
"flows.toolAllowUnknown": "Este servidor no ofrece ahora mismo {count} función(es) seleccionada(s).",
|
|
361
|
+
"flows.toolStepNeedsServer": "Al paso «{label}» todavía le falta un servidor.",
|
|
362
|
+
"flows.toolFunctionsFailed": "No se pudo cargar la lista de funciones, así que la selección puede estar incompleta.",
|
|
363
|
+
"flows.publishPreviewToolUnavailable": "No alcanzas este servidor, así que la publicación será rechazada.",
|
|
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"
|
|
355
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
|
-
|
|
36
|
-
|
|
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
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
-
* ⚠️
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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
|
|
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
|
-
{/*
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
|
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
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
//
|
|
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
|
-
|
|
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 [
|
|
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: ["
|
|
724
|
-
queryFn: () => data.listGrants(
|
|
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:
|
|
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
|
-
|
|
735
|
-
await data.
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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
|
-
{
|
|
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(
|
|
825
|
-
|
|
826
|
-
|
|
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>
|
|
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("
|
|
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
|
-
{
|
|
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">
|
|
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}
|