From 14308d4531d7db8484054fd7e179e8c34b017cd4 Mon Sep 17 00:00:00 2001 From: Shuchang Zheng Date: Tue, 11 Mar 2025 11:18:26 -0700 Subject: [PATCH] Credential selector add credential option (#1926) --- skyvern-frontend/src/api/types.ts | 33 +++ .../src/routes/credentials/CredentialItem.tsx | 56 +++++ .../routes/credentials/CredentialsList.tsx | 33 +++ .../routes/credentials/CredentialsModal.tsx | 181 +++++++++++++ .../routes/credentials/CredentialsPage.tsx | 63 +++++ .../credentials/CredentialsPageLayout.tsx | 13 + .../CreditCardCredentialContent.tsx | 136 ++++++++++ .../credentials/DeleteCredentialButton.tsx | 100 ++++++++ .../credentials/PasswordCredentialContent.tsx | 237 ++++++++++++++++++ .../credentials/useCredentialModalState.ts | 59 +++++ .../CredentialParameterSourceSelector.tsx | 53 +++- .../components/CredentialSelector.tsx | 71 ++++-- .../LoginBlockCredentialSelector.tsx | 110 ++++---- 13 files changed, 1069 insertions(+), 76 deletions(-) create mode 100644 skyvern-frontend/src/routes/credentials/CredentialItem.tsx create mode 100644 skyvern-frontend/src/routes/credentials/CredentialsList.tsx create mode 100644 skyvern-frontend/src/routes/credentials/CredentialsModal.tsx create mode 100644 skyvern-frontend/src/routes/credentials/CredentialsPage.tsx create mode 100644 skyvern-frontend/src/routes/credentials/CredentialsPageLayout.tsx create mode 100644 skyvern-frontend/src/routes/credentials/CreditCardCredentialContent.tsx create mode 100644 skyvern-frontend/src/routes/credentials/DeleteCredentialButton.tsx create mode 100644 skyvern-frontend/src/routes/credentials/PasswordCredentialContent.tsx create mode 100644 skyvern-frontend/src/routes/credentials/useCredentialModalState.ts diff --git a/skyvern-frontend/src/api/types.ts b/skyvern-frontend/src/api/types.ts index 8b02f77a..3804d9b1 100644 --- a/skyvern-frontend/src/api/types.ts +++ b/skyvern-frontend/src/api/types.ts @@ -317,3 +317,36 @@ export type CredentialApiResponse = { credential_type: "password" | "credit_card"; name: string; }; + +export function isPasswordCredential( + credential: PasswordCredentialApiResponse | CreditCardCredentialApiResponse, +): credential is PasswordCredentialApiResponse { + return "username" in credential; +} + +export function isCreditCardCredential( + credential: PasswordCredentialApiResponse | CreditCardCredentialApiResponse, +): credential is CreditCardCredentialApiResponse { + return "last_four" in credential; +} + +export type CreateCredentialRequest = { + name: string; + credential_type: "password" | "credit_card"; + credential: PasswordCredential | CreditCardCredential; +}; + +export type PasswordCredential = { + username: string; + password: string; + totp: string | null; +}; + +export type CreditCardCredential = { + card_number: string; + card_cvv: string; + card_exp_month: string; + card_exp_year: string; + card_brand: string; + card_holder_name: string; +}; diff --git a/skyvern-frontend/src/routes/credentials/CredentialItem.tsx b/skyvern-frontend/src/routes/credentials/CredentialItem.tsx new file mode 100644 index 00000000..122b2e3c --- /dev/null +++ b/skyvern-frontend/src/routes/credentials/CredentialItem.tsx @@ -0,0 +1,56 @@ +import { isPasswordCredential } from "@/api/types"; +import { DeleteCredentialButton } from "./DeleteCredentialButton"; +import { CredentialApiResponse } from "@/api/types"; + +type Props = { + credential: CredentialApiResponse; +}; + +function CredentialItem({ credential }: Props) { + return ( +
+
+

+ {credential.name} +

+

{credential.credential_id}

+
+ {isPasswordCredential(credential.credential) ? ( +
+
+
+

Username/Email

+

Password

+
+
+

{credential.credential.username}

+

{"********"}

+
+
+
+ ) : ( +
+
+
+

Card Number

+

Brand

+
+
+
+
+

+ {"************" + credential.credential.last_four} +

+

{credential.credential.brand}

+
+
+
+ )} +
+ +
+
+ ); +} + +export { CredentialItem }; diff --git a/skyvern-frontend/src/routes/credentials/CredentialsList.tsx b/skyvern-frontend/src/routes/credentials/CredentialsList.tsx new file mode 100644 index 00000000..e57d21d5 --- /dev/null +++ b/skyvern-frontend/src/routes/credentials/CredentialsList.tsx @@ -0,0 +1,33 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { CredentialItem } from "./CredentialItem"; +import { useCredentialsQuery } from "@/routes/workflows/hooks/useCredentialsQuery"; + +function CredentialsList() { + const { data: credentials, isLoading } = useCredentialsQuery(); + + if (isLoading) { + return ( +
+ + +
+ ); + } + + if (!credentials) { + return null; + } + + return ( +
+ {credentials.map((credential) => ( + + ))} +
+ ); +} + +export { CredentialsList }; diff --git a/skyvern-frontend/src/routes/credentials/CredentialsModal.tsx b/skyvern-frontend/src/routes/credentials/CredentialsModal.tsx new file mode 100644 index 00000000..e690a855 --- /dev/null +++ b/skyvern-frontend/src/routes/credentials/CredentialsModal.tsx @@ -0,0 +1,181 @@ +import { + Dialog, + DialogTitle, + DialogContent, + DialogHeader, + DialogFooter, +} from "@/components/ui/dialog"; +import { + useCredentialModalState, + CredentialModalTypes, +} from "./useCredentialModalState"; +import { PasswordCredentialContent } from "./PasswordCredentialContent"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { CreditCardCredentialContent } from "./CreditCardCredentialContent"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { CreateCredentialRequest } from "@/api/types"; +import { getClient } from "@/api/AxiosClient"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { toast } from "@/components/ui/use-toast"; +import { AxiosError } from "axios"; +import { ReloadIcon } from "@radix-ui/react-icons"; + +const PASSWORD_CREDENTIAL_INITIAL_VALUES = { + name: "", + username: "", + password: "", + totp: "", +}; + +const CREDIT_CARD_CREDENTIAL_INITIAL_VALUES = { + name: "", + cardNumber: "", + cardExpirationDate: "", + cardCode: "", + cardBrand: "", + cardHolderName: "", +}; + +type Props = { + onCredentialCreated?: (id: string) => void; +}; + +function CredentialsModal({ onCredentialCreated }: Props) { + const credentialGetter = useCredentialGetter(); + const queryClient = useQueryClient(); + const { isOpen, type, setIsOpen } = useCredentialModalState(); + const [passwordCredentialValues, setPasswordCredentialValues] = useState( + PASSWORD_CREDENTIAL_INITIAL_VALUES, + ); + const [creditCardCredentialValues, setCreditCardCredentialValues] = useState( + CREDIT_CARD_CREDENTIAL_INITIAL_VALUES, + ); + + function reset() { + setPasswordCredentialValues(PASSWORD_CREDENTIAL_INITIAL_VALUES); + setCreditCardCredentialValues(CREDIT_CARD_CREDENTIAL_INITIAL_VALUES); + } + + const createCredentialMutation = useMutation({ + mutationFn: async (request: CreateCredentialRequest) => { + const client = await getClient(credentialGetter); + const response = await client.post("/credentials", request); + return response.data; + }, + onSuccess: (data) => { + setIsOpen(false); + queryClient.invalidateQueries({ + queryKey: ["credentials"], + }); + toast({ + title: "Credential created", + description: "Your credential has been created successfully", + variant: "success", + }); + onCredentialCreated?.(data.credential_id); + }, + onError: (error: AxiosError) => { + const detail = (error.response?.data as { detail?: string })?.detail; + toast({ + title: "Error", + description: detail ? detail : error.message, + variant: "destructive", + }); + }, + }); + + const handleSave = () => { + if (type === CredentialModalTypes.PASSWORD) { + createCredentialMutation.mutate({ + name: passwordCredentialValues.name, + credential_type: "password", + credential: { + username: passwordCredentialValues.username, + password: passwordCredentialValues.password, + totp: + passwordCredentialValues.totp === "" + ? null + : passwordCredentialValues.totp, + }, + }); + } else if (type === CredentialModalTypes.CREDIT_CARD) { + const cardExpirationDate = creditCardCredentialValues.cardExpirationDate; + const cardExpirationDateParts = cardExpirationDate.split("/"); + if (cardExpirationDateParts.length !== 2) { + toast({ + title: "Error", + description: "Invalid card expiration date", + variant: "destructive", + }); + return; + } + const cardExpirationMonth = cardExpirationDateParts[0]; + const cardExpirationYear = cardExpirationDateParts[1]; + if (!cardExpirationMonth || !cardExpirationYear) { + toast({ + title: "Error", + description: "Invalid card expiration date", + variant: "destructive", + }); + return; + } + // remove all spaces from the card number + const number = creditCardCredentialValues.cardNumber.replace(/\s/g, ""); + createCredentialMutation.mutate({ + name: creditCardCredentialValues.name, + credential_type: "credit_card", + credential: { + card_number: number, + card_cvv: creditCardCredentialValues.cardCode, + card_exp_month: cardExpirationMonth, + card_exp_year: cardExpirationYear, + card_brand: creditCardCredentialValues.cardBrand, + card_holder_name: creditCardCredentialValues.cardHolderName, + }, + }); + } + }; + + return ( + { + if (!open) { + reset(); + } + setIsOpen(open); + }} + > + + + Add Credential + + {type === CredentialModalTypes.PASSWORD ? ( + + ) : ( + + )} + + + + + + ); +} + +export { CredentialsModal }; diff --git a/skyvern-frontend/src/routes/credentials/CredentialsPage.tsx b/skyvern-frontend/src/routes/credentials/CredentialsPage.tsx new file mode 100644 index 00000000..d571455e --- /dev/null +++ b/skyvern-frontend/src/routes/credentials/CredentialsPage.tsx @@ -0,0 +1,63 @@ +import { Button } from "@/components/ui/button"; +import { CardStackIcon, PlusIcon } from "@radix-ui/react-icons"; +import { + CredentialModalTypes, + useCredentialModalState, +} from "./useCredentialModalState"; +import { CredentialsModal } from "./CredentialsModal"; +import { CredentialsList } from "./CredentialsList"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { KeyIcon } from "@/components/icons/KeyIcon"; + +const subHeaderText = + "Securely store your passwords or credit cards here to link them throughout your workflows."; + +function CredentialsPage() { + const { setIsOpen, setType } = useCredentialModalState(); + return ( +
+

Credentials

+
+
{subHeaderText}
+ + + + + + { + setIsOpen(true); + setType(CredentialModalTypes.PASSWORD); + }} + className="cursor-pointer" + > + + Password + + { + setIsOpen(true); + setType(CredentialModalTypes.CREDIT_CARD); + }} + className="cursor-pointer" + > + + Credit Card + + + +
+ + +
+ ); +} + +export { CredentialsPage }; diff --git a/skyvern-frontend/src/routes/credentials/CredentialsPageLayout.tsx b/skyvern-frontend/src/routes/credentials/CredentialsPageLayout.tsx new file mode 100644 index 00000000..12b0715f --- /dev/null +++ b/skyvern-frontend/src/routes/credentials/CredentialsPageLayout.tsx @@ -0,0 +1,13 @@ +import { Outlet } from "react-router-dom"; + +function CredentialsPageLayout() { + return ( +
+
+ +
+
+ ); +} + +export { CredentialsPageLayout }; diff --git a/skyvern-frontend/src/routes/credentials/CreditCardCredentialContent.tsx b/skyvern-frontend/src/routes/credentials/CreditCardCredentialContent.tsx new file mode 100644 index 00000000..dd2313e2 --- /dev/null +++ b/skyvern-frontend/src/routes/credentials/CreditCardCredentialContent.tsx @@ -0,0 +1,136 @@ +import { DropdownWithOptions } from "@/components/DropdownWithOptions"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; + +type Props = { + values: { + name: string; + cardNumber: string; + cardExpirationDate: string; + cardCode: string; + cardBrand: string; + cardHolderName: string; + }; + onChange: (values: { + name: string; + cardNumber: string; + cardExpirationDate: string; + cardCode: string; + cardBrand: string; + cardHolderName: string; + }) => void; +}; + +const brandOptions = [ + "Visa", + "Mastercard", + "American Express", + "Discover", + "JCB", + "Diners Club", + "Maestro", + "UnionPay", + "RuPay", + "Other", +]; + +function formatCardNumber(cardNumber: string) { + // put spaces every 4 digits + return cardNumber.replace(/(\d{4})(?=\d)/g, "$1 "); +} + +function formatCardExpirationDate(cardExpirationDate: string) { + // put a slash between the month and year + return cardExpirationDate.replace(/(\d{2})(?=\d)/g, "$1/"); +} + +function CreditCardCredentialContent({ values, onChange }: Props) { + return ( +
+
+
+
Name
+
+ The name of the credential +
+
+ onChange({ ...values, name: e.target.value })} + /> +
+ +
+ + + onChange({ ...values, cardHolderName: e.target.value }) + } + /> +
+
+ + { + onChange({ + ...values, + cardNumber: formatCardNumber(event.target.value), + }); + }} + pattern="[0-9]{13,19}" + placeholder="XXXX XXXX XXXX XXXX" + maxLength={19} + autoComplete="cc-number" + /> +
+
+ + ({ + label: brand, + value: brand, + }))} + value={values.cardBrand} + onChange={(value) => onChange({ ...values, cardBrand: value })} + placeholder="Select Brand" + /> +
+
+
+ + { + onChange({ + ...values, + cardExpirationDate: formatCardExpirationDate( + event.target.value, + ), + }); + }} + placeholder="MM/YY" + pattern="[0-9]{2}/[0-9]{2}" + maxLength={5} + /> +
+
+ + { + onChange({ ...values, cardCode: event.target.value }); + }} + placeholder="XXX" + pattern="[0-9]{3,4}" + maxLength={4} + /> +
+
+
+ ); +} + +export { CreditCardCredentialContent }; diff --git a/skyvern-frontend/src/routes/credentials/DeleteCredentialButton.tsx b/skyvern-frontend/src/routes/credentials/DeleteCredentialButton.tsx new file mode 100644 index 00000000..19b90d0a --- /dev/null +++ b/skyvern-frontend/src/routes/credentials/DeleteCredentialButton.tsx @@ -0,0 +1,100 @@ +import { DialogClose } from "@/components/ui/dialog"; +import { + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { getClient } from "@/api/AxiosClient"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { toast } from "@/components/ui/use-toast"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import { ReloadIcon, TrashIcon } from "@radix-ui/react-icons"; +import { CredentialApiResponse } from "@/api/types"; +type Props = { + credential: CredentialApiResponse; +}; + +function DeleteCredentialButton({ credential }: Props) { + const credentialGetter = useCredentialGetter(); + const queryClient = useQueryClient(); + + const deleteCredentialMutation = useMutation({ + mutationFn: async (id: string) => { + const client = await getClient(credentialGetter); + return client.delete(`/credentials/${id}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["credentials"], + }); + toast({ + title: "Credential deleted", + variant: "success", + description: "The credential has been deleted.", + }); + }, + onError: (error: AxiosError) => { + toast({ + variant: "destructive", + title: "Failed to delete credential", + description: error.message, + }); + }, + }); + + return ( + + + + + + + + + Delete Credential + + + e.preventDefault()}> + + Are you sure? + +
+ The credential{" "} + {credential.name} will + be PERMANENTLY deleted. The Skyvern team has no way to restore a + credential once it's deleted. +
+ + + + + + +
+
+ ); +} + +export { DeleteCredentialButton }; diff --git a/skyvern-frontend/src/routes/credentials/PasswordCredentialContent.tsx b/skyvern-frontend/src/routes/credentials/PasswordCredentialContent.tsx new file mode 100644 index 00000000..cb34c0c9 --- /dev/null +++ b/skyvern-frontend/src/routes/credentials/PasswordCredentialContent.tsx @@ -0,0 +1,237 @@ +import { QRCodeIcon } from "@/components/icons/QRCodeIcon"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/util/utils"; +import { + EnvelopeClosedIcon, + EyeNoneIcon, + EyeOpenIcon, + MobileIcon, +} from "@radix-ui/react-icons"; +import { useState } from "react"; +import { Link } from "react-router-dom"; + +type Props = { + values: { + name: string; + username: string; + password: string; + totp: string; + }; + onChange: (values: { + name: string; + username: string; + password: string; + totp: string; + }) => void; +}; + +function PasswordCredentialContent({ + values: { name, username, password, totp }, + onChange, +}: Props) { + const [totpMethod, setTotpMethod] = useState< + "text" | "email" | "authenticator" + >("authenticator"); + const [showPassword, setShowPassword] = useState(false); + + return ( +
+
+
+ +
+ The name of the credential +
+
+ + onChange({ + name: e.target.value, + username, + password, + totp, + }) + } + /> +
+ +
+
+ +
+ + onChange({ + name, + username: e.target.value, + password, + totp, + }) + } + /> +
+
+
+ +
+
+ + onChange({ + name, + username, + totp, + password: e.target.value, + }) + } + /> +
{ + setShowPassword((value) => !value); + }} + aria-label="Toggle password visibility" + > + {showPassword ? ( + + ) : ( + + )} +
+
+
+ +
+
+

Two-Factor Authentication

+

+ Set up Skyvern to automatically retrieve two-factor authentication + codes. +

+
+
+
setTotpMethod("authenticator")} + > + + +
+
setTotpMethod("email")} + > + + +
+
setTotpMethod("text")} + > + + +
+
+ {(totpMethod === "text" || totpMethod === "email") && ( +

+ + Contact us to set up two-factor authentication in workflows + {" "} + or{" "} + + see our documentation on how to set up two-factor authentication + in workflows + {" "} + to get started. +

+ )} + {totpMethod === "authenticator" && ( +
+
+
+ +
+ + onChange({ + name, + username, + password, + totp: e.target.value, + }) + } + /> +
+

+ You need to find the authenticator secret from the website where + you are using the credential. Here are some guides from popular + authenticator apps:{" "} + + Bitwarden + + {", "} + + 1Password + + {", and "} + + LastPass + + {"."} +

+
+ )} +
+
+ ); +} + +export { PasswordCredentialContent }; diff --git a/skyvern-frontend/src/routes/credentials/useCredentialModalState.ts b/skyvern-frontend/src/routes/credentials/useCredentialModalState.ts new file mode 100644 index 00000000..ab5eb4f3 --- /dev/null +++ b/skyvern-frontend/src/routes/credentials/useCredentialModalState.ts @@ -0,0 +1,59 @@ +import { useSearchParams } from "react-router-dom"; + +const modalParam = "modal"; +const typeParam = "type"; + +export const CredentialModalTypes = { + PASSWORD: "password", + CREDIT_CARD: "credit-card", +} as const; + +export type CredentialModalType = + (typeof CredentialModalTypes)[keyof typeof CredentialModalTypes]; + +type ReturnType = { + isOpen: boolean; + type: CredentialModalType; + setIsOpen: (isOpen: boolean) => void; + setType: (type: CredentialModalType) => void; +}; + +function getCredentialModalType(type: string): CredentialModalType { + if ( + Object.values(CredentialModalTypes).includes(type as CredentialModalType) + ) { + return type as CredentialModalType; + } + return CredentialModalTypes.PASSWORD; +} + +function useCredentialModalState(): ReturnType { + const [searchParams, setSearchParams] = useSearchParams(); + + const modal = searchParams.get(modalParam); + const isOpen = modal === "true"; + const type = getCredentialModalType(searchParams.get(typeParam) ?? ""); + + const setIsOpen = (isOpen: boolean) => { + setSearchParams((prev) => { + prev.set(modalParam, isOpen.toString()); + return prev; + }); + }; + + const setType = (type: CredentialModalType) => { + setSearchParams((prev) => { + prev.set(typeParam, type); + return prev; + }); + }; + + return { + isOpen, + type, + setIsOpen, + setType, + }; +} + +export { useCredentialModalState }; diff --git a/skyvern-frontend/src/routes/workflows/components/CredentialParameterSourceSelector.tsx b/skyvern-frontend/src/routes/workflows/components/CredentialParameterSourceSelector.tsx index dcb5eb65..0d05b2d6 100644 --- a/skyvern-frontend/src/routes/workflows/components/CredentialParameterSourceSelector.tsx +++ b/skyvern-frontend/src/routes/workflows/components/CredentialParameterSourceSelector.tsx @@ -9,6 +9,12 @@ import { Skeleton } from "@/components/ui/skeleton"; import { useCredentialsQuery } from "../hooks/useCredentialsQuery"; import { useWorkflowParametersState } from "../editor/useWorkflowParametersState"; import { WorkflowParameterValueType } from "../types/workflowTypes"; +import { PlusIcon } from "@radix-ui/react-icons"; +import { + CredentialModalTypes, + useCredentialModalState, +} from "@/routes/credentials/useCredentialModalState"; +import { CredentialsModal } from "@/routes/credentials/CredentialsModal"; type Props = { value: string; @@ -17,6 +23,7 @@ type Props = { function CredentialParameterSourceSelector({ value, onChange }: Props) { const { data: credentials, isFetching } = useCredentialsQuery(); + const { setIsOpen, setType } = useCredentialModalState(); const [workflowParameters] = useWorkflowParametersState(); const workflowParametersOfTypeCredentialId = workflowParameters.filter( (parameter) => @@ -51,18 +58,42 @@ function CredentialParameterSourceSelector({ value, onChange }: Props) { ]; return ( - { + if (value === "new") { + setIsOpen(true); + setType(CredentialModalTypes.PASSWORD); + return; + } + onChange(value); + }} + > + + + + + {options.map((option) => ( + + {option.label} + + ))} + +
+ + Add new credential +
- ))} -
- + + + { + onChange(id); + setIsOpen(false); + }} + /> + ); } diff --git a/skyvern-frontend/src/routes/workflows/components/CredentialSelector.tsx b/skyvern-frontend/src/routes/workflows/components/CredentialSelector.tsx index bdd8f452..76c1af7d 100644 --- a/skyvern-frontend/src/routes/workflows/components/CredentialSelector.tsx +++ b/skyvern-frontend/src/routes/workflows/components/CredentialSelector.tsx @@ -7,6 +7,12 @@ import { } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { useCredentialsQuery } from "../hooks/useCredentialsQuery"; +import { PlusIcon } from "@radix-ui/react-icons"; +import { CredentialsModal } from "@/routes/credentials/CredentialsModal"; +import { + CredentialModalTypes, + useCredentialModalState, +} from "@/routes/credentials/useCredentialModalState"; type Props = { value: string; @@ -14,6 +20,7 @@ type Props = { }; function CredentialSelector({ value, onChange }: Props) { + const { setIsOpen, setType } = useCredentialModalState(); const { data: credentials, isFetching } = useCredentialsQuery(); if (isFetching) { @@ -25,28 +32,52 @@ function CredentialSelector({ value, onChange }: Props) { } return ( - { + if (value === "new") { + setIsOpen(true); + setType(CredentialModalTypes.PASSWORD); + } else { + onChange(value); + } + }} + > + + + + + {credentials.map((credential) => ( + +
+

{credential.name}

+

+ {credential.credential_type === "password" + ? "Password" + : "Credit Card"} +

+
+
+ ))} + +
+ + Add new credential
- ))} -
- + + + { + onChange(id); + setIsOpen(false); + }} + /> + ); } 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 297465da..f0b0c2e5 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/LoginBlockCredentialSelector.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/LoginBlockCredentialSelector.tsx @@ -10,6 +10,12 @@ import { useCredentialsQuery } from "@/routes/workflows/hooks/useCredentialsQuer import CloudContext from "@/store/CloudContext"; import { useContext, useId } from "react"; import { useWorkflowParametersState } from "../../useWorkflowParametersState"; +import { CredentialsModal } from "@/routes/credentials/CredentialsModal"; +import { PlusIcon } from "@radix-ui/react-icons"; +import { + CredentialModalTypes, + useCredentialModalState, +} from "@/routes/credentials/useCredentialModalState"; type Props = { value?: string; @@ -17,6 +23,7 @@ type Props = { }; function LoginBlockCredentialSelector({ value, onChange }: Props) { + const { setIsOpen, setType } = useCredentialModalState(); const [workflowParameters, setWorkflowParameters] = useWorkflowParametersState(); const credentialParameters = workflowParameters.filter( @@ -71,53 +78,66 @@ function LoginBlockCredentialSelector({ value, onChange }: Props) { ]; return ( - { + if (value === "new") { + setIsOpen(true); + setType(CredentialModalTypes.PASSWORD); + return; } - } - if (value === noneItemValue) { - onChange?.(""); - } else { - onChange?.(value); - } - }} - > - - - - - {options.map((option) => ( - - {option.label} + const option = options.find((option) => option.value === value); + if (option?.type === "credential") { + const existingCredential = workflowParameters.find((parameter) => { + return ( + parameter.parameterType === "credential" && + "credentialId" in parameter && + parameter.credentialId === value && + parameter.key === value + ); + }); + if (!existingCredential) { + setWorkflowParameters((prev) => [ + ...prev, + { + parameterType: "credential", + credentialId: value, + key: value, + }, + ]); + } + } + if (value === noneItemValue) { + onChange?.(""); + } else { + onChange?.(value); + } + }} + > + + + + + {options.map((option) => ( + + {option.label} + + ))} + +
+ + Add new credential +
- ))} - {options.length === 0 && ( - - No credential parameters found - - )} -
- +
+ + { + onChange?.(id); + }} + /> + ); }