Credential selector add credential option (#1926)
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
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 { 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 (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a credential" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
<>
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(value) => {
|
||||
if (value === "new") {
|
||||
setIsOpen(true);
|
||||
setType(CredentialModalTypes.PASSWORD);
|
||||
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>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<CredentialsModal
|
||||
onCredentialCreated={(id) => {
|
||||
onChange(id);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a credential" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{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>
|
||||
<>
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(value) => {
|
||||
if (value === "new") {
|
||||
setIsOpen(true);
|
||||
setType(CredentialModalTypes.PASSWORD);
|
||||
} else {
|
||||
onChange(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a credential" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{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>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectContent>
|
||||
</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 { 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 (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(value) => {
|
||||
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,
|
||||
},
|
||||
]);
|
||||
<>
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(value) => {
|
||||
if (value === "new") {
|
||||
setIsOpen(true);
|
||||
setType(CredentialModalTypes.PASSWORD);
|
||||
return;
|
||||
}
|
||||
}
|
||||
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}
|
||||
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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
{options.length === 0 && (
|
||||
<SelectItem value={noneItemValue} key={noneItemValue}>
|
||||
No credential parameters found
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<CredentialsModal
|
||||
onCredentialCreated={(id) => {
|
||||
onChange?.(id);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user