diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/CredentialParameterSelector.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/CredentialParameterSelector.tsx new file mode 100644 index 00000000..aed355e0 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/CredentialParameterSelector.tsx @@ -0,0 +1,53 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useWorkflowParametersState } from "../../useWorkflowParametersState"; +import { useId } from "react"; + +type Props = { + value?: string; + onChange?: (value: string) => void; +}; + +function CredentialParameterSelector({ value, onChange }: Props) { + const [workflowParameters] = useWorkflowParametersState(); + const credentialParameters = workflowParameters.filter( + (parameter) => parameter.parameterType === "credential", + ); + const noneItemValue = useId(); + + return ( + + ); +} + +export { CredentialParameterSelector }; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/LoginNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/LoginNode.tsx new file mode 100644 index 00000000..07389b4a --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/LoginNode.tsx @@ -0,0 +1,364 @@ +import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback"; +import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler"; +import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; +import { useState } from "react"; +import { EditableNodeTitle } from "../components/EditableNodeTitle"; +import { NodeActionMenu } from "../NodeActionMenu"; +import { HelpTooltip } from "@/components/HelpTooltip"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { errorMappingExampleValue } from "../types"; +import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; +import { Switch } from "@/components/ui/switch"; +import type { LoginNode } from "./types"; +import { + commonFieldPlaceholders, + commonHelpTooltipContent, +} from "../../constants"; +import { LockOpen1Icon } from "@radix-ui/react-icons"; +import { CredentialParameterSelector } from "./CredentialParameterSelector"; + +const urlTooltip = + "The URL Skyvern is navigating to. Leave this field blank to pick up from where the last block left off."; +const urlPlaceholder = "https://"; +const navigationGoalTooltip = + "Give Skyvern an objective. Make sure to include when the task is complete, when it should self-terminate, and any guardrails."; +const navigationGoalPlaceholder = "Tell Skyvern what to do."; + +function LoginNode({ id, data }: NodeProps) { + const { updateNodeData } = useReactFlow(); + const { editable } = data; + const [label, setLabel] = useNodeLabelChangeHandler({ + id, + initialValue: data.label, + }); + const [inputs, setInputs] = useState({ + url: data.url, + navigationGoal: data.navigationGoal, + errorCodeMapping: data.errorCodeMapping, + maxRetries: data.maxRetries, + maxStepsOverride: data.maxStepsOverride, + continueOnFailure: data.continueOnFailure, + cacheActions: data.cacheActions, + totpVerificationUrl: data.totpVerificationUrl, + totpIdentifier: data.totpIdentifier, + }); + const deleteNodeCallback = useDeleteNodeCallback(); + + function handleChange(key: string, value: unknown) { + if (!editable) { + return; + } + setInputs({ ...inputs, [key]: value }); + updateNodeData(id, { [key]: value }); + } + + return ( +
+ + +
+
+
+
+ +
+
+ + Login Block +
+
+ { + deleteNodeCallback(id); + }} + /> +
+
+
+
+ + +
+ { + if (!editable) { + return; + } + handleChange("url", event.target.value); + }} + value={inputs.url} + placeholder={urlPlaceholder} + className="nopan text-xs" + /> +
+
+
+ + +
+ { + if (!editable) { + return; + } + handleChange("navigationGoal", event.target.value); + }} + value={inputs.navigationGoal} + placeholder={navigationGoalPlaceholder} + className="nopan text-xs" + /> +
+
+ + 0 + ? data.parameterKeys[0] + : undefined + } + onChange={(value) => { + if (!editable) { + return; + } + updateNodeData(id, { parameterKeys: [value] }); + }} + /> +
+
+
+
Credentials need to be added with the help of our team.
+
+ Reach out to{" "} + support@skyvern.com for + assistance. +
+
+
+
+ + + + + Advanced Settings + + +
+
+
+ + +
+ { + if (!editable) { + return; + } + const value = + event.target.value === "" + ? null + : Number(event.target.value); + handleChange("maxRetries", value); + }} + /> +
+
+
+ + +
+ { + if (!editable) { + return; + } + const value = + event.target.value === "" + ? null + : Number(event.target.value); + handleChange("maxStepsOverride", value); + }} + /> +
+
+
+
+ + +
+ { + if (!editable) { + return; + } + handleChange( + "errorCodeMapping", + checked + ? JSON.stringify(errorMappingExampleValue, null, 2) + : "null", + ); + }} + /> +
+ {inputs.errorCodeMapping !== "null" && ( +
+ { + if (!editable) { + return; + } + handleChange("errorCodeMapping", value); + }} + className="nowheel nopan" + fontSize={8} + /> +
+ )} +
+ +
+
+ + +
+
+ { + if (!editable) { + return; + } + handleChange("continueOnFailure", checked); + }} + /> +
+
+
+
+ + +
+
+ { + if (!editable) { + return; + } + handleChange("cacheActions", checked); + }} + /> +
+
+ +
+
+ + +
+ { + if (!editable) { + return; + } + handleChange("totpVerificationUrl", event.target.value); + }} + value={inputs.totpVerificationUrl ?? ""} + placeholder={commonFieldPlaceholders["totpVerificationUrl"]} + className="nopan text-xs" + /> +
+
+
+ + +
+ { + if (!editable) { + return; + } + handleChange("totpIdentifier", event.target.value); + }} + value={inputs.totpIdentifier ?? ""} + placeholder={commonFieldPlaceholders["totpIdentifier"]} + className="nopan text-xs" + /> +
+
+
+
+
+
+
+ ); +} + +export { LoginNode }; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/types.ts new file mode 100644 index 00000000..3868f5d5 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/types.ts @@ -0,0 +1,36 @@ +import type { Node } from "@xyflow/react"; +import { NodeBaseData } from "../types"; + +export type LoginNodeData = NodeBaseData & { + url: string; + navigationGoal: string; + errorCodeMapping: string; + maxRetries: number | null; + maxStepsOverride: number | null; + parameterKeys: Array; + totpVerificationUrl: string | null; + totpIdentifier: string | null; + cacheActions: boolean; +}; + +export type LoginNode = Node; + +export const loginNodeDefaultData: LoginNodeData = { + label: "", + url: "", + navigationGoal: + "If you're not on the login page, navigate to login page and login using the credentials given. First, take actions on promotional popups or cookie prompts that could prevent taking other action on the web page. If you fail to login to find the login page or can't login after several trials, terminate. If login is completed, you're successful. ", + errorCodeMapping: "null", + maxRetries: null, + maxStepsOverride: null, + editable: true, + parameterKeys: [], + totpVerificationUrl: null, + totpIdentifier: null, + continueOnFailure: false, + cacheActions: false, +} as const; + +export function isLoginNode(node: Node): node is LoginNode { + return node.type === "login"; +} diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts index c2b4e784..80e73fad 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts @@ -27,6 +27,8 @@ import { NavigationNode } from "./NavigationNode/types"; import { NavigationNode as NavigationNodeComponent } from "./NavigationNode/NavigationNode"; import { ExtractionNode } from "./ExtractionNode/types"; import { ExtractionNode as ExtractionNodeComponent } from "./ExtractionNode/ExtractionNode"; +import { LoginNode } from "./LoginNode/types"; +import { LoginNode as LoginNodeComponent } from "./LoginNode/LoginNode"; export type UtilityNode = StartNode | NodeAdderNode; @@ -42,7 +44,8 @@ export type WorkflowBlockNode = | ValidationNode | ActionNode | NavigationNode - | ExtractionNode; + | ExtractionNode + | LoginNode; export function isUtilityNode(node: AppNode): node is UtilityNode { return node.type === "nodeAdder" || node.type === "start"; @@ -69,4 +72,5 @@ export const nodeTypes = { action: memo(ActionNodeComponent), navigation: memo(NavigationNodeComponent), extraction: memo(ExtractionNodeComponent), + login: memo(LoginNodeComponent), } as const; diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx index 4189897e..bc5c4805 100644 --- a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx @@ -6,6 +6,7 @@ import { EnvelopeClosedIcon, FileIcon, ListBulletIcon, + LockOpen1Icon, PlusIcon, UpdateIcon, UploadIcon, @@ -97,6 +98,12 @@ const nodeLibraryItems: Array<{ title: "Extraction Block", description: "Extract data from the page", }, + { + nodeType: "login", + icon: , + title: "Login Block", + description: "Login to a website", + }, ]; type Props = { diff --git a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts index f2f48259..ea66e5ad 100644 --- a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts +++ b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts @@ -26,6 +26,7 @@ import { NavigationBlockYAML, WorkflowCreateYAMLRequest, ExtractionBlockYAML, + LoginBlockYAML, } from "../types/workflowYamlTypes"; import { EMAIL_BLOCK_SENDER, @@ -69,6 +70,7 @@ import { extractionNodeDefaultData, isExtractionNode, } from "./nodes/ExtractionNode/types"; +import { loginNodeDefaultData } from "./nodes/LoginNode/types"; export const NEW_NODE_LABEL_PREFIX = "block_"; @@ -252,6 +254,25 @@ function convertToNode( }, }; } + case "login": { + return { + ...identifiers, + ...common, + type: "login", + data: { + ...commonData, + url: block.url ?? "", + navigationGoal: block.navigation_goal ?? "", + errorCodeMapping: JSON.stringify(block.error_code_mapping, null, 2), + maxRetries: block.max_retries ?? null, + parameterKeys: block.parameters.map((p) => p.key), + totpIdentifier: block.totp_identifier ?? null, + totpVerificationUrl: block.totp_verification_url ?? null, + cacheActions: block.cache_actions, + maxStepsOverride: block.max_steps_per_run ?? null, + }, + }; + } case "code": { return { ...identifiers, @@ -586,6 +607,17 @@ function createNode( }, }; } + case "login": { + return { + ...identifiers, + ...common, + type: "login", + data: { + ...loginNodeDefaultData, + label, + }, + }; + } case "loop": { return { ...identifiers, @@ -779,6 +811,27 @@ function getWorkflowBlock(node: WorkflowBlockNode): BlockYAML { cache_actions: node.data.cacheActions, }; } + case "login": { + return { + ...base, + block_type: "login", + title: node.data.label, + navigation_goal: node.data.navigationGoal, + error_code_mapping: JSONParseSafe(node.data.errorCodeMapping) as Record< + string, + string + > | null, + url: node.data.url, + ...(node.data.maxRetries !== null && { + max_retries: node.data.maxRetries, + }), + max_steps_per_run: node.data.maxStepsOverride, + parameter_keys: node.data.parameterKeys, + totp_identifier: node.data.totpIdentifier, + totp_verification_url: node.data.totpVerificationUrl, + cache_actions: node.data.cacheActions, + }; + } case "sendEmail": { return { ...base, @@ -1315,6 +1368,23 @@ function convertBlocksToBlockYAML( }; return blockYaml; } + case "login": { + const blockYaml: LoginBlockYAML = { + ...base, + block_type: "login", + url: block.url, + title: block.title, + navigation_goal: block.navigation_goal, + error_code_mapping: block.error_code_mapping, + max_retries: block.max_retries, + max_steps_per_run: block.max_steps_per_run, + parameter_keys: block.parameters.map((p) => p.key), + totp_identifier: block.totp_identifier, + totp_verification_url: block.totp_verification_url, + cache_actions: block.cache_actions, + }; + return blockYaml; + } case "for_loop": { const blockYaml: ForLoopBlockYAML = { ...base, diff --git a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts index b7dd18bf..9a12a347 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts @@ -113,7 +113,8 @@ export type WorkflowBlock = | ValidationBlock | ActionBlock | NavigationBlock - | ExtractionBlock; + | ExtractionBlock + | LoginBlock; export const WorkflowBlockType = { Task: "task", @@ -128,6 +129,7 @@ export const WorkflowBlockType = { Action: "action", Navigation: "navigation", Extraction: "extraction", + Login: "login", } as const; export type WorkflowBlockType = @@ -259,6 +261,20 @@ export type ExtractionBlock = WorkflowBlockBase & { cache_actions: boolean; }; +export type LoginBlock = WorkflowBlockBase & { + block_type: "login"; + url: string | null; + title: string; + navigation_goal: string | null; + error_code_mapping: Record | null; + max_retries?: number; + max_steps_per_run?: number | null; + parameters: Array; + totp_verification_url?: string | null; + totp_identifier?: string | null; + cache_actions: boolean; +}; + export type WorkflowDefinition = { parameters: Array; blocks: Array; diff --git a/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts index 81c32a06..c5192848 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts @@ -79,6 +79,7 @@ const BlockTypes = { ACTION: "action", NAVIGATION: "navigation", EXTRACTION: "extraction", + LOGIN: "login", } as const; export type BlockType = (typeof BlockTypes)[keyof typeof BlockTypes]; @@ -95,7 +96,8 @@ export type BlockYAML = | ValidationBlockYAML | ActionBlockYAML | NavigationBlockYAML - | ExtractionBlockYAML; + | ExtractionBlockYAML + | LoginBlockYAML; export type BlockYAMLBase = { block_type: BlockType; @@ -172,6 +174,20 @@ export type ExtractionBlockYAML = BlockYAMLBase & { cache_actions: boolean; }; +export type LoginBlockYAML = BlockYAMLBase & { + block_type: "login"; + url: string | null; + title?: string; + navigation_goal: string | null; + error_code_mapping: Record | null; + max_retries?: number; + max_steps_per_run?: number | null; + parameter_keys?: Array | null; + totp_verification_url?: string | null; + totp_identifier?: string | null; + cache_actions: boolean; +}; + export type CodeBlockYAML = BlockYAMLBase & { block_type: "code"; code: string;