feat(frontend): add oauth2 credential support in new builder (#11107)

In this PR, I have added support of oAuth2 in new builder.


https://github.com/user-attachments/assets/89472ebb-8ec2-467a-9824-79a80a71af8a

### Changes 🏗️
- Updated the FlowEditor to support OAuth2 credential selection.
- Improved the UI for API key and OAuth2 modals, enhancing user
experience.
- Refactored credential field components for better modularity and
maintainability.
- Updated OpenAPI documentation to reflect changes in OAuth flow
endpoints.

### 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] Able to create OAuth credentials
  - [x] OAuth2 is correctly selected using the Credential Selector.
This commit is contained in:
Abhimanyu Yadav
2025-10-09 12:17:15 +05:30
committed by GitHub
parent 59c27fe248
commit 7982c34450
10 changed files with 454 additions and 185 deletions

View File

@@ -64,7 +64,7 @@ class LoginResponse(BaseModel):
state_token: str
@router.get("/{provider}/login")
@router.get("/{provider}/login", summary="Initiate OAuth flow")
async def login(
provider: Annotated[
ProviderName, Path(title="The provider to initiate an OAuth flow for")
@@ -102,7 +102,7 @@ class CredentialsMetaResponse(BaseModel):
)
@router.post("/{provider}/callback")
@router.post("/{provider}/callback", summary="Exchange OAuth code for tokens")
async def callback(
provider: Annotated[
ProviderName, Path(title="The target provider for this OAuth exchange")

View File

@@ -1,13 +1,11 @@
import React, { useEffect } from "react";
import { FieldProps } from "@rjsf/utils";
import { useCredentialField } from "./useCredentialField";
import { KeyIcon, PlusIcon } from "@phosphor-icons/react";
import { Button } from "@/components/atoms/Button/Button";
import { SelectCredential } from "./SelectCredential";
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api";
import { APIKeyCredentialsModal } from "./models/APIKeyCredentialModal/APIKeyCredentialModal";
import { Text } from "@/components/atoms/Text/Text";
import { OAuthCredentialModal } from "./models/OAuthCredentialModal/OAuthCredentialModal";
export const CredentialsField = (props: FieldProps) => {
const { formData = {}, onChange, required: _required, schema } = props;
@@ -16,8 +14,6 @@ export const CredentialsField = (props: FieldProps) => {
isCredentialListLoading,
supportsApiKey,
supportsOAuth2,
isAPIKeyModalOpen,
setIsAPIKeyModalOpen,
credentialsExists,
} = useCredentialField({
credentialSchema: schema as BlockIOCredentialsSubSchema,
@@ -61,31 +57,13 @@ export const CredentialsField = (props: FieldProps) => {
<div>
{supportsApiKey && (
<>
<APIKeyCredentialsModal
schema={schema as BlockIOCredentialsSubSchema}
open={isAPIKeyModalOpen}
onClose={() => setIsAPIKeyModalOpen(false)}
onSuccess={handleCredentialCreated}
/>
<Button
type="button"
className="w-auto min-w-0"
size="small"
onClick={() => setIsAPIKeyModalOpen(true)}
>
<KeyIcon />
<Text variant="body-medium" className="!text-white opacity-100">
Add API key
</Text>
</Button>
</>
<APIKeyCredentialsModal
schema={schema as BlockIOCredentialsSubSchema}
onSuccess={handleCredentialCreated}
/>
)}
{supportsOAuth2 && (
<Button type="button" className="w-fit" size="small">
<PlusIcon />
Add OAuth2
</Button>
<OAuthCredentialModal provider={schema.credentials_provider[0]} />
)}
</div>
</div>

View File

@@ -1,9 +1,14 @@
import React from "react";
import { Select } from "@/components/atoms/Select/Select";
import { CredentialsMetaResponse } from "@/app/api/__generated__/models/credentialsMetaResponse";
import { ArrowSquareOutIcon, KeyIcon } from "@phosphor-icons/react";
import {
ArrowSquareOutIcon,
KeyholeIcon,
KeyIcon,
} from "@phosphor-icons/react";
import { Button } from "@/components/atoms/Button/Button";
import Link from "next/link";
import { providerIcons } from "./helpers";
type SelectCredentialProps = {
credentials: CredentialsMetaResponse[];
@@ -38,10 +43,22 @@ export const SelectCredential: React.FC<SelectCredentialProps> = ({
? `${cred.provider} (${details.join(" - ")})`
: cred.provider;
const Icon = providerIcons[cred.provider];
const icon =
cred.type === "oauth2" ? (
Icon ? (
<Icon />
) : (
<KeyholeIcon />
)
) : (
<KeyIcon className="h-4 w-4" />
);
return {
value: cred.id,
label,
icon: <KeyIcon className="h-4 w-4" />,
icon,
};
});

View File

@@ -1,14 +1,14 @@
import { CredentialsMetaResponse } from "@/app/api/__generated__/models/credentialsMetaResponse";
// Need to replace these icons with phosphor icons
import {
FaDiscord,
FaMedium,
FaGithub,
FaGoogle,
FaHubspot,
FaTwitter,
} from "react-icons/fa";
import { GoogleLogoIcon, KeyIcon, NotionLogoIcon } from "@phosphor-icons/react";
GoogleLogoIcon,
KeyholeIcon,
NotionLogoIcon,
DiscordLogoIcon,
MediumLogoIcon,
GithubLogoIcon,
TwitterLogoIcon,
Icon,
} from "@phosphor-icons/react";
export const filterCredentialsByProvider = (
credentials: CredentialsMetaResponse[] | undefined,
@@ -56,46 +56,44 @@ export function isCredentialFieldSchema(schema: any): boolean {
);
}
export const providerIcons: Partial<
Record<string, React.FC<{ className?: string }>>
> = {
aiml_api: KeyIcon,
anthropic: KeyIcon,
apollo: KeyIcon,
e2b: KeyIcon,
github: FaGithub,
export const providerIcons: Partial<Record<string, Icon>> = {
aiml_api: KeyholeIcon,
anthropic: KeyholeIcon,
apollo: KeyholeIcon,
e2b: KeyholeIcon,
github: GithubLogoIcon,
google: GoogleLogoIcon,
groq: KeyIcon,
http: KeyIcon,
groq: KeyholeIcon,
http: KeyholeIcon,
notion: NotionLogoIcon,
nvidia: KeyIcon,
discord: FaDiscord,
d_id: KeyIcon,
google_maps: FaGoogle,
jina: KeyIcon,
ideogram: KeyIcon,
linear: KeyIcon,
medium: FaMedium,
mem0: KeyIcon,
ollama: KeyIcon,
openai: KeyIcon,
openweathermap: KeyIcon,
open_router: KeyIcon,
llama_api: KeyIcon,
pinecone: KeyIcon,
enrichlayer: KeyIcon,
slant3d: KeyIcon,
screenshotone: KeyIcon,
smtp: KeyIcon,
replicate: KeyIcon,
reddit: KeyIcon,
fal: KeyIcon,
revid: KeyIcon,
twitter: FaTwitter,
unreal_speech: KeyIcon,
exa: KeyIcon,
hubspot: FaHubspot,
smartlead: KeyIcon,
todoist: KeyIcon,
zerobounce: KeyIcon,
nvidia: KeyholeIcon,
discord: DiscordLogoIcon,
d_id: KeyholeIcon,
google_maps: GoogleLogoIcon,
jina: KeyholeIcon,
ideogram: KeyholeIcon,
linear: KeyholeIcon,
medium: MediumLogoIcon,
mem0: KeyholeIcon,
ollama: KeyholeIcon,
openai: KeyholeIcon,
openweathermap: KeyholeIcon,
open_router: KeyholeIcon,
llama_api: KeyholeIcon,
pinecone: KeyholeIcon,
enrichlayer: KeyholeIcon,
slant3d: KeyholeIcon,
screenshotone: KeyholeIcon,
smtp: KeyholeIcon,
replicate: KeyholeIcon,
reddit: KeyholeIcon,
fal: KeyholeIcon,
revid: KeyholeIcon,
twitter: TwitterLogoIcon,
unreal_speech: KeyholeIcon,
exa: KeyholeIcon,
hubspot: KeyholeIcon,
smartlead: KeyholeIcon,
todoist: KeyholeIcon,
zerobounce: KeyholeIcon,
};

View File

@@ -9,111 +9,127 @@ import {
import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api/types"; // we need to find a way to replace it with autogenerated types
import { useAPIKeyCredentialsModal } from "./useAPIKeyCredentialsModal";
import { toDisplayName } from "../../helpers";
import { KeyIcon } from "@phosphor-icons/react";
import { Text } from "@/components/atoms/Text/Text";
type Props = {
schema: BlockIOCredentialsSubSchema;
open: boolean;
onClose: () => void;
onSuccess: (credentialId: string) => void;
};
export function APIKeyCredentialsModal({
schema,
open,
onClose,
onSuccess,
}: Props) {
const { form, isLoading, schemaDescription, onSubmit, provider } =
useAPIKeyCredentialsModal({ schema, onClose, onSuccess });
export function APIKeyCredentialsModal({ schema, onSuccess }: Props) {
const {
form,
isLoading,
schemaDescription,
onSubmit,
provider,
isOpen,
setIsOpen,
} = useAPIKeyCredentialsModal({ schema, onSuccess });
if (isLoading) {
return null;
}
return (
<Dialog
title={`Add new API key for ${toDisplayName(provider) ?? ""}`}
controlled={{
isOpen: open,
set: (isOpen) => {
if (!isOpen) onClose();
},
}}
onClose={onClose}
styling={{
maxWidth: "25rem",
}}
>
<Dialog.Content>
{schemaDescription && (
<p className="mb-4 text-sm text-zinc-600">{schemaDescription}</p>
)}
<>
<Dialog
title={`Add new API key for ${toDisplayName(provider) ?? ""}`}
controlled={{
isOpen: isOpen,
set: (isOpen) => {
if (!isOpen) setIsOpen(false);
},
}}
onClose={() => setIsOpen(false)}
styling={{
maxWidth: "25rem",
}}
>
<Dialog.Content>
{schemaDescription && (
<p className="mb-4 text-sm text-zinc-600">{schemaDescription}</p>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<>
<Input
id="apiKey"
label="API Key"
type="password"
placeholder="Enter API key..."
size="small"
hint={
schema.credentials_scopes ? (
<FormDescription>
Required scope(s) for this block:{" "}
{schema.credentials_scopes?.map((s, i, a) => (
<span key={i}>
<code className="text-xs font-bold">{s}</code>
{i < a.length - 1 && ", "}
</span>
))}
</FormDescription>
) : null
}
{...field}
/>
</>
)}
/>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<Input
id="apiKey"
label="API Key"
type="password"
placeholder="Enter API key..."
id="title"
label="Name"
type="text"
placeholder="Enter a name for this API key..."
size="small"
hint={
schema.credentials_scopes ? (
<FormDescription>
Required scope(s) for this block:{" "}
{schema.credentials_scopes?.map((s, i, a) => (
<span key={i}>
<code className="text-xs font-bold">{s}</code>
{i < a.length - 1 && ", "}
</span>
))}
</FormDescription>
) : null
}
{...field}
/>
</>
)}
/>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<Input
id="title"
label="Name"
type="text"
placeholder="Enter a name for this API key..."
size="small"
{...field}
/>
)}
/>
<FormField
control={form.control}
name="expiresAt"
render={({ field }) => (
<Input
id="expiresAt"
label="Expiration Date"
type="datetime-local"
placeholder="Select expiration date..."
size="small"
{...field}
/>
)}
/>
<Button type="submit" size="small" className="min-w-68">
Save & use this API key
</Button>
</form>
</Form>
</Dialog.Content>
</Dialog>
)}
/>
<FormField
control={form.control}
name="expiresAt"
render={({ field }) => (
<Input
id="expiresAt"
label="Expiration Date"
type="datetime-local"
placeholder="Select expiration date..."
size="small"
{...field}
/>
)}
/>
<Button type="submit" size="small" className="min-w-68">
Save & use this API key
</Button>
</form>
</Form>
</Dialog.Content>
</Dialog>
<Button
type="button"
className="w-auto min-w-0"
size="small"
onClick={() => setIsOpen(true)}
>
<KeyIcon />
<Text variant="body-medium" className="!text-white opacity-100">
Add API key
</Text>
</Button>
</>
);
}

View File

@@ -10,6 +10,7 @@ import { useToast } from "@/components/molecules/Toast/use-toast";
import { APIKeyCredentials } from "@/app/api/__generated__/models/aPIKeyCredentials";
import { useQueryClient } from "@tanstack/react-query";
import { PostV1CreateCredentials201 } from "@/app/api/__generated__/models/postV1CreateCredentials201";
import { useState } from "react";
export type APIKeyFormValues = {
apiKey: string;
@@ -19,13 +20,11 @@ export type APIKeyFormValues = {
type useAPIKeyCredentialsModalType = {
schema: BlockIOCredentialsSubSchema;
onClose: () => void;
onSuccess: (credentialId: string) => void;
};
export function useAPIKeyCredentialsModal({
schema,
onClose,
onSuccess,
}: useAPIKeyCredentialsModalType): {
form: UseFormReturn<APIKeyFormValues>;
@@ -33,8 +32,11 @@ export function useAPIKeyCredentialsModal({
provider: string;
schemaDescription?: string;
onSubmit: (values: APIKeyFormValues) => Promise<void>;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
} {
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const queryClient = useQueryClient();
const { mutateAsync: createCredentials, isPending: isCreatingCredentials } =
@@ -43,8 +45,8 @@ export function useAPIKeyCredentialsModal({
onSuccess: async (response) => {
const credentialId = (response.data as PostV1CreateCredentials201)
?.id;
onClose();
form.reset();
setIsOpen(false);
toast({
title: "Success",
description: "Credentials created successfully",
@@ -107,5 +109,7 @@ export function useAPIKeyCredentialsModal({
provider: schema.credentials_provider[0],
schemaDescription: schema.description,
onSubmit,
isOpen,
setIsOpen,
};
}

View File

@@ -0,0 +1,61 @@
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { providerIcons, toDisplayName } from "../../helpers";
import { useOAuthCredentialModal } from "./useOAuthCredentialModal";
import { Text } from "@/components/atoms/Text/Text";
type OAuthCredentialModalProps = {
provider: string;
};
export const OAuthCredentialModal = ({
provider,
}: OAuthCredentialModalProps) => {
const Icon = providerIcons[provider];
const { handleOAuthLogin, loading, error, onClose, open, setOpen } =
useOAuthCredentialModal({
provider,
});
return (
<>
<Dialog
title={`Waiting on ${toDisplayName(provider)} sign-in process...`}
controlled={{
isOpen: open,
set: (isOpen) => {
if (!isOpen) setOpen(false);
},
}}
onClose={onClose}
>
<Dialog.Content>
<p className="text-sm text-zinc-600">
Complete the sign-in process in the pop-up window.
<br />
Closing this dialog will cancel the sign-in process.
</p>
</Dialog.Content>
</Dialog>
<Button
type="button"
className="w-fit"
size="small"
onClick={() => {
handleOAuthLogin();
}}
disabled={loading}
>
{Icon && <Icon className="size-4" />}
Add OAuth2
</Button>
{error && (
<div className="mt-2 flex w-fit items-center rounded-full bg-red-50 p-1 px-3 ring-1 ring-red-600">
<Text variant="small" className="!text-red-600">
{error as string}
</Text>
</div>
)}
</>
);
};

View File

@@ -0,0 +1,198 @@
import {
getGetV1ListCredentialsQueryKey,
useGetV1InitiateOauthFlow,
usePostV1ExchangeOauthCodeForTokens,
} from "@/app/api/__generated__/endpoints/integrations/integrations";
import { LoginResponse } from "@/app/api/__generated__/models/loginResponse";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
type useOAuthCredentialModalProps = {
provider: string;
scopes?: string[];
};
export type OAuthPopupResultMessage = { message_type: "oauth_popup_result" } & (
| {
success: true;
code: string;
state: string;
}
| {
success: false;
message: string;
}
);
export const useOAuthCredentialModal = ({
provider,
scopes,
}: useOAuthCredentialModalProps) => {
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [oAuthPopupController, setOAuthPopupController] =
useState<AbortController | null>(null);
const [oAuthError, setOAuthError] = useState<string | null>(null);
const [isOAuth2FlowInProgress, setOAuth2FlowInProgress] = useState(false);
const queryClient = useQueryClient();
const {
refetch: initiateOauthFlow,
isRefetching: isInitiatingOauthFlow,
isRefetchError: initiatingOauthFlowError,
} = useGetV1InitiateOauthFlow(
provider,
{
scopes: scopes?.join(","),
},
{
query: {
enabled: false,
select: (res) => {
return res.data as LoginResponse;
},
},
},
);
const {
mutateAsync: oAuthCallback,
isPending: isOAuthCallbackPending,
error: oAuthCallbackError,
} = usePostV1ExchangeOauthCodeForTokens({
mutation: {
onSuccess: (data) => {
console.log("OAuth callback successful", data);
queryClient.invalidateQueries({
queryKey: getGetV1ListCredentialsQueryKey(),
});
setOpen(false);
toast({
title: "Success",
description: "Credential added successfully",
variant: "default",
});
},
},
});
const handleOAuthLogin = async () => {
const { data } = await initiateOauthFlow();
if (!data || !data.login_url || !data.state_token) {
toast({
title: "Failed to initiate OAuth flow",
variant: "destructive",
});
setOAuthError(
data && typeof data === "object" && "detail" in data
? (data.detail as string)
: "Failed to initiate OAuth flow",
);
return;
}
setOpen(true);
setOAuth2FlowInProgress(true);
const { login_url, state_token } = data;
const popup = window.open(login_url, "_blank", "popup=true");
if (!popup) {
throw new Error(
"Failed to open popup window. Please allow popups for this site.",
);
}
const controller = new AbortController();
setOAuthPopupController(controller);
controller.signal.onabort = () => {
console.debug("OAuth flow aborted");
popup.close();
};
const handleMessage = async (e: MessageEvent<OAuthPopupResultMessage>) => {
console.log("inside handleMessage");
console.debug("Message received:", e.data);
if (
typeof e.data != "object" ||
!("message_type" in e.data) ||
e.data.message_type !== "oauth_popup_result"
) {
console.debug("Ignoring irrelevant message");
return;
}
if (!e.data.success) {
console.error("OAuth flow failed:", e.data.message);
setOAuthError(`OAuth flow failed: ${e.data.message}`);
setOAuth2FlowInProgress(false);
return;
}
if (e.data.state !== state_token) {
console.error("Invalid state token received");
setOAuthError("Invalid state token received");
setOAuth2FlowInProgress(false);
return;
}
try {
console.debug("Processing OAuth callback");
await oAuthCallback({
provider,
data: {
code: e.data.code,
state_token: e.data.state,
},
});
console.debug("OAuth callback processed successfully");
} catch (error) {
console.error("Error in OAuth callback:", error);
setOAuthError(
`Error in OAuth callback: ${
error instanceof Error ? error.message : String(error)
}`,
);
} finally {
console.debug("Finalizing OAuth flow");
setOAuth2FlowInProgress(false);
controller.abort("success");
}
};
window.addEventListener("message", handleMessage, {
signal: controller.signal,
});
setTimeout(
() => {
console.debug("OAuth flow timed out");
controller.abort("timeout");
setOAuth2FlowInProgress(false);
setOAuthError("OAuth flow timed out");
},
5 * 60 * 1000,
);
};
const onClose = () => {
oAuthPopupController?.abort("canceled");
setOpen(false);
};
return {
handleOAuthLogin,
loading:
isOAuth2FlowInProgress || isOAuthCallbackPending || isInitiatingOauthFlow,
error: oAuthError || initiatingOauthFlowError || oAuthCallbackError,
onClose,
open,
setOpen,
};
};

View File

@@ -1,7 +1,6 @@
import { useGetV1ListCredentials } from "@/app/api/__generated__/endpoints/integrations/integrations";
import { CredentialsMetaResponse } from "@/app/api/__generated__/models/credentialsMetaResponse";
import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api";
import { useState } from "react";
import { filterCredentialsByProvider } from "./helpers";
export const useCredentialField = ({
@@ -9,8 +8,6 @@ export const useCredentialField = ({
}: {
credentialSchema: BlockIOCredentialsSubSchema; // Here we are using manual typing, we need to fix it with automatic one
}) => {
const [isAPIKeyModalOpen, setIsAPIKeyModalOpen] = useState(false);
// 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
@@ -36,8 +33,6 @@ export const useCredentialField = ({
isCredentialListLoading,
supportsApiKey,
supportsOAuth2,
isAPIKeyModalOpen,
setIsAPIKeyModalOpen,
credentialsExists,
};
};

View File

@@ -9,8 +9,8 @@
"/api/integrations/{provider}/login": {
"get": {
"tags": ["v1", "integrations"],
"summary": "Login",
"operationId": "getV1Login",
"summary": "Initiate OAuth flow",
"operationId": "getV1Initiate oauth flow",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
@@ -60,8 +60,8 @@
"/api/integrations/{provider}/callback": {
"post": {
"tags": ["v1", "integrations"],
"summary": "Callback",
"operationId": "postV1Callback",
"summary": "Exchange OAuth code for tokens",
"operationId": "postV1Exchange oauth code for tokens",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
@@ -79,7 +79,9 @@
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Body_postV1Callback" }
"schema": {
"$ref": "#/components/schemas/Body_postV1Exchange_oauth_code_for_tokens"
}
}
}
},
@@ -5062,7 +5064,7 @@
"required": ["blocks", "pagination"],
"title": "BlockResponse"
},
"Body_postV1Callback": {
"Body_postV1Exchange_oauth_code_for_tokens": {
"properties": {
"code": {
"type": "string",
@@ -5072,7 +5074,7 @@
},
"type": "object",
"required": ["code", "state_token"],
"title": "Body_postV1Callback"
"title": "Body_postV1Exchange oauth code for tokens"
},
"Body_postV1Execute_graph_agent": {
"properties": {