mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-06 22:03:59 -05:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user