Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 113 additions & 10 deletions dashboard/src/v2/NodesPage.tsx

Large diffs are not rendered by default.

239 changes: 239 additions & 0 deletions dashboard/src/v2/components/nodes/NodeCredentialPicker.tsx
Original file line number Diff line number Diff line change
@@ -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<CredentialSelectionResult>;
}

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<NodeCredentialPickerProps> = ({
projectId,
identity,
requirement,
boundCredentialId,
disabled = false,
onSelect,
}) => {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [options, setOptions] = useState<CredentialOption[]>([]);
const [backendReady, setBackendReady] = useState<boolean | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [selectingId, setSelectingId] = useState<string | null>(null);
const requestRef = useRef(0);
const triggerRef = useRef<HTMLElement>(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<void> => {
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<void> => {
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 (
<DropdownMenu
isOpen={open}
onOpenChange={setPickerOpen}
triggerRef={triggerRef}
position="bottom"
align="end"
className="w-[min(24rem,calc(100vw-1rem))] p-3"
menuAriaLabel={`Credential picker for ${requirement.label}`}
content={(
<div className="flex max-h-[28rem] flex-col gap-3 overflow-y-auto">
<div className="px-1">
<p className="text-sm font-bold text-slate-900 dark:text-white">{requirement.label}</p>
<p className="mt-1 text-xs leading-relaxed text-slate-500 dark:text-slate-400">
Choose a project-visible credential with {requirement.requiredCapabilities.join(", ") || "the declared"} access. Secret values never enter this page.
</p>
</div>
{loading ? <p role="status" className="rounded-xl bg-black/[0.03] p-3 text-xs text-slate-500 dark:bg-white/[0.04]">Checking credential compatibility…</p> : null}
{loadError ? <div role="alert" className="rounded-xl border border-status-red/20 bg-status-red/[0.06] p-3 text-xs text-status-red">{loadError}</div> : null}
{!loading && backendReady === false ? (
<div role="alert" className="flex gap-2 rounded-xl border border-amber-500/25 bg-amber-500/[0.08] p-3 text-xs leading-relaxed text-amber-800 dark:text-amber-200">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
Secure credential storage is unavailable. Existing bindings remain unchanged.
</div>
) : null}
{!loading && compatibleOptions.length > 0 ? (
<div className="flex flex-col gap-1" aria-label="Compatible credentials">
<p className="px-1 text-[10px] font-bold uppercase tracking-[0.14em] text-slate-400">Compatible</p>
{compatibleOptions.map((option) => (
<DropdownMenuItem
key={option.id}
disabled={Boolean(selectingId) || disabled}
aria-current={option.id === boundCredentialId ? "true" : undefined}
className="flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2 text-left text-sm text-slate-700 transition hover:bg-signal-500/[0.08] focus:outline-none focus-visible:ring-2 focus-visible:ring-signal-500/40 disabled:opacity-50 dark:text-slate-200"
onClick={() => void choose(option.id)}
>
<span className="min-w-0"><span className="block truncate font-bold">{option.name}</span><span className="block truncate text-xs text-slate-500">{option.kind}</span></span>
{option.id === boundCredentialId ? <Check className="h-4 w-4 shrink-0 text-status-green" aria-label="Currently bound" /> : null}
</DropdownMenuItem>
))}
</div>
) : null}
{!loading && unavailableOptions.length > 0 ? (
<div className="flex flex-col gap-1" aria-label="Unavailable credentials">
<p className="px-1 text-[10px] font-bold uppercase tracking-[0.14em] text-slate-400">Unavailable for this slot</p>
{unavailableOptions.map((option) => (
<div key={option.id} className="rounded-xl border border-black/[0.05] px-3 py-2 opacity-75 dark:border-white/[0.06]">
<p className="text-sm font-bold text-slate-600 dark:text-slate-300">{option.name} <span className="font-normal text-slate-400">· {option.kind}</span></p>
<p className="mt-1 text-xs leading-relaxed text-slate-500">{option.reasons.join(" ") || "This credential is not compatible with the slot policy."}</p>
</div>
))}
</div>
) : null}
{!loading && !loadError && !hasCompatibleChoice ? (
<div className="rounded-xl border border-black/[0.06] bg-black/[0.025] p-3 text-xs leading-relaxed text-slate-600 dark:border-white/[0.06] dark:bg-white/[0.03] dark:text-slate-300">
<p>No other compatible credential is available for this slot.</p>
<a
role="menuitem"
href="/config"
className="mt-2 inline-flex items-center gap-1.5 rounded-lg font-bold text-signal-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-signal-500/40 dark:text-signal-400"
onClick={() => writeSettingsNavigationState({ activeCategory: "integrations", activeInvocationRoute: "task_coding", focusedSections: {} })}
>
<Settings className="h-3.5 w-3.5" aria-hidden="true" />Open credential Settings
</a>
</div>
) : null}
{boundCredentialId ? (
<DropdownMenuItem
disabled={Boolean(selectingId) || disabled}
className="flex w-full items-center gap-2 rounded-xl border border-status-red/20 px-3 py-2 text-left text-xs font-bold text-status-red focus:outline-none focus-visible:ring-2 focus-visible:ring-status-red/30 disabled:opacity-50"
onClick={() => void choose(null)}
>
<Unlink className="h-3.5 w-3.5" aria-hidden="true" />
{selectingId === "__unbind__" ? "Removing binding…" : `Remove ${currentOption?.name ?? "credential"} binding`}
</DropdownMenuItem>
) : null}
</div>
)}
>
<button
type="button"
disabled={disabled}
aria-label={`Choose credential for ${requirement.label}`}
className="mt-2 inline-flex items-center gap-1.5 rounded-lg border border-signal-500/30 px-2.5 py-1.5 text-xs font-bold text-signal-600 transition hover:bg-signal-500/[0.06] focus:outline-none focus-visible:ring-2 focus-visible:ring-signal-500/40 disabled:opacity-50 dark:text-signal-400"
>
<KeyRound className="h-3.5 w-3.5" aria-hidden="true" />{boundCredentialId ? "Replace or remove" : "Bind credential"}
</button>
</DropdownMenu>
);
};
73 changes: 64 additions & 9 deletions dashboard/src/v2/components/nodes/NodeFlowInspector.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand All @@ -30,9 +41,12 @@ interface NodeFlowInspectorProps {
onDetachAgent: (agentPresetId: string) => void;
onRetryAttachments?: () => void;
onNodeChange: (nodeId: string, update: Partial<NodeFlowNode>) => 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<CredentialSelectionResult>;
}

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";
Expand All @@ -51,9 +65,12 @@ export const NodeFlowInspector: FunctionComponent<NodeFlowInspectorProps> = ({
onDetachAgent,
onRetryAttachments,
onNodeChange,
projectId,
flowId,
definition = null,
requiredCredentials = [],
onRequestCredential,
credentialFeedback = null,
onCredentialChange,
}) => {
const messagesByField = buildValidationMessagesByField(validation);

Expand All @@ -67,6 +84,15 @@ export const NodeFlowInspector: FunctionComponent<NodeFlowInspectorProps> = ({

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, {
Expand Down Expand Up @@ -130,13 +156,42 @@ export const NodeFlowInspector: FunctionComponent<NodeFlowInspectorProps> = ({

<section className="flex flex-col gap-3 border-t border-black/[0.06] pt-4 dark:border-white/[0.06]" aria-labelledby="node-credentials-heading">
<h3 id="node-credentials-heading" className="text-xs font-bold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">Credential bindings</h3>
{requiredCredentials.length === 0 ? <p className="text-xs text-slate-500">This node does not request credentials.</p> : requiredCredentials.map((credential) => (
<div key={credential.slot} className="rounded-xl border border-black/[0.06] bg-white/60 p-3 dark:border-white/[0.06] dark:bg-white/[0.03]">
<div className="flex items-center justify-between gap-3"><span className="text-sm font-bold text-slate-800 dark:text-slate-100">{credential.slot}</span><span className={`text-[10px] font-bold uppercase ${credential.status === "bound" ? "text-status-green" : "text-status-red"}`}>{credential.status}</span></div>
<p className="mt-1 text-xs text-slate-500">{credential.allowedKinds.join(", ")} · secret value never displayed</p>
{credential.status !== "bound" && onRequestCredential ? <button type="button" className="mt-2 rounded-lg border border-signal-500/30 px-2.5 py-1.5 text-xs font-bold text-signal-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-signal-500/40" onClick={() => onRequestCredential(selectedNode.id, credential.slot)}>Request binding</button> : null}
</div>
))}
{credentialRequirements.length === 0 ? <p className="text-xs text-slate-500">This node does not request credentials.</p> : 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 (
<div key={requirement.slot} className="rounded-xl border border-black/[0.06] bg-white/60 p-3 dark:border-white/[0.06] dark:bg-white/[0.03]">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-bold text-slate-800 dark:text-slate-100">{requirement.label}</span>
<span className={`text-[10px] font-bold uppercase ${status === "bound" ? "text-status-green" : status === "denied" ? "text-status-red" : "text-amber-600"}`}>{status}</span>
</div>
<p className="mt-1 text-xs text-slate-500">{requirement.allowedKinds.join(", ")} · {requirement.requiredCapabilities.join(", ") || "declared"} access · secret value never displayed</p>
<NodeCredentialPicker
projectId={projectId}
identity={`${projectId}:${flowId}:${selectedNode.id}:${requirement.slot}`}
requirement={requirement}
boundCredentialId={binding?.credentialId ?? null}
disabled={feedback?.status === "saving"}
onSelect={(credentialId) => onCredentialChange(selectedNode.id, requirement.slot, credentialId)}
/>
{feedback ? (
<p
role={feedback.status === "saved" || feedback.status === "saving" ? "status" : "alert"}
aria-live={feedback.status === "saved" || feedback.status === "saving" ? "polite" : "assertive"}
className={`mt-2 text-xs leading-relaxed ${feedback.status === "saved" ? "text-status-green" : feedback.status === "saving" ? "text-slate-500" : feedback.status === "conflict" ? "text-amber-700 dark:text-amber-300" : "text-status-red"}`}
>
{feedback.message}
</p>
) : null}
</div>
);
})}
</section>

<section className="flex flex-col gap-3 border-t border-black/[0.06] pt-4 dark:border-white/[0.06]" aria-labelledby="node-agent-attachments-heading" aria-busy={attachmentsLoading || attaching}>
Expand Down
Loading
Loading