feat(frontend): add new credential field for new builder (#11066)

In this PR, I’ve added a feature to select a credential from a list and
also provided a UI to create a new credential if desired.

<img width="443" height="157" alt="Screenshot 2025-10-06 at 9 28 07 AM"
src="https://github.com/user-attachments/assets/d9e72a14-255d-45b6-aa61-b55c2465dd7e"
/>

#### Frontend Changes:
- **Refactored credential field** from a single component to a modular
architecture:
  - Created `CredentialField/` directory with separated concerns
- Added `SelectCredential.tsx` component for credential selection UI
with provider details display
- Implemented `useCredentialField.ts` custom hook for credential data
fetching with 10-minute caching
- Added `helpers.ts` with credential filtering and provider name
formatting utilities
  - Added loading states with skeleton UI while fetching credentials

- **Enhanced UI/UX features**:
- Dropdown selector showing credentials with provider, title, username,
and host details
  - Visual key icon for each credential option
  - Placeholder "Add API Key" button (implementation pending)
  - Loading skeleton UI for better perceived performance
  - Smart filtering of credentials based on provider requirements

- **Template improvements**:
- Updated `FieldTemplate.tsx` to properly handle credential field
display
- Special handling for credential field labels showing provider-specific
names
  - Removed input handle for credential fields in the node editor

#### Backend Changes:
- **API Documentation improvements**:
- Added OpenAPI summaries to `/credentials` endpoint ("List
Credentials")
- Added summary to `/{provider}/credentials/{cred_id}` endpoint ("Get
Specific Credential By ID")

### Test Plan 📋

   - [x] Navigate to the flow builder
   - [x] Add a block that requires credentials (e.g., API block)
- [x] Verify the credential dropdown loads and displays available
credentials
- [x] Check that only credentials matching the provider requirements are
shown
This commit is contained in:
Abhimanyu Yadav
2025-10-06 18:22:45 +05:30
committed by GitHub
parent 4e1557e498
commit c42f94ce2a
11 changed files with 201 additions and 45 deletions

View File

@@ -180,7 +180,7 @@ async def callback(
)
@router.get("/credentials")
@router.get("/credentials", summary="List Credentials")
async def list_credentials(
user_id: Annotated[str, Security(get_user_id)],
) -> list[CredentialsMetaResponse]:
@@ -221,7 +221,9 @@ async def list_credentials_by_provider(
]
@router.get("/{provider}/credentials/{cred_id}")
@router.get(
"/{provider}/credentials/{cred_id}", summary="Get Specific Credential By ID"
)
async def get_credential(
provider: Annotated[
ProviderName, Path(title="The provider to retrieve credentials for")

View File

@@ -1,34 +0,0 @@
import React from "react";
import { FieldProps } from "@rjsf/utils";
import { Input } from "@/components/atoms/Input/Input";
// We need to add all the logic for the credential fields here
export const CredentialsField = (props: FieldProps) => {
const { formData = {}, onChange, required: _required, schema } = props;
const _credentialProvider = schema.credentials_provider;
const _credentialType = schema.credentials_types;
const _description = schema.description;
const _title = schema.title;
// Helper to update one property
const setField = (key: string, value: any) =>
onChange({ ...formData, [key]: value });
return (
<div className="flex flex-col gap-2">
<Input
hideLabel={true}
label={""}
id="credentials-id"
type="text"
value={formData.id || ""}
onChange={(e) => setField("id", e.target.value)}
placeholder="Enter your API Key"
required
size="small"
wrapperClassName="mb-0"
/>
</div>
);
};

View File

@@ -0,0 +1,49 @@
import React from "react";
import { FieldProps } from "@rjsf/utils";
import { useCredentialField } from "./useCredentialField";
import { filterCredentialsByProvider } from "./helpers";
import { PlusIcon } from "@phosphor-icons/react";
import { Button } from "@/components/atoms/Button/Button";
import { SelectCredential } from "./SelectCredential";
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
export const CredentialsField = (props: FieldProps) => {
const { formData = {}, onChange, required: _required, schema } = props;
const { credentials, isCredentialListLoading } = useCredentialField();
const credentialProviders = schema.credentials_provider;
const { credentials: filteredCredentials, exists: credentialsExists } =
filterCredentialsByProvider(credentials, credentialProviders);
const setField = (key: string, value: any) =>
onChange({ ...formData, [key]: value });
if (isCredentialListLoading) {
return (
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-full rounded-xlarge" />
<Skeleton className="h-8 w-[30%] rounded-xlarge" />
</div>
);
}
return (
<div className="flex flex-col gap-2">
{credentialsExists && (
<SelectCredential
credentials={filteredCredentials}
value={formData.id}
onChange={(value) => setField("id", value)}
disabled={false}
label="Credential"
placeholder="Select credential"
/>
)}
{/* TODO : We need to add a modal to add a new credential */}
<Button type="button" className="w-fit" size="small">
<PlusIcon /> Add API Key
</Button>
</div>
);
};

View File

@@ -0,0 +1,60 @@
import React from "react";
import { Select } from "@/components/atoms/Select/Select";
import { CredentialsMetaResponse } from "@/app/api/__generated__/models/credentialsMetaResponse";
import { KeyIcon } from "@phosphor-icons/react";
type SelectCredentialProps = {
credentials: CredentialsMetaResponse[];
value?: string;
onChange: (credentialId: string) => void;
disabled?: boolean;
label?: string;
placeholder?: string;
};
export const SelectCredential: React.FC<SelectCredentialProps> = ({
credentials,
value,
onChange,
disabled = false,
label = "Credential",
placeholder = "Select credential",
}) => {
const options = credentials.map((cred) => {
const details: string[] = [];
if (cred.title && cred.title !== cred.provider) {
details.push(cred.title);
}
if (cred.username) {
details.push(cred.username);
}
if (cred.host) {
details.push(cred.host);
}
const label =
details.length > 0
? `${cred.provider} (${details.join(" - ")})`
: cred.provider;
return {
value: cred.id,
label,
icon: <KeyIcon className="h-4 w-4" />,
};
});
return (
<Select
label={label}
id="select-credential"
wrapperClassName="!mb-0"
value={value}
onValueChange={onChange}
options={options}
disabled={disabled}
placeholder={placeholder}
size="small"
hideLabel
/>
);
};

View File

@@ -0,0 +1,47 @@
import { CredentialsMetaResponse } from "@/app/api/__generated__/models/credentialsMetaResponse";
export const filterCredentialsByProvider = (
credentials: CredentialsMetaResponse[] | undefined,
provider: string[],
) => {
const filtered =
credentials?.filter((credential) =>
provider.includes(credential.provider),
) ?? [];
return {
credentials: filtered,
exists: filtered.length > 0,
};
};
export function toDisplayName(provider: string): string {
console.log("provider", provider);
// Special cases that need manual handling
const specialCases: Record<string, string> = {
aiml_api: "AI/ML",
d_id: "D-ID",
e2b: "E2B",
llama_api: "Llama API",
open_router: "Open Router",
smtp: "SMTP",
revid: "Rev.ID",
};
if (specialCases[provider]) {
return specialCases[provider];
}
// General case: convert snake_case to Title Case
return provider
.split(/[_-]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
}
export function isCredentialFieldSchema(schema: any): boolean {
return (
typeof schema === "object" &&
schema !== null &&
"credentials_provider" in schema
);
}

View File

@@ -0,0 +1,21 @@
import { useGetV1ListCredentials } from "@/app/api/__generated__/endpoints/integrations/integrations";
import { CredentialsMetaResponse } from "@/app/api/__generated__/models/credentialsMetaResponse";
export const useCredentialField = () => {
// Fetch all the credentials from the backend
// We will save it in cache for 10 min, if user edits the credential, we will invalidate the cache
// Whenever user adds a block, we filter the credentials list and check if this block's provider is in the list
const { data: credentials, isLoading: isCredentialListLoading } =
useGetV1ListCredentials({
query: {
refetchInterval: 10 * 60 * 1000,
select: (x) => {
return x.data as CredentialsMetaResponse[];
},
},
});
return {
credentials,
isCredentialListLoading,
};
};

View File

@@ -1,5 +1,5 @@
import { RegistryFieldsType } from "@rjsf/utils";
import { CredentialsField } from "./CredentialField";
import { CredentialsField } from "./CredentialField/CredentialField";
import { AnyOfField } from "./AnyOfField/AnyOfField";
import { ObjectField } from "./ObjectField";

View File

@@ -15,6 +15,11 @@ import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { generateHandleId } from "../../handlers/helpers";
import { getTypeDisplayInfo } from "../helpers";
import { ArrayEditorContext } from "../../components/ArrayEditor/ArrayEditorContext";
import {
isCredentialFieldSchema,
toDisplayName,
} from "../fields/CredentialField/helpers";
import { cn } from "@/lib/utils";
const FieldTemplate: React.FC<FieldTemplateProps> = ({
id,
@@ -47,6 +52,7 @@ const FieldTemplate: React.FC<FieldTemplateProps> = ({
}
const isAnyOf = Array.isArray((schema as any)?.anyOf);
const isOneOf = Array.isArray((schema as any)?.oneOf);
const isCredential = isCredentialFieldSchema(schema);
const suppressHandle = isAnyOf || isOneOf;
if (!showAdvanced && schema.advanced === true && !isConnected) {
@@ -63,12 +69,17 @@ const FieldTemplate: React.FC<FieldTemplateProps> = ({
<div className="mt-4 w-[400px] space-y-1">
{label && schema.type && (
<label htmlFor={id} className="flex items-center gap-1">
{!suppressHandle && !fromAnyOf && (
{!suppressHandle && !fromAnyOf && !isCredential && (
<NodeHandle id={fieldKey} isConnected={isConnected} side="left" />
)}
{!fromAnyOf && (
<Text variant="body" className="line-clamp-1">
{label}
<Text
variant="body"
className={cn("line-clamp-1", isCredential && "ml-3")}
>
{isCredential
? toDisplayName(schema.credentials_provider[0]) + " credentials"
: label}
</Text>
)}
{!fromAnyOf && (

View File

@@ -112,7 +112,7 @@
"get": {
"tags": ["v1", "integrations"],
"summary": "List Credentials",
"operationId": "getV1ListCredentials",
"operationId": "getV1List credentials",
"responses": {
"200": {
"description": "Successful Response",
@@ -123,7 +123,7 @@
"$ref": "#/components/schemas/CredentialsMetaResponse"
},
"type": "array",
"title": "Response Getv1Listcredentials"
"title": "Response Getv1List Credentials"
}
}
}
@@ -268,8 +268,8 @@
"/api/integrations/{provider}/credentials/{cred_id}": {
"get": {
"tags": ["v1", "integrations"],
"summary": "Get Credential",
"operationId": "getV1GetCredential",
"summary": "Get Specific Credential By ID",
"operationId": "getV1Get specific credential by id",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
@@ -315,7 +315,7 @@
"host_scoped": "#/components/schemas/HostScopedCredentials-Output"
}
},
"title": "Response Getv1Getcredential"
"title": "Response Getv1Get Specific Credential By Id"
}
}
}