@burdenoff/fluidgrids-fe-sdk 2026.1028.4 → 2026.1028.5

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.
@@ -1,6 +1,7 @@
1
- export { ExecutionDetailPage } from './chunk-CPEIOFYR.js';
1
+ export { ExecutionDetailPage } from './chunk-S4VYW4BR.js';
2
2
  import './chunk-SSJIK6WS.js';
3
3
  import './chunk-UUUDSVC7.js';
4
+ import './chunk-VUVTOUQ5.js';
4
5
  import './chunk-5ERAJC2I.js';
5
6
  import './chunk-JAKWVVVQ.js';
6
7
  import './chunk-QGXA2USP.js';
@@ -1,5 +1,6 @@
1
1
  import { buildNodeDisplayMap, ExecutionStatusBadge, formatRunVersion, NodeTimeline, RunLogViewer } from './chunk-SSJIK6WS.js';
2
2
  import { useExecution, useExecutionActions, useAutoRefresh } from './chunk-UUUDSVC7.js';
3
+ import { useRunPolicyDecisions, RunPolicyDecisions } from './chunk-VUVTOUQ5.js';
3
4
  import { EntityTagPicker, useWorkflowProposal, WorkflowProposalCard } from './chunk-JAKWVVVQ.js';
4
5
  import { throwIfResultError, useFluidGridsPermission, FLUIDGRIDS_PERMISSIONS } from './chunk-5LKZ4PNK.js';
5
6
  import { getFluidGridsConfig } from './chunk-CY6KX4RG.js';
@@ -952,6 +953,9 @@ function ExecutionDetailPage() {
952
953
  } = useRunHumanRequests(runId, {
953
954
  pollIntervalMs: runCanChange ? 1e4 : 0
954
955
  });
956
+ const { decisions: policyDecisions, decide: decidePolicyApproval } = useRunPolicyDecisions(runId, {
957
+ pollIntervalMs: runCanChange ? 1e4 : 0
958
+ });
955
959
  const awaitingPerson = humanRequests.some(
956
960
  (request) => request.status === "PENDING"
957
961
  );
