Credential selector add credential option (#1926)
This commit is contained in:
@@ -317,3 +317,36 @@ export type CredentialApiResponse = {
|
|||||||
credential_type: "password" | "credit_card";
|
credential_type: "password" | "credit_card";
|
||||||
name: string;
|
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;
|
||||||
|
};
|
||||||
|
|||||||
56
skyvern-frontend/src/routes/credentials/CredentialItem.tsx
Normal file
56
skyvern-frontend/src/routes/credentials/CredentialItem.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex gap-5 rounded-lg bg-slate-elevation2 p-4">
|
||||||
|
<div className="w-48 space-y-2">
|
||||||
|
<p className="w-full truncate" title={credential.name}>
|
||||||
|
{credential.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-400">{credential.credential_id}</p>
|
||||||
|
</div>
|
||||||
|
{isPasswordCredential(credential.credential) ? (
|
||||||
|
<div className="border-l pl-5">
|
||||||
|
<div className="flex gap-5">
|
||||||
|
<div className="shrink-0 space-y-2">
|
||||||
|
<p className="text-sm text-slate-400">Username/Email</p>
|
||||||
|
<p className="text-sm text-slate-400">Password</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm">{credential.credential.username}</p>
|
||||||
|
<p className="text-sm">{"********"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-5 border-l pl-5">
|
||||||
|
<div className="flex gap-5">
|
||||||
|
<div className="shrink-0 space-y-2">
|
||||||
|
<p className="text-sm text-slate-400">Card Number</p>
|
||||||
|
<p className="text-sm text-slate-400">Brand</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-5">
|
||||||
|
<div className="shrink-0 space-y-2">
|
||||||
|
<p className="text-sm">
|
||||||
|
{"************" + credential.credential.last_four}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{credential.credential.brand}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto">
|
||||||
|
<DeleteCredentialButton credential={credential} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CredentialItem };
|
||||||
33
skyvern-frontend/src/routes/credentials/CredentialsList.tsx
Normal file
33
skyvern-frontend/src/routes/credentials/CredentialsList.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!credentials) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{credentials.map((credential) => (
|
||||||
|
<CredentialItem
|
||||||
|
key={credential.credential_id}
|
||||||
|
credential={credential}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CredentialsList };
|
||||||
181
skyvern-frontend/src/routes/credentials/CredentialsModal.tsx
Normal file
181
skyvern-frontend/src/routes/credentials/CredentialsModal.tsx
Normal file
@@ -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 (
|
||||||
|
<Dialog
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
setIsOpen(open);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="w-[700px] max-w-[700px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="font-bold">Add Credential</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{type === CredentialModalTypes.PASSWORD ? (
|
||||||
|
<PasswordCredentialContent
|
||||||
|
values={passwordCredentialValues}
|
||||||
|
onChange={setPasswordCredentialValues}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CreditCardCredentialContent
|
||||||
|
values={creditCardCredentialValues}
|
||||||
|
onChange={setCreditCardCredentialValues}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={createCredentialMutation.isPending}
|
||||||
|
>
|
||||||
|
{createCredentialMutation.isPending ? (
|
||||||
|
<ReloadIcon className="mr-2 size-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CredentialsModal };
|
||||||
63
skyvern-frontend/src/routes/credentials/CredentialsPage.tsx
Normal file
63
skyvern-frontend/src/routes/credentials/CredentialsPage.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<h1 className="text-2xl">Credentials</h1>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="w-96 text-sm text-slate-300">{subHeaderText}</div>
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<PlusIcon className="mr-2 size-6" /> Add
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-48">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
setIsOpen(true);
|
||||||
|
setType(CredentialModalTypes.PASSWORD);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<KeyIcon className="mr-2 size-4" />
|
||||||
|
Password
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
setIsOpen(true);
|
||||||
|
setType(CredentialModalTypes.CREDIT_CARD);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<CardStackIcon className="mr-2 size-4" />
|
||||||
|
Credit Card
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
<CredentialsList />
|
||||||
|
<CredentialsModal />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CredentialsPage };
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
function CredentialsPageLayout() {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto">
|
||||||
|
<main>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CredentialsPageLayout };
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="w-72 shrink-0 space-y-1">
|
||||||
|
<div>Name</div>
|
||||||
|
<div className="text-sm text-slate-400">
|
||||||
|
The name of the credential
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={values.name}
|
||||||
|
onChange={(e) => onChange({ ...values, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Cardholder Name</Label>
|
||||||
|
<Input
|
||||||
|
value={values.cardHolderName}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...values, cardHolderName: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Number</Label>
|
||||||
|
<Input
|
||||||
|
value={values.cardNumber}
|
||||||
|
onChange={(event) => {
|
||||||
|
onChange({
|
||||||
|
...values,
|
||||||
|
cardNumber: formatCardNumber(event.target.value),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
pattern="[0-9]{13,19}"
|
||||||
|
placeholder="XXXX XXXX XXXX XXXX"
|
||||||
|
maxLength={19}
|
||||||
|
autoComplete="cc-number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Brand</Label>
|
||||||
|
<DropdownWithOptions
|
||||||
|
options={brandOptions.map((brand) => ({
|
||||||
|
label: brand,
|
||||||
|
value: brand,
|
||||||
|
}))}
|
||||||
|
value={values.cardBrand}
|
||||||
|
onChange={(value) => onChange({ ...values, cardBrand: value })}
|
||||||
|
placeholder="Select Brand"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Expiration</Label>
|
||||||
|
<Input
|
||||||
|
value={values.cardExpirationDate}
|
||||||
|
onChange={(event) => {
|
||||||
|
onChange({
|
||||||
|
...values,
|
||||||
|
cardExpirationDate: formatCardExpirationDate(
|
||||||
|
event.target.value,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="MM/YY"
|
||||||
|
pattern="[0-9]{2}/[0-9]{2}"
|
||||||
|
maxLength={5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>CVV</Label>
|
||||||
|
<Input
|
||||||
|
value={values.cardCode}
|
||||||
|
onChange={(event) => {
|
||||||
|
onChange({ ...values, cardCode: event.target.value });
|
||||||
|
}}
|
||||||
|
placeholder="XXX"
|
||||||
|
pattern="[0-9]{3,4}"
|
||||||
|
maxLength={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CreditCardCredentialContent };
|
||||||
@@ -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 (
|
||||||
|
<Dialog>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="icon" variant="tertiary" className="h-8 w-9">
|
||||||
|
<TrashIcon className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Delete Credential</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Are you sure?</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="text-sm text-slate-400">
|
||||||
|
The credential{" "}
|
||||||
|
<span className="font-bold text-primary">{credential.name}</span> will
|
||||||
|
be PERMANENTLY deleted. The Skyvern team has no way to restore a
|
||||||
|
credential once it's deleted.
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="secondary">Cancel</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
deleteCredentialMutation.mutate(credential.credential_id);
|
||||||
|
}}
|
||||||
|
disabled={deleteCredentialMutation.isPending}
|
||||||
|
>
|
||||||
|
{deleteCredentialMutation.isPending && (
|
||||||
|
<ReloadIcon className="mr-2 size-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DeleteCredentialButton };
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="w-72 shrink-0 space-y-1">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<div className="text-sm text-slate-400">
|
||||||
|
The name of the credential
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
name: e.target.value,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
totp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex items-center gap-12">
|
||||||
|
<div className="w-40 shrink-0">
|
||||||
|
<Label>Username or email</Label>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
name,
|
||||||
|
username: e.target.value,
|
||||||
|
password,
|
||||||
|
totp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-12">
|
||||||
|
<div className="w-40 shrink-0">
|
||||||
|
<Label>Password</Label>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
className="pr-9"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
name,
|
||||||
|
username,
|
||||||
|
totp,
|
||||||
|
password: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-0 flex size-9 cursor-pointer items-center justify-center"
|
||||||
|
onClick={() => {
|
||||||
|
setShowPassword((value) => !value);
|
||||||
|
}}
|
||||||
|
aria-label="Toggle password visibility"
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOpenIcon className="size-4" />
|
||||||
|
) : (
|
||||||
|
<EyeNoneIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1>Two-Factor Authentication</h1>
|
||||||
|
<h2 className="text-sm text-slate-400">
|
||||||
|
Set up Skyvern to automatically retrieve two-factor authentication
|
||||||
|
codes.
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid h-36 grid-cols-3 gap-4">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-pointer items-center justify-center gap-2 rounded-lg bg-slate-elevation1 hover:bg-slate-elevation3",
|
||||||
|
{
|
||||||
|
"bg-slate-elevation3": totpMethod === "authenticator",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
onClick={() => setTotpMethod("authenticator")}
|
||||||
|
>
|
||||||
|
<QRCodeIcon className="h-6 w-6" />
|
||||||
|
<Label>Authenticator App</Label>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-pointer items-center justify-center gap-2 rounded-lg bg-slate-elevation1 hover:bg-slate-elevation3",
|
||||||
|
{
|
||||||
|
"bg-slate-elevation3": totpMethod === "email",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
onClick={() => setTotpMethod("email")}
|
||||||
|
>
|
||||||
|
<EnvelopeClosedIcon className="h-6 w-6" />
|
||||||
|
<Label>Email</Label>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-pointer items-center justify-center gap-2 rounded-lg bg-slate-elevation1 hover:bg-slate-elevation3",
|
||||||
|
{
|
||||||
|
"bg-slate-elevation3": totpMethod === "text",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
onClick={() => setTotpMethod("text")}
|
||||||
|
>
|
||||||
|
<MobileIcon className="h-6 w-6" />
|
||||||
|
<Label>Text Message</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(totpMethod === "text" || totpMethod === "email") && (
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
<Link
|
||||||
|
to="https://meetings.hubspot.com/skyvern/demo"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline underline-offset-2"
|
||||||
|
>
|
||||||
|
Contact us to set up two-factor authentication in workflows
|
||||||
|
</Link>{" "}
|
||||||
|
or{" "}
|
||||||
|
<Link
|
||||||
|
to="https://docs.skyvern.com/running-tasks/advanced-features#time-based-one-time-password-totp"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline underline-offset-2"
|
||||||
|
>
|
||||||
|
see our documentation on how to set up two-factor authentication
|
||||||
|
in workflows
|
||||||
|
</Link>{" "}
|
||||||
|
to get started.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{totpMethod === "authenticator" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-12">
|
||||||
|
<div className="w-40 shrink-0">
|
||||||
|
<Label className="whitespace-nowrap">Authenticator Key</Label>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={totp}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
name,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
totp: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
You need to find the authenticator secret from the website where
|
||||||
|
you are using the credential. Here are some guides from popular
|
||||||
|
authenticator apps:{" "}
|
||||||
|
<Link
|
||||||
|
to="https://bitwarden.com/help/integrated-authenticator/#manually-enter-a-secret"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline underline-offset-2"
|
||||||
|
>
|
||||||
|
Bitwarden
|
||||||
|
</Link>
|
||||||
|
{", "}
|
||||||
|
<Link
|
||||||
|
to="https://support.1password.com/one-time-passwords#on-1passwordcom"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline underline-offset-2"
|
||||||
|
>
|
||||||
|
1Password
|
||||||
|
</Link>
|
||||||
|
{", and "}
|
||||||
|
<Link
|
||||||
|
to="https://support.lastpass.com/s/document-item?language=en_US&bundleId=lastpass&topicId=LastPass/create-totp-vault.html&_LANG=enus"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline underline-offset-2"
|
||||||
|
>
|
||||||
|
LastPass
|
||||||
|
</Link>
|
||||||
|
{"."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PasswordCredentialContent };
|
||||||
@@ -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 };
|
||||||
@@ -9,6 +9,12 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
import { useCredentialsQuery } from "../hooks/useCredentialsQuery";
|
import { useCredentialsQuery } from "../hooks/useCredentialsQuery";
|
||||||
import { useWorkflowParametersState } from "../editor/useWorkflowParametersState";
|
import { useWorkflowParametersState } from "../editor/useWorkflowParametersState";
|
||||||
import { WorkflowParameterValueType } from "../types/workflowTypes";
|
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 = {
|
type Props = {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -17,6 +23,7 @@ type Props = {
|
|||||||
|
|
||||||
function CredentialParameterSourceSelector({ value, onChange }: Props) {
|
function CredentialParameterSourceSelector({ value, onChange }: Props) {
|
||||||
const { data: credentials, isFetching } = useCredentialsQuery();
|
const { data: credentials, isFetching } = useCredentialsQuery();
|
||||||
|
const { setIsOpen, setType } = useCredentialModalState();
|
||||||
const [workflowParameters] = useWorkflowParametersState();
|
const [workflowParameters] = useWorkflowParametersState();
|
||||||
const workflowParametersOfTypeCredentialId = workflowParameters.filter(
|
const workflowParametersOfTypeCredentialId = workflowParameters.filter(
|
||||||
(parameter) =>
|
(parameter) =>
|
||||||
@@ -51,18 +58,42 @@ function CredentialParameterSourceSelector({ value, onChange }: Props) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select value={value} onValueChange={onChange}>
|
<>
|
||||||
<SelectTrigger>
|
<Select
|
||||||
<SelectValue placeholder="Select a credential" />
|
value={value}
|
||||||
</SelectTrigger>
|
onValueChange={(value) => {
|
||||||
<SelectContent>
|
if (value === "new") {
|
||||||
{options.map((option) => (
|
setIsOpen(true);
|
||||||
<SelectItem key={option.value} value={option.value}>
|
setType(CredentialModalTypes.PASSWORD);
|
||||||
{option.label}
|
return;
|
||||||
|
}
|
||||||
|
onChange(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a credential" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="new">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
<span>Add new credential</span>
|
||||||
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
</SelectContent>
|
||||||
</SelectContent>
|
</Select>
|
||||||
</Select>
|
<CredentialsModal
|
||||||
|
onCredentialCreated={(id) => {
|
||||||
|
onChange(id);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useCredentialsQuery } from "../hooks/useCredentialsQuery";
|
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 = {
|
type Props = {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -14,6 +20,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function CredentialSelector({ value, onChange }: Props) {
|
function CredentialSelector({ value, onChange }: Props) {
|
||||||
|
const { setIsOpen, setType } = useCredentialModalState();
|
||||||
const { data: credentials, isFetching } = useCredentialsQuery();
|
const { data: credentials, isFetching } = useCredentialsQuery();
|
||||||
|
|
||||||
if (isFetching) {
|
if (isFetching) {
|
||||||
@@ -25,28 +32,52 @@ function CredentialSelector({ value, onChange }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select value={value} onValueChange={onChange}>
|
<>
|
||||||
<SelectTrigger>
|
<Select
|
||||||
<SelectValue placeholder="Select a credential" />
|
value={value}
|
||||||
</SelectTrigger>
|
onValueChange={(value) => {
|
||||||
<SelectContent>
|
if (value === "new") {
|
||||||
{credentials.map((credential) => (
|
setIsOpen(true);
|
||||||
<SelectItem
|
setType(CredentialModalTypes.PASSWORD);
|
||||||
key={credential.credential_id}
|
} else {
|
||||||
value={credential.credential_id}
|
onChange(value);
|
||||||
>
|
}
|
||||||
<div className="space-y-2">
|
}}
|
||||||
<p className="text-sm font-medium">{credential.name}</p>
|
>
|
||||||
<p className="text-xs text-slate-400">
|
<SelectTrigger>
|
||||||
{credential.credential_type === "password"
|
<SelectValue placeholder="Select a credential" />
|
||||||
? "Password"
|
</SelectTrigger>
|
||||||
: "Credit Card"}
|
<SelectContent>
|
||||||
</p>
|
{credentials.map((credential) => (
|
||||||
|
<SelectItem
|
||||||
|
key={credential.credential_id}
|
||||||
|
value={credential.credential_id}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">{credential.name}</p>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
{credential.credential_type === "password"
|
||||||
|
? "Password"
|
||||||
|
: "Credit Card"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="new">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
<span>Add new credential</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
</SelectContent>
|
||||||
</SelectContent>
|
</Select>
|
||||||
</Select>
|
<CredentialsModal
|
||||||
|
onCredentialCreated={(id) => {
|
||||||
|
onChange(id);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ import { useCredentialsQuery } from "@/routes/workflows/hooks/useCredentialsQuer
|
|||||||
import CloudContext from "@/store/CloudContext";
|
import CloudContext from "@/store/CloudContext";
|
||||||
import { useContext, useId } from "react";
|
import { useContext, useId } from "react";
|
||||||
import { useWorkflowParametersState } from "../../useWorkflowParametersState";
|
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 = {
|
type Props = {
|
||||||
value?: string;
|
value?: string;
|
||||||
@@ -17,6 +23,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function LoginBlockCredentialSelector({ value, onChange }: Props) {
|
function LoginBlockCredentialSelector({ value, onChange }: Props) {
|
||||||
|
const { setIsOpen, setType } = useCredentialModalState();
|
||||||
const [workflowParameters, setWorkflowParameters] =
|
const [workflowParameters, setWorkflowParameters] =
|
||||||
useWorkflowParametersState();
|
useWorkflowParametersState();
|
||||||
const credentialParameters = workflowParameters.filter(
|
const credentialParameters = workflowParameters.filter(
|
||||||
@@ -71,53 +78,66 @@ function LoginBlockCredentialSelector({ value, onChange }: Props) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<>
|
||||||
value={value}
|
<Select
|
||||||
onValueChange={(value) => {
|
value={value}
|
||||||
const option = options.find((option) => option.value === value);
|
onValueChange={(value) => {
|
||||||
if (option?.type === "credential") {
|
if (value === "new") {
|
||||||
const existingCredential = workflowParameters.find((parameter) => {
|
setIsOpen(true);
|
||||||
return (
|
setType(CredentialModalTypes.PASSWORD);
|
||||||
parameter.parameterType === "credential" &&
|
return;
|
||||||
"credentialId" in parameter &&
|
|
||||||
parameter.credentialId === value &&
|
|
||||||
parameter.key === value
|
|
||||||
);
|
|
||||||
});
|
|
||||||
if (!existingCredential) {
|
|
||||||
setWorkflowParameters((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
parameterType: "credential",
|
|
||||||
credentialId: value,
|
|
||||||
key: value,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
const option = options.find((option) => option.value === value);
|
||||||
if (value === noneItemValue) {
|
if (option?.type === "credential") {
|
||||||
onChange?.("");
|
const existingCredential = workflowParameters.find((parameter) => {
|
||||||
} else {
|
return (
|
||||||
onChange?.(value);
|
parameter.parameterType === "credential" &&
|
||||||
}
|
"credentialId" in parameter &&
|
||||||
}}
|
parameter.credentialId === value &&
|
||||||
>
|
parameter.key === value
|
||||||
<SelectTrigger className="w-full">
|
);
|
||||||
<SelectValue placeholder="Select a credential parameter" />
|
});
|
||||||
</SelectTrigger>
|
if (!existingCredential) {
|
||||||
<SelectContent>
|
setWorkflowParameters((prev) => [
|
||||||
{options.map((option) => (
|
...prev,
|
||||||
<SelectItem key={option.value} value={option.value}>
|
{
|
||||||
{option.label}
|
parameterType: "credential",
|
||||||
|
credentialId: value,
|
||||||
|
key: value,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value === noneItemValue) {
|
||||||
|
onChange?.("");
|
||||||
|
} else {
|
||||||
|
onChange?.(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select a credential parameter" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="new">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
<span>Add new credential</span>
|
||||||
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
</SelectContent>
|
||||||
{options.length === 0 && (
|
</Select>
|
||||||
<SelectItem value={noneItemValue} key={noneItemValue}>
|
<CredentialsModal
|
||||||
No credential parameters found
|
onCredentialCreated={(id) => {
|
||||||
</SelectItem>
|
onChange?.(id);
|
||||||
)}
|
}}
|
||||||
</SelectContent>
|
/>
|
||||||
</Select>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user