Credential selector add credential option (#1926)

This commit is contained in:
Shuchang Zheng
2025-03-11 11:18:26 -07:00
committed by GitHub
parent 0eada40ffc
commit 14308d4531
13 changed files with 1069 additions and 76 deletions

View File

@@ -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;
};

View 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 };

View 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 };

View 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 };

View 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 };

View File

@@ -0,0 +1,13 @@
import { Outlet } from "react-router-dom";
function CredentialsPageLayout() {
return (
<div className="container mx-auto">
<main>
<Outlet />
</main>
</div>
);
}
export { CredentialsPageLayout };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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);
}}
/>
</>
);
}

View File

@@ -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);
}}
/>
</>
);
}

View File

@@ -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);
}}
/>
</>
);
}