@@ -1537,6 +1541,17 @@ function ExecutionDetailPage() {
1537
1541
  /* @__PURE__ */ jsx(RunQueuedBanner, { status: execution.status, context: execution.context }),
1538
1542
  /* @__PURE__ */ jsx(RunDataPrunedNotice, { dataPrunedAt: execution.dataPrunedAt }),
1539
1543
  /* @__PURE__ */ jsx(RunHumanRequests, { requests: humanRequests }),
1544
+ /* @__PURE__ */ jsx(
1545
+ RunPolicyDecisions,
1546
+ {
1547
+ decisions: policyDecisions,
1548
+ waiting: execution.status === "WAITING",
1549
+ onDecide: async (id, approve) => {
1550
+ await decidePolicyApproval(id, approve);
1551
+ await Promise.all([refetch(), refetchHumanRequests()]);
1552
+ }
1553
+ }
1554
+ ),
1540
1555
  /* @__PURE__ */ jsx("div", { className: "border-b border-border-secondary mb-6", children: /* @__PURE__ */ jsx("nav", { className: "flex gap-4 -mb-px", children: tabs.map((tab) => /* @__PURE__ */ jsxs(
1541
1556
  "button",
1542
1557
  {
@@ -0,0 +1,1201 @@
1
+ import { throwIfResultError, describeOperationError } from './chunk-5LKZ4PNK.js';
2
+ import { useTr } from './chunk-UULVIT25.js';
3
+ import { getFluidGridsClient } from './chunk-NTWG3CJX.js';
4
+ import { useState, useCallback, useEffect, useMemo } from 'react';
5
+ import { gql } from '@apollo/client';
6
+ import { Button } from '@burdenoff/fe-libs/button';
7
+ import { toast } from 'sonner';
8
+ import { Badge, Label, Input, Switch } from '@burdenoff/fe-libs/ui';
9
+ import { cn } from '@burdenoff/fe-libs/utils';
10
+ import { jsxs, jsx } from 'react/jsx-runtime';
11
+ import { Icon } from '@iconify/react';
12
+
13
+ var POLICY_FIELDS = `
14
+ id
15
+ name
16
+ description
17
+ enabled
18
+ priority
19
+ effect
20
+ matcher { nodeTypes operations connectionProviders workflowTags }
21
+ condition
22
+ environments
23
+ updatedAt
24
+ `;
25
+ var APPROVAL_FIELDS = `
26
+ id
27
+ runId
28
+ nodeId
29
+ workflowKey
30
+ policyName
31
+ nodeType
32
+ operation
33
+ environment
34
+ reason
35
+ payloadHash
36
+ status
37
+ decision
38
+ expiresAt
39
+ createdAt
40
+ `;
41
+ var ACTION_POLICIES = gql`
42
+ query ActionPolicies {
43
+ actionPolicies {
44
+ ${POLICY_FIELDS}
45
+ }
46
+ }
47
+ `;
48
+ var UPSERT_ACTION_POLICY = gql`
49
+ mutation UpsertActionPolicy($input: ActionPolicyInput!) {
50
+ upsertActionPolicy(input: $input) {
51
+ ${POLICY_FIELDS}
52
+ }
53
+ }
54
+ `;
55
+ var DELETE_ACTION_POLICY = gql`
56
+ mutation DeleteActionPolicy($id: ID!) {
57
+ deleteActionPolicy(id: $id)
58
+ }
59
+ `;
60
+ var DRY_RUN_ACTION_POLICY = gql`
61
+ query DryRunActionPolicy($input: ActionPolicyDryRunInput!) {
62
+ dryRunActionPolicy(input: $input) {
63
+ runsScanned
64
+ actionsEvaluated
65
+ wouldAllow
66
+ wouldLog
67
+ wouldRequireApproval
68
+ wouldDeny
69
+ changed
70
+ runsAffected
71
+ rows {
72
+ runId
73
+ workflowKey
74
+ runCreatedAt
75
+ nodeId
76
+ nodeType
77
+ operation
78
+ environment
79
+ currentEffect
80
+ effect
81
+ reason
82
+ policyName
83
+ changed
84
+ }
85
+ }
86
+ }
87
+ `;
88
+ var PENDING_APPROVALS = gql`
89
+ query PendingActionPolicyApprovals($limit: Int) {
90
+ pendingActionPolicyApprovals(limit: $limit) {
91
+ ${APPROVAL_FIELDS}
92
+ }
93
+ }
94
+ `;
95
+ var DECIDE_APPROVAL = gql`
96
+ mutation DecideActionPolicyApproval($id: ID!, $approve: Boolean!) {
97
+ decideActionPolicyApproval(id: $id, approve: $approve) {
98
+ ${APPROVAL_FIELDS}
99
+ }
100
+ }
101
+ `;
102
+ var RUN_DECISIONS = gql`
103
+ query ActionPolicyDecisions($runId: ID!) {
104
+ actionPolicyDecisions(runId: $runId) {
105
+ id
106
+ nodeId
107
+ nodeType
108
+ operation
109
+ environment
110
+ effect
111
+ policyName
112
+ reason
113
+ payloadHash
114
+ approvalRequestId
115
+ consumedAt
116
+ createdAt
117
+ }
118
+ }
119
+ `;
120
+ function asError(err) {
121
+ return err instanceof Error ? err : new Error(String(err));
122
+ }
123
+ function useActionPolicies() {
124
+ const [policies, setPolicies] = useState([]);
125
+ const [approvals, setApprovals] = useState([]);
126
+ const [loading, setLoading] = useState(true);
127
+ const [error, setError] = useState(null);
128
+ const reload = useCallback(async () => {
129
+ try {
130
+ const client = getFluidGridsClient();
131
+ const [policyResult, approvalResult] = await Promise.all([
132
+ client.query({
133
+ query: ACTION_POLICIES,
134
+ fetchPolicy: "network-only"
135
+ }),
136
+ client.query({
137
+ query: PENDING_APPROVALS,
138
+ variables: { limit: 50 },
139
+ fetchPolicy: "network-only"
140
+ })
141
+ ]);
142
+ throwIfResultError(policyResult);
143
+ throwIfResultError(approvalResult);
144
+ setPolicies(policyResult.data?.actionPolicies ?? []);
145
+ setApprovals(approvalResult.data?.pendingActionPolicyApprovals ?? []);
146
+ setError(null);
147
+ } catch (err) {
148
+ setError(asError(err));
149
+ } finally {
150
+ setLoading(false);
151
+ }
152
+ }, []);
153
+ useEffect(() => {
154
+ void reload();
155
+ }, [reload]);
156
+ const save = useCallback(
157
+ async (input) => {
158
+ const result = await getFluidGridsClient().mutate({ mutation: UPSERT_ACTION_POLICY, variables: { input } });
159
+ throwIfResultError(result);
160
+ await reload();
161
+ return result.data?.upsertActionPolicy ?? null;
162
+ },
163
+ [reload]
164
+ );
165
+ const remove = useCallback(
166
+ async (id) => {
167
+ const result = await getFluidGridsClient().mutate({ mutation: DELETE_ACTION_POLICY, variables: { id } });
168
+ throwIfResultError(result);
169
+ await reload();
170
+ },
171
+ [reload]
172
+ );
173
+ const dryRun = useCallback(
174
+ async (input) => {
175
+ const result = await getFluidGridsClient().query({
176
+ query: DRY_RUN_ACTION_POLICY,
177
+ variables: { input: { ...input, runLimit: 100 } },
178
+ fetchPolicy: "network-only"
179
+ });
180
+ throwIfResultError(result);
181
+ const data = result.data?.dryRunActionPolicy;
182
+ if (!data) throw new Error("No dry-run result returned");
183
+ return data;
184
+ },
185
+ []
186
+ );
187
+ const decide = useCallback(
188
+ async (id, approve) => {
189
+ const result = await getFluidGridsClient().mutate({ mutation: DECIDE_APPROVAL, variables: { id, approve } });
190
+ throwIfResultError(result);
191
+ await reload();
192
+ },
193
+ [reload]
194
+ );
195
+ return {
196
+ policies,
197
+ approvals,
198
+ loading,
199
+ error,
200
+ reload,
201
+ save,
202
+ remove,
203
+ dryRun,
204
+ decide
205
+ };
206
+ }
207
+ function useRunPolicyDecisions(runId, options) {
208
+ const [decisions, setDecisions] = useState([]);
209
+ const pollIntervalMs = options?.pollIntervalMs ?? 0;
210
+ const load = useCallback(async () => {
211
+ if (!runId) return;
212
+ try {
213
+ const result = await getFluidGridsClient().query({
214
+ query: RUN_DECISIONS,
215
+ variables: { runId },
216
+ fetchPolicy: "network-only"
217
+ });
218
+ throwIfResultError(result);
219
+ setDecisions(result.data?.actionPolicyDecisions ?? []);
220
+ } catch {
221
+ setDecisions([]);
222
+ }
223
+ }, [runId]);
224
+ useEffect(() => {
225
+ void load();
226
+ if (!pollIntervalMs) return;
227
+ const timer = setInterval(() => void load(), pollIntervalMs);
228
+ return () => clearInterval(timer);
229
+ }, [load, pollIntervalMs]);
230
+ const decide = useCallback(
231
+ async (id, approve) => {
232
+ const result = await getFluidGridsClient().mutate({
233
+ mutation: DECIDE_APPROVAL,
234
+ variables: { id, approve }
235
+ });
236
+ throwIfResultError(result);
237
+ await load();
238
+ },
239
+ [load]
240
+ );
241
+ return { decisions, reload: load, decide };
242
+ }
243
+ var EFFECT_CLASSES = {
244
+ ALLOW: "bg-status-success-bg-subtle text-status-success-text border-status-success-border",
245
+ LOG: "bg-status-info-bg-subtle text-status-info-text border-status-info-border",
246
+ REQUIRE_APPROVAL: "bg-status-warning-bg-subtle text-status-warning-text border-status-warning-border",
247
+ DENY: "bg-status-error-bg-subtle text-status-error-text border-status-error-border"
248
+ };
249
+ function effectLabel(effect, tr) {
250
+ switch (effect) {
251
+ case "ALLOW":
252
+ return tr("fluidgrids.actionPolicies.effect.allow", "Allow");
253
+ case "LOG":
254
+ return tr("fluidgrids.actionPolicies.effect.log", "Log");
255
+ case "REQUIRE_APPROVAL":
256
+ return tr(
257
+ "fluidgrids.actionPolicies.effect.requireApproval",
258
+ "Require approval"
259
+ );
260
+ default:
261
+ return tr("fluidgrids.actionPolicies.effect.deny", "Deny");
262
+ }
263
+ }
264
+ function EffectBadge({
265
+ effect,
266
+ className
267
+ }) {
268
+ const tr = useTr();
269
+ return /* @__PURE__ */ jsx(
270
+ Badge,
271
+ {
272
+ variant: "outline",
273
+ className: cn("text-xs", EFFECT_CLASSES[effect], className),
274
+ children: effectLabel(effect, tr)
275
+ }
276
+ );
277
+ }
278
+ function RunPolicyDecisions({
279
+ decisions,
280
+ waiting,
281
+ onDecide
282
+ }) {
283
+ const tr = useTr();
284
+ const [busy, setBusy] = useState(null);
285
+ if (decisions.length === 0) return null;
286
+ const latestHeld = [...decisions].reverse().find((decision) => decision.effect === "REQUIRE_APPROVAL");
287
+ const held = waiting && latestHeld?.approvalRequestId && !latestHeld.consumedAt ? latestHeld : null;
288
+ const decide = async (approve) => {
289
+ if (!held?.approvalRequestId) return;
290
+ setBusy(held.approvalRequestId);
291
+ try {
292
+ await onDecide(held.approvalRequestId, approve);
293
+ toast.success(
294
+ approve ? tr(
295
+ "fluidgrids.actionPolicies.approved",
296
+ "Approved \u2014 the run continues"
297
+ ) : tr(
298
+ "fluidgrids.actionPolicies.declined",
299
+ "Declined \u2014 the node fails"
300
+ )
301
+ );
302
+ } catch (err) {
303
+ toast.error(describeOperationError(err));
304
+ } finally {
305
+ setBusy(null);
306
+ }
307
+ };
308
+ return /* @__PURE__ */ jsxs(
309
+ "div",
310
+ {
311
+ className: "mb-6 rounded-lg border border-border-secondary bg-background-secondary p-4",
312
+ "data-testid": "run-policy-decisions",
313
+ children: [
314
+ /* @__PURE__ */ jsx("h3", { className: "mb-2 text-sm font-semibold text-text-primary", children: tr("fluidgrids.actionPolicies.runTitle", "Action policies") }),
315
+ /* @__PURE__ */ jsx("ul", { className: "space-y-1.5", children: decisions.map((decision) => /* @__PURE__ */ jsxs(
316
+ "li",
317
+ {
318
+ className: "flex flex-wrap items-center gap-2 text-sm text-text-primary",
319
+ children: [
320
+ /* @__PURE__ */ jsx(EffectBadge, { effect: decision.effect }),
321
+ /* @__PURE__ */ jsx("span", { className: "font-medium", children: decision.nodeId }),
322
+ /* @__PURE__ */ jsxs("span", { className: "text-xs text-text-secondary", children: [
323
+ decision.policyName ?? tr(
324
+ "fluidgrids.actionPolicies.defaultDeny",
325
+ "default deny"
326
+ ),
327
+ " ",
328
+ "\xB7 ",
329
+ decision.reason,
330
+ " \xB7 ",
331
+ decision.environment
332
+ ] })
333
+ ]
334
+ },
335
+ decision.id
336
+ )) }),
337
+ held ? /* @__PURE__ */ jsxs("div", { className: "mt-3 flex flex-wrap items-center gap-2", children: [
338
+ /* @__PURE__ */ jsx("span", { className: "text-xs text-status-warning-text", children: tr(
339
+ "fluidgrids.actionPolicies.heldHint",
340
+ "{{node}} is waiting for a workspace admin. Approving runs exactly the held payload.",
341
+ { node: held.nodeId }
342
+ ) }),
343
+ /* @__PURE__ */ jsx(
344
+ Button,
345
+ {
346
+ size: "sm",
347
+ disabled: busy !== null,
348
+ onClick: () => decide(true),
349
+ children: tr("fluidgrids.actionPolicies.approve", "Approve")
350
+ }
351
+ ),
352
+ /* @__PURE__ */ jsx(
353
+ Button,
354
+ {
355
+ size: "sm",
356
+ variant: "outline",
357
+ disabled: busy !== null,
358
+ onClick: () => decide(false),
359
+ children: tr("fluidgrids.actionPolicies.decline", "Decline")
360
+ }
361
+ )
362
+ ] }) : null
363
+ ]
364
+ }
365
+ );
366
+ }
367
+
368
+ // src/modules/action-policies/utils/policy-form.ts
369
+ var POLICY_EFFECTS = [
370
+ "REQUIRE_APPROVAL",
371
+ "DENY",
372
+ "ALLOW",
373
+ "LOG"
374
+ ];
375
+ var POLICY_ENVIRONMENTS = [
376
+ "DEV",
377
+ "STAGING",
378
+ "PROD"
379
+ ];
380
+ var CONDITION_OPERATORS = [
381
+ { value: "gt", label: "is greater than", kind: "number" },
382
+ { value: "gte", label: "is at least", kind: "number" },
383
+ { value: "lt", label: "is less than", kind: "number" },
384
+ { value: "lte", label: "is at most", kind: "number" },
385
+ { value: "eq", label: "equals", kind: "single" },
386
+ { value: "neq", label: "does not equal", kind: "single" },
387
+ { value: "in", label: "is one of", kind: "list" },
388
+ { value: "not_in", label: "is not one of", kind: "list" },
389
+ { value: "contains", label: "contains", kind: "single" },
390
+ { value: "count_gt", label: "has more items than", kind: "number" },
391
+ { value: "count_gte", label: "has at least \u2026 items", kind: "number" },
392
+ { value: "count_lt", label: "has fewer items than", kind: "number" },
393
+ { value: "count_lte", label: "has at most \u2026 items", kind: "number" },
394
+ { value: "exists", label: "is set", kind: "none" },
395
+ { value: "not_exists", label: "is not set", kind: "none" }
396
+ ];
397
+ var PolicyFormError = class extends Error {
398
+ };
399
+ var OPERATOR_KIND = new Map(
400
+ CONDITION_OPERATORS.map((operator) => [operator.value, operator.kind])
401
+ );
402
+ function operatorKind(op) {
403
+ return OPERATOR_KIND.get(op) ?? "single";
404
+ }
405
+ function emptyPolicyForm() {
406
+ return {
407
+ name: "",
408
+ description: "",
409
+ enabled: true,
410
+ priority: "100",
411
+ effect: "REQUIRE_APPROVAL",
412
+ nodeTypes: "",
413
+ operations: "",
414
+ connectionProviders: "",
415
+ workflowTags: "",
416
+ environments: ["PROD"],
417
+ join: "all",
418
+ rows: []
419
+ };
420
+ }
421
+ function splitList(text) {
422
+ return [
423
+ ...new Set(
424
+ text.split(",").map((item) => item.trim()).filter(Boolean)
425
+ )
426
+ ];
427
+ }
428
+ function parseScalar(text) {
429
+ const trimmed = text.trim();
430
+ if (trimmed === "true") return true;
431
+ if (trimmed === "false") return false;
432
+ if (trimmed === "null") return null;
433
+ if (trimmed !== "" && Number.isFinite(Number(trimmed)))
434
+ return Number(trimmed);
435
+ return trimmed;
436
+ }
437
+ function formatScalar(value) {
438
+ if (value === null) return "null";
439
+ return String(value);
440
+ }
441
+ function rowToLeaf(row, index) {
442
+ const path = row.path.trim();
443
+ if (!path)
444
+ throw new PolicyFormError(`Condition ${index + 1} needs a parameter path`);
445
+ switch (operatorKind(row.op)) {
446
+ case "none":
447
+ return { path, op: row.op };
448
+ case "number": {
449
+ const value = Number(row.value.trim());
450
+ if (row.value.trim() === "" || !Number.isFinite(value))
451
+ throw new PolicyFormError(`Condition ${index + 1} needs a number`);
452
+ return { path, op: row.op, value };
453
+ }
454
+ case "list": {
455
+ const values = splitList(row.value).map(parseScalar);
456
+ if (values.length === 0)
457
+ throw new PolicyFormError(
458
+ `Condition ${index + 1} needs at least one value`
459
+ );
460
+ return { path, op: row.op, value: values };
461
+ }
462
+ default:
463
+ return { path, op: row.op, value: parseScalar(row.value) };
464
+ }
465
+ }
466
+ function formToInput(form) {
467
+ const name = form.name.trim();
468
+ if (!name) throw new PolicyFormError("Give the policy a name");
469
+ const priority = Number(form.priority);
470
+ if (!Number.isInteger(priority) || priority < 0 || priority > 1e5)
471
+ throw new PolicyFormError("Priority is a whole number from 0 to 100000");
472
+ const leaves = form.rows.map(rowToLeaf);
473
+ let condition = null;
474
+ if (leaves.length === 1) condition = leaves[0];
475
+ else if (leaves.length > 1) condition = { [form.join]: leaves };
476
+ return {
477
+ ...form.id ? { id: form.id } : {},
478
+ name,
479
+ description: form.description.trim() || null,
480
+ enabled: form.enabled,
481
+ priority,
482
+ effect: form.effect,
483
+ matcher: {
484
+ nodeTypes: splitList(form.nodeTypes),
485
+ operations: splitList(form.operations),
486
+ connectionProviders: splitList(form.connectionProviders),
487
+ workflowTags: splitList(form.workflowTags)
488
+ },
489
+ condition,
490
+ environments: form.environments
491
+ };
492
+ }
493
+ function isRecord(value) {
494
+ return typeof value === "object" && value !== null && !Array.isArray(value);
495
+ }
496
+ function leafToRow(leaf) {
497
+ if (!isRecord(leaf) || typeof leaf.path !== "string") return null;
498
+ const op = String(leaf.op);
499
+ if (!OPERATOR_KIND.has(op)) return null;
500
+ const value = Array.isArray(leaf.value) ? leaf.value.map(formatScalar).join(", ") : leaf.value === void 0 ? "" : formatScalar(leaf.value);
501
+ return { path: leaf.path, op, value };
502
+ }
503
+ function policyToForm(policy) {
504
+ const base = {
505
+ id: policy.id,
506
+ name: policy.name,
507
+ description: policy.description ?? "",
508
+ enabled: policy.enabled,
509
+ priority: String(policy.priority),
510
+ effect: policy.effect,
511
+ nodeTypes: policy.matcher.nodeTypes.join(", "),
512
+ operations: policy.matcher.operations.join(", "),
513
+ connectionProviders: policy.matcher.connectionProviders.join(", "),
514
+ workflowTags: policy.matcher.workflowTags.join(", "),
515
+ environments: policy.environments,
516
+ join: "all",
517
+ rows: []
518
+ };
519
+ const condition = policy.condition;
520
+ if (condition === null || condition === void 0)
521
+ return { ...base, conditionEditable: true };
522
+ const single = leafToRow(condition);
523
+ if (single) return { ...base, rows: [single], conditionEditable: true };
524
+ if (isRecord(condition)) {
525
+ const join = Array.isArray(condition.all) ? "all" : Array.isArray(condition.any) ? "any" : null;
526
+ const children = join ? condition[join] : [];
527
+ const rows = children.map(leafToRow);
528
+ if (join && rows.every((row) => row !== null))
529
+ return { ...base, join, rows, conditionEditable: true };
530
+ }
531
+ return { ...base, conditionEditable: false };
532
+ }
533
+ function describePolicy(policy) {
534
+ const scope = [
535
+ policy.matcher.nodeTypes.length ? `nodes ${policy.matcher.nodeTypes.join(", ")}` : "",
536
+ policy.matcher.operations.length ? `operations ${policy.matcher.operations.join(", ")}` : "",
537
+ policy.matcher.connectionProviders.length ? `connections ${policy.matcher.connectionProviders.join(", ")}` : "",
538
+ policy.matcher.workflowTags.length ? `tagged ${policy.matcher.workflowTags.join(", ")}` : ""
539
+ ].filter(Boolean);
540
+ const where = scope.length ? scope.join("; ") : "every outbound action";
541
+ const env = policy.environments.length ? ` in ${policy.environments.join("/")}` : "";
542
+ const form = policyToForm(policy);
543
+ if (!form.conditionEditable) return `${where}${env}, with a custom condition`;
544
+ if (form.rows.length === 0) return `${where}${env}`;
545
+ const parts = form.rows.map((row) => {
546
+ const label = CONDITION_OPERATORS.find((operator) => operator.value === row.op)?.label ?? row.op;
547
+ return operatorKind(row.op) === "none" ? `${row.path} ${label}` : `${row.path} ${label} ${row.value}`;
548
+ });
549
+ return `${where}${env}, when ${parts.join(form.join === "all" ? " and " : " or ")}`;
550
+ }
551
+ function needsAllowFallback(draft, others) {
552
+ if (draft.effect === "ALLOW" || draft.condition === null) return false;
553
+ return !others.some(
554
+ (policy) => policy.enabled && policy.effect === "ALLOW" && (policy.condition === null || policy.condition === void 0)
555
+ );
556
+ }
557
+ var FIELD = "rounded-md border border-border-secondary bg-background-secondary px-2 py-1.5 text-sm text-text-primary";
558
+ function errorMessage(err) {
559
+ if (err instanceof PolicyFormError) return err.message;
560
+ return describeOperationError(err);
561
+ }
562
+ function DryRunSummary({ result }) {
563
+ const tr = useTr();
564
+ return /* @__PURE__ */ jsxs(
565
+ "div",
566
+ {
567
+ className: "rounded-md border border-border-secondary bg-background-secondary p-3 space-y-3",
568
+ "data-testid": "action-policy-dry-run",
569
+ children: [
570
+ /* @__PURE__ */ jsx("p", { className: "text-sm text-text-primary", children: tr(
571
+ "fluidgrids.actionPolicies.dryRun.summary",
572
+ "Across the last {{runs}} runs, {{actions}} outbound actions were checked: {{approval}} would wait for approval and {{deny}} would be denied ({{affected}} runs affected, {{changed}} decisions change).",
573
+ {
574
+ runs: result.runsScanned,
575
+ actions: result.actionsEvaluated,
576
+ approval: result.wouldRequireApproval,
577
+ deny: result.wouldDeny,
578
+ affected: result.runsAffected,
579
+ changed: result.changed
580
+ }
581
+ ) }),
582
+ result.rows.length > 0 ? /* @__PURE__ */ jsx("div", { className: "max-h-72 overflow-auto", children: /* @__PURE__ */ jsxs("table", { className: "w-full text-xs", children: [
583
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { className: "text-left text-text-secondary", children: [
584
+ /* @__PURE__ */ jsx("th", { className: "py-1 pr-2 font-medium", children: tr("fluidgrids.actionPolicies.dryRun.workflow", "Workflow") }),
585
+ /* @__PURE__ */ jsx("th", { className: "py-1 pr-2 font-medium", children: tr("fluidgrids.actionPolicies.dryRun.node", "Node") }),
586
+ /* @__PURE__ */ jsx("th", { className: "py-1 pr-2 font-medium", children: tr("fluidgrids.actionPolicies.dryRun.env", "Env") }),
587
+ /* @__PURE__ */ jsx("th", { className: "py-1 pr-2 font-medium", children: tr("fluidgrids.actionPolicies.dryRun.now", "Today") }),
588
+ /* @__PURE__ */ jsx("th", { className: "py-1 font-medium", children: tr("fluidgrids.actionPolicies.dryRun.with", "With draft") })
589
+ ] }) }),
590
+ /* @__PURE__ */ jsx("tbody", { children: result.rows.map((row) => /* @__PURE__ */ jsxs(
591
+ "tr",
592
+ {
593
+ className: "border-t border-border-secondary text-text-primary",
594
+ children: [
595
+ /* @__PURE__ */ jsxs("td", { className: "py-1 pr-2", children: [
596
+ /* @__PURE__ */ jsx("div", { children: row.workflowKey }),
597
+ /* @__PURE__ */ jsx("div", { className: "text-text-tertiary", children: new Date(row.runCreatedAt).toLocaleString() })
598
+ ] }),
599
+ /* @__PURE__ */ jsxs("td", { className: "py-1 pr-2", children: [
600
+ /* @__PURE__ */ jsx("div", { children: row.nodeId }),
601
+ /* @__PURE__ */ jsxs("div", { className: "font-mono text-text-tertiary", children: [
602
+ row.nodeType,
603
+ row.operation ? ` \xB7 ${row.operation}` : ""
604
+ ] })
605
+ ] }),
606
+ /* @__PURE__ */ jsx("td", { className: "py-1 pr-2", children: row.environment }),
607
+ /* @__PURE__ */ jsx("td", { className: "py-1 pr-2", children: /* @__PURE__ */ jsx(EffectBadge, { effect: row.currentEffect }) }),
608
+ /* @__PURE__ */ jsxs("td", { className: "py-1", children: [
609
+ /* @__PURE__ */ jsx(EffectBadge, { effect: row.effect }),
610
+ row.policyName ? /* @__PURE__ */ jsx("div", { className: "text-text-tertiary", children: row.policyName }) : null
611
+ ] })
612
+ ]
613
+ },
614
+ `${row.runId}:${row.nodeId}`
615
+ )) })
616
+ ] }) }) : /* @__PURE__ */ jsx("p", { className: "text-xs text-text-secondary", children: tr(
617
+ "fluidgrids.actionPolicies.dryRun.nothingBlocked",
618
+ "Nothing in these runs would be blocked or change."
619
+ ) })
620
+ ]
621
+ }
622
+ );
623
+ }
624
+ function PolicyEditor({
625
+ initial,
626
+ conditionEditable,
627
+ storedCondition,
628
+ others,
629
+ onCancel,
630
+ onSave,
631
+ onDryRun
632
+ }) {
633
+ const tr = useTr();
634
+ const [form, setForm] = useState(initial);
635
+ const [busy, setBusy] = useState(false);
636
+ const keepCondition = !conditionEditable;
637
+ const fallbackWarning = useMemo(() => {
638
+ try {
639
+ const input = formToInput(form);
640
+ return needsAllowFallback(
641
+ {
642
+ effect: input.effect,
643
+ condition: keepCondition ? storedCondition : input.condition
644
+ },
645
+ others
646
+ );
647
+ } catch {
648
+ return false;
649
+ }
650
+ }, [form, others, keepCondition, storedCondition]);
651
+ const run = async (action) => {
652
+ setBusy(true);
653
+ try {
654
+ await action();
655
+ } catch (err) {
656
+ toast.error(errorMessage(err));
657
+ } finally {
658
+ setBusy(false);
659
+ }
660
+ };
661
+ const setRow = (index, patch) => setForm((current) => ({
662
+ ...current,
663
+ rows: current.rows.map(
664
+ (row, i) => i === index ? { ...row, ...patch } : row
665
+ )
666
+ }));
667
+ return /* @__PURE__ */ jsxs(
668
+ "div",
669
+ {
670
+ className: "rounded-lg border border-border-secondary bg-background-primary p-4 space-y-4",
671
+ "data-testid": "action-policy-editor",
672
+ children: [
673
+ /* @__PURE__ */ jsxs("div", { className: "grid gap-3 sm:grid-cols-2", children: [
674
+ /* @__PURE__ */ jsxs("div", { className: "space-y-1", children: [
675
+ /* @__PURE__ */ jsx(Label, { htmlFor: "policy-name", children: tr("fluidgrids.actionPolicies.name", "Name") }),
676
+ /* @__PURE__ */ jsx(
677
+ Input,
678
+ {
679
+ id: "policy-name",
680
+ value: form.name,
681
+ maxLength: 200,
682
+ onChange: (e) => setForm({ ...form, name: e.target.value }),
683
+ placeholder: tr(
684
+ "fluidgrids.actionPolicies.namePlaceholder",
685
+ "Payments over 1,000 need approval"
686
+ )
687
+ }
688
+ )
689
+ ] }),
690
+ /* @__PURE__ */ jsxs("div", { className: "grid grid-cols-2 gap-3", children: [
691
+ /* @__PURE__ */ jsxs("div", { className: "space-y-1", children: [
692
+ /* @__PURE__ */ jsx(Label, { htmlFor: "policy-effect", children: tr("fluidgrids.actionPolicies.effectLabel", "Effect") }),
693
+ /* @__PURE__ */ jsx(
694
+ "select",
695
+ {
696
+ id: "policy-effect",
697
+ className: `w-full ${FIELD}`,
698
+ value: form.effect,
699
+ onChange: (e) => setForm({ ...form, effect: e.target.value }),
700
+ children: POLICY_EFFECTS.map((effect) => /* @__PURE__ */ jsx("option", { value: effect, children: effectLabel(effect, tr) }, effect))
701
+ }
702
+ )
703
+ ] }),
704
+ /* @__PURE__ */ jsxs("div", { className: "space-y-1", children: [
705
+ /* @__PURE__ */ jsx(Label, { htmlFor: "policy-priority", children: tr("fluidgrids.actionPolicies.priority", "Priority") }),
706
+ /* @__PURE__ */ jsx(
707
+ Input,
708
+ {
709
+ id: "policy-priority",
710
+ inputMode: "numeric",
711
+ value: form.priority,
712
+ onChange: (e) => setForm({ ...form, priority: e.target.value })
713
+ }
714
+ )
715
+ ] })
716
+ ] })
717
+ ] }),
718
+ /* @__PURE__ */ jsxs("div", { className: "space-y-1", children: [
719
+ /* @__PURE__ */ jsx(Label, { htmlFor: "policy-description", children: tr("fluidgrids.actionPolicies.description", "Description") }),
720
+ /* @__PURE__ */ jsx(
721
+ Input,
722
+ {
723
+ id: "policy-description",
724
+ value: form.description,
725
+ maxLength: 2e3,
726
+ onChange: (e) => setForm({ ...form, description: e.target.value })
727
+ }
728
+ )
729
+ ] }),
730
+ /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
731
+ /* @__PURE__ */ jsx("p", { className: "text-sm font-medium text-text-primary", children: tr("fluidgrids.actionPolicies.appliesTo", "Applies to") }),
732
+ /* @__PURE__ */ jsx("p", { className: "text-xs text-text-secondary", children: tr(
733
+ "fluidgrids.actionPolicies.appliesToHelp",
734
+ "Comma-separated. Node types and operations accept * and ? wildcards (store/stripe*, internal:http). Leave a field empty to match anything."
735
+ ) }),
736
+ /* @__PURE__ */ jsx("div", { className: "grid gap-3 sm:grid-cols-2", children: [
737
+ ["nodeTypes", "Node types", "store/stripe*, internal:http"],
738
+ ["operations", "Operations", "refund*, createCharge"],
739
+ ["connectionProviders", "Connection providers", "stripe"],
740
+ ["workflowTags", "Workflow tags", "finance"]
741
+ ].map(([key, label, placeholder]) => /* @__PURE__ */ jsxs("div", { className: "space-y-1", children: [
742
+ /* @__PURE__ */ jsx(Label, { htmlFor: `policy-${key}`, children: tr(`fluidgrids.actionPolicies.matcher.${key}`, label) }),
743
+ /* @__PURE__ */ jsx(
744
+ Input,
745
+ {
746
+ id: `policy-${key}`,
747
+ value: form[key],
748
+ placeholder,
749
+ onChange: (e) => setForm({ ...form, [key]: e.target.value })
750
+ }
751
+ )
752
+ ] }, key)) }),
753
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-4 pt-1", children: [
754
+ /* @__PURE__ */ jsx("span", { className: "text-sm text-text-secondary", children: tr("fluidgrids.actionPolicies.environments", "Environments") }),
755
+ POLICY_ENVIRONMENTS.map((environment) => /* @__PURE__ */ jsxs(
756
+ "label",
757
+ {
758
+ className: "flex items-center gap-1.5 text-sm text-text-primary",
759
+ children: [
760
+ /* @__PURE__ */ jsx(
761
+ "input",
762
+ {
763
+ type: "checkbox",
764
+ checked: form.environments.includes(environment),
765
+ onChange: (e) => setForm({
766
+ ...form,
767
+ environments: e.target.checked ? [...form.environments, environment] : form.environments.filter(
768
+ (item) => item !== environment
769
+ )
770
+ })
771
+ }
772
+ ),
773
+ environment
774
+ ]
775
+ },
776
+ environment
777
+ )),
778
+ /* @__PURE__ */ jsx("span", { className: "text-xs text-text-tertiary", children: tr(
779
+ "fluidgrids.actionPolicies.environmentsHelp",
780
+ "None checked applies in every environment. Test runs are DEV; triggered and API runs are PROD."
781
+ ) })
782
+ ] })
783
+ ] }),
784
+ /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
785
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [
786
+ /* @__PURE__ */ jsx("p", { className: "text-sm font-medium text-text-primary", children: tr("fluidgrids.actionPolicies.condition", "Condition") }),
787
+ conditionEditable && form.rows.length > 1 ? /* @__PURE__ */ jsxs(
788
+ "select",
789
+ {
790
+ "aria-label": tr(
791
+ "fluidgrids.actionPolicies.join",
792
+ "Combine conditions"
793
+ ),
794
+ className: FIELD,
795
+ value: form.join,
796
+ onChange: (e) => setForm({ ...form, join: e.target.value }),
797
+ children: [
798
+ /* @__PURE__ */ jsx("option", { value: "all", children: tr("fluidgrids.actionPolicies.joinAll", "all must hold") }),
799
+ /* @__PURE__ */ jsx("option", { value: "any", children: tr("fluidgrids.actionPolicies.joinAny", "any may hold") })
800
+ ]
801
+ }
802
+ ) : null
803
+ ] }),
804
+ !conditionEditable ? /* @__PURE__ */ jsx("p", { className: "text-xs text-text-secondary", children: tr(
805
+ "fluidgrids.actionPolicies.customCondition",
806
+ "This policy has a nested condition saved through the API. It is kept as it is."
807
+ ) }) : form.rows.length === 0 ? /* @__PURE__ */ jsx("p", { className: "text-xs text-text-secondary", children: tr(
808
+ "fluidgrids.actionPolicies.noCondition",
809
+ "No condition: the policy fires on every action it applies to."
810
+ ) }) : form.rows.map((row, index) => /* @__PURE__ */ jsxs(
811
+ "div",
812
+ {
813
+ className: "flex flex-col gap-2 sm:flex-row sm:items-center",
814
+ "data-testid": "action-policy-condition-row",
815
+ children: [
816
+ /* @__PURE__ */ jsx(
817
+ Input,
818
+ {
819
+ "aria-label": tr(
820
+ "fluidgrids.actionPolicies.path",
821
+ "Parameter path"
822
+ ),
823
+ className: "sm:w-56",
824
+ value: row.path,
825
+ placeholder: "body.amount",
826
+ onChange: (e) => setRow(index, { path: e.target.value })
827
+ }
828
+ ),
829
+ /* @__PURE__ */ jsx(
830
+ "select",
831
+ {
832
+ "aria-label": tr(
833
+ "fluidgrids.actionPolicies.operator",
834
+ "Operator"
835
+ ),
836
+ className: FIELD,
837
+ value: row.op,
838
+ onChange: (e) => setRow(index, { op: e.target.value }),
839
+ children: CONDITION_OPERATORS.map((operator) => /* @__PURE__ */ jsx("option", { value: operator.value, children: tr(
840
+ `fluidgrids.actionPolicies.op.${operator.value}`,
841
+ operator.label
842
+ ) }, operator.value))
843
+ }
844
+ ),
845
+ operatorKind(row.op) === "none" ? null : /* @__PURE__ */ jsx(
846
+ Input,
847
+ {
848
+ "aria-label": tr("fluidgrids.actionPolicies.value", "Value"),
849
+ className: "sm:w-48",
850
+ value: row.value,
851
+ placeholder: operatorKind(row.op) === "list" ? "usd, eur" : "1000",
852
+ onChange: (e) => setRow(index, { value: e.target.value })
853
+ }
854
+ ),
855
+ /* @__PURE__ */ jsx(
856
+ Button,
857
+ {
858
+ variant: "ghost",
859
+ size: "sm",
860
+ "aria-label": tr(
861
+ "fluidgrids.actionPolicies.removeCondition",
862
+ "Remove condition"
863
+ ),
864
+ onClick: () => setForm({
865
+ ...form,
866
+ rows: form.rows.filter((_, i) => i !== index)
867
+ }),
868
+ children: /* @__PURE__ */ jsx(Icon, { icon: "mdi:close", width: 16 })
869
+ }
870
+ )
871
+ ]
872
+ },
873
+ index
874
+ )),
875
+ conditionEditable ? /* @__PURE__ */ jsxs(
876
+ Button,
877
+ {
878
+ variant: "outline",
879
+ size: "sm",
880
+ onClick: () => setForm({
881
+ ...form,
882
+ rows: [...form.rows, { path: "", op: "gt", value: "" }]
883
+ }),
884
+ children: [
885
+ /* @__PURE__ */ jsx(Icon, { icon: "mdi:plus", width: 16, className: "mr-1" }),
886
+ tr("fluidgrids.actionPolicies.addCondition", "Add condition")
887
+ ]
888
+ }
889
+ ) : null
890
+ ] }),
891
+ fallbackWarning ? /* @__PURE__ */ jsx("p", { className: "rounded-md border border-status-warning-border bg-status-warning-bg-subtle p-2 text-xs text-status-warning-text", children: tr(
892
+ "fluidgrids.actionPolicies.defaultDenyWarning",
893
+ "Default deny: actions this policy applies to but whose condition does not hold are denied unless another policy allows them. Add an Allow policy with the same scope and a higher priority number to let the rest through, and check the dry run."
894
+ ) }) : null,
895
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center justify-between gap-2", children: [
896
+ /* @__PURE__ */ jsxs("label", { className: "flex items-center gap-2 text-sm text-text-primary", children: [
897
+ /* @__PURE__ */ jsx(
898
+ Switch,
899
+ {
900
+ checked: form.enabled,
901
+ onCheckedChange: (enabled) => setForm({ ...form, enabled })
902
+ }
903
+ ),
904
+ tr("fluidgrids.actionPolicies.enabled", "Enabled")
905
+ ] }),
906
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap gap-2", children: [
907
+ /* @__PURE__ */ jsx(Button, { variant: "ghost", onClick: onCancel, disabled: busy, children: tr("fluidgrids.actionPolicies.cancel", "Cancel") }),
908
+ /* @__PURE__ */ jsxs(
909
+ Button,
910
+ {
911
+ variant: "outline",
912
+ disabled: busy,
913
+ onClick: () => run(() => onDryRun(form, keepCondition)),
914
+ children: [
915
+ /* @__PURE__ */ jsx(Icon, { icon: "mdi:test-tube", width: 16, className: "mr-1" }),
916
+ tr("fluidgrids.actionPolicies.tryIt", "What would this block?")
917
+ ]
918
+ }
919
+ ),
920
+ /* @__PURE__ */ jsx(
921
+ Button,
922
+ {
923
+ disabled: busy,
924
+ onClick: () => run(() => onSave(form, keepCondition)),
925
+ children: tr("fluidgrids.actionPolicies.save", "Save policy")
926
+ }
927
+ )
928
+ ] })
929
+ ] })
930
+ ]
931
+ }
932
+ );
933
+ }
934
+ function ApprovalsList({
935
+ approvals,
936
+ onDecide
937
+ }) {
938
+ const tr = useTr();
939
+ const [busy, setBusy] = useState(null);
940
+ if (approvals.length === 0) return null;
941
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-2", "data-testid": "action-policy-approvals", children: [
942
+ /* @__PURE__ */ jsx("h3", { className: "text-sm font-semibold text-text-primary", children: tr("fluidgrids.actionPolicies.pendingTitle", "Waiting for approval") }),
943
+ approvals.map((approval) => /* @__PURE__ */ jsxs(
944
+ "div",
945
+ {
946
+ className: "flex flex-col gap-2 rounded-md border border-status-warning-border bg-status-warning-bg-subtle p-3 sm:flex-row sm:items-center sm:justify-between",
947
+ children: [
948
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 text-sm text-text-primary", children: [
949
+ /* @__PURE__ */ jsxs("div", { className: "font-medium", children: [
950
+ approval.workflowKey,
951
+ " \xB7 ",
952
+ approval.nodeId
953
+ ] }),
954
+ /* @__PURE__ */ jsxs("div", { className: "text-xs text-text-secondary", children: [
955
+ approval.policyName ?? "",
956
+ " \xB7 ",
957
+ approval.nodeType,
958
+ approval.operation ? ` \xB7 ${approval.operation}` : "",
959
+ " \xB7",
960
+ " ",
961
+ approval.environment,
962
+ approval.reason === "PAYLOAD_CHANGED" ? ` \xB7 ${tr("fluidgrids.actionPolicies.payloadChanged", "changed after an earlier approval")}` : ""
963
+ ] }),
964
+ /* @__PURE__ */ jsxs("div", { className: "font-mono text-[10px] text-text-tertiary", children: [
965
+ tr("fluidgrids.actionPolicies.payload", "Payload"),
966
+ " ",
967
+ approval.payloadHash.slice(0, 16)
968
+ ] })
969
+ ] }),
970
+ /* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
971
+ /* @__PURE__ */ jsx(
972
+ Button,
973
+ {
974
+ size: "sm",
975
+ disabled: busy === approval.id,
976
+ onClick: async () => {
977
+ setBusy(approval.id);
978
+ try {
979
+ await onDecide(approval.id, true);
980
+ toast.success(
981
+ tr(
982
+ "fluidgrids.actionPolicies.approved",
983
+ "Approved \u2014 the run continues"
984
+ )
985
+ );
986
+ } catch (err) {
987
+ toast.error(errorMessage(err));
988
+ } finally {
989
+ setBusy(null);
990
+ }
991
+ },
992
+ children: tr("fluidgrids.actionPolicies.approve", "Approve")
993
+ }
994
+ ),
995
+ /* @__PURE__ */ jsx(
996
+ Button,
997
+ {
998
+ size: "sm",
999
+ variant: "outline",
1000
+ disabled: busy === approval.id,
1001
+ onClick: async () => {
1002
+ setBusy(approval.id);
1003
+ try {
1004
+ await onDecide(approval.id, false);
1005
+ toast.success(
1006
+ tr(
1007
+ "fluidgrids.actionPolicies.declined",
1008
+ "Declined \u2014 the node fails"
1009
+ )
1010
+ );
1011
+ } catch (err) {
1012
+ toast.error(errorMessage(err));
1013
+ } finally {
1014
+ setBusy(null);
1015
+ }
1016
+ },
1017
+ children: tr("fluidgrids.actionPolicies.decline", "Decline")
1018
+ }
1019
+ )
1020
+ ] })
1021
+ ]
1022
+ },
1023
+ approval.id
1024
+ ))
1025
+ ] });
1026
+ }
1027
+ function ActionPoliciesTab() {
1028
+ const tr = useTr();
1029
+ const { policies, approvals, loading, error, save, remove, dryRun, decide } = useActionPolicies();
1030
+ const [editing, setEditing] = useState(null);
1031
+ const [preview, setPreview] = useState(null);
1032
+ const [confirmDelete, setConfirmDelete] = useState(null);
1033
+ const toInput = (form, keepCondition) => {
1034
+ const input = formToInput(form);
1035
+ return keepCondition && editing ? { ...input, condition: editing.storedCondition } : input;
1036
+ };
1037
+ const others = policies.filter((policy) => policy.id !== editing?.form.id);
1038
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-6", "data-testid": "action-policies-tab", children: [
1039
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between", children: [
1040
+ /* @__PURE__ */ jsxs("div", { children: [
1041
+ /* @__PURE__ */ jsx("h2", { className: "text-lg font-semibold text-text-primary", children: tr("fluidgrids.actionPolicies.title", "Action policies") }),
1042
+ /* @__PURE__ */ jsx("p", { className: "text-sm text-text-secondary max-w-2xl", children: tr(
1043
+ "fluidgrids.actionPolicies.subtitle",
1044
+ "Rules every workflow in this workspace follows before it calls the outside world, whoever built it: require approval above a threshold, deny an operation, or log it. Policies are checked in priority order (lowest first). Without a policy that applies, an action runs as before."
1045
+ ) })
1046
+ ] }),
1047
+ !editing ? /* @__PURE__ */ jsxs(
1048
+ Button,
1049
+ {
1050
+ onClick: () => {
1051
+ setPreview(null);
1052
+ setEditing({
1053
+ form: emptyPolicyForm(),
1054
+ conditionEditable: true,
1055
+ storedCondition: null
1056
+ });
1057
+ },
1058
+ children: [
1059
+ /* @__PURE__ */ jsx(Icon, { icon: "mdi:plus", width: 16, className: "mr-1" }),
1060
+ tr("fluidgrids.actionPolicies.add", "Add policy")
1061
+ ]
1062
+ }
1063
+ ) : null
1064
+ ] }),
1065
+ /* @__PURE__ */ jsx(ApprovalsList, { approvals, onDecide: decide }),
1066
+ editing ? /* @__PURE__ */ jsx(
1067
+ PolicyEditor,
1068
+ {
1069
+ initial: editing.form,
1070
+ conditionEditable: editing.conditionEditable,
1071
+ storedCondition: editing.storedCondition,
1072
+ others,
1073
+ onCancel: () => {
1074
+ setEditing(null);
1075
+ setPreview(null);
1076
+ },
1077
+ onDryRun: async (form, keepCondition) => {
1078
+ setPreview(await dryRun({ policy: toInput(form, keepCondition) }));
1079
+ },
1080
+ onSave: async (form, keepCondition) => {
1081
+ await save(toInput(form, keepCondition));
1082
+ toast.success(
1083
+ tr("fluidgrids.actionPolicies.saved", "Policy saved")
1084
+ );
1085
+ setEditing(null);
1086
+ setPreview(null);
1087
+ }
1088
+ },
1089
+ editing.form.id ?? "new"
1090
+ ) : null,
1091
+ preview ? /* @__PURE__ */ jsx(DryRunSummary, { result: preview }) : null,
1092
+ loading ? /* @__PURE__ */ jsx("p", { className: "text-sm text-text-secondary", children: tr("fluidgrids.actionPolicies.loading", "Loading policies\u2026") }) : error ? /* @__PURE__ */ jsx("p", { className: "text-sm text-status-error-text", children: describeOperationError(error) }) : policies.length === 0 ? /* @__PURE__ */ jsx(
1093
+ "p",
1094
+ {
1095
+ className: "text-sm text-text-secondary",
1096
+ "data-testid": "action-policies-empty",
1097
+ children: tr(
1098
+ "fluidgrids.actionPolicies.empty",
1099
+ "No policies yet. Every action runs as its workflow says."
1100
+ )
1101
+ }
1102
+ ) : /* @__PURE__ */ jsx("ul", { className: "divide-y divide-border-secondary rounded-lg border border-border-secondary", children: policies.map((policy) => /* @__PURE__ */ jsxs(
1103
+ "li",
1104
+ {
1105
+ className: "flex flex-col gap-2 p-3 sm:flex-row sm:items-center sm:justify-between",
1106
+ "data-testid": "action-policy-row",
1107
+ children: [
1108
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
1109
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [
1110
+ /* @__PURE__ */ jsx("span", { className: "font-medium text-text-primary", children: policy.name }),
1111
+ /* @__PURE__ */ jsx(EffectBadge, { effect: policy.effect }),
1112
+ /* @__PURE__ */ jsx(Badge, { variant: "outline", className: "text-xs", children: tr(
1113
+ "fluidgrids.actionPolicies.priorityShort",
1114
+ "#{{priority}}",
1115
+ {
1116
+ priority: policy.priority
1117
+ }
1118
+ ) }),
1119
+ !policy.enabled ? /* @__PURE__ */ jsx(
1120
+ Badge,
1121
+ {
1122
+ variant: "outline",
1123
+ className: "text-xs text-text-tertiary",
1124
+ children: tr("fluidgrids.actionPolicies.disabled", "Disabled")
1125
+ }
1126
+ ) : null
1127
+ ] }),
1128
+ /* @__PURE__ */ jsx("p", { className: "text-xs text-text-secondary", children: describePolicy(policy) })
1129
+ ] }),
1130
+ /* @__PURE__ */ jsxs("div", { className: "flex gap-1", children: [
1131
+ /* @__PURE__ */ jsx(
1132
+ Button,
1133
+ {
1134
+ variant: "ghost",
1135
+ size: "sm",
1136
+ onClick: async () => {
1137
+ try {
1138
+ setPreview(await dryRun({ excludePolicyId: policy.id }));
1139
+ } catch (err) {
1140
+ toast.error(errorMessage(err));
1141
+ }
1142
+ },
1143
+ children: tr("fluidgrids.actionPolicies.withoutIt", "Without it")
1144
+ }
1145
+ ),
1146
+ /* @__PURE__ */ jsx(
1147
+ Button,
1148
+ {
1149
+ variant: "ghost",
1150
+ size: "sm",
1151
+ onClick: () => {
1152
+ const { conditionEditable, ...form } = policyToForm(policy);
1153
+ setPreview(null);
1154
+ setEditing({
1155
+ form,
1156
+ conditionEditable,
1157
+ storedCondition: policy.condition ?? null
1158
+ });
1159
+ },
1160
+ children: tr("fluidgrids.actionPolicies.edit", "Edit")
1161
+ }
1162
+ ),
1163
+ /* @__PURE__ */ jsx(
1164
+ Button,
1165
+ {
1166
+ variant: "ghost",
1167
+ size: "sm",
1168
+ className: "text-destructive",
1169
+ onClick: async () => {
1170
+ if (confirmDelete !== policy.id) {
1171
+ setConfirmDelete(policy.id);
1172
+ return;
1173
+ }
1174
+ setConfirmDelete(null);
1175
+ try {
1176
+ await remove(policy.id);
1177
+ toast.success(
1178
+ tr(
1179
+ "fluidgrids.actionPolicies.deleted",
1180
+ "Policy deleted"
1181
+ )
1182
+ );
1183
+ } catch (err) {
1184
+ toast.error(errorMessage(err));
1185
+ }
1186
+ },
1187
+ children: confirmDelete === policy.id ? tr(
1188
+ "fluidgrids.actionPolicies.confirmDelete",
1189
+ "Confirm delete"
1190
+ ) : tr("fluidgrids.actionPolicies.delete", "Delete")
1191
+ }
1192
+ )
1193
+ ] })
1194
+ ]
1195
+ },
1196
+ policy.id
1197
+ )) })
1198
+ ] });
1199
+ }
1200
+
1201
+ export { ActionPoliciesTab, RunPolicyDecisions, useRunPolicyDecisions };
@@ -1,3 +1,4 @@
1
+ import { ActionPoliciesTab } from './chunk-VUVTOUQ5.js';
1
2
  import { useFluidGridsFlag, FLUIDGRIDS_FLAGS, LogStreamingTab } from './chunk-LWF5J43N.js';
2
3
  import { FluidGridsDocsLink } from './chunk-4VJ3MDOV.js';
3
4
  import { useFlowStore, useSaveStatusStore, useFluidGridsPermission, FLUIDGRIDS_PERMISSIONS, throwIfResultError } from './chunk-5LKZ4PNK.js';
@@ -1240,6 +1241,18 @@ function SettingsPage({
1240
1241
  /* @__PURE__ */ jsx(Icon, { icon: "mdi:database-clock-outline", width: 16 }),
1241
1242
  tr("fluidgrids.settings.page.runDataTab", "Run data")
1242
1243
  ] }),
1244
+ /* @__PURE__ */ jsxs(
1245
+ TabsTrigger,
1246
+ {
1247
+ value: "policies",
1248
+ className: "gap-1.5",
1249
+ "data-tour": "fluidgrids-settings-policies-tab",
1250
+ children: [
1251
+ /* @__PURE__ */ jsx(Icon, { icon: "mdi:shield-check-outline", width: 16 }),
1252
+ tr("fluidgrids.settings.page.policiesTab", "Policies")
1253
+ ]
1254
+ }
1255
+ ),
1243
1256
  /* @__PURE__ */ jsxs(TabsTrigger, { value: "notifications", className: "gap-1.5", children: [
1244
1257
  /* @__PURE__ */ jsx(Icon, { icon: "mdi:bell-outline", width: 16 }),
1245
1258
  tr("fluidgrids.settings.page.notificationsTab", "Notifications")
@@ -1266,6 +1279,7 @@ function SettingsPage({
1266
1279
  /* @__PURE__ */ jsx(TabsContent, { value: "general", children: /* @__PURE__ */ jsx(GeneralTab, {}) }),
1267
1280
  /* @__PURE__ */ jsx(TabsContent, { value: "execution", children: /* @__PURE__ */ jsx(ExecutionTab, { workflowKey }) }),
1268
1281
  /* @__PURE__ */ jsx(TabsContent, { value: "run-data", children: /* @__PURE__ */ jsx(RunDataTab, {}) }),
1282
+ /* @__PURE__ */ jsx(TabsContent, { value: "policies", children: /* @__PURE__ */ jsx(ActionPoliciesTab, {}) }),
1269
1283
  /* @__PURE__ */ jsx(TabsContent, { value: "notifications", children: /* @__PURE__ */ jsx(NotificationsTab, {}) }),
1270
1284
  logStreamsEnabled ? /* @__PURE__ */ jsx(TabsContent, { value: "log-streaming", children: /* @__PURE__ */ jsx(LogStreamingTab, {}) }) : null,
1271
1285
  /* @__PURE__ */ jsx(TabsContent, { value: "api", children: /* @__PURE__ */ jsx(ApiTab, {}) })
@@ -1,8 +1,9 @@
1
1
  import './chunk-AD3BXTJS.js';
2
2
  export { ExecutionsListPage } from './chunk-H7CJXSMI.js';
3
- export { ExecutionDetailPage } from './chunk-CPEIOFYR.js';
3
+ export { ExecutionDetailPage } from './chunk-S4VYW4BR.js';
4
4
  import './chunk-SSJIK6WS.js';
5
5
  import './chunk-UUUDSVC7.js';
6
+ import './chunk-VUVTOUQ5.js';
6
7
  import './chunk-5ERAJC2I.js';
7
8
  import './chunk-JAKWVVVQ.js';
8
9
  import './chunk-QGXA2USP.js';
package/dist/index.js CHANGED
@@ -5,14 +5,15 @@ export { FirstWorkflowChecklist, FirstWorkflowChecklistView, WorkflowsList, useC
5
5
  export { CreateWorkflowRefusalDialog, GUIDED_FIRST_WORKFLOW_NAV, GUIDED_FIRST_WORKFLOW_STATE, GUIDED_TOUR_STATE, firstWorkflowProgress, isGuidedTourIntent, pickGuidedTour } from './chunk-CSBKB5EX.js';
6
6
  import './chunk-AD3BXTJS.js';
7
7
  export { ExecutionsListPage } from './chunk-H7CJXSMI.js';
8
- import { usePendingHumanRequests, HumanRequestCard } from './chunk-CPEIOFYR.js';
9
- export { ExecutionDetailPage, HumanRequestCard, RunHumanRequests, usePendingHumanRequests, useRespondToHumanRequest, useRunHumanRequests } from './chunk-CPEIOFYR.js';
8
+ import { usePendingHumanRequests, HumanRequestCard } from './chunk-S4VYW4BR.js';
9
+ export { ExecutionDetailPage, HumanRequestCard, RunHumanRequests, usePendingHumanRequests, useRespondToHumanRequest, useRunHumanRequests } from './chunk-S4VYW4BR.js';
10
10
  import { TagFilterSelect } from './chunk-SSJIK6WS.js';
11
11
  import './chunk-UUUDSVC7.js';
12
12
  export { HistoryPage } from './chunk-Y6YOY33Z.js';
13
13
  export { NodesPage, fetchAllNodesWithManifests, useLazyNodeManifest, useNodeEndpoint, useNodeEndpoints, useNodeManifest } from './chunk-AYFKS4MM.js';
14
14
  export { DataSinkDashboardPage, ErrorAnalyticsPage, InsightsPage } from './chunk-FOEEDKBP.js';
15
- export { SettingsPage } from './chunk-VXLUYQDO.js';
15
+ export { SettingsPage } from './chunk-WRMIYMXE.js';
16
+ import './chunk-VUVTOUQ5.js';
16
17
  export { FLUIDGRIDS_FLAGS, useDataSinkData, useDataSinks, useFluidGridsFlag, useInsightsData, useLogStreamDestinationActions, useLogStreamDestinations, useRunReplayActions } from './chunk-LWF5J43N.js';
17
18
  import './chunk-5ERAJC2I.js';
18
19
  import { ThemeProvider, extractTemplateGraph } from './chunk-ZF55RVJ3.js';
@@ -82,7 +83,7 @@ var ExecutionsListPage2 = lazy(
82
83
  }))
83
84
  );
84
85
  var ExecutionDetailPage2 = lazy(
85
- () => import('./ExecutionDetailPage-MYBCDBV7.js').then((m) => ({
86
+ () => import('./ExecutionDetailPage-UFB7VMGE.js').then((m) => ({
86
87
  default: m.ExecutionDetailPage
87
88
  }))
88
89
  );
package/dist/settings.js CHANGED
@@ -1,4 +1,5 @@
1
- export { SettingsPage } from './chunk-VXLUYQDO.js';
1
+ export { SettingsPage } from './chunk-WRMIYMXE.js';
2
+ import './chunk-VUVTOUQ5.js';
2
3
  import './chunk-LWF5J43N.js';
3
4
  import './chunk-5ERAJC2I.js';
4
5
  import './chunk-4VJ3MDOV.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@burdenoff/fluidgrids-fe-sdk",
3
- "version": "2026.1028.4",
3
+ "version": "2026.1028.5",
4
4
  "description": "FluidGrids Workflow Builder SDK - Embeddable visual workflow automation",
5
5
  "type": "module",
6
6
  "imports": {