[Frontend] Add SECRET credential type for storing generic sensitive values (#4258)

This commit is contained in:
Marc Kelechava
2025-12-10 08:56:12 -08:00
committed by GitHub
parent e953dad878
commit 0207d13855
9 changed files with 283 additions and 66 deletions

View File

@@ -500,29 +500,51 @@ export type CreditCardCredentialApiResponse = {
brand: string;
};
export type SecretCredentialResponse = {
secret_label?: string | null;
};
export type CredentialApiResponse = {
credential_id: string;
credential: PasswordCredentialApiResponse | CreditCardCredentialApiResponse;
credential_type: "password" | "credit_card";
credential:
| PasswordCredentialApiResponse
| CreditCardCredentialApiResponse
| SecretCredentialResponse;
credential_type: "password" | "credit_card" | "secret";
name: string;
};
export function isPasswordCredential(
credential: PasswordCredentialApiResponse | CreditCardCredentialApiResponse,
credential:
| PasswordCredentialApiResponse
| CreditCardCredentialApiResponse
| SecretCredentialResponse,
): credential is PasswordCredentialApiResponse {
return "username" in credential;
}
export function isCreditCardCredential(
credential: PasswordCredentialApiResponse | CreditCardCredentialApiResponse,
credential:
| PasswordCredentialApiResponse
| CreditCardCredentialApiResponse
| SecretCredentialResponse,
): credential is CreditCardCredentialApiResponse {
return "last_four" in credential;
}
export function isSecretCredential(
credential:
| PasswordCredentialApiResponse
| CreditCardCredentialApiResponse
| SecretCredentialResponse,
): credential is SecretCredentialResponse {
return !("username" in credential) && !("last_four" in credential);
}
export type CreateCredentialRequest = {
name: string;
credential_type: "password" | "credit_card";
credential: PasswordCredential | CreditCardCredential;
credential_type: "password" | "credit_card" | "secret";
credential: PasswordCredential | CreditCardCredential | SecretCredential;
};
export type PasswordCredential = {
@@ -542,6 +564,11 @@ export type CreditCardCredential = {
card_holder_name: string;
};
export type SecretCredential = {
secret_value: string;
secret_label?: string | null;
};
export const OtpType = {
Totp: "totp",
MagicLink: "magic_link",

View File

@@ -1,12 +1,17 @@
import { isPasswordCredential } from "@/api/types";
import {
CredentialApiResponse,
isCreditCardCredential,
isPasswordCredential,
isSecretCredential,
} from "@/api/types";
import { DeleteCredentialButton } from "./DeleteCredentialButton";
import { CredentialApiResponse } from "@/api/types";
type Props = {
credential: CredentialApiResponse;
};
function CredentialItem({ credential }: Props) {
const credentialData = credential.credential;
const getTotpTypeDisplay = (totpType: string) => {
switch (totpType) {
case "authenticator":
@@ -21,6 +26,69 @@ function CredentialItem({ credential }: Props) {
}
};
let credentialDetails = null;
if (isPasswordCredential(credentialData)) {
credentialDetails = (
<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>
{credentialData.totp_type !== "none" && (
<p className="text-sm text-slate-400">2FA Type</p>
)}
</div>
<div className="space-y-2">
<p className="text-sm">{credentialData.username}</p>
<p className="text-sm">{"********"}</p>
{credentialData.totp_type !== "none" && (
<p className="text-sm text-blue-400">
{getTotpTypeDisplay(credentialData.totp_type)}
</p>
)}
</div>
</div>
</div>
);
} else if (isCreditCardCredential(credentialData)) {
credentialDetails = (
<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">
{"************" + credentialData.last_four}
</p>
<p className="text-sm">{credentialData.brand}</p>
</div>
</div>
</div>
);
} else if (isSecretCredential(credentialData)) {
credentialDetails = (
<div className="flex gap-5 border-l pl-5">
<div className="shrink-0 space-y-2">
<p className="text-sm text-slate-400">Secret Value</p>
{credentialData.secret_label ? (
<p className="text-sm text-slate-400">Type</p>
) : null}
</div>
<div className="space-y-2">
<p className="text-sm">{"************"}</p>
{credentialData.secret_label ? (
<p className="text-sm">{credentialData.secret_label}</p>
) : null}
</div>
</div>
);
}
return (
<div className="flex gap-5 rounded-lg bg-slate-elevation2 p-4">
<div className="w-48 space-y-2">
@@ -29,45 +97,7 @@ function CredentialItem({ credential }: Props) {
</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>
{credential.credential.totp_type !== "none" && (
<p className="text-sm text-slate-400">2FA Type</p>
)}
</div>
<div className="space-y-2">
<p className="text-sm">{credential.credential.username}</p>
<p className="text-sm">{"********"}</p>
{credential.credential.totp_type !== "none" && (
<p className="text-sm text-blue-400">
{getTotpTypeDisplay(credential.credential.totp_type)}
</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>
)}
{credentialDetails}
<div className="ml-auto">
<DeleteCredentialButton credential={credential} />
</div>

View File

@@ -2,7 +2,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { CredentialItem } from "./CredentialItem";
import { useCredentialsQuery } from "@/routes/workflows/hooks/useCredentialsQuery";
type CredentialFilter = "password" | "credit_card";
type CredentialFilter = "password" | "credit_card" | "secret";
type Props = {
filter?: CredentialFilter;
@@ -11,6 +11,7 @@ type Props = {
const EMPTY_MESSAGE: Record<CredentialFilter, string> = {
password: "No password credentials stored yet.",
credit_card: "No credit cards stored yet.",
secret: "No secrets stored yet.",
};
function CredentialsList({ filter }: Props = {}) {

View File

@@ -10,6 +10,7 @@ import {
CredentialModalTypes,
} from "./useCredentialModalState";
import { PasswordCredentialContent } from "./PasswordCredentialContent";
import { SecretCredentialContent } from "./SecretCredentialContent";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { CreditCardCredentialContent } from "./CreditCardCredentialContent";
@@ -40,6 +41,12 @@ const CREDIT_CARD_CREDENTIAL_INITIAL_VALUES = {
cardHolderName: "",
};
const SECRET_CREDENTIAL_INITIAL_VALUES = {
name: "",
secretLabel: "",
secretValue: "",
};
// Function to generate a unique credential name
function generateDefaultCredentialName(existingNames: string[]): string {
const baseName = "credentials";
@@ -73,6 +80,9 @@ function CredentialsModal({ onCredentialCreated }: Props) {
const [creditCardCredentialValues, setCreditCardCredentialValues] = useState(
CREDIT_CARD_CREDENTIAL_INITIAL_VALUES,
);
const [secretCredentialValues, setSecretCredentialValues] = useState(
SECRET_CREDENTIAL_INITIAL_VALUES,
);
// Set default name when modal opens
useEffect(() => {
@@ -88,12 +98,17 @@ function CredentialsModal({ onCredentialCreated }: Props) {
...prev,
name: defaultName,
}));
setSecretCredentialValues((prev) => ({
...prev,
name: defaultName,
}));
}
}, [isOpen, credentials]);
function reset() {
setPasswordCredentialValues(PASSWORD_CREDENTIAL_INITIAL_VALUES);
setCreditCardCredentialValues(CREDIT_CARD_CREDENTIAL_INITIAL_VALUES);
setSecretCredentialValues(SECRET_CREDENTIAL_INITIAL_VALUES);
}
const createCredentialMutation = useMutation({
@@ -128,7 +143,9 @@ function CredentialsModal({ onCredentialCreated }: Props) {
const name =
type === CredentialModalTypes.PASSWORD
? passwordCredentialValues.name.trim()
: creditCardCredentialValues.name.trim();
: type === CredentialModalTypes.CREDIT_CARD
? creditCardCredentialValues.name.trim()
: secretCredentialValues.name.trim();
if (name === "") {
toast({
title: "Error",
@@ -219,9 +236,55 @@ function CredentialsModal({ onCredentialCreated }: Props) {
card_holder_name: cardHolderName,
},
});
} else if (type === CredentialModalTypes.SECRET) {
const secretValue = secretCredentialValues.secretValue.trim();
const secretLabel = secretCredentialValues.secretLabel.trim();
if (secretValue === "") {
toast({
title: "Error",
description: "Secret value is required",
variant: "destructive",
});
return;
}
createCredentialMutation.mutate({
name,
credential_type: "secret",
credential: {
secret_value: secretValue,
secret_label: secretLabel === "" ? null : secretLabel,
},
});
}
};
const credentialContent = (() => {
if (type === CredentialModalTypes.PASSWORD) {
return (
<PasswordCredentialContent
values={passwordCredentialValues}
onChange={setPasswordCredentialValues}
/>
);
}
if (type === CredentialModalTypes.CREDIT_CARD) {
return (
<CreditCardCredentialContent
values={creditCardCredentialValues}
onChange={setCreditCardCredentialValues}
/>
);
}
return (
<SecretCredentialContent
values={secretCredentialValues}
onChange={setSecretCredentialValues}
/>
);
})();
return (
<Dialog
open={isOpen}
@@ -236,17 +299,7 @@ function CredentialsModal({ onCredentialCreated }: Props) {
<DialogHeader>
<DialogTitle className="font-bold">Add Credential</DialogTitle>
</DialogHeader>
{type === CredentialModalTypes.PASSWORD ? (
<PasswordCredentialContent
values={passwordCredentialValues}
onChange={setPasswordCredentialValues}
/>
) : (
<CreditCardCredentialContent
values={creditCardCredentialValues}
onChange={setCreditCardCredentialValues}
/>
)}
{credentialContent}
<DialogFooter>
<Button
onClick={handleSave}

View File

@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
import { CardStackIcon, PlusIcon } from "@radix-ui/react-icons";
import { CardStackIcon, LockClosedIcon, PlusIcon } from "@radix-ui/react-icons";
import {
CredentialModalTypes,
useCredentialModalState,
@@ -19,9 +19,14 @@ import { CredentialsTotpTab } from "./CredentialsTotpTab";
import { useSearchParams } from "react-router-dom";
const subHeaderText =
"Securely store your passwords, credit cards, and manage incoming 2FA codes for your workflows.";
"Securely store your passwords, credit cards, secrets, and manage incoming 2FA codes for your workflows.";
const TAB_VALUES = ["passwords", "creditCards", "twoFactor"] as const;
const TAB_VALUES = [
"passwords",
"creditCards",
"secrets",
"twoFactor",
] as const;
type TabValue = (typeof TAB_VALUES)[number];
const DEFAULT_TAB: TabValue = "passwords";
@@ -79,6 +84,16 @@ function CredentialsPage() {
<CardStackIcon className="mr-2 size-4" />
Credit Card
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
setIsOpen(true);
setType(CredentialModalTypes.SECRET);
}}
className="cursor-pointer"
>
<LockClosedIcon className="mr-2 size-4" />
Secret
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -90,6 +105,7 @@ function CredentialsPage() {
<TabsList className="bg-slate-elevation1">
<TabsTrigger value="passwords">Passwords</TabsTrigger>
<TabsTrigger value="creditCards">Credit Cards</TabsTrigger>
<TabsTrigger value="secrets">Secrets</TabsTrigger>
<TabsTrigger value="twoFactor">2FA</TabsTrigger>
</TabsList>
@@ -101,6 +117,10 @@ function CredentialsPage() {
<CredentialsList filter="credit_card" />
</TabsContent>
<TabsContent value="secrets" className="space-y-4">
<CredentialsList filter="secret" />
</TabsContent>
<TabsContent value="twoFactor" className="space-y-4">
<CredentialsTotpTab />
</TabsContent>

View File

@@ -0,0 +1,82 @@
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { EyeNoneIcon, EyeOpenIcon } from "@radix-ui/react-icons";
import { useState } from "react";
type Props = {
values: {
name: string;
secretLabel: string;
secretValue: string;
};
onChange: (values: {
name: string;
secretLabel: string;
secretValue: string;
}) => void;
};
function SecretCredentialContent({ values, onChange }: Props) {
const { name, secretLabel, secretValue } = values;
const [showSecret, setShowSecret] = useState(false);
return (
<div className="space-y-4">
<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({ ...values, name: e.target.value })}
/>
</div>
<Separator />
<div className="space-y-2">
<Label>Secret Label (optional)</Label>
<Input
placeholder="e.g., API Key, Bearer Token"
value={secretLabel}
onChange={(e) => onChange({ ...values, secretLabel: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Secret Value</Label>
<div className="relative w-full">
<Input
className="pr-9"
type={showSecret ? "text" : "password"}
value={secretValue}
onChange={(e) =>
onChange({ ...values, secretValue: e.target.value })
}
/>
<div
className="absolute right-0 top-0 flex size-9 cursor-pointer items-center justify-center"
onClick={() => {
setShowSecret((value) => !value);
}}
aria-label="Toggle secret value visibility"
>
{showSecret ? (
<EyeOpenIcon className="size-4" />
) : (
<EyeNoneIcon className="size-4" />
)}
</div>
</div>
<p className="text-sm text-slate-400">
{
"Use in HTTP Request blocks with: {{ credential_name.secret_value }}"
}
</p>
</div>
</div>
);
}
export { SecretCredentialContent };

View File

@@ -6,6 +6,7 @@ const typeParam = "type";
export const CredentialModalTypes = {
PASSWORD: "password",
CREDIT_CARD: "credit-card",
SECRET: "secret",
} as const;
export type CredentialModalType =

View File

@@ -62,7 +62,9 @@ function CredentialSelector({ value, onChange }: Props) {
<p className="text-xs text-slate-400">
{credential.credential_type === "password"
? "Password"
: "Credit Card"}
: credential.credential_type === "credit_card"
? "Credit Card"
: "Secret"}
</p>
</div>
</CustomSelectItem>

View File

@@ -399,9 +399,10 @@ function HttpRequestNode({ id, data }: NodeProps<HttpRequestNodeType>) {
authentication and content headers
</li>
<li>
Pass a credential/secret parameter and reference it in headers
or body with {"{{ my_credential.password }}"}
Password credential: {"{{ my_credential.username }}"} /{" "}
{"{{ my_credential.password }}"}
</li>
<li>Secret credential: {"{{ my_secret.secret_value }}"}</li>
<li>
The request will return response data including status, headers,
and body