diff --git a/alembic/versions/2026_07_03_1853-aff1632dc377_add_workflow_run_credential_selections.py b/alembic/versions/2026_07_03_1853-aff1632dc377_add_workflow_run_credential_selections.py new file mode 100644 index 000000000..814f1a24d --- /dev/null +++ b/alembic/versions/2026_07_03_1853-aff1632dc377_add_workflow_run_credential_selections.py @@ -0,0 +1,49 @@ +"""add workflow run credential selections + +Revision ID: aff1632dc377 +Revises: 2ac47bc1c075 +Create Date: 2026-07-03T18:53:23.324057+00:00 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "aff1632dc377" +down_revision: Union[str, None] = "2ac47bc1c075" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("credential_parameters", sa.Column("credential_ids", sa.JSON(), nullable=True)) + op.add_column("credential_parameters", sa.Column("selection_strategy", sa.String(), nullable=True)) + op.create_table( + "workflow_run_credential_selections", + sa.Column("selection_id", sa.String(), nullable=False), + sa.Column("organization_id", sa.String(), nullable=False), + sa.Column("workflow_run_id", sa.String(), nullable=False), + sa.Column("workflow_permanent_id", sa.String(), nullable=False), + sa.Column("parameter_key", sa.String(), nullable=False), + sa.Column("credential_id", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("selection_id"), + sa.UniqueConstraint("workflow_run_id", "parameter_key", name="uq_wrcs_workflow_run_parameter_key"), + ) + op.create_index( + "idx_wrcs_lru_lookup", + "workflow_run_credential_selections", + ["organization_id", "workflow_permanent_id", "parameter_key", "credential_id", "created_at"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("idx_wrcs_lru_lookup", table_name="workflow_run_credential_selections") + op.drop_table("workflow_run_credential_selections") + op.drop_column("credential_parameters", "selection_strategy") + op.drop_column("credential_parameters", "credential_ids") diff --git a/docs/api-reference/openapi.json b/docs/api-reference/openapi.json index 8e3352142..b8e5f02a9 100644 --- a/docs/api-reference/openapi.json +++ b/docs/api-reference/openapi.json @@ -5809,6 +5809,44 @@ }, "description": "Exact-match filter on the error_code field inside each task's errors JSON array. A run matches if any of its tasks contains an error with a matching error_code. Error codes are user-defined strings set during workflow execution." }, + { + "name": "created_at_start", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "description": "Only include runs created at or after this UTC timestamp (ISO 8601).", + "title": "Created At Start" + }, + "description": "Only include runs created at or after this UTC timestamp (ISO 8601)." + }, + { + "name": "created_at_end", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "description": "Only include runs created strictly before this UTC timestamp (ISO 8601).", + "title": "Created At End" + }, + "description": "Only include runs created strictly before this UTC timestamp (ISO 8601)." + }, { "name": "x-api-key", "in": "header", diff --git a/skyvern-frontend/src/hooks/useAnalyticsDashboardFlag.test.ts b/skyvern-frontend/src/hooks/useAnalyticsDashboardFlag.test.ts new file mode 100644 index 000000000..c2c82a589 --- /dev/null +++ b/skyvern-frontend/src/hooks/useAnalyticsDashboardFlag.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment jsdom +import { renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { mockEnabled } = vi.hoisted(() => ({ mockEnabled: vi.fn() })); + +vi.mock("posthog-js/react", () => ({ + useFeatureFlagEnabled: () => mockEnabled(), +})); + +import { useAnalyticsDashboardFlag } from "./useAnalyticsDashboardFlag"; + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); +}); + +describe("useAnalyticsDashboardFlag", () => { + it("passes through the posthog flag value when mock analytics is off", () => { + mockEnabled.mockReturnValue(false); + expect(renderHook(() => useAnalyticsDashboardFlag()).result.current).toBe( + false, + ); + + mockEnabled.mockReturnValue(undefined); + expect( + renderHook(() => useAnalyticsDashboardFlag()).result.current, + ).toBeUndefined(); + + mockEnabled.mockReturnValue(true); + expect(renderHook(() => useAnalyticsDashboardFlag()).result.current).toBe( + true, + ); + }); + + it("forces true under VITE_MOCK_ANALYTICS=1, even when the posthog flag is false or undefined", () => { + vi.stubEnv("VITE_MOCK_ANALYTICS", "1"); + + mockEnabled.mockReturnValue(false); + expect(renderHook(() => useAnalyticsDashboardFlag()).result.current).toBe( + true, + ); + + mockEnabled.mockReturnValue(undefined); + expect(renderHook(() => useAnalyticsDashboardFlag()).result.current).toBe( + true, + ); + }); +}); diff --git a/skyvern-frontend/src/hooks/useAnalyticsDashboardFlag.ts b/skyvern-frontend/src/hooks/useAnalyticsDashboardFlag.ts new file mode 100644 index 000000000..79d695f90 --- /dev/null +++ b/skyvern-frontend/src/hooks/useAnalyticsDashboardFlag.ts @@ -0,0 +1,13 @@ +import { useFeatureFlagEnabled } from "posthog-js/react"; +import { ANALYTICS_DASHBOARD_FLAG } from "@/util/featureFlags"; + +// VITE_MOCK_ANALYTICS fakes API responses (see cloud/dev/mockAnalyticsServer.ts) +// but can't reach real PostHog, so every ANALYTICS_DASHBOARD-gated surface +// would stay hidden in local dev without this override. +export function useAnalyticsDashboardFlag(): boolean | undefined { + const enabled = useFeatureFlagEnabled(ANALYTICS_DASHBOARD_FLAG); + if (import.meta.env.DEV && import.meta.env.VITE_MOCK_ANALYTICS === "1") { + return true; + } + return enabled; +} diff --git a/skyvern-frontend/src/routes/workflows/WorkflowPage.test.tsx b/skyvern-frontend/src/routes/workflows/WorkflowPage.test.tsx index 98e8f0b2b..ab50ab236 100644 --- a/skyvern-frontend/src/routes/workflows/WorkflowPage.test.tsx +++ b/skyvern-frontend/src/routes/workflows/WorkflowPage.test.tsx @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import CloudContext from "@/store/CloudContext"; +import { PageSlotsProvider, type PageSlots } from "@/store/PageSlots"; import { WorkflowPage } from "./WorkflowPage"; const { mockFeatureFlagEnabled } = vi.hoisted(() => ({ @@ -114,24 +115,28 @@ afterEach(() => { type RenderOptions = { isCloud?: boolean; analyticsFlagEnabled?: boolean; + pageSlots?: PageSlots; }; function renderWorkflowPage({ isCloud = true, analyticsFlagEnabled = true, + pageSlots = {}, }: RenderOptions = {}) { mockFeatureFlagEnabled.mockReturnValue(analyticsFlagEnabled); return render( - - - } - /> - - + + + + } + /> + + + , ); } @@ -158,4 +163,15 @@ describe("WorkflowPage analytics button", () => { expect(screen.queryByRole("link", { name: /analytics/i })).toBeNull(); }); + + it("renders the injected workflow analytics panel above Past Runs", () => { + const PanelStub = () =>
; + const { container } = renderWorkflowPage({ + pageSlots: { workflowAnalyticsPanel: PanelStub }, + }); + + expect( + container.querySelector('[data-testid="analytics-panel-stub"]'), + ).not.toBeNull(); + }); }); diff --git a/skyvern-frontend/src/routes/workflows/WorkflowPage.tsx b/skyvern-frontend/src/routes/workflows/WorkflowPage.tsx index de51b2912..3345ff99a 100644 --- a/skyvern-frontend/src/routes/workflows/WorkflowPage.tsx +++ b/skyvern-frontend/src/routes/workflows/WorkflowPage.tsx @@ -44,7 +44,7 @@ import { } from "@/util/timeFormat"; import { cn } from "@/util/utils"; import CloudContext from "@/store/CloudContext"; -import React, { useContext, useEffect, useMemo, useState } from "react"; +import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; import { Link, useNavigate, @@ -78,18 +78,15 @@ import { useParameterExpansion } from "./hooks/useParameterExpansion"; import { ParameterDisplayInline } from "./components/ParameterDisplayInline"; import { getOrderedRunParameters } from "./utils"; import { buildWorkflowAnalyticsPath } from "./workflowAnalyticsPath"; -import { - useFeatureFlagEnabled, - useFeatureFlagVariantKey, -} from "posthog-js/react"; +import { useFeatureFlagVariantKey } from "posthog-js/react"; import { EXPERIMENT, isABVariant } from "@/util/onboarding/experimentConfig"; -import { - ANALYTICS_DASHBOARD_FLAG, - WORKFLOW_TAGGING_FLAG, -} from "@/util/featureFlags"; +import { WORKFLOW_TAGGING_FLAG } from "@/util/featureFlags"; +import { useAnalyticsDashboardFlag } from "@/hooks/useAnalyticsDashboardFlag"; import { useFeatureFlag } from "@/hooks/useFeatureFlag"; import { useOnboardingStateOptional } from "@/store/onboarding/useOnboardingState"; import { OnboardingEmptyState } from "@/components/onboarding/OnboardingEmptyState"; +import { usePageSlots } from "@/store/PageSlots"; +import { resolveRunWindow } from "./resolveRunWindow"; function WorkflowPage() { const { workflowPermanentId } = useParams(); @@ -98,9 +95,15 @@ function WorkflowPage() { const isNewUser = onboarding?.isNewUser ?? false; const onboardingState = onboarding?.state ?? null; const onboardingFlag = useFeatureFlagVariantKey(EXPERIMENT.flagKey); - const analyticsEnabled = - useFeatureFlagEnabled(ANALYTICS_DASHBOARD_FLAG) === true; + const analyticsEnabled = useAnalyticsDashboardFlag() === true; const [searchParams, setSearchParams] = useSearchParams(); + // Snapped once on mount so the window stays stable across unrelated + // searchParams changes (page flips, search) instead of re-sampling `now`. + const runWindowNow = useRef(new Date()); + const runWindow = useMemo( + () => resolveRunWindow(searchParams, runWindowNow.current), + [searchParams], + ); const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1; const [statusFilters, setStatusFilters] = useState>([]); const navigate = useNavigate(); @@ -121,6 +124,8 @@ function WorkflowPage() { page, pageSize, search: debouncedSearch, + createdAtStart: runWindow.createdAtStart, + createdAtEnd: runWindow.createdAtEnd, refetchOnMount: "always", }); @@ -158,76 +163,85 @@ function WorkflowPage() { ); const { data: tagColors } = useTagValuesQuery({ enabled: taggingEnabled }); + const { workflowAnalyticsPanel: WorkflowAnalyticsPanel } = usePageSlots(); + if (!workflowPermanentId) { return null; // this should never happen } return (
-
-
-
- {workflowIsLoading ? ( - <> - - - - ) : ( - <> -

{workflow?.title}

-

{workflowPermanentId}

- - )} +
+
+
+
+ {workflowIsLoading ? ( + <> + + + + ) : ( + <> +

{workflow?.title}

+

+ {workflowPermanentId} +

+ + )} +
+ {taggingEnabled && + !workflowIsLoading && + workflowTags && + workflowTags.length > 0 ? ( + + ) : null}
- {taggingEnabled && - !workflowIsLoading && - workflowTags && - workflowTags.length > 0 ? ( - - ) : null} -
-
- {workflow && ( - navigate("/agents")} - /> - )} - {isCloud && analyticsEnabled ? ( +
+ {workflow && ( + navigate("/agents")} + /> + )} + {isCloud && analyticsEnabled ? ( + + ) : null} - ) : null} - - - + + +
+ {WorkflowAnalyticsPanel ? ( + + ) : null}
diff --git a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx index 456ad4b5d..63c314bbc 100644 --- a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx @@ -308,10 +308,26 @@ function convertToParametersYAML( BITWARDEN_MASTER_PASSWORD_AWS_SECRET_KEY, }; } else if (parameterIsSkyvernCredential(parameter)) { + const hasCredentialRotation = + (parameter.credentialIds?.length ?? 0) >= 2; + if ( + parameter.dataType === WorkflowParameterValueType.CredentialId && + !hasCredentialRotation + ) { + return { + parameter_type: WorkflowParameterTypes.Workflow, + workflow_parameter_type: + WorkflowParameterValueType.CredentialId, + default_value: parameter.credentialId, + key: parameter.key, + description: parameter.description || null, + }; + } return { - parameter_type: WorkflowParameterTypes.Workflow, - workflow_parameter_type: WorkflowParameterValueType.CredentialId, - default_value: parameter.credentialId, + parameter_type: WorkflowParameterTypes.Credential, + credential_id: parameter.credentialId, + credential_ids: parameter.credentialIds ?? null, + selection_strategy: parameter.selectionStrategy ?? null, key: parameter.key, description: parameter.description || null, }; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/ConditionalNode/BranchesEditor.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/ConditionalNode/BranchesEditor.tsx index dc0d8e33d..f26544290 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/ConditionalNode/BranchesEditor.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/ConditionalNode/BranchesEditor.tsx @@ -21,6 +21,10 @@ import { useUpdate } from "../../useUpdate"; import { AppNode, isWorkflowBlockNode } from ".."; import { updateNodeAndDescendantsVisibility } from "../../workflowEditorUtils"; import { applyEdgeVisibility } from "./applyEdgeVisibility"; +import { + getConditionLabel, + orderBranchesWithDefaultsLast, +} from "./branchDisplayUtils"; import { ConditionalNodeData, createBranchCondition, @@ -61,11 +65,10 @@ function BranchesEditor({ nodeId, data }: Props) { editable: data.editable, }); - const orderedBranches = useMemo(() => { - const defaultBranch = data.branches.find((branch) => branch.is_default); - const nonDefault = data.branches.filter((branch) => !branch.is_default); - return defaultBranch ? [...nonDefault, defaultBranch] : nonDefault; - }, [data.branches]); + const orderedBranches = useMemo( + () => orderBranchesWithDefaultsLast(data.branches), + [data.branches], + ); const activeBranch = orderedBranches.find((branch) => branch.id === data.activeBranchId) ?? @@ -204,12 +207,10 @@ function BranchesEditor({ nodeId, data }: Props) { if (!data.editable) { return; } - const defaultBranch = data.branches.find((branch) => branch.is_default); + const defaultBranches = data.branches.filter((branch) => branch.is_default); const otherBranches = data.branches.filter((branch) => !branch.is_default); const newBranch = createBranchCondition(); - const updatedBranches = defaultBranch - ? [...otherBranches, newBranch, defaultBranch] - : [...otherBranches, newBranch]; + const updatedBranches = [...otherBranches, newBranch, ...defaultBranches]; // Find the START and NodeAdder nodes inside this conditional const startNode = nodes.find( @@ -335,11 +336,9 @@ function BranchesEditor({ nodeId, data }: Props) { newNonDefaultBranches[currentIndex]!, ]; - // Reconstruct the array with default branch at the end - const defaultBranch = data.branches.find((b) => b.is_default); - const reorderedBranches = defaultBranch - ? [...newNonDefaultBranches, defaultBranch] - : newNonDefaultBranches; + // Reconstruct the array with default branch(es) at the end. + const defaultBranches = data.branches.filter((b) => b.is_default); + const reorderedBranches = [...newNonDefaultBranches, ...defaultBranches]; update({ branches: reorderedBranches }); }; @@ -366,11 +365,9 @@ function BranchesEditor({ nodeId, data }: Props) { newNonDefaultBranches[currentIndex]!, ]; - // Reconstruct with default branch at the end - const defaultBranch = data.branches.find((b) => b.is_default); - const reorderedBranches = defaultBranch - ? [...newNonDefaultBranches, defaultBranch] - : newNonDefaultBranches; + // Reconstruct with default branch(es) at the end. + const defaultBranches = data.branches.filter((b) => b.is_default); + const reorderedBranches = [...newNonDefaultBranches, ...defaultBranches]; update({ branches: reorderedBranches }); }; @@ -395,34 +392,6 @@ function BranchesEditor({ nodeId, data }: Props) { }); }; - // Convert number to Excel-style letter (A, B, C... Z, AA, AB, AC...) - const getExcelStyleLetter = (index: number): string => { - let result = ""; - let num = index; - - while (num >= 0) { - result = String.fromCharCode(65 + (num % 26)) + result; - num = Math.floor(num / 26) - 1; - } - - return result; - }; - - // Generate condition label: A • If, B • Else, C • Else If, etc. - const getConditionLabel = (branch: BranchCondition, index: number) => { - const letter = getExcelStyleLetter(index); - - if (branch.is_default) { - return `${letter} • Else`; - } - - if (index === 0) { - return `${letter} • If`; - } - - return `${letter} • Else If`; - }; - return (
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/ConditionalNode/branchDisplayUtils.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/ConditionalNode/branchDisplayUtils.ts new file mode 100644 index 000000000..97ab98c74 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/ConditionalNode/branchDisplayUtils.ts @@ -0,0 +1,32 @@ +import type { BranchCondition } from "../../../types/workflowTypes"; + +function getExcelStyleLetter(index: number): string { + let result = ""; + let num = index; + + while (num >= 0) { + result = String.fromCharCode(65 + (num % 26)) + result; + num = Math.floor(num / 26) - 1; + } + + return result; +} + +export function getConditionLabel( + branch: BranchCondition, + index: number, +): string { + const letter = getExcelStyleLetter(index); + if (branch.is_default) return `${letter} • Else`; + if (index === 0) return `${letter} • If`; + return `${letter} • Else If`; +} + +export function orderBranchesWithDefaultsLast( + branches: Array, +): Array { + const nonDefaultBranches = branches.filter((branch) => !branch.is_default); + // Keep every default branch visible so invalid or imported data remains editable. + const defaultBranches = branches.filter((branch) => branch.is_default); + return [...nonDefaultBranches, ...defaultBranches]; +} diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/LoginBlockCredentialSelector.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/LoginBlockCredentialSelector.tsx index 9cbbdd698..93e7c8620 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/LoginBlockCredentialSelector.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/LoginBlockCredentialSelector.tsx @@ -1,3 +1,17 @@ +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { Select, SelectContent, @@ -7,21 +21,34 @@ import { SelectValue, } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; import { useCredentialsQuery } from "@/routes/workflows/hooks/useCredentialsQuery"; import CloudContext from "@/store/CloudContext"; -import { useContext, useMemo } from "react"; +import { useContext, useEffect, useMemo, useState } from "react"; import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore"; import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore"; import { CredentialsModal } from "@/routes/credentials/CredentialsModal"; -import { ExclamationTriangleIcon, PlusIcon } from "@radix-ui/react-icons"; +import { + CheckIcon, + ChevronDownIcon, + Cross2Icon, + ExclamationTriangleIcon, + PlusIcon, + ReloadIcon, +} from "@radix-ui/react-icons"; import { getHostname } from "@/util/getHostname"; import { CredentialModalTypes, useCredentialModalState, } from "@/routes/credentials/useCredentialModalState"; -import { useNodes } from "@xyflow/react"; +import { useNodes, useReactFlow } from "@xyflow/react"; import { AppNode } from ".."; import { isLoginNode } from "./types"; +import { + isStartNode, + isWorkflowStartNodeData, + type WorkflowStartNodeData, +} from "../StartNode/types"; import { parameterIsSkyvernCredential, parameterIsBitwardenCredential, @@ -30,11 +57,15 @@ import { isAutoGeneratedCredentialKey, computeWrappedCredentialIds, } from "../../types"; +import { cn } from "@/util/utils"; + +const ROTATION_SELECTION_STRATEGY = "round_robin" as const; type Props = { nodeId: string; value?: string; onChange?: (value: string) => void; + editable?: boolean; /** Called when a credential with a tested_url is selected to auto-fill the login block URL */ onUrlAutoFill?: (url: string) => void; /** Current URL value of the login block — skip auto-fill if already set */ @@ -63,11 +94,14 @@ function LoginBlockCredentialSelector({ nodeId, value, onChange, + editable = true, onUrlAutoFill, currentUrl, }: Props) { const { setIsOpen, setType } = useCredentialModalState(); const nodes = useNodes(); + const { updateNodeData } = useReactFlow(); + const [rotationDraft, setRotationDraft] = useState(false); const { parameters: workflowParameters, setParameters: setWorkflowParameters, @@ -92,6 +126,19 @@ function LoginBlockCredentialSelector({ [credentials], ); + const selectedSkyvernCredentialParameter = useMemo(() => { + if (!value) return undefined; + const parameter = credentialParameters.find((p) => p.key === value); + if (!parameter || !parameterIsSkyvernCredential(parameter)) { + return undefined; + } + return parameter; + }, [value, credentialParameters]); + + useEffect(() => { + setRotationDraft(false); + }, [value]); + // Determine which credential is currently selected (by credential_id) // This handles multiple cases: // 1. Skyvern credential parameters (have credentialId) @@ -121,6 +168,33 @@ function LoginBlockCredentialSelector({ return undefined; }, [value, credentialParameters, workflowParameters]); + const rotationCredentialIds = useMemo(() => { + const sourceIds = selectedSkyvernCredentialParameter?.credentialIds?.length + ? selectedSkyvernCredentialParameter.credentialIds + : selectedCredentialId + ? [selectedCredentialId] + : []; + + return sourceIds.filter((id, index, ids): id is string => { + return typeof id === "string" && ids.indexOf(id) === index; + }); + }, [selectedSkyvernCredentialParameter, selectedCredentialId]); + + const rotationIsSaved = rotationCredentialIds.length > 1; + const rotationMode = + Boolean(selectedSkyvernCredentialParameter) && + (rotationDraft || rotationIsSaved); + + const workflowStartNode = useMemo< + { id: string; data: WorkflowStartNodeData } | undefined + >(() => { + const node = nodes.find(isStartNode); + if (!node || !isWorkflowStartNodeData(node.data)) { + return undefined; + } + return { id: node.id, data: node.data }; + }, [nodes]); + // Check if the selected credential is missing (deleted) const isCredentialMissing = useMemo(() => { if (!selectedCredentialId) return false; @@ -160,15 +234,30 @@ function LoginBlockCredentialSelector({ return ; } - const credentialOptions = credentials - .filter((credential) => !wrappedCredentialIds.has(credential.credential_id)) - .map((credential) => ({ - label: credential.name, - value: credential.credential_id, - type: "credential" as const, - hasBrowserProfile: !!credential.browser_profile_id, - browserProfileUrl: credential.tested_url ?? null, - })); + const allCredentialOptions = credentials.map((credential) => ({ + label: credential.name, + value: credential.credential_id, + type: "credential" as const, + hasBrowserProfile: !!credential.browser_profile_id, + browserProfileUrl: credential.tested_url ?? null, + })); + + const credentialOptions = allCredentialOptions.filter( + (credential) => !wrappedCredentialIds.has(credential.value), + ); + + const selectedRotationCredentials = rotationCredentialIds.map( + (credentialId) => { + const option = allCredentialOptions.find( + (credential) => credential.value === credentialId, + ); + return { + label: option?.label ?? credentialId, + value: credentialId, + hasBrowserProfile: option?.hasBrowserProfile ?? false, + }; + }, + ); // Auto-generated Skyvern wrappers are hidden (they back a direct credential // pick); user-authored Skyvern params and external vault params are listed. @@ -216,12 +305,250 @@ function LoginBlockCredentialSelector({ selectValue = selectedCredentialId ?? value ?? ""; } + const writeRotationCredentialIds = (credentialIds: Array) => { + if (!selectedSkyvernCredentialParameter || credentialIds.length === 0) { + return; + } + + const orderedCredentialIds = credentialIds.filter((id, index, ids) => { + return ids.indexOf(id) === index; + }); + const firstCredentialId = orderedCredentialIds[0]; + if (!firstCredentialId) { + return; + } + + const newParameters = workflowParameters.map((parameter) => { + if ( + parameter.key !== selectedSkyvernCredentialParameter.key || + parameter.parameterType !== "credential" || + !parameterIsSkyvernCredential(parameter) + ) { + return parameter; + } + + if (orderedCredentialIds.length < 2) { + return { + ...parameter, + credentialId: firstCredentialId, + credentialIds: null, + selectionStrategy: null, + dataType: undefined, + }; + } + + return { + ...parameter, + credentialId: firstCredentialId, + credentialIds: orderedCredentialIds, + selectionStrategy: ROTATION_SELECTION_STRATEGY, + dataType: undefined, + }; + }); + + setWorkflowParameters(newParameters); + setHasChanges(true); + }; + + const toggleRotationCredential = (credentialId: string) => { + if (!editable) return; + const nextCredentialIds = rotationCredentialIds.includes(credentialId) + ? rotationCredentialIds.filter((id) => id !== credentialId) + : [...rotationCredentialIds, credentialId]; + + if (nextCredentialIds.length === 0) { + return; + } + + writeRotationCredentialIds(nextCredentialIds); + if (nextCredentialIds.length < 2) { + setRotationDraft(false); + } + }; + + const disableRotation = () => { + if (!editable) return; + if (rotationCredentialIds.length > 0) { + writeRotationCredentialIds([rotationCredentialIds[0]!]); + } + setRotationDraft(false); + }; + + const setRunSequentially = (checked: boolean) => { + if (!workflowStartNode) { + return; + } + updateNodeData(workflowStartNode.id, { + runSequentially: checked, + sequentialKey: checked ? workflowStartNode.data.sequentialKey : null, + }); + setHasChanges(true); + }; + + if (rotationMode) { + return ( + <> +
+ + + + + + + + + No credentials found. + + {allCredentialOptions.map((option) => { + const isSelected = rotationCredentialIds.includes( + option.value, + ); + return ( + + toggleRotationCredential(option.value) + } + > +
+ +
+
+ {option.label} + {option.hasBrowserProfile ? ( + + saved-profile + + ) : null} + {option.browserProfileUrl ? ( + + {getHostname(option.browserProfileUrl)} + + ) : null} +
+
+ ); + })} +
+
+
+
+
+ +
+ {selectedRotationCredentials.map((credential, index) => ( +
+ + {index + 1} + + + {credential.label} + + {credential.hasBrowserProfile ? ( + + Profile + + ) : null} + +
+ ))} +
+ + {rotationCredentialIds.length > 1 ? ( +
+
+ Disable parallel runs +

+ Queues this workflow's runs one at a time so rotated accounts + never log in simultaneously. +

+
+ +
+ ) : null} + + +
+ { + if (!editable) return; + const existingKeys = workflowParameters.map((param) => param.key); + const newKey = generateDefaultCredentialParameterKey(existingKeys); + + setWorkflowParameters([ + ...workflowParameters, + { + parameterType: "credential", + credentialId: id, + key: newKey, + }, + ]); + setHasChanges(true); + onChange?.(newKey); + }} + /> + + ); + } + return ( <> + {!rotationMode && + selectedSkyvernCredentialParameter && + selectedCredentialId && + !isCredentialMissing ? ( + + ) : null} { + if (!editable) return; const existingKeys = workflowParameters.map((param) => param.key); const newKey = generateDefaultCredentialParameterKey(existingKeys); diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/LoginEditor.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/LoginEditor.tsx index 8fbddfeef..86644aec6 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/LoginEditor.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/LoginEditor.tsx @@ -1,6 +1,10 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; -import { ArrowTopRightIcon, PlusIcon } from "@radix-ui/react-icons"; +import { + ArrowTopRightIcon, + ExclamationTriangleIcon, + PlusIcon, +} from "@radix-ui/react-icons"; import { useEdges, useNodes, useNodesData } from "@xyflow/react"; import { HelpTooltip } from "@/components/HelpTooltip"; @@ -18,12 +22,14 @@ import { Switch } from "@/components/ui/switch"; import { RunEngineSelector } from "@/components/EngineSelector"; import { ModelSelector } from "@/components/ModelSelector"; import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea"; +import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore"; import { ErrorCodeMappingEditor } from "../../ErrorCodeMappingEditor"; import { AI_IMPROVE_CONFIGS } from "../../constants"; import { helpTooltips, placeholders } from "../../helpContent"; import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow"; import { type AppNode } from ".."; +import { parameterIsSkyvernCredential } from "../../types"; import { BlockExecutionOptions } from "../components/BlockExecutionOptions"; import { DisableCache } from "../DisableCache"; import { IgnoreWorkflowSystemPrompt } from "../IgnoreWorkflowSystemPrompt"; @@ -74,9 +80,28 @@ function LoginEditorBody({ const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id: blockId }); const isInsideForLoop = isNodeInsideForLoop(nodes, blockId); const parentLoopSkipsOnFail = getParentLoopSkipsOnFail(nodes, blockId); + const workflowParameters = useWorkflowParametersStore( + (state) => state.parameters, + ); const credentialTotpIdentifier = useSelectedCredentialTotpIdentifier( data.parameterKeys.length > 0 ? data.parameterKeys[0] : undefined, ); + const credentialParameterKey = + data.parameterKeys.length > 0 ? data.parameterKeys[0] : undefined; + const credentialIsRotating = useMemo(() => { + if (!credentialParameterKey) return false; + + const parameter = workflowParameters.find( + (workflowParameter) => workflowParameter.key === credentialParameterKey, + ); + + if (!parameter || parameter.parameterType !== "credential") return false; + + return ( + parameterIsSkyvernCredential(parameter) && + (parameter.credentialIds?.length ?? 0) >= 2 + ); + }, [credentialParameterKey, workflowParameters]); const hasTotpValues = Boolean( data.totpIdentifier?.trim() || data.totpVerificationUrl?.trim(), ); @@ -133,6 +158,7 @@ function LoginEditorBody({

0 ? data.parameterKeys[0] : undefined } @@ -154,6 +180,16 @@ function LoginEditorBody({
{showTwoFactorFields ? (
+ {credentialIsRotating && hasTotpValues ? ( +

+ + + Block-level 2FA overrides every rotated account with a single + destination. Remove it unless all accounts share one 2FA + inbox. + +

+ ) : null}
@@ -205,6 +241,11 @@ function LoginEditorBody({ ) : null}
+ ) : credentialIsRotating ? ( +

+ 2FA is handled per account — each rotated credential uses its own + verification settings. +

) : showCredentialTotpSummary ? ( @@ -163,6 +164,7 @@ function LoopEditorBody({ inferBranchCriteriaTypeFromExpression(v), }) } + data-testid="while-condition-input" />
diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/BlockConfigForm.test.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/BlockConfigForm.test.tsx index e02b899e3..04c9d545b 100644 --- a/skyvern-frontend/src/routes/workflows/editor/panels/BlockConfigForm.test.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/panels/BlockConfigForm.test.tsx @@ -21,6 +21,12 @@ vi.mock("@xyflow/react", async () => { }; }); +vi.mock("@/components/WorkflowBlockInputTextarea", () => ({ + WorkflowBlockInputTextarea: ({ value }: { value: string }) => ( +