From 57c25a75bc4c6518bec1000e7fedce40d748448d Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 23:03:04 +0000 Subject: [PATCH] feat(task T07): implement via codex --- dashboard/src/v2/NodesPage.tsx | 123 ++++++++- .../components/nodes/NodeCredentialPicker.tsx | 239 ++++++++++++++++++ .../v2/components/nodes/NodeFlowInspector.tsx | 73 +++++- dashboard/src/v2/lib/node-flow-api.ts | 43 +++- .../docs/user-dashboard-node-flows.mdx | 4 +- docs-web/user/dashboard/node-flows.md | 4 +- docs/dashboard/node-flows.md | 4 +- tests/dashboard/v2/nodes-inspector.test.tsx | 128 +++++++++- tests/dashboard/v2/nodes-page.test.tsx | 182 ++++++++++++- 9 files changed, 764 insertions(+), 36 deletions(-) create mode 100644 dashboard/src/v2/components/nodes/NodeCredentialPicker.tsx diff --git a/dashboard/src/v2/NodesPage.tsx b/dashboard/src/v2/NodesPage.tsx index 850a24b10b..4fa60c9e7a 100644 --- a/dashboard/src/v2/NodesPage.tsx +++ b/dashboard/src/v2/NodesPage.tsx @@ -7,7 +7,8 @@ import { EmptyState } from "./components/ui/EmptyState.js"; import { Button } from "./components/ui/Button.js"; import { NodeFlowLibrary } from "./components/nodes/NodeFlowLibrary.js"; import { NodeFlowCanvas } from "./components/nodes/NodeFlowCanvas.js"; -import { NodeFlowInspector } from "./components/nodes/NodeFlowInspector.js"; +import { NodeFlowInspector, type CredentialBindingFeedback } from "./components/nodes/NodeFlowInspector.js"; +import type { CredentialSelectionResult } from "./components/nodes/NodeCredentialPicker.js"; import { NodePalette } from "./components/nodes/NodePalette.js"; import { NodeGovernancePanel } from "./components/nodes/NodeGovernancePanel.js"; import { NodeRunDebugger } from "./components/nodes/NodeRunDebugger.js"; @@ -19,9 +20,9 @@ import { fetchAgentPresets } from "./lib/agent-preset-api.js"; import { attachNodeFlowToAgent, cancelNodeFlowRun, compareNodeFlowVersions, createNodeFlowDraft, decideNodeFlowApproval, deleteNodeFlow, detachNodeFlowFromAgent, dryRunNodeFlowDraft, fetchNodeDefinition, fetchNodeFlow, fetchNodeFlowApprovals, fetchNodeFlowAttempts, fetchNodeFlowCatalog, fetchNodeFlowNodeRuns, - fetchNodeFlowAgentSkills, fetchNodeFlowRuns, fetchNodeFlows, patchNodeFlowDraft, publishNodeFlowDraft, requestNodeFlowCredential, + fetchNodeFlowAgentSkills, fetchNodeFlowRuns, fetchNodeFlows, patchNodeFlowDraft, publishNodeFlowDraft, retryNodeFlowRun, rollbackNodeFlow, runNodeFlow, validateNodeFlowDraft, - type NodeDefinitionSummary, type NodeFlowDryRunResponse, type NodeFlowVersionDiff, + NodeFlowDraftSaveError, type NodeDefinitionSummary, type NodeFlowDryRunResponse, type NodeFlowVersionDiff, } from "./lib/node-flow-api.js"; export const NODES_CANVAS_STORAGE_KEY = "codeux:nodes-canvas:v1"; @@ -34,7 +35,9 @@ export const NodesPage: FunctionComponent = () => { const projectRef = useRef(projectId); projectRef.current = projectId; const flowRef = useRef(null); const selectedFlowRef = useRef(null); + const selectedNodeRef = useRef(null); const reviewRequestRef = useRef(0); + const credentialMutationRequestRef = useRef(0); const mountedRef = useRef(true); const [flows, setFlows] = useState([]); const [catalog, setCatalog] = useState([]); @@ -67,6 +70,7 @@ export const NodesPage: FunctionComponent = () => { const [error, setError] = useState(null); const [migrationWarning, setMigrationWarning] = useState(null); const [notice, setNotice] = useState(null); + const [credentialFeedback, setCredentialFeedback] = useState(null); const selectedNode = useMemo(() => graph.nodes.find((node) => node.id === selectedNodeId) ?? null, [graph.nodes, selectedNodeId]); const selectedDefinition = selectedNode?.definition ? definitions[`${selectedNode.definition.type}@${selectedNode.definition.version}`] ?? null : null; @@ -75,13 +79,13 @@ export const NodesPage: FunctionComponent = () => { useEffect(() => { mountedRef.current = true; - return () => { mountedRef.current = false; reviewRequestRef.current += 1; }; + return () => { mountedRef.current = false; reviewRequestRef.current += 1; credentialMutationRequestRef.current += 1; }; }, []); const applyRecord = useCallback((flow: NodeFlowRecord): void => { flowRef.current = flow.id; selectedFlowRef.current = flow.id; setRecord(flow); setSelectedFlowId(flow.id); setTitle(flow.title); setDescription(flow.description); setGraph(flow.graph); - setSelectedNodeId(flow.graph.nodes[0]?.id ?? null); setReview(null); setDryRun(null); setDiff(null); + selectedNodeRef.current = flow.graph.nodes[0]?.id ?? null; setSelectedNodeId(selectedNodeRef.current); setReview(null); setDryRun(null); setDiff(null); }, []); const loadReview = useCallback(async (nextProjectId: string, flowId: string, signal?: AbortSignal): Promise => { @@ -130,8 +134,9 @@ export const NodesPage: FunctionComponent = () => { }, [applyRecord, loadReview]); useEffect(() => { - flowRef.current = null; selectedFlowRef.current = null; reviewRequestRef.current += 1; + flowRef.current = null; selectedFlowRef.current = null; selectedNodeRef.current = null; reviewRequestRef.current += 1; credentialMutationRequestRef.current += 1; setFlows([]); setRecord(null); setSelectedFlowId(null); setRuns([]); setAgents([]); setAttachments([]); setAttachAgentId(""); setAgentsError(null); setFlowAttachmentError(null); setAttachmentMutationError(null); setAttachmentBusy(false); setError(null); setMigrationWarning(null); setNotice(null); + setCredentialFeedback(null); if (!projectId) return; const controller = new AbortController(); void loadLibrary(projectId, controller.signal); return () => controller.abort(); }, [projectId, loadLibrary]); @@ -192,6 +197,9 @@ export const NodesPage: FunctionComponent = () => { useEffect(() => { if (!selectedRunId) { setNodeRuns([]); setAttempts([]); setApprovals([]); return; } const controller = new AbortController(); void Promise.all([fetchNodeFlowNodeRuns(selectedRunId, controller.signal), fetchNodeFlowAttempts(selectedRunId, controller.signal), fetchNodeFlowApprovals(selectedRunId, controller.signal)]).then(([nodes, history, governed]) => { setNodeRuns(nodes.nodeRuns); setAttempts(history.attempts); setApprovals(governed.approvals); }).catch((requestError) => { if (!controller.signal.aborted) setError(errorMessage(requestError)); }); return () => controller.abort(); }, [selectedRunId]); const act = async (action: () => Promise): Promise => { setBusy(true); setError(null); setNotice(null); try { await action(); } catch (requestError) { if (mountedRef.current) setError(errorMessage(requestError)); } finally { if (mountedRef.current) setBusy(false); } }; + const editTitle = (value: string): void => { credentialMutationRequestRef.current += 1; setCredentialFeedback(null); setTitle(value); }; + const editDescription = (value: string): void => { credentialMutationRequestRef.current += 1; setCredentialFeedback(null); setDescription(value); }; + const editGraph = (update: (current: NodeFlowGraph) => NodeFlowGraph): void => { credentialMutationRequestRef.current += 1; setCredentialFeedback(null); setGraph(update); }; const refreshAttachmentData = (): void => { setAttachmentMutationError(null); if (projectId) void loadAgents(projectId); @@ -228,16 +236,111 @@ export const NodesPage: FunctionComponent = () => { if (projectRef.current === mutationProjectId && flowRef.current === flowId) setAttachmentBusy(false); }); }; - const selectFlow = (flowId: string): void => { const flow = flows.find((item) => item.id === flowId); if (!flow || !projectId) return; applyRecord(flow); void loadReview(projectId, flow.id); }; + const selectFlow = (flowId: string): void => { const flow = flows.find((item) => item.id === flowId); if (!flow || !projectId) return; credentialMutationRequestRef.current += 1; setCredentialFeedback(null); applyRecord(flow); void loadReview(projectId, flow.id); }; const createFlow = (): void => { if (!projectId) return; const targetProjectId = projectId; void act(async () => { const created = await createNodeFlowDraft(targetProjectId, { title: "Untitled automation", description: "", graph: createDefaultNodeFlowGraph() }); if (projectRef.current !== targetProjectId) return; const flow = await fetchNodeFlow(created.flowId); if (projectRef.current !== targetProjectId || !mountedRef.current) return; setFlows((current) => [flow, ...current.filter((item) => item.id !== flow.id)]); applyRecord(flow); setReview(created); setNotice("Draft created in the selected project."); }); }; const save = (): void => { if (!projectId || !record) return; void act(async () => { const result = await patchNodeFlowDraft(record.id, { projectId, draftRevision: record.version, title, description, graph }); if (result.conflict) { setError(`${result.conflict.message} Current revision is ${result.conflict.actualDraftRevision}.`); return; } const saved = await fetchNodeFlow(record.id); setFlows((current) => current.map((item) => item.id === saved.id ? saved : item)); applyRecord(saved); setReview(result.draft ?? null); setNotice("Draft saved to the canonical flow repository."); }); }; - const addNode = (summary: NodeDefinitionSummary): void => { void act(async () => { const definition = await fetchNodeDefinition(summary.type, summary.version); setDefinitions((current) => ({ ...current, [`${definition.type}@${definition.version}`]: definition })); let suffix = 1; while (graph.nodes.some((node) => node.id === `${definition.type}-${suffix}`)) suffix += 1; const node: NodeFlowNode = { id: `${definition.type}-${suffix}`, type: definition.type, title: definition.ui.label, description: definition.ui.description, definition: { type: definition.type, version: definition.version }, ports: definition.ports, widgetSchema: definition.ui.widgetSchema, data: {}, capabilities: definition.capabilities, sideEffect: definition.sideEffect, policy: definition.defaultPolicy, credentialBindings: [], position: { x: 80 + graph.nodes.length * 260, y: 100 } }; setGraph((current) => ({ ...current, nodes: [...current.nodes, node] })); setSelectedNodeId(node.id); }); }; + const addNode = (summary: NodeDefinitionSummary): void => { void act(async () => { const definition = await fetchNodeDefinition(summary.type, summary.version); setDefinitions((current) => ({ ...current, [`${definition.type}@${definition.version}`]: definition })); let suffix = 1; while (graph.nodes.some((node) => node.id === `${definition.type}-${suffix}`)) suffix += 1; const node: NodeFlowNode = { id: `${definition.type}-${suffix}`, type: definition.type, title: definition.ui.label, description: definition.ui.description, definition: { type: definition.type, version: definition.version }, ports: definition.ports, widgetSchema: definition.ui.widgetSchema, data: {}, capabilities: definition.capabilities, sideEffect: definition.sideEffect, policy: definition.defaultPolicy, credentialBindings: [], position: { x: 80 + graph.nodes.length * 260, y: 100 } }; setGraph((current) => ({ ...current, nodes: [...current.nodes, node] })); selectedNodeRef.current = node.id; credentialMutationRequestRef.current += 1; setCredentialFeedback(null); setSelectedNodeId(node.id); }); }; const validate = (): void => { if (!projectId || !record) return; void act(async () => setReview(await validateNodeFlowDraft(projectId, record.id))); }; const runDry = (): void => { if (!projectId || !record) return; void act(async () => setDryRun(await dryRunNodeFlowDraft(projectId, record.id))); }; const publish = (): void => { if (!projectId || !record || !review) return; void act(async () => { setReview(await publishNodeFlowDraft(projectId, record.id, review.draftRevision)); setNotice("Draft published after governed review."); }); }; const compare = (): void => { if (!projectId || !record || !review?.publishedVersion) return; const publishedVersion = review.publishedVersion; void act(async () => setDiff(await compareNodeFlowVersions(projectId, record.id, publishedVersion, record.version))); }; const rollback = (): void => { if (!projectId || !record || !review?.publishedVersion) return; void act(async () => { const next = await rollbackNodeFlow(projectId, record.id, review.publishedVersion!, record.version); const flow = await fetchNodeFlow(record.id); applyRecord(flow); setReview(next); }); }; const run = (): void => { if (!projectId || !record) return; void act(async () => { const result = await runNodeFlow(record.id, { projectId, input: {} }); setRuns((current) => [result.run, ...current]); setSelectedRunId(result.run.id); setNodeRuns(result.nodeRuns); setAttempts(result.attempts ?? []); }); }; + const selectNode = (nodeId: string | null): void => { + selectedNodeRef.current = nodeId; + credentialMutationRequestRef.current += 1; + setCredentialFeedback(null); + setSelectedNodeId(nodeId); + }; + const applyCredentialRecord = (saved: NodeFlowRecord, nextReview: NodeFlowDraftReview, nodeId: string): void => { + flowRef.current = saved.id; selectedFlowRef.current = saved.id; + setFlows((current) => current.map((item) => item.id === saved.id ? saved : item)); + setRecord(saved); setSelectedFlowId(saved.id); setTitle(saved.title); setDescription(saved.description); setGraph(saved.graph); + const nextSelectedNodeId = saved.graph.nodes.some((node) => node.id === nodeId) ? nodeId : null; + selectedNodeRef.current = nextSelectedNodeId; setSelectedNodeId(nextSelectedNodeId); setReview(nextReview); setDryRun(null); setDiff(null); + }; + const changeCredential = async (nodeId: string, slot: string, credentialId: string | null): Promise => { + if (!projectId || !record || selectedNodeRef.current !== nodeId) return "stale"; + const node = graph.nodes.find((candidate) => candidate.id === nodeId); + if (!node) return "stale"; + const currentCredentialId = node.credentialBindings?.find((binding) => binding.slot === slot)?.credentialId ?? null; + if (currentCredentialId === credentialId) { + setCredentialFeedback({ nodeId, slot, status: "saved", message: "This credential is already bound to the slot." }); + return "saved"; + } + const bindings: NonNullable = []; + let replaced = false; + for (const binding of node.credentialBindings ?? []) { + if (binding.slot !== slot) { bindings.push(binding); continue; } + if (!replaced && credentialId) bindings.push({ slot, credentialId }); + replaced = true; + } + if (!replaced && credentialId) bindings.push({ slot, credentialId }); + const nextGraph = updateNodeInGraph(graph, nodeId, { credentialBindings: bindings }); + const targetProjectId = projectId; + const flowId = record.id; + const draftRevision = record.version; + const requestId = ++credentialMutationRequestRef.current; + const isCurrent = (): boolean => mountedRef.current + && credentialMutationRequestRef.current === requestId + && projectRef.current === targetProjectId + && selectedFlowRef.current === flowId + && selectedNodeRef.current === nodeId; + setCredentialFeedback({ nodeId, slot, status: "saving", message: credentialId ? "Saving credential binding…" : "Removing credential binding…" }); + try { + const result = await patchNodeFlowDraft(flowId, { + projectId: targetProjectId, + draftRevision, + title, + description, + graph: nextGraph, + }); + if (!isCurrent()) return "stale"; + if (result.conflict) { + const conflictMessage = `${result.conflict.message} Loaded revision ${result.conflict.actualDraftRevision}; choose the credential again to retry.`; + try { + const [latest, nextReview] = await Promise.all([ + fetchNodeFlow(flowId), + validateNodeFlowDraft(targetProjectId, flowId), + ]); + if (!isCurrent()) return "stale"; + applyCredentialRecord(latest, nextReview, nodeId); + setCredentialFeedback({ nodeId, slot, status: "conflict", message: conflictMessage }); + } catch (refreshError) { + if (!isCurrent()) return "stale"; + setCredentialFeedback({ nodeId, slot, status: "conflict", message: `${conflictMessage} The latest draft could not be refreshed: ${errorMessage(refreshError)}` }); + } + return "conflict"; + } + const [saved, nextReview] = await Promise.all([ + fetchNodeFlow(flowId), + validateNodeFlowDraft(targetProjectId, flowId), + ]); + if (!isCurrent()) return "stale"; + applyCredentialRecord(saved, nextReview, nodeId); + const reviewedCredential = nextReview.requiredCredentials.find((credential) => credential.nodeId === nodeId && credential.slot === slot); + if (credentialId && reviewedCredential?.status === "denied") { + setCredentialFeedback({ nodeId, slot, status: "policy-denied", message: "The binding was saved, but current credential policy denies its use. Choose another credential or update it in Settings." }); + return "policy-denied"; + } + setCredentialFeedback({ nodeId, slot, status: "saved", message: credentialId ? "Credential binding saved and draft review refreshed." : "Credential binding removed and draft review refreshed." }); + return "saved"; + } catch (requestError) { + if (!isCurrent()) return "stale"; + const policyDenied = requestError instanceof NodeFlowDraftSaveError + ? requestError.status === 401 || requestError.status === 403 + : /policy|denied|forbidden|not authorized|permission/i.test(errorMessage(requestError)); + setCredentialFeedback({ + nodeId, + slot, + status: policyDenied ? "policy-denied" : "error", + message: policyDenied + ? `Credential binding was not saved because policy denied the change. ${errorMessage(requestError)}` + : `Credential binding was not saved. ${errorMessage(requestError)}`, + }); + return policyDenied ? "policy-denied" : "error"; + } + }; if (projectLoading) return
Loading project workspace…
; if (!selectedProject) return } title="Select a project" description="Flows, credentials, publications, and run history are always scoped to a project." />; @@ -247,9 +350,9 @@ export const NodesPage: FunctionComponent = () => { {migrationWarning ?
: null} {notice ?
{notice}
: null}
void act(async () => { await deleteNodeFlow(id); await loadLibrary(selectedProject.id); })} /> -
{record ?
: null}{record ? setGraph((current) => updateNodeInGraph(current, id, { position }))} /> : } title="No flows in this project" description="Create a draft to start from the canonical backend workspace." primaryAction={} />}
+
{record ?
: null}{record ? editGraph((current) => updateNodeInGraph(current, id, { position }))} /> : } title="No flows in this project" description="Create a draft to start from the canonical backend workspace." primaryAction={} />}
- {record ? item.nodeId === selectedNode?.id) ?? []} agents={agents} attachments={attachments} attachAgentId={attachAgentId} attachmentsLoading={agentsLoading || attachmentsLoading} attachmentError={attachmentMutationError ?? flowAttachmentError ?? agentsError} attaching={attachmentBusy} onAttachAgentIdChange={setAttachAgentId} onAttachAgent={attachAgent} onDetachAgent={detachAgent} onRetryAttachments={refreshAttachmentData} onNodeChange={(id, update) => setGraph((current) => updateNodeInGraph(current, id, update))} onRequestCredential={(nodeId, slot) => { if (projectId && record) void act(async () => { await requestNodeFlowCredential(projectId, record.id, nodeId, slot); setNotice("Credential binding request recorded; secret material remains outside the graph."); }); }} /> : null} + {record ? item.nodeId === selectedNode?.id) ?? []} projectId={selectedProject.id} flowId={record.id} credentialFeedback={credentialFeedback} onCredentialChange={changeCredential} agents={agents} attachments={attachments} attachAgentId={attachAgentId} attachmentsLoading={agentsLoading || attachmentsLoading} attachmentError={attachmentMutationError ?? flowAttachmentError ?? agentsError} attaching={attachmentBusy} onAttachAgentIdChange={setAttachAgentId} onAttachAgent={attachAgent} onDetachAgent={detachAgent} onRetryAttachments={refreshAttachmentData} onNodeChange={(id, update) => editGraph((current) => updateNodeInGraph(current, id, update))} /> : null}
{record ? <> void act(refreshRuns)} onCancel={() => { const active = runs.find((item) => item.id === selectedRunId); if (projectId && active) void act(async () => { await cancelNodeFlowRun(projectId, active.id); await refreshRuns(); }); }} onRetry={() => { const active = runs.find((item) => item.id === selectedRunId); if (projectId && active) void act(async () => { const result = await retryNodeFlowRun(projectId, active.id); setRuns((current) => [result.run, ...current]); setSelectedRunId(result.run.id); }); }} onApprovalDecision={(approvalId, decision) => void act(async () => { const result = await decideNodeFlowApproval(approvalId, decision); setRuns((current) => current.map((item) => item.id === result.run.id ? result.run : item)); setNodeRuns(result.nodeRuns); setAttempts(result.attempts ?? []); setApprovals((current) => current.map((item) => item.id === approvalId ? { ...item, status: result.status, decidedAt: result.decidedAt, decidedBy: result.decidedBy, decision: result.decision, updatedAt: result.updatedAt } : item)); })} /> : null} ; diff --git a/dashboard/src/v2/components/nodes/NodeCredentialPicker.tsx b/dashboard/src/v2/components/nodes/NodeCredentialPicker.tsx new file mode 100644 index 0000000000..06e0c2edcd --- /dev/null +++ b/dashboard/src/v2/components/nodes/NodeCredentialPicker.tsx @@ -0,0 +1,239 @@ +import type { FunctionComponent } from "preact"; +import { useCallback, useEffect, useRef, useState } from "preact/hooks"; +import { Check, KeyRound, Settings, ShieldAlert, Unlink } from "lucide-preact"; +import type { AutomationCredentialCompatibilityIssue } from "../../../../../src/contracts/automation-credential-types.js"; +import type { NodeDefinitionCredentialRequirement } from "../../../../../src/contracts/node-definition-types.js"; +import { + assessAutomationCredentialCompatibility, + fetchAutomationCredentials, + fetchCredentialHealth, +} from "../../lib/automation-credential-api.js"; +import { writeSettingsNavigationState } from "../../lib/settings-navigation-state.js"; +import { DropdownMenu, DropdownMenuItem } from "../ui/DropdownMenu.js"; + +export type CredentialSelectionResult = "saved" | "conflict" | "policy-denied" | "error" | "stale"; + +interface CredentialOption { + id: string; + name: string; + kind: string; + compatible: boolean; + reasons: string[]; +} + +interface NodeCredentialPickerProps { + projectId: string; + identity: string; + requirement: NodeDefinitionCredentialRequirement; + boundCredentialId: string | null; + disabled?: boolean; + onSelect: (credentialId: string | null) => Promise; +} + +const issueText = ( + issue: AutomationCredentialCompatibilityIssue, + missingCapabilities: string[], + allowedKinds: string[], +): string => { + switch (issue) { + case "backend_unavailable": return "Secure credential storage is unavailable."; + case "backend_insecure": return "Secure credential storage is not ready."; + case "not_configured": return "Credential setup is incomplete."; + case "not_active": return "Credential is not active."; + case "project_access_denied": return "Credential is not available to this project."; + case "kind_not_allowed": return `Requires one of these kinds: ${allowedKinds.join(", ")}.`; + case "capability_missing": return missingCapabilities.length > 0 + ? `Missing required access: ${missingCapabilities.join(", ")}.` + : "The credential does not grant the required access."; + } +}; + +export const NodeCredentialPicker: FunctionComponent = ({ + projectId, + identity, + requirement, + boundCredentialId, + disabled = false, + onSelect, +}) => { + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [options, setOptions] = useState([]); + const [backendReady, setBackendReady] = useState(null); + const [loadError, setLoadError] = useState(null); + const [selectingId, setSelectingId] = useState(null); + const requestRef = useRef(0); + const triggerRef = useRef(null); + + const setPickerOpen = useCallback((nextOpen: boolean): void => { + setOpen(nextOpen); + if (!nextOpen && typeof window !== "undefined") { + window.setTimeout(() => triggerRef.current?.focus({ preventScroll: true }), 50); + } + }, []); + + const load = useCallback(async (): Promise => { + const requestId = ++requestRef.current; + setLoading(true); + setLoadError(null); + setOptions([]); + try { + const [credentials, health] = await Promise.all([ + fetchAutomationCredentials(projectId), + fetchCredentialHealth(), + ]); + const ready = health.available + && health.secure + && typeof health.keyId === "string" + && health.keyId.length > 0 + && health.keyVersion !== null; + const assessments = await Promise.all(credentials.map(async (credential) => ({ + id: credential.id, + name: credential.name, + kind: credential.kind, + assessment: await assessAutomationCredentialCompatibility(projectId, credential.id, { + allowedKinds: requirement.allowedKinds, + requiredCapabilities: requirement.requiredCapabilities, + }), + }))); + if (requestRef.current !== requestId) return; + setBackendReady(ready); + setOptions(assessments.map(({ id, name, kind, assessment }) => ({ + id, + name, + kind, + compatible: ready && assessment.compatible, + reasons: assessment.issues.map((issue) => issueText( + issue, + assessment.missingCapabilities, + requirement.allowedKinds, + )), + }))); + } catch { + if (requestRef.current !== requestId) return; + setBackendReady(false); + setLoadError("Credential metadata could not be loaded. Retry or open Settings to review credential access."); + } finally { + if (requestRef.current === requestId) setLoading(false); + } + }, [projectId, requirement.allowedKinds, requirement.requiredCapabilities]); + + useEffect(() => { + requestRef.current += 1; + setOpen(false); + setOptions([]); + setBackendReady(null); + setLoadError(null); + setSelectingId(null); + }, [identity]); + + useEffect(() => { + if (!open) return; + void load(); + return () => { requestRef.current += 1; }; + }, [open, load]); + + const choose = async (credentialId: string | null): Promise => { + const pendingId = credentialId ?? "__unbind__"; + if (selectingId || disabled) return; + setSelectingId(pendingId); + const result = await onSelect(credentialId); + setSelectingId(null); + if (result === "saved") setPickerOpen(false); + }; + + const compatibleOptions = options.filter((option) => option.compatible); + const unavailableOptions = options.filter((option) => !option.compatible); + const hasCompatibleChoice = compatibleOptions.some((option) => option.id !== boundCredentialId); + const currentOption = options.find((option) => option.id === boundCredentialId); + + return ( + +
+

{requirement.label}

+

+ Choose a project-visible credential with {requirement.requiredCapabilities.join(", ") || "the declared"} access. Secret values never enter this page. +

+
+ {loading ?

Checking credential compatibility…

: null} + {loadError ?
{loadError}
: null} + {!loading && backendReady === false ? ( +
+
+ ) : null} + {!loading && compatibleOptions.length > 0 ? ( +
+

Compatible

+ {compatibleOptions.map((option) => ( + void choose(option.id)} + > + {option.name}{option.kind} + {option.id === boundCredentialId ? : null} + + ))} +
+ ) : null} + {!loading && unavailableOptions.length > 0 ? ( +
+

Unavailable for this slot

+ {unavailableOptions.map((option) => ( +
+

{option.name} · {option.kind}

+

{option.reasons.join(" ") || "This credential is not compatible with the slot policy."}

+
+ ))} +
+ ) : null} + {!loading && !loadError && !hasCompatibleChoice ? ( + + ) : null} + {boundCredentialId ? ( + void choose(null)} + > + + ) : null} + + )} + > + +
+ ); +}; diff --git a/dashboard/src/v2/components/nodes/NodeFlowInspector.tsx b/dashboard/src/v2/components/nodes/NodeFlowInspector.tsx index 3deec2cd55..98beaa5ab9 100644 --- a/dashboard/src/v2/components/nodes/NodeFlowInspector.tsx +++ b/dashboard/src/v2/components/nodes/NodeFlowInspector.tsx @@ -1,5 +1,6 @@ import type { FunctionComponent } from "preact"; import { Link2, Unlink } from "lucide-preact"; +import type { NodeDefinitionCredentialRequirement } from "../../../../../src/contracts/node-definition-types.js"; import type { AgentPreset, NodeFlowJsonObject, @@ -15,6 +16,16 @@ import { buildValidationMessagesByField, } from "../../lib/node-flow-view-models.js"; import { NodeWidgetField } from "./NodeWidgetField.js"; +import { NodeCredentialPicker, type CredentialSelectionResult } from "./NodeCredentialPicker.js"; + +export type CredentialBindingSaveStatus = "saving" | "saved" | "conflict" | "policy-denied" | "error"; + +export interface CredentialBindingFeedback { + nodeId: string; + slot: string; + status: CredentialBindingSaveStatus; + message: string; +} interface NodeFlowInspectorProps { selectedNode: NodeFlowNode | null; @@ -30,9 +41,12 @@ interface NodeFlowInspectorProps { onDetachAgent: (agentPresetId: string) => void; onRetryAttachments?: () => void; onNodeChange: (nodeId: string, update: Partial) => void; + projectId: string; + flowId: string; definition?: NodeDefinitionManifest | null; requiredCredentials?: NodeFlowRequiredCredential[]; - onRequestCredential?: (nodeId: string, slot: string) => void; + credentialFeedback?: CredentialBindingFeedback | null; + onCredentialChange: (nodeId: string, slot: string, credentialId: string | null) => Promise; } const inputClass = "w-full rounded-xl border border-black/[0.08] bg-white/75 px-3 py-2 text-sm text-slate-800 shadow-sm outline-none transition focus:border-signal-500/50 focus:ring-2 focus:ring-signal-500/20 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-slate-100"; @@ -51,9 +65,12 @@ export const NodeFlowInspector: FunctionComponent = ({ onDetachAgent, onRetryAttachments, onNodeChange, + projectId, + flowId, definition = null, requiredCredentials = [], - onRequestCredential, + credentialFeedback = null, + onCredentialChange, }) => { const messagesByField = buildValidationMessagesByField(validation); @@ -67,6 +84,15 @@ export const NodeFlowInspector: FunctionComponent = ({ const widgetSchema = definition?.ui?.widgetSchema ?? selectedNode.widgetSchema; const data = applyWidgetDefaults(widgetSchema, selectedNode.data); + const credentialRequirements: NodeDefinitionCredentialRequirement[] = definition + ? definition.credentials + : requiredCredentials.map((credential) => ({ + slot: credential.slot, + label: credential.slot, + required: credential.required, + allowedKinds: credential.allowedKinds, + requiredCapabilities: credential.requiredCapabilities, + })); const updateDataField = (fieldId: string, value: NodeFlowJsonValue): void => { onNodeChange(selectedNode.id, { @@ -130,13 +156,42 @@ export const NodeFlowInspector: FunctionComponent = ({

Credential bindings

- {requiredCredentials.length === 0 ?

This node does not request credentials.

: requiredCredentials.map((credential) => ( -
-
{credential.slot}{credential.status}
-

{credential.allowedKinds.join(", ")} · secret value never displayed

- {credential.status !== "bound" && onRequestCredential ? : null} -
- ))} + {credentialRequirements.length === 0 ?

This node does not request credentials.

: credentialRequirements.map((requirement) => { + const reviewCredential = requiredCredentials.find((credential) => credential.slot === requirement.slot); + const binding = selectedNode.credentialBindings?.find((entry) => entry.slot === requirement.slot) ?? null; + const feedback = credentialFeedback?.nodeId === selectedNode.id && credentialFeedback.slot === requirement.slot + ? credentialFeedback + : null; + const status = binding + ? reviewCredential?.status ?? "bound" + : "missing"; + return ( +
+
+ {requirement.label} + {status} +
+

{requirement.allowedKinds.join(", ")} · {requirement.requiredCapabilities.join(", ") || "declared"} access · secret value never displayed

+ onCredentialChange(selectedNode.id, requirement.slot, credentialId)} + /> + {feedback ? ( +

+ {feedback.message} +

+ ) : null} +
+ ); + })}
diff --git a/dashboard/src/v2/lib/node-flow-api.ts b/dashboard/src/v2/lib/node-flow-api.ts index c442ccfc55..b06210c037 100644 --- a/dashboard/src/v2/lib/node-flow-api.ts +++ b/dashboard/src/v2/lib/node-flow-api.ts @@ -44,9 +44,18 @@ export interface NodeDefinitionSummary { ports: NodeDefinitionManifest["ports"]; } -export interface PatchNodeFlowDraftResponse { - draft?: NodeFlowDraftReview; - conflict?: NodeFlowConcurrencyConflict; +export type PatchNodeFlowDraftResponse = + | { draft: NodeFlowDraftReview; conflict?: never } + | { draft?: never; conflict: NodeFlowConcurrencyConflict }; + +export class NodeFlowDraftSaveError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + this.name = "NodeFlowDraftSaveError"; + } } export interface NodeFlowDryRunResponse { @@ -84,10 +93,27 @@ export const createNodeFlowDraft = async (projectId: string, input: CreateNodeFl method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input), }); -export const patchNodeFlowDraft = async (flowId: string, input: PatchNodeFlowDraftInput): Promise => - fetchJson(`/api/node-flow-drafts/${encodeURIComponent(flowId)}`, { - method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input), +export const patchNodeFlowDraft = async (flowId: string, input: PatchNodeFlowDraftInput): Promise => { + const path = `/api/node-flow-drafts/${encodeURIComponent(flowId)}`; + const response = await fetch(path, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + cache: "no-store", }); + const body = await response.json().catch(() => ({})) as Partial & { error?: unknown; message?: unknown }; + if (response.status === 409 && body.conflict) return { conflict: body.conflict }; + if (!response.ok) { + const message = typeof body.error === "string" + ? body.error + : typeof body.message === "string" + ? body.message + : `Request failed: ${path}`; + throw new NodeFlowDraftSaveError(response.status, message); + } + if (!body.draft) throw new NodeFlowDraftSaveError(response.status, "The draft save response did not include a review."); + return { draft: body.draft }; +}; export const validateNodeFlowDraft = async (projectId: string, flowId: string, signal?: AbortSignal): Promise => fetchJson(`/api/node-flow-drafts/${encodeURIComponent(flowId)}/validate`, { @@ -99,11 +125,6 @@ export const dryRunNodeFlowDraft = async (projectId: string, flowId: string, inp method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ projectId, input }), }); -export const requestNodeFlowCredential = async (projectId: string, flowId: string, nodeId: string, slot: string): Promise> => - fetchJson>(`/api/node-flow-drafts/${encodeURIComponent(flowId)}/credential-requests`, { - method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ projectId, nodeId, slot }), - }); - export const publishNodeFlowDraft = async (projectId: string, flowId: string, draftRevision: number): Promise => fetchJson(`/api/node-flow-drafts/${encodeURIComponent(flowId)}/publish`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ projectId, draftRevision, publishedBy: "dashboard" }), diff --git a/docs-web/content/docs/user-dashboard-node-flows.mdx b/docs-web/content/docs/user-dashboard-node-flows.mdx index 809bb5783e..c767364e31 100644 --- a/docs-web/content/docs/user-dashboard-node-flows.mdx +++ b/docs-web/content/docs/user-dashboard-node-flows.mdx @@ -12,7 +12,9 @@ The former browser graph at `codeux:nodes-canvas:v1` is eligible for one import The registry list returns flat versioned palette summaries. Selecting a definition loads the full manifest from the node-type detail endpoint, including nested `ui.widgetSchema`, configuration schema, policies, documentation, and deprecation metadata. The inspector renders from that full contract. Graphs reference a definition version and store non-secret configuration and credential ids; they do not contain custom-node source or resolved credentials. -Credential slots display metadata-only states such as bound, missing, or denied and can request a binding. Secret values remain behind the credential broker and are excluded from graphs and browser output. +Credential slots use the versioned definition's allowed kinds and required capabilities to offer project-visible credential metadata. Only active, configured credentials with project access and a healthy secure backend are selectable; unavailable entries explain the operator-facing reason without exposing secret or key-custody details, and an empty compatible set links directly to **Settings → Integrations**. + +Selecting, replacing, or removing a credential updates only that slot in the node's canonical `credentialBindings` and immediately saves the complete draft through the current optimistic revision. The dashboard then adopts the canonical flow revision and refreshes governed review. Saving, saved, policy-denial, and error states are announced. A revision conflict loads the latest draft, preserves the selected slot workflow and sibling edits, and requires the operator to choose again rather than replaying the stale mutation. Credential plaintext remains behind the broker and is excluded from graph data, component state, and browser output. The complete governed built-in set currently registered with executable handlers is `input`, `set_fields`, `template`, `provider_prompt`, `http_request`, `condition`, `switch`, `foreach`, `merge`, `delay`, `approval`, `email_draft`, `email_send`, `execute_subflow`, `webhook_trigger`, and `output`. diff --git a/docs-web/user/dashboard/node-flows.md b/docs-web/user/dashboard/node-flows.md index 8211b4f256..5668b99750 100644 --- a/docs-web/user/dashboard/node-flows.md +++ b/docs-web/user/dashboard/node-flows.md @@ -12,7 +12,9 @@ The former browser graph at `codeux:nodes-canvas:v1` is eligible for one import The registry list returns flat versioned palette summaries. Selecting a definition loads the full manifest from the node-type detail endpoint, including nested `ui.widgetSchema`, configuration schema, policies, documentation, and deprecation metadata. The inspector renders from that full contract. Graphs reference a definition version and store non-secret configuration and credential ids; they do not contain custom-node source or resolved credentials. -Credential slots display metadata-only states such as bound, missing, or denied and can request a binding. Secret values remain behind the credential broker and are excluded from graphs and browser output. +Credential slots use the versioned definition's allowed kinds and required capabilities to offer project-visible credential metadata. Only active, configured credentials with project access and a healthy secure backend are selectable; unavailable entries explain the operator-facing reason without exposing secret or key-custody details, and an empty compatible set links directly to **Settings → Integrations**. + +Selecting, replacing, or removing a credential updates only that slot in the node's canonical `credentialBindings` and immediately saves the complete draft through the current optimistic revision. The dashboard then adopts the canonical flow revision and refreshes governed review. Saving, saved, policy-denial, and error states are announced. A revision conflict loads the latest draft, preserves the selected slot workflow and sibling edits, and requires the operator to choose again rather than replaying the stale mutation. Credential plaintext remains behind the broker and is excluded from graph data, component state, and browser output. The complete governed built-in set currently registered with executable handlers is `input`, `set_fields`, `template`, `provider_prompt`, `http_request`, `condition`, `switch`, `foreach`, `merge`, `delay`, `approval`, `email_draft`, `email_send`, `execute_subflow`, `webhook_trigger`, and `output`. diff --git a/docs/dashboard/node-flows.md b/docs/dashboard/node-flows.md index 4a2b50aeb9..66ad383700 100644 --- a/docs/dashboard/node-flows.md +++ b/docs/dashboard/node-flows.md @@ -12,7 +12,9 @@ The former browser graph at `codeux:nodes-canvas:v1` is eligible for one import `GET /api/node-flow-catalog` returns flat versioned palette summaries. `GET /api/node-flow-catalog/:nodeType` returns the full `NodeDefinitionManifest`, including nested `ui.widgetSchema`, configuration schema, policies, documentation, and deprecation metadata. The inspector renders from that full contract. Graphs reference a definition version and store non-secret configuration and credential ids; they do not contain custom-node source or resolved credentials. -Credential slots display metadata-only states such as bound, missing, or denied and can request a binding. Secret values remain behind the credential broker and are excluded from graphs, browser output, logs, and documentation examples. +Credential slots use the versioned definition's allowed kinds and required capabilities to offer project-visible credential metadata. Only active, configured credentials with project access and a healthy secure backend are selectable; unavailable entries explain the operator-facing reason without exposing secret or key-custody details, and an empty compatible set links directly to **Settings → Integrations**. + +Selecting, replacing, or removing a credential updates only that slot in the node's canonical `credentialBindings` and immediately saves the complete draft through the current optimistic revision. The dashboard then adopts the canonical flow revision and refreshes governed review. Saving, saved, policy-denial, and error states are announced. A revision conflict loads the latest draft, preserves the selected slot workflow and sibling edits, and requires the operator to choose again rather than replaying the stale mutation. Credential plaintext remains behind the broker and is excluded from graph data, component state, browser output, logs, and documentation examples. The complete governed built-in set currently registered with executable handlers is `input`, `set_fields`, `template`, `provider_prompt`, `http_request`, `condition`, `switch`, `foreach`, `merge`, `delay`, `approval`, `email_draft`, `email_send`, `execute_subflow`, `webhook_trigger`, and `output`. diff --git a/tests/dashboard/v2/nodes-inspector.test.tsx b/tests/dashboard/v2/nodes-inspector.test.tsx index 61ca404121..6f0d9eb704 100644 --- a/tests/dashboard/v2/nodes-inspector.test.tsx +++ b/tests/dashboard/v2/nodes-inspector.test.tsx @@ -1,8 +1,8 @@ /** @vitest-environment jsdom */ -import { cleanup, fireEvent, render, screen } from "@testing-library/preact"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/preact"; import userEvent from "@testing-library/user-event"; import * as matchers from "@testing-library/jest-dom/matchers"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { h } from "preact"; import { createInitialNodeCanvasGraph, @@ -14,6 +14,15 @@ import { import { NodeInspector } from "../../../dashboard/src/v2/components/nodes/NodeInspector.js"; import { NodePalette } from "../../../dashboard/src/v2/components/nodes/NodePalette.js"; import { NodeValidationPanel } from "../../../dashboard/src/v2/components/nodes/NodeValidationPanel.js"; +import { NodeFlowInspector } from "../../../dashboard/src/v2/components/nodes/NodeFlowInspector.js"; + +const credentialApi = vi.hoisted(() => ({ + fetchAutomationCredentials: vi.fn(), + fetchCredentialHealth: vi.fn(), + assessAutomationCredentialCompatibility: vi.fn(), +})); +vi.mock("../../../dashboard/src/v2/lib/automation-credential-api.js", () => credentialApi); +vi.mock("../../../dashboard/src/v2/hooks/use-reduced-motion.js", () => ({ useReducedMotion: () => true, useResolvedMotionDuration: (value: T): T => value })); expect.extend(matchers); @@ -49,6 +58,15 @@ const renderInspector = (node: NodeCanvasNode | null, graph = createInitialNodeC }; describe("nodes inspector panels", () => { + beforeEach(() => { + credentialApi.fetchCredentialHealth.mockResolvedValue({ available: true, secure: true, provider: "secure", keyId: "key", keyVersion: 1 }); + credentialApi.fetchAutomationCredentials.mockResolvedValue([]); + credentialApi.assessAutomationCredentialCompatibility.mockResolvedValue({ + credentialId: "credential-1", projectId: "project-1", compatible: true, backendReady: true, + configured: true, active: true, projectAccess: true, kindAllowed: true, capabilitiesAllowed: true, + missingCapabilities: [], issues: [], metadata: null, + }); + }); it("emits governed registry definitions from the palette", async () => { const user = userEvent.setup(); const onCreateNode = vi.fn(); @@ -174,4 +192,110 @@ describe("nodes inspector panels", () => { expect(onSelectEdge).toHaveBeenCalledWith("edge-missing-target"); expect(onFocusEdge).toHaveBeenCalledWith("edge-missing-target"); }); + + const credentialNode = { + id: "provider-1", + type: "provider_prompt", + title: "Provider prompt", + description: "Run a prompt.", + definition: { type: "provider_prompt", version: 1 }, + data: { prompt: "Public configuration only" }, + credentialBindings: [], + position: { x: 10, y: 10 }, + }; + const credentialDefinition = { + type: "provider_prompt", version: 1, executable: true, executionKind: "provider" as const, + configurationSchema: { type: "object" as const }, + ui: { label: "Provider prompt", description: "Run a prompt.", category: "Providers", widgetSchema: { fields: [] } }, + ports: [], credentials: [{ slot: "provider", label: "Provider connection", required: true, allowedKinds: ["provider"], requiredCapabilities: ["read"] }], + capabilities: [], sideEffect: "none" as const, defaultPolicy: {}, documentation: "", deprecation: { deprecated: false }, + }; + const renderCredentialInspector = (onCredentialChange = vi.fn(async () => "saved" as const)) => { + render( + , + ); + return onCredentialChange; + }; + + it("shows only compatible credentials as selectable and explains unavailable metadata", async () => { + const user = userEvent.setup(); + credentialApi.fetchAutomationCredentials.mockResolvedValue([ + { id: "credential-good", name: "Provider read token", kind: "provider" }, + { id: "credential-bad", name: "Revoked deployment token", kind: "http", value: "plaintext-canary" }, + ]); + credentialApi.assessAutomationCredentialCompatibility.mockImplementation(async (_projectId: string, credentialId: string) => credentialId === "credential-good" ? { + credentialId, projectId: "project-1", compatible: true, backendReady: true, configured: true, active: true, + projectAccess: true, kindAllowed: true, capabilitiesAllowed: true, missingCapabilities: [], issues: [], metadata: null, + } : { + credentialId, projectId: "project-1", compatible: false, backendReady: true, configured: true, active: false, + projectAccess: true, kindAllowed: false, capabilitiesAllowed: true, missingCapabilities: [], issues: ["not_active", "kind_not_allowed"], + metadata: { value: "plaintext-canary" }, + }); + renderCredentialInspector(); + + await user.click(screen.getByRole("button", { name: "Choose credential for Provider connection" })); + + expect(await screen.findByRole("menuitem", { name: /Provider read token/ })).toBeInTheDocument(); + expect(screen.queryByRole("menuitem", { name: /Revoked deployment token/ })).not.toBeInTheDocument(); + expect(screen.getByText(/Credential is not active/)).toBeInTheDocument(); + expect(screen.getByText(/Requires one of these kinds: provider/)).toBeInTheDocument(); + expect(document.body).not.toHaveTextContent("plaintext-canary"); + }); + + it("blocks selection when secure storage is unavailable and links directly to Settings", async () => { + const user = userEvent.setup(); + credentialApi.fetchCredentialHealth.mockResolvedValue({ available: false, secure: false, provider: "secure", keyId: null, keyVersion: null, reason: "low-level backend detail" }); + credentialApi.fetchAutomationCredentials.mockResolvedValue([{ id: "credential-1", name: "Provider token", kind: "provider" }]); + credentialApi.assessAutomationCredentialCompatibility.mockResolvedValue({ + credentialId: "credential-1", projectId: "project-1", compatible: false, backendReady: false, configured: true, + active: true, projectAccess: true, kindAllowed: true, capabilitiesAllowed: true, missingCapabilities: [], + issues: ["backend_unavailable"], metadata: null, + }); + renderCredentialInspector(); + + await user.click(screen.getByRole("button", { name: "Choose credential for Provider connection" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Secure credential storage is unavailable"); + expect(screen.getByRole("menuitem", { name: "Open credential Settings" })).toHaveAttribute("href", "/config"); + expect(screen.queryByRole("menuitem", { name: /Provider token/ })).not.toBeInTheDocument(); + expect(document.body).not.toHaveTextContent("low-level backend detail"); + }); + + it("supports keyboard selection and Escape while restoring focus to the trigger", async () => { + const user = userEvent.setup(); + const onCredentialChange = vi.fn(async () => "saved" as const); + credentialApi.fetchAutomationCredentials.mockResolvedValue([{ id: "credential-1", name: "Provider token", kind: "provider" }]); + renderCredentialInspector(onCredentialChange); + const trigger = screen.getByRole("button", { name: "Choose credential for Provider connection" }); + + trigger.focus(); + await user.keyboard("{Enter}"); + const option = await screen.findByRole("menuitem", { name: /Provider token/ }); + option.focus(); + await user.keyboard("{Enter}"); + + expect(onCredentialChange).toHaveBeenCalledWith("provider-1", "provider", "credential-1"); + await waitFor(() => expect(trigger).toHaveFocus()); + + await user.keyboard("{Enter}"); + await screen.findByRole("menu", { name: "Credential picker for Provider connection" }); + await user.keyboard("{Escape}"); + await waitFor(() => expect(trigger).toHaveFocus()); + }); }); diff --git a/tests/dashboard/v2/nodes-page.test.tsx b/tests/dashboard/v2/nodes-page.test.tsx index a6540dae23..fc99275375 100644 --- a/tests/dashboard/v2/nodes-page.test.tsx +++ b/tests/dashboard/v2/nodes-page.test.tsx @@ -10,19 +10,81 @@ import { ProjectDataContext } from "../../../dashboard/src/v2/context/project-da const api = vi.hoisted(() => ({ fetchNodeFlows: vi.fn(), fetchNodeFlowCatalog: vi.fn(), createNodeFlowDraft: vi.fn(), fetchNodeFlow: vi.fn(), fetchNodeFlowRuns: vi.fn(), fetchNodeFlowNodeRuns: vi.fn(), fetchNodeFlowAttempts: vi.fn(), fetchNodeFlowApprovals: vi.fn(), fetchNodeFlowAgentSkills: vi.fn(), attachNodeFlowToAgent: vi.fn(), detachNodeFlowFromAgent: vi.fn(), decideNodeFlowApproval: vi.fn(), patchNodeFlowDraft: vi.fn(), fetchNodeDefinition: vi.fn(), validateNodeFlowDraft: vi.fn(), deleteNodeFlow: vi.fn() })); const agentApi = vi.hoisted(() => ({ fetchAgentPresets: vi.fn() })); +const credentialApi = vi.hoisted(() => ({ fetchAutomationCredentials: vi.fn(), fetchCredentialHealth: vi.fn(), assessAutomationCredentialCompatibility: vi.fn() })); vi.mock("../../../dashboard/src/v2/lib/node-flow-api.js", async (original) => ({ ...(await original()), ...api })); vi.mock("../../../dashboard/src/v2/lib/agent-preset-api.js", async (original) => ({ ...(await original()), ...agentApi })); +vi.mock("../../../dashboard/src/v2/lib/automation-credential-api.js", () => credentialApi); vi.mock("../../../dashboard/src/v2/hooks/use-reduced-motion.js", () => ({ useReducedMotion: () => true, useResolvedMotionDuration: (value: T): T => value })); const flow = { id: "flow-1", projectId: "project-1", title: "Release automation", description: "Governed", graph: { schemaVersion: 2 as const, nodes: [{ id: "input-1", type: "input", title: "Input", definition: { type: "input", version: 1 }, position: { x: 40, y: 40 } }], edges: [] }, version: 2, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }; const agent = { id: "agent-1", projectId: "project-1", name: "Release Agent", description: "Release helper", instructionMarkdown: "PRIVATE AGENT INSTRUCTIONS", labels: [], sourcePath: null, sourceScope: null, sourceUpdatedAt: null, sourceImportedAt: null, sourceExists: false, syncStatus: "manual", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }; const attachment = { flowId: "flow-1", projectId: "project-1", agentPresetId: "agent-1", skillName: "Release skill", description: "Governed", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }; const context = { projects: [{ id: "project-1", name: "Test project" }], selectedProjectId: "project-1", selectedProject: { id: "project-1", name: "Test project" }, loading: false, error: null, refreshProjects: async () => undefined, selectProject: async () => undefined, createProject: async () => { throw new Error("unused"); }, updateProject: async () => { throw new Error("unused"); }, deleteProject: async () => undefined }; +const credentialDefinition = { + type: "provider_prompt", version: 1, executable: true, executionKind: "provider", configurationSchema: { type: "object" }, + ui: { label: "Provider prompt", description: "Prompt", category: "Providers", widgetSchema: { fields: [] } }, ports: [], + credentials: [{ slot: "provider", label: "Provider connection", required: true, allowedKinds: ["provider"], requiredCapabilities: ["read"] }], + capabilities: [], sideEffect: "none", defaultPolicy: {}, documentation: "", deprecation: { deprecated: false }, +}; +const credentialFlow = { + ...flow, + graph: { + schemaVersion: 2 as const, + nodes: [{ + id: "provider-1", type: "provider_prompt", title: "Provider prompt", description: "Prompt", + definition: { type: "provider_prompt", version: 1 }, data: { prompt: "Keep this configuration" }, + credentialBindings: [{ slot: "audit", credentialId: "credential-audit" }], position: { x: 40, y: 40 }, + }], + edges: [], + }, +}; +const credentialMetadata = (id: string, name: string) => ({ + id, name, kind: "provider", scope: "project", projectId: "project-1", managementProjectId: "project-1", + allowedProjectIds: [], capabilities: ["read"], status: "active", configured: true, keyId: "hidden-key", + keyVersion: 1, version: 1, lastValidatedAt: null, validationStatus: "valid", createdAt: "now", updatedAt: "now", +}); describe("NodesPage governed workspace", () => { const review = { flowId: "flow-1", projectId: "project-1", name: "Release automation", description: "Governed", draftRevision: 2, nodeCount: 1, edgeCount: 0, valid: true, validationIssues: [], policyFindings: [], requiredCredentials: [], requestedCapabilities: [], sideEffectDiffs: [], publishedVersion: 1 }; + const credentialReview = (currentFlow: typeof credentialFlow, status: "bound" | "missing" | "denied" = "bound") => { + const credentialId = currentFlow.graph.nodes[0]?.credentialBindings.find((binding) => binding.slot === "provider")?.credentialId ?? null; + return { + ...review, + draftRevision: currentFlow.version, + requiredCredentials: [{ + nodeId: "provider-1", slot: "provider", allowedKinds: ["provider"], requiredCapabilities: ["read"], required: true, + credentialId, status: credentialId ? status : "missing", backendReady: credentialId ? true : null, configured: credentialId ? true : null, + active: credentialId ? true : null, projectAccess: credentialId ? true : null, kindAllowed: credentialId ? true : null, + capabilitiesAllowed: credentialId ? true : null, missingCapabilities: credentialId ? [] : ["read"], compatibilityIssues: [], + }], + }; + }; + const setupCredentialFlow = (initialFlow: typeof credentialFlow = credentialFlow) => { + let canonical = initialFlow; + api.fetchNodeFlows.mockResolvedValue({ flows: [canonical] }); + api.fetchNodeDefinition.mockResolvedValue(credentialDefinition); + api.fetchNodeFlow.mockImplementation(async () => canonical); + api.validateNodeFlowDraft.mockImplementation(async () => credentialReview(canonical)); + credentialApi.fetchAutomationCredentials.mockResolvedValue([ + credentialMetadata("credential-old", "Existing provider token"), + credentialMetadata("credential-new", "Replacement provider token"), + ]); + credentialApi.assessAutomationCredentialCompatibility.mockImplementation(async (_projectId: string, credentialId: string) => ({ + credentialId, projectId: "project-1", compatible: true, backendReady: true, configured: true, active: true, + projectAccess: true, kindAllowed: true, capabilitiesAllowed: true, missingCapabilities: [], issues: [], metadata: null, + })); + return { + current: () => canonical, + updateFromPatch: (input: { graph: typeof credentialFlow.graph }) => { + canonical = { ...canonical, graph: input.graph, version: canonical.version + 1, updatedAt: "2026-01-01T00:01:00.000Z" }; + return canonical; + }, + replace: (next: typeof credentialFlow) => { canonical = next; }, + }; + }; + beforeEach(() => { api.patchNodeFlowDraft.mockReset(); api.fetchNodeFlow.mockReset(); }); beforeEach(() => { api.validateNodeFlowDraft.mockResolvedValue(review); }); - beforeEach(() => { window.localStorage.clear(); api.fetchNodeFlows.mockResolvedValue({ flows: [flow] }); api.fetchNodeFlowCatalog.mockResolvedValue({ nodes: [{ type: "input", version: 1, executable: true, executionKind: "local", label: "Input", description: "Input", category: "Core", credentials: [], capabilities: [], sideEffect: "none", ports: [] }] }); api.fetchNodeFlowRuns.mockResolvedValue({ runs: [] }); api.fetchNodeFlowNodeRuns.mockResolvedValue({ nodeRuns: [] }); api.fetchNodeFlowAttempts.mockResolvedValue({ attempts: [] }); api.fetchNodeFlowApprovals.mockResolvedValue({ approvals: [] }); api.fetchNodeFlowAgentSkills.mockResolvedValue([]); api.attachNodeFlowToAgent.mockResolvedValue(attachment); api.detachNodeFlowFromAgent.mockResolvedValue(undefined); agentApi.fetchAgentPresets.mockResolvedValue([agent]); api.fetchNodeDefinition.mockResolvedValue({ type: "input", version: 1, executable: true, executionKind: "local", configurationSchema: { type: "object" }, ui: { label: "Input", description: "Input", category: "Core", widgetSchema: { fields: [] } }, ports: [], credentials: [], capabilities: [], sideEffect: "none", defaultPolicy: {}, documentation: "", deprecation: { deprecated: false } }); }); + beforeEach(() => { window.localStorage.clear(); api.fetchNodeFlows.mockResolvedValue({ flows: [flow] }); api.fetchNodeFlowCatalog.mockResolvedValue({ nodes: [{ type: "input", version: 1, executable: true, executionKind: "local", label: "Input", description: "Input", category: "Core", credentials: [], capabilities: [], sideEffect: "none", ports: [] }] }); api.fetchNodeFlowRuns.mockResolvedValue({ runs: [] }); api.fetchNodeFlowNodeRuns.mockResolvedValue({ nodeRuns: [] }); api.fetchNodeFlowAttempts.mockResolvedValue({ attempts: [] }); api.fetchNodeFlowApprovals.mockResolvedValue({ approvals: [] }); api.fetchNodeFlowAgentSkills.mockResolvedValue([]); api.attachNodeFlowToAgent.mockResolvedValue(attachment); api.detachNodeFlowFromAgent.mockResolvedValue(undefined); agentApi.fetchAgentPresets.mockResolvedValue([agent]); api.fetchNodeDefinition.mockResolvedValue({ type: "input", version: 1, executable: true, executionKind: "local", configurationSchema: { type: "object" }, ui: { label: "Input", description: "Input", category: "Core", widgetSchema: { fields: [] } }, ports: [], credentials: [], capabilities: [], sideEffect: "none", defaultPolicy: {}, documentation: "", deprecation: { deprecated: false } }); credentialApi.fetchAutomationCredentials.mockResolvedValue([]); credentialApi.fetchCredentialHealth.mockResolvedValue({ available: true, secure: true, provider: "secure", keyId: "key", keyVersion: 1 }); credentialApi.assessAutomationCredentialCompatibility.mockResolvedValue({ credentialId: "credential-1", projectId: "project-1", compatible: true, backendReady: true, configured: true, active: true, projectAccess: true, kindAllowed: true, capabilitiesAllowed: true, missingCapabilities: [], issues: [], metadata: null }); }); afterEach(() => { cleanup(); vi.clearAllMocks(); }); it("loads a project flow library and registry-backed editor", async () => { @@ -203,4 +265,122 @@ describe("NodesPage governed workspace", () => { await user.click(screen.getByRole("button", { name: "Save draft" })); expect(await screen.findByRole("alert")).toHaveTextContent("Current revision is 3"); }); + + it("binds a compatible credential immediately and refreshes the canonical review", async () => { + const user = userEvent.setup(); + const state = setupCredentialFlow(); + api.patchNodeFlowDraft.mockImplementation(async (_flowId: string, input: { graph: typeof credentialFlow.graph }) => { + const saved = state.updateFromPatch(input); + return { draft: credentialReview(saved) }; + }); + render(); + + await user.click(await screen.findByRole("button", { name: "Choose credential for Provider connection" })); + await user.click(await screen.findByRole("menuitem", { name: /Replacement provider token/ })); + + await waitFor(() => expect(api.patchNodeFlowDraft).toHaveBeenCalledTimes(1)); + const patchInput = api.patchNodeFlowDraft.mock.calls[0]?.[1]; + expect(patchInput).toMatchObject({ projectId: "project-1", draftRevision: 2 }); + expect(patchInput.graph.nodes[0]).toMatchObject({ + data: { prompt: "Keep this configuration" }, + credentialBindings: [ + { slot: "audit", credentialId: "credential-audit" }, + { slot: "provider", credentialId: "credential-new" }, + ], + }); + expect(await screen.findByText("Credential binding saved and draft review refreshed.")).toBeInTheDocument(); + expect(api.fetchNodeFlow).toHaveBeenCalledWith("flow-1"); + expect(api.validateNodeFlowDraft).toHaveBeenCalledTimes(2); + expect(document.body).not.toHaveTextContent("hidden-key"); + }); + + it("rebinds and explicitly unbinds one slot without changing sibling bindings or node data", async () => { + const user = userEvent.setup(); + const initiallyBound = { + ...credentialFlow, + graph: { + ...credentialFlow.graph, + nodes: [{ + ...credentialFlow.graph.nodes[0]!, + credentialBindings: [ + { slot: "audit", credentialId: "credential-audit" }, + { slot: "provider", credentialId: "credential-old" }, + ], + }], + }, + }; + const state = setupCredentialFlow(initiallyBound); + api.patchNodeFlowDraft.mockImplementation(async (_flowId: string, input: { graph: typeof credentialFlow.graph }) => { + const saved = state.updateFromPatch(input); + return { draft: credentialReview(saved) }; + }); + render(); + + await user.click(await screen.findByRole("button", { name: "Choose credential for Provider connection" })); + await user.click(await screen.findByRole("menuitem", { name: /Replacement provider token/ })); + expect(await screen.findByText("Credential binding saved and draft review refreshed.")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Choose credential for Provider connection" })); + await user.click(await screen.findByRole("menuitem", { name: /Remove Replacement provider token binding/ })); + + expect(await screen.findByText("Credential binding removed and draft review refreshed.")).toBeInTheDocument(); + const unbindInput = api.patchNodeFlowDraft.mock.calls[1]?.[1]; + expect(unbindInput).toMatchObject({ draftRevision: 3 }); + expect(unbindInput.graph.nodes[0]).toMatchObject({ + data: { prompt: "Keep this configuration" }, + credentialBindings: [{ slot: "audit", credentialId: "credential-audit" }], + }); + }); + + it("refreshes a conflicted draft, keeps the slot picker open, and requires an explicit retry", async () => { + const user = userEvent.setup(); + const state = setupCredentialFlow(); + const latest = { + ...credentialFlow, + version: 3, + graph: { + ...credentialFlow.graph, + nodes: [{ ...credentialFlow.graph.nodes[0]!, data: { prompt: "Sibling edit from latest draft" } }], + }, + }; + api.patchNodeFlowDraft.mockResolvedValueOnce({ + conflict: { code: "draft_revision_conflict", flowId: "flow-1", expectedDraftRevision: 2, actualDraftRevision: 3, message: "The draft changed after it was read; reload the summary and reapply the patch." }, + }).mockImplementationOnce(async (_flowId: string, input: { graph: typeof credentialFlow.graph }) => { + const saved = state.updateFromPatch(input); + return { draft: credentialReview(saved) }; + }); + let fetchCount = 0; + api.fetchNodeFlow.mockImplementation(async () => { + fetchCount += 1; + if (fetchCount === 1) { state.replace(latest); return latest; } + return state.current(); + }); + render(); + + await user.click(await screen.findByRole("button", { name: "Choose credential for Provider connection" })); + await user.click(await screen.findByRole("menuitem", { name: /Replacement provider token/ })); + + expect(await screen.findByRole("alert")).toHaveTextContent("choose the credential again to retry"); + expect(api.patchNodeFlowDraft).toHaveBeenCalledTimes(1); + expect(screen.getByRole("menu", { name: "Credential picker for Provider connection" })).toBeInTheDocument(); + + await user.click(screen.getByRole("menuitem", { name: /Replacement provider token/ })); + expect(await screen.findByText("Credential binding saved and draft review refreshed.")).toBeInTheDocument(); + expect(api.patchNodeFlowDraft.mock.calls[1]?.[1]).toMatchObject({ draftRevision: 3 }); + expect(api.patchNodeFlowDraft.mock.calls[1]?.[1].graph.nodes[0].data).toEqual({ prompt: "Sibling edit from latest draft" }); + }); + + it("announces policy denial without presenting the requested status as saved", async () => { + const user = userEvent.setup(); + setupCredentialFlow(); + api.patchNodeFlowDraft.mockRejectedValueOnce(new Error("Policy denied this project change")); + render(); + + await user.click(await screen.findByRole("button", { name: "Choose credential for Provider connection" })); + await user.click(await screen.findByRole("menuitem", { name: /Replacement provider token/ })); + + expect(await screen.findByRole("alert")).toHaveTextContent("policy denied the change"); + expect(screen.queryByText("Request binding")).not.toBeInTheDocument(); + expect(screen.queryByText("Credential binding saved and draft review refreshed.")).not.toBeInTheDocument(); + }); });