feat(frontend): add host-scoped credentials support to CredentialField (#11546)

### Changes 🏗️

This PR adds support for `host_scoped` credential type in the new
builder's `CredentialField` component. This enables blocks that require
sensitive headers for custom API endpoints to configure host-scoped
credentials directly from the credential field.
<img width="745" height="843" alt="Screenshot 2025-12-04 at 4 31 09 PM"
src="https://github.com/user-attachments/assets/d076b797-64c4-4c31-9c88-47a064814055"
/>

<img width="418" height="180" alt="Screenshot 2025-12-04 at 4 36 02 PM"
src="https://github.com/user-attachments/assets/b4fa6d8d-d8f4-41ff-ab11-7c708017f8fd"
/>

**Key changes:**

- **Added `HostScopedCredentialsModal` component**
(`models/HostScopedCredentialsModal/`)
- Modal dialog for creating host-scoped credentials with host pattern,
optional title, and dynamic header pairs (key-value)
- Auto-populates host from discriminator value (URL field) when
available
  - Supports adding/removing multiple header pairs with validation

- **Enhanced credential filtering logic** (`helpers.ts`)
- Updated `filterCredentialsByProvider` to accept `schema` and
`discriminatorValue` parameters
  - Added intelligent filtering for:
    - Credential types supported by the block
    - OAuth credentials with sufficient scopes
    - Host-scoped credentials matched by host from discriminator value
  - Extracted `getDiscriminatorValue` helper function for reusability

- **Updated `CredentialField` component**
  - Added `supportsHostScoped` check in `useCredentialField` hook
- Conditionally renders `HostScopedCredentialsModal` when
`supportsHostScoped && discriminatorValue` is true
  - Exports `discriminatorValue` for use in child components

- **Updated `useCredentialField` hook**
- Calculates `discriminatorValue` using new `getDiscriminatorValue`
helper
- Passes `schema` and `discriminatorValue` to enhanced
`filterCredentialsByProvider` function
- Returns `supportsHostScoped` and `discriminatorValue` for component
consumption

**Technical details:**
- Host extraction uses `getHostFromUrl` utility to parse host from
discriminator value (URL)
- Header pairs are managed as state with add/remove functionality
- Form validation uses `react-hook-form` with `zod` schema
- Credential creation integrates with existing API endpoints and query
invalidation

### Checklist 📋

- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Verify `HostScopedCredentialsModal` appears when block supports
`host_scoped` credentials and discriminator value is present
  - [x] Test host auto-population from discriminator value (URL field)
  - [x] Test manual host entry when discriminator value is not available
  - [x] Test adding/removing multiple header pairs
- [x] Test form validation (host required, empty header pairs filtered
out)
  - [x] Test credential creation and successful toast notification
  - [x] Verify credentials list refreshes after creation
- [x] Test host-scoped credential filtering matches credentials by host
from URL
- [x] Verify existing credential types (api_key, oauth2, user_password)
still work correctly
  - [x] Test OAuth scope filtering still works as expected
- [x] Verify modal only shows when `supportsHostScoped &&
discriminatorValue` conditions are met
This commit is contained in:
Abhimanyu Yadav
2025-12-04 20:43:23 +05:30
committed by GitHub
parent 2b9816cfa5
commit 3ccc712463
5 changed files with 430 additions and 8 deletions

View File

@@ -7,6 +7,7 @@ import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api";
import { APIKeyCredentialsModal } from "./models/APIKeyCredentialModal/APIKeyCredentialModal";
import { OAuthCredentialModal } from "./models/OAuthCredentialModal/OAuthCredentialModal";
import { PasswordCredentialsModal } from "./models/PasswordCredentialModal/PasswordCredentialModal";
import { HostScopedCredentialsModal } from "./models/HostScopedCredentialsModal/HostScopedCredentialsModal";
export const CredentialsField = (props: FieldProps) => {
const {
@@ -22,9 +23,11 @@ export const CredentialsField = (props: FieldProps) => {
supportsApiKey,
supportsOAuth2,
supportsUserPassword,
supportsHostScoped,
credentialsExists,
credentialProvider,
setCredential,
discriminatorValue,
} = useCredentialField({
credentialSchema: schema as BlockIOCredentialsSubSchema,
formData,
@@ -71,6 +74,13 @@ export const CredentialsField = (props: FieldProps) => {
{supportsUserPassword && (
<PasswordCredentialsModal provider={credentialProvider} />
)}
{supportsHostScoped && discriminatorValue && (
<HostScopedCredentialsModal
schema={schema as BlockIOCredentialsSubSchema}
provider={credentialProvider}
discriminatorValue={discriminatorValue}
/>
)}
</div>
</div>
);

View File

@@ -1,5 +1,6 @@
import { CredentialsMetaResponse } from "@/app/api/__generated__/models/credentialsMetaResponse";
import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api";
import { getHostFromUrl } from "@/lib/utils/url";
import {
GoogleLogoIcon,
KeyholeIcon,
@@ -14,9 +15,44 @@ import {
export const filterCredentialsByProvider = (
credentials: CredentialsMetaResponse[] | undefined,
provider: string,
schema?: BlockIOCredentialsSubSchema,
discriminatorValue?: string,
) => {
const filtered =
credentials?.filter((credential) => provider === credential.provider) ?? [];
credentials?.filter((credential) => {
// First filter by provider
if (provider !== credential.provider) {
return false;
}
// Check if credential type is supported by this block
if (schema && !schema.credentials_types.includes(credential.type)) {
return false;
}
// Filter OAuth credentials that have sufficient scopes for this block
if (credential.type === "oauth2" && schema?.credentials_scopes) {
const credentialScopes = new Set(credential.scopes || []);
const requiredScopes = new Set(schema.credentials_scopes);
const hasAllScopes = [...requiredScopes].every((scope) =>
credentialScopes.has(scope),
);
if (!hasAllScopes) {
return false;
}
}
// Filter host_scoped credentials by host matching
if (credential.type === "host_scoped") {
if (!discriminatorValue) {
return false;
}
const hostFromUrl = getHostFromUrl(discriminatorValue);
return hostFromUrl === credential.host;
}
return true;
}) ?? [];
return {
credentials: filtered,
exists: filtered.length > 0,
@@ -96,22 +132,31 @@ export const providerIcons: Partial<Record<string, Icon>> = {
zerobounce: KeyholeIcon,
};
export const getDiscriminatorValue = (
formData: Record<string, any>,
schema: BlockIOCredentialsSubSchema,
): string | undefined => {
const discriminator = schema.discriminator;
const discriminatorValues = schema.discriminator_values;
return [
discriminator ? formData[discriminator] : null,
...(discriminatorValues || []),
].find(Boolean);
};
export const getCredentialProviderFromSchema = (
formData: Record<string, any>,
schema: BlockIOCredentialsSubSchema,
) => {
const discriminator = schema.discriminator;
const discriminatorMapping = schema.discriminator_mapping;
const discriminatorValues = schema.discriminator_values;
const providers = schema.credentials_provider;
const discriminatorValue = [
discriminator ? formData[discriminator] : null,
...(discriminatorValues || []),
].find(Boolean);
const discriminatorValue = getDiscriminatorValue(formData, schema);
const discriminatedProvider = discriminatorMapping
? discriminatorMapping[discriminatorValue]
? discriminatorMapping[discriminatorValue ?? ""]
: null;
if (providers.length > 1) {

View File

@@ -0,0 +1,185 @@
import { Input } from "@/components/atoms/Input/Input";
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
Form,
FormDescription,
FormField,
FormLabel,
} from "@/components/__legacy__/ui/form";
import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api/types";
import { useHostScopedCredentialsModal } from "./useHostScopedCredentialsModal";
import { toDisplayName } from "../../helpers";
import { GlobeIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react";
import { Text } from "@/components/atoms/Text/Text";
type Props = {
schema: BlockIOCredentialsSubSchema;
provider: string;
discriminatorValue?: string;
};
export function HostScopedCredentialsModal({
schema,
provider,
discriminatorValue,
}: Props) {
const {
form,
schemaDescription,
onSubmit,
isOpen,
setIsOpen,
headerPairs,
addHeaderPair,
removeHeaderPair,
updateHeaderPair,
currentHost,
} = useHostScopedCredentialsModal({ schema, provider, discriminatorValue });
return (
<>
<Dialog
title={`Add sensitive headers for ${toDisplayName(provider) ?? ""}`}
controlled={{
isOpen: isOpen,
set: (isOpen) => {
if (!isOpen) setIsOpen(false);
},
}}
onClose={() => setIsOpen(false)}
styling={{
maxWidth: "38rem",
}}
>
<Dialog.Content>
<div className="px-1">
{schemaDescription && (
<p className="mb-4 text-sm text-zinc-600">{schemaDescription}</p>
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="host"
render={({ field }) => (
<Input
id="host"
label="Host Pattern"
type="text"
size="small"
readOnly={!!currentHost}
hint={
currentHost
? "Auto-populated from the URL field. Headers will be applied to requests to this host."
: "Enter the host/domain to match against request URLs (e.g., api.example.com)."
}
placeholder={
currentHost
? undefined
: "Enter host (e.g., api.example.com)"
}
{...field}
/>
)}
/>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<Input
id="title"
label="Name (optional)"
type="text"
placeholder="Enter a name for these credentials..."
size="small"
{...field}
/>
)}
/>
<div className="space-y-2">
<FormLabel>Headers</FormLabel>
<FormDescription className="max-w-md">
Add sensitive headers (like Authorization, X-API-Key) that
should be automatically included in requests to the
specified host.
</FormDescription>
{headerPairs.map((pair, index) => (
<div key={index} className="flex w-full items-center gap-2">
<Input
id={`header-${index}-key`}
label="Header Name"
placeholder="e.g., Authorization"
size="small"
value={pair.key}
className="flex-1"
onChange={(e) =>
updateHeaderPair(index, "key", e.target.value)
}
/>
<Input
id={`header-${index}-value`}
label="Header Value"
size="small"
type="password"
className="flex-1"
placeholder="e.g., Bearer token123"
value={pair.value}
onChange={(e) =>
updateHeaderPair(index, "value", e.target.value)
}
/>
<Button
type="button"
variant="secondary"
size="small"
onClick={() => removeHeaderPair(index)}
disabled={headerPairs.length === 1}
className="min-w-0"
>
<TrashIcon className="size-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="small"
onClick={addHeaderPair}
>
<PlusIcon className="size-4" /> Add Another Header
</Button>
</div>
<Button type="submit" size="small" className="min-w-68">
Save & use these credentials
</Button>
</form>
</Form>
</div>
</Dialog.Content>
</Dialog>
<Button
type="button"
className="w-fit px-2"
size="small"
onClick={() => setIsOpen(true)}
>
<GlobeIcon />
<Text variant="small" className="truncate !text-white opacity-100">
Add sensitive headers for{" "}
{toDisplayName(discriminatorValue || provider) ?? ""}
</Text>
</Button>
</>
);
}

View File

@@ -0,0 +1,167 @@
import { z } from "zod";
import { useForm, type UseFormReturn } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api/types";
import {
getGetV1ListCredentialsQueryKey,
usePostV1CreateCredentials,
} from "@/app/api/__generated__/endpoints/integrations/integrations";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { HostScopedCredentialsInput } from "@/app/api/__generated__/models/hostScopedCredentialsInput";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { getHostFromUrl } from "@/lib/utils/url";
export type HeaderPair = {
key: string;
value: string;
};
export type HostScopedFormValues = {
host: string;
title?: string;
};
type UseHostScopedCredentialsModalType = {
schema: BlockIOCredentialsSubSchema;
provider: string;
discriminatorValue?: string;
};
export function useHostScopedCredentialsModal({
schema,
provider,
discriminatorValue,
}: UseHostScopedCredentialsModalType): {
form: UseFormReturn<HostScopedFormValues>;
schemaDescription?: string;
onSubmit: (values: HostScopedFormValues) => Promise<void>;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
headerPairs: HeaderPair[];
addHeaderPair: () => void;
removeHeaderPair: (index: number) => void;
updateHeaderPair: (
index: number,
field: "key" | "value",
value: string,
) => void;
currentHost: string | null;
} {
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const [headerPairs, setHeaderPairs] = useState<HeaderPair[]>([
{ key: "", value: "" },
]);
const queryClient = useQueryClient();
// Get current host from discriminatorValue (URL field)
const currentHost = discriminatorValue
? getHostFromUrl(discriminatorValue)
: null;
const { mutateAsync: createCredentials } = usePostV1CreateCredentials({
mutation: {
onSuccess: async () => {
form.reset();
setHeaderPairs([{ key: "", value: "" }]);
setIsOpen(false);
toast({
title: "Success",
description: "Host-scoped credentials created successfully",
variant: "default",
});
await queryClient.refetchQueries({
queryKey: getGetV1ListCredentialsQueryKey(),
});
},
onError: () => {
toast({
title: "Error",
description: "Failed to create host-scoped credentials.",
variant: "destructive",
});
},
},
});
const formSchema = z.object({
host: z.string().min(1, "Host is required"),
title: z.string().optional().default(""),
});
const form = useForm<HostScopedFormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
host: currentHost || "",
title: currentHost || "Manual Entry",
},
});
// Update form values when modal opens and discriminatorValue changes
const handleSetIsOpen = (open: boolean) => {
if (open && currentHost) {
form.setValue("host", currentHost);
form.setValue("title", currentHost);
}
setIsOpen(open);
};
const addHeaderPair = () => {
setHeaderPairs([...headerPairs, { key: "", value: "" }]);
};
const removeHeaderPair = (index: number) => {
if (headerPairs.length > 1) {
setHeaderPairs(headerPairs.filter((_, i) => i !== index));
}
};
const updateHeaderPair = (
index: number,
field: "key" | "value",
value: string,
) => {
const newPairs = [...headerPairs];
newPairs[index][field] = value;
setHeaderPairs(newPairs);
};
async function onSubmit(values: HostScopedFormValues) {
// Convert header pairs to object, filtering out empty pairs
const headers = headerPairs.reduce(
(acc, pair) => {
if (pair.key.trim() && pair.value.trim()) {
acc[pair.key.trim()] = pair.value.trim();
}
return acc;
},
{} as Record<string, string>,
);
createCredentials({
provider: provider,
data: {
provider: provider,
type: "host_scoped",
host: values.host,
title: values.title || values.host,
headers: headers,
} as HostScopedCredentialsInput,
});
}
return {
form,
schemaDescription: schema.description,
onSubmit,
isOpen,
setIsOpen: handleSetIsOpen,
headerPairs,
addHeaderPair,
removeHeaderPair,
updateHeaderPair,
currentHost,
};
}

View File

@@ -4,6 +4,7 @@ import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api";
import {
filterCredentialsByProvider,
getCredentialProviderFromSchema,
getDiscriminatorValue,
} from "./helpers";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { useEffect, useRef } from "react";
@@ -44,13 +45,25 @@ export const useCredentialField = ({
credentialSchema,
);
const discriminatorValue = getDiscriminatorValue(
hardcodedValues,
credentialSchema,
);
const supportsApiKey = credentialSchema.credentials_types.includes("api_key");
const supportsOAuth2 = credentialSchema.credentials_types.includes("oauth2");
const supportsUserPassword =
credentialSchema.credentials_types.includes("user_password");
const supportsHostScoped =
credentialSchema.credentials_types.includes("host_scoped");
const { credentials: filteredCredentials, exists: credentialsExists } =
filterCredentialsByProvider(credentials, credentialProvider ?? "");
filterCredentialsByProvider(
credentials,
credentialProvider ?? "",
credentialSchema,
discriminatorValue,
);
const setCredential = (credentialId: string) => {
const selectedCredential = filteredCredentials.find(
@@ -120,7 +133,9 @@ export const useCredentialField = ({
supportsApiKey,
supportsOAuth2,
supportsUserPassword,
supportsHostScoped,
credentialsExists,
credentialProvider,
discriminatorValue,
};
};