feat(platform): Add username+password credentials type; fix email and reddit blocks (#9113)

<!-- Clearly explain the need for these changes: -->
Update and adds a basic credential field for use in integrations like
reddit


### Changes 🏗️

<!-- Concisely describe all of the changes made in this pull request:
-->
- Reddit
	- Drops the Username and Password for reddit from the .env
	- Updates Reddit block with modern provider and credential system
- moves clientid and secret to reading from `Settings().secrets` rather
than input on the block
	- moves user agent to `Settings().config`

- SMTP
  - update the block to support user password and modern credentials

- Add `UserPasswordCredentials`
	- Default API key expiry to None explicitly to help type cohesion
- add `UserPasswordCredentials` with a weird form of `bearer` which we
ideally remove because `basic` is a more appropriate name. This is
dependent on `Webhook _base` allowing a subset of `Credentials`
	- Update `Credentials` and `CredentialsType`
- Fix various `OAuth2Credentials | APIKeyCredentials` -> `Credentials`
mismatches between base and derived classes
- Replace `router/@post(create_api_key_credentials)` with
`create_credentials` which now takes a credential and is discriminated
by `type` provided by the credential

- UI/Frontend
- Updated various pages to have saved credential types, icons, and text
for User Pass Credentials
- Update credential input to have an input/modals/selects for user/pass
combos
- Update the types to support having user/pass credentials too (we
should make this more centralized)
	- Update Credential Providres to support user_password
- Update `client.ts` to support the new streamlined credential creation
method and endpoint

- DX
	- Sort the provider names **again**


TODO:
- [x] Reactivate Conditionally Disabling Reddit
~~- [ ] Look into moving Webhooks base to allow subset of `Credentials`
rather than requiring all webhooks to support the input of all valid
`Credentials` types~~ Out of scope
- [x] Figure out the `singleCredential` calculator in
`credentials-input.tsx` so that it also respects User Pass credentials
and isn't a logic mess

### Checklist 📋

#### For code changes:
- [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:
  <!-- Put your test plan here: -->
  - [x] Test with agents

---------

Co-authored-by: Zamil Majdy <zamil.majdy@agpt.co>
pull/9138/head
Nicholas Tindle 2025-01-28 08:07:50 +00:00 committed by GitHub
parent 97ecaf5639
commit ac8a466cda
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 506 additions and 131 deletions

View File

@ -91,10 +91,12 @@ GROQ_API_KEY=
OPEN_ROUTER_API_KEY=
# Reddit
# Go to https://www.reddit.com/prefs/apps and create a new app
# Choose "script" for the type
# Fill in the redirect uri as <your_frontend_url>/auth/integrations/oauth_callback, e.g. http://localhost:3000/auth/integrations/oauth_callback
REDDIT_CLIENT_ID=
REDDIT_CLIENT_SECRET=
REDDIT_USERNAME=
REDDIT_PASSWORD=
REDDIT_USER_AGENT="AutoGPT:1.0 (by /u/autogpt)"
# Discord
DISCORD_BOT_TOKEN=

View File

@ -1,22 +1,53 @@
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Literal
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, SecretStr
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
from backend.data.model import BlockSecret, SchemaField, SecretField
from backend.data.model import (
CredentialsField,
CredentialsMetaInput,
SchemaField,
UserPasswordCredentials,
)
from backend.integrations.providers import ProviderName
TEST_CREDENTIALS = UserPasswordCredentials(
id="01234567-89ab-cdef-0123-456789abcdef",
provider="smtp",
username=SecretStr("mock-smtp-username"),
password=SecretStr("mock-smtp-password"),
title="Mock SMTP credentials",
)
TEST_CREDENTIALS_INPUT = {
"provider": TEST_CREDENTIALS.provider,
"id": TEST_CREDENTIALS.id,
"type": TEST_CREDENTIALS.type,
"title": TEST_CREDENTIALS.title,
}
SMTPCredentials = UserPasswordCredentials
SMTPCredentialsInput = CredentialsMetaInput[
Literal[ProviderName.SMTP],
Literal["user_password"],
]
class EmailCredentials(BaseModel):
def SMTPCredentialsField() -> SMTPCredentialsInput:
return CredentialsField(
description="The SMTP integration requires a username and password.",
)
class SMTPConfig(BaseModel):
smtp_server: str = SchemaField(
default="smtp.gmail.com", description="SMTP server address"
default="smtp.example.com", description="SMTP server address"
)
smtp_port: int = SchemaField(default=25, description="SMTP port number")
smtp_username: BlockSecret = SecretField(key="smtp_username")
smtp_password: BlockSecret = SecretField(key="smtp_password")
model_config = ConfigDict(title="Email Credentials")
model_config = ConfigDict(title="SMTP Config")
class SendEmailBlock(Block):
@ -30,10 +61,11 @@ class SendEmailBlock(Block):
body: str = SchemaField(
description="Body of the email", placeholder="Enter the email body"
)
creds: EmailCredentials = SchemaField(
description="SMTP credentials",
default=EmailCredentials(),
config: SMTPConfig = SchemaField(
description="SMTP Config",
default=SMTPConfig(),
)
credentials: SMTPCredentialsInput = SMTPCredentialsField()
class Output(BlockSchema):
status: str = SchemaField(description="Status of the email sending operation")
@ -43,7 +75,6 @@ class SendEmailBlock(Block):
def __init__(self):
super().__init__(
disabled=True,
id="4335878a-394e-4e67-adf2-919877ff49ae",
description="This block sends an email using the provided SMTP credentials.",
categories={BlockCategory.OUTPUT},
@ -53,25 +84,29 @@ class SendEmailBlock(Block):
"to_email": "recipient@example.com",
"subject": "Test Email",
"body": "This is a test email.",
"creds": {
"config": {
"smtp_server": "smtp.gmail.com",
"smtp_port": 25,
"smtp_username": "your-email@gmail.com",
"smtp_password": "your-gmail-password",
},
"credentials": TEST_CREDENTIALS_INPUT,
},
test_credentials=TEST_CREDENTIALS,
test_output=[("status", "Email sent successfully")],
test_mock={"send_email": lambda *args, **kwargs: "Email sent successfully"},
)
@staticmethod
def send_email(
creds: EmailCredentials, to_email: str, subject: str, body: str
config: SMTPConfig,
to_email: str,
subject: str,
body: str,
credentials: SMTPCredentials,
) -> str:
smtp_server = creds.smtp_server
smtp_port = creds.smtp_port
smtp_username = creds.smtp_username.get_secret_value()
smtp_password = creds.smtp_password.get_secret_value()
smtp_server = config.smtp_server
smtp_port = config.smtp_port
smtp_username = credentials.username.get_secret_value()
smtp_password = credentials.password.get_secret_value()
msg = MIMEMultipart()
msg["From"] = smtp_username
@ -86,10 +121,13 @@ class SendEmailBlock(Block):
return "Email sent successfully"
def run(self, input_data: Input, **kwargs) -> BlockOutput:
def run(
self, input_data: Input, *, credentials: SMTPCredentials, **kwargs
) -> BlockOutput:
yield "status", self.send_email(
input_data.creds,
input_data.to_email,
input_data.subject,
input_data.body,
config=input_data.config,
to_email=input_data.to_email,
subject=input_data.subject,
body=input_data.body,
credentials=credentials,
)

View File

@ -30,7 +30,7 @@ def _convert_to_api_url(url: str) -> str:
def _get_headers(credentials: GithubCredentials) -> dict[str, str]:
return {
"Authorization": credentials.bearer(),
"Authorization": credentials.auth_header(),
"Accept": "application/vnd.github.v3+json",
}

View File

@ -40,7 +40,7 @@ class LinearClient:
"Content-Type": "application/json",
}
if credentials:
headers["Authorization"] = credentials.bearer()
headers["Authorization"] = credentials.auth_header()
self._requests = Requests(
extra_headers=headers,

View File

@ -1,22 +1,48 @@
from datetime import datetime, timezone
from typing import Iterator
from typing import Iterator, Literal
import praw
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, SecretStr
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
from backend.data.model import BlockSecret, SchemaField, SecretField
from backend.data.model import (
CredentialsField,
CredentialsMetaInput,
SchemaField,
UserPasswordCredentials,
)
from backend.integrations.providers import ProviderName
from backend.util.mock import MockObject
from backend.util.settings import Settings
RedditCredentials = UserPasswordCredentials
RedditCredentialsInput = CredentialsMetaInput[
Literal[ProviderName.REDDIT],
Literal["user_password"],
]
class RedditCredentials(BaseModel):
client_id: BlockSecret = SecretField(key="reddit_client_id")
client_secret: BlockSecret = SecretField(key="reddit_client_secret")
username: BlockSecret = SecretField(key="reddit_username")
password: BlockSecret = SecretField(key="reddit_password")
user_agent: str = "AutoGPT:1.0 (by /u/autogpt)"
def RedditCredentialsField() -> RedditCredentialsInput:
"""Creates a Reddit credentials input on a block."""
return CredentialsField(
description="The Reddit integration requires a username and password.",
)
model_config = ConfigDict(title="Reddit Credentials")
TEST_CREDENTIALS = UserPasswordCredentials(
id="01234567-89ab-cdef-0123-456789abcdef",
provider="reddit",
username=SecretStr("mock-reddit-username"),
password=SecretStr("mock-reddit-password"),
title="Mock Reddit credentials",
)
TEST_CREDENTIALS_INPUT = {
"provider": TEST_CREDENTIALS.provider,
"id": TEST_CREDENTIALS.id,
"type": TEST_CREDENTIALS.type,
"title": TEST_CREDENTIALS.title,
}
class RedditPost(BaseModel):
@ -31,13 +57,16 @@ class RedditComment(BaseModel):
comment: str
settings = Settings()
def get_praw(creds: RedditCredentials) -> praw.Reddit:
client = praw.Reddit(
client_id=creds.client_id.get_secret_value(),
client_secret=creds.client_secret.get_secret_value(),
client_id=settings.secrets.reddit_client_id,
client_secret=settings.secrets.reddit_client_secret,
username=creds.username.get_secret_value(),
password=creds.password.get_secret_value(),
user_agent=creds.user_agent,
user_agent=settings.config.reddit_user_agent,
)
me = client.user.me()
if not me:
@ -48,11 +77,11 @@ def get_praw(creds: RedditCredentials) -> praw.Reddit:
class GetRedditPostsBlock(Block):
class Input(BlockSchema):
subreddit: str = SchemaField(description="Subreddit name")
creds: RedditCredentials = SchemaField(
description="Reddit credentials",
default=RedditCredentials(),
subreddit: str = SchemaField(
description="Subreddit name, excluding the /r/ prefix",
default="writingprompts",
)
credentials: RedditCredentialsInput = RedditCredentialsField()
last_minutes: int | None = SchemaField(
description="Post time to stop minutes ago while fetching posts",
default=None,
@ -70,20 +99,18 @@ class GetRedditPostsBlock(Block):
def __init__(self):
super().__init__(
disabled=True,
id="c6731acb-4285-4ee1-bc9b-03d0766c370f",
description="This block fetches Reddit posts from a defined subreddit name.",
categories={BlockCategory.SOCIAL},
disabled=(
not settings.secrets.reddit_client_id
or not settings.secrets.reddit_client_secret
),
input_schema=GetRedditPostsBlock.Input,
output_schema=GetRedditPostsBlock.Output,
test_credentials=TEST_CREDENTIALS,
test_input={
"creds": {
"client_id": "client_id",
"client_secret": "client_secret",
"username": "username",
"password": "password",
"user_agent": "user_agent",
},
"credentials": TEST_CREDENTIALS_INPUT,
"subreddit": "subreddit",
"last_post": "id3",
"post_limit": 2,
@ -103,7 +130,7 @@ class GetRedditPostsBlock(Block):
),
],
test_mock={
"get_posts": lambda _: [
"get_posts": lambda input_data, credentials: [
MockObject(id="id1", title="title1", selftext="body1"),
MockObject(id="id2", title="title2", selftext="body2"),
MockObject(id="id3", title="title2", selftext="body2"),
@ -112,14 +139,18 @@ class GetRedditPostsBlock(Block):
)
@staticmethod
def get_posts(input_data: Input) -> Iterator[praw.reddit.Submission]:
client = get_praw(input_data.creds)
def get_posts(
input_data: Input, *, credentials: RedditCredentials
) -> Iterator[praw.reddit.Submission]:
client = get_praw(credentials)
subreddit = client.subreddit(input_data.subreddit)
return subreddit.new(limit=input_data.post_limit or 10)
def run(self, input_data: Input, **kwargs) -> BlockOutput:
def run(
self, input_data: Input, *, credentials: RedditCredentials, **kwargs
) -> BlockOutput:
current_time = datetime.now(tz=timezone.utc)
for post in self.get_posts(input_data):
for post in self.get_posts(input_data=input_data, credentials=credentials):
if input_data.last_minutes:
post_datetime = datetime.fromtimestamp(
post.created_utc, tz=timezone.utc
@ -141,9 +172,7 @@ class GetRedditPostsBlock(Block):
class PostRedditCommentBlock(Block):
class Input(BlockSchema):
creds: RedditCredentials = SchemaField(
description="Reddit credentials", default=RedditCredentials()
)
credentials: RedditCredentialsInput = RedditCredentialsField()
data: RedditComment = SchemaField(description="Reddit comment")
class Output(BlockSchema):
@ -156,7 +185,15 @@ class PostRedditCommentBlock(Block):
categories={BlockCategory.SOCIAL},
input_schema=PostRedditCommentBlock.Input,
output_schema=PostRedditCommentBlock.Output,
test_input={"data": {"post_id": "id", "comment": "comment"}},
disabled=(
not settings.secrets.reddit_client_id
or not settings.secrets.reddit_client_secret
),
test_credentials=TEST_CREDENTIALS,
test_input={
"credentials": TEST_CREDENTIALS_INPUT,
"data": {"post_id": "id", "comment": "comment"},
},
test_output=[("comment_id", "dummy_comment_id")],
test_mock={"reply_post": lambda creds, comment: "dummy_comment_id"},
)
@ -170,5 +207,7 @@ class PostRedditCommentBlock(Block):
raise ValueError("Failed to post comment.")
return new_comment.id
def run(self, input_data: Input, **kwargs) -> BlockOutput:
yield "comment_id", self.reply_post(input_data.creds, input_data.data)
def run(
self, input_data: Input, *, credentials: RedditCredentials, **kwargs
) -> BlockOutput:
yield "comment_id", self.reply_post(credentials, input_data.data)

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import base64
import logging
from typing import (
TYPE_CHECKING,
@ -199,27 +200,42 @@ class OAuth2Credentials(_BaseCredentials):
scopes: list[str]
metadata: dict[str, Any] = Field(default_factory=dict)
def bearer(self) -> str:
def auth_header(self) -> str:
return f"Bearer {self.access_token.get_secret_value()}"
class APIKeyCredentials(_BaseCredentials):
type: Literal["api_key"] = "api_key"
api_key: SecretStr
expires_at: Optional[int]
expires_at: Optional[int] = Field(
default=None,
description="Unix timestamp (seconds) indicating when the API key expires (if at all)",
)
"""Unix timestamp (seconds) indicating when the API key expires (if at all)"""
def bearer(self) -> str:
def auth_header(self) -> str:
return f"Bearer {self.api_key.get_secret_value()}"
class UserPasswordCredentials(_BaseCredentials):
type: Literal["user_password"] = "user_password"
username: SecretStr
password: SecretStr
def auth_header(self) -> str:
# Converting the string to bytes using encode()
# Base64 encoding it with base64.b64encode()
# Converting the resulting bytes back to a string with decode()
return f"Basic {base64.b64encode(f'{self.username.get_secret_value()}:{self.password.get_secret_value()}'.encode()).decode()}"
Credentials = Annotated[
OAuth2Credentials | APIKeyCredentials,
OAuth2Credentials | APIKeyCredentials | UserPasswordCredentials,
Field(discriminator="type"),
]
CredentialsType = Literal["api_key", "oauth2"]
CredentialsType = Literal["api_key", "oauth2", "user_password"]
class OAuthState(BaseModel):

View File

@ -26,9 +26,11 @@ class ProviderName(str, Enum):
OPENWEATHERMAP = "openweathermap"
OPEN_ROUTER = "open_router"
PINECONE = "pinecone"
REDDIT = "reddit"
REPLICATE = "replicate"
REVID = "revid"
SLANT3D = "slant3d"
SMTP = "smtp"
TWITTER = "twitter"
UNREAL_SPEECH = "unreal_speech"
# --8<-- [end:ProviderName]

View File

@ -168,7 +168,7 @@ class BaseWebhooksManager(ABC, Generic[WT]):
id = str(uuid4())
secret = secrets.token_hex(32)
provider_name = self.PROVIDER_NAME
provider_name: ProviderName = self.PROVIDER_NAME
ingress_url = webhook_ingress_url(provider_name=provider_name, webhook_id=id)
if register:
if not credentials:

View File

@ -1,7 +1,7 @@
import logging
from backend.data import integrations
from backend.data.model import APIKeyCredentials, Credentials, OAuth2Credentials
from backend.data.model import Credentials
from ._base import WT, BaseWebhooksManager
@ -25,6 +25,6 @@ class ManualWebhookManagerBase(BaseWebhooksManager[WT]):
async def _deregister_webhook(
self,
webhook: integrations.Webhook,
credentials: OAuth2Credentials | APIKeyCredentials,
credentials: Credentials,
) -> None:
pass

View File

@ -67,7 +67,7 @@ class GithubWebhooksManager(BaseWebhooksManager):
headers = {
**self.GITHUB_API_DEFAULT_HEADERS,
"Authorization": credentials.bearer(),
"Authorization": credentials.auth_header(),
}
repo, github_hook_id = webhook.resource, webhook.provider_webhook_id
@ -96,7 +96,7 @@ class GithubWebhooksManager(BaseWebhooksManager):
headers = {
**self.GITHUB_API_DEFAULT_HEADERS,
"Authorization": credentials.bearer(),
"Authorization": credentials.auth_header(),
}
webhook_data = {
"name": "web",
@ -142,7 +142,7 @@ class GithubWebhooksManager(BaseWebhooksManager):
headers = {
**self.GITHUB_API_DEFAULT_HEADERS,
"Authorization": credentials.bearer(),
"Authorization": credentials.auth_header(),
}
if webhook_type == self.WebhookType.REPO:

View File

@ -2,7 +2,7 @@ import logging
from typing import TYPE_CHECKING, Annotated, Literal
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request
from pydantic import BaseModel, Field, SecretStr
from pydantic import BaseModel, Field
from backend.data.graph import set_node_webhook
from backend.data.integrations import (
@ -12,12 +12,7 @@ from backend.data.integrations import (
publish_webhook_event,
wait_for_webhook_event,
)
from backend.data.model import (
APIKeyCredentials,
Credentials,
CredentialsType,
OAuth2Credentials,
)
from backend.data.model import Credentials, CredentialsType, OAuth2Credentials
from backend.executor.manager import ExecutionManager
from backend.integrations.creds_manager import IntegrationCredentialsManager
from backend.integrations.oauth import HANDLERS_BY_NAME
@ -204,31 +199,21 @@ def get_credential(
@router.post("/{provider}/credentials", status_code=201)
def create_api_key_credentials(
def create_credentials(
user_id: Annotated[str, Depends(get_user_id)],
provider: Annotated[
ProviderName, Path(title="The provider to create credentials for")
],
api_key: Annotated[str, Body(title="The API key to store")],
title: Annotated[str, Body(title="Optional title for the credentials")],
expires_at: Annotated[
int | None, Body(title="Unix timestamp when the key expires")
] = None,
) -> APIKeyCredentials:
new_credentials = APIKeyCredentials(
provider=provider,
api_key=SecretStr(api_key),
title=title,
expires_at=expires_at,
)
credentials: Credentials,
) -> Credentials:
credentials.provider = provider
try:
creds_manager.create(user_id, new_credentials)
creds_manager.create(user_id, credentials)
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to store credentials: {str(e)}"
)
return new_credentials
return credentials
class CredentialsDeletionResponse(BaseModel):

View File

@ -157,6 +157,11 @@ class Config(UpdateTrackingModel["Config"], BaseSettings):
description="The name of the Google Cloud Storage bucket for media files",
)
reddit_user_agent: str = Field(
default="AutoGPT:1.0 (by /u/autogpt)",
description="The user agent for the Reddit API",
)
scheduler_db_pool_size: int = Field(
default=3,
description="The pool size for the scheduler database connection pool",
@ -280,8 +285,6 @@ class Secrets(UpdateTrackingModel["Secrets"], BaseSettings):
reddit_client_id: str = Field(default="", description="Reddit client ID")
reddit_client_secret: str = Field(default="", description="Reddit client secret")
reddit_username: str = Field(default="", description="Reddit username")
reddit_password: str = Field(default="", description="Reddit password")
openweathermap_api_key: str = Field(
default="", description="OpenWeatherMap API key"

View File

@ -124,14 +124,22 @@ export default function PrivatePage() {
const allCredentials = providers
? Object.values(providers).flatMap((provider) =>
[...provider.savedOAuthCredentials, ...provider.savedApiKeys]
[
...provider.savedOAuthCredentials,
...provider.savedApiKeys,
...provider.savedUserPasswordCredentials,
]
.filter((cred) => !hiddenCredentials.includes(cred.id))
.map((credentials) => ({
...credentials,
provider: provider.provider,
providerName: provider.providerName,
ProviderIcon: providerIcons[provider.provider],
TypeIcon: { oauth2: IconUser, api_key: IconKey }[credentials.type],
TypeIcon: {
oauth2: IconUser,
api_key: IconKey,
user_password: IconKey,
}[credentials.type],
})),
)
: [];

View File

@ -124,14 +124,22 @@ export default function PrivatePage() {
const allCredentials = providers
? Object.values(providers).flatMap((provider) =>
[...provider.savedOAuthCredentials, ...provider.savedApiKeys]
[
...provider.savedOAuthCredentials,
...provider.savedApiKeys,
...provider.savedUserPasswordCredentials,
]
.filter((cred) => !hiddenCredentials.includes(cred.id))
.map((credentials) => ({
...credentials,
provider: provider.provider,
providerName: provider.providerName,
ProviderIcon: providerIcons[provider.provider],
TypeIcon: { oauth2: IconUser, api_key: IconKey }[credentials.type],
TypeIcon: {
oauth2: IconUser,
api_key: IconKey,
user_password: IconKey,
}[credentials.type],
})),
)
: [];
@ -176,6 +184,7 @@ export default function PrivatePage() {
{
oauth2: "OAuth2 credentials",
api_key: "API key",
user_password: "User password",
}[cred.type]
}{" "}
- <code>{cred.id}</code>

View File

@ -75,7 +75,9 @@ export const providerIcons: Record<
open_router: fallbackIcon,
pinecone: fallbackIcon,
slant3d: fallbackIcon,
smtp: fallbackIcon,
replicate: fallbackIcon,
reddit: fallbackIcon,
fal: fallbackIcon,
revid: fallbackIcon,
twitter: FaTwitter,
@ -107,6 +109,10 @@ export const CredentialsInput: FC<{
const credentials = useCredentials(selfKey);
const [isAPICredentialsModalOpen, setAPICredentialsModalOpen] =
useState(false);
const [
isUserPasswordCredentialsModalOpen,
setUserPasswordCredentialsModalOpen,
] = useState(false);
const [isOAuth2FlowInProgress, setOAuth2FlowInProgress] = useState(false);
const [oAuthPopupController, setOAuthPopupController] =
useState<AbortController | null>(null);
@ -122,8 +128,10 @@ export const CredentialsInput: FC<{
providerName,
supportsApiKey,
supportsOAuth2,
supportsUserPassword,
savedApiKeys,
savedOAuthCredentials,
savedUserPasswordCredentials,
oAuthCallback,
} = credentials;
@ -237,6 +245,17 @@ export const CredentialsInput: FC<{
providerName={providerName}
/>
)}
{supportsUserPassword && (
<UserPasswordCredentialsModal
credentialsFieldName={selfKey}
open={isUserPasswordCredentialsModalOpen}
onClose={() => setUserPasswordCredentialsModalOpen(false)}
onCredentialsCreate={(creds) => {
onSelectCredentials(creds);
setUserPasswordCredentialsModalOpen(false);
}}
/>
)}
</>
);
@ -245,13 +264,18 @@ export const CredentialsInput: FC<{
selectedCredentials &&
!savedApiKeys
.concat(savedOAuthCredentials)
.concat(savedUserPasswordCredentials)
.some((c) => c.id === selectedCredentials.id)
) {
onSelectCredentials(undefined);
}
// No saved credentials yet
if (savedApiKeys.length === 0 && savedOAuthCredentials.length === 0) {
if (
savedApiKeys.length === 0 &&
savedOAuthCredentials.length === 0 &&
savedUserPasswordCredentials.length === 0
) {
return (
<>
<div className="mb-2 flex gap-1">
@ -273,6 +297,12 @@ export const CredentialsInput: FC<{
Enter API key
</Button>
)}
{supportsUserPassword && (
<Button onClick={() => setUserPasswordCredentialsModalOpen(true)}>
<ProviderIcon className="mr-2 h-4 w-4" />
Enter username and password
</Button>
)}
</div>
{modals}
{oAuthError && (
@ -282,12 +312,29 @@ export const CredentialsInput: FC<{
);
}
const singleCredential =
savedApiKeys.length === 1 && savedOAuthCredentials.length === 0
? savedApiKeys[0]
: savedOAuthCredentials.length === 1 && savedApiKeys.length === 0
? savedOAuthCredentials[0]
: null;
const getCredentialCounts = () => ({
apiKeys: savedApiKeys.length,
oauth: savedOAuthCredentials.length,
userPass: savedUserPasswordCredentials.length,
});
const getSingleCredential = () => {
const counts = getCredentialCounts();
const totalCredentials = Object.values(counts).reduce(
(sum, count) => sum + count,
0,
);
if (totalCredentials !== 1) return null;
if (counts.apiKeys === 1) return savedApiKeys[0];
if (counts.oauth === 1) return savedOAuthCredentials[0];
if (counts.userPass === 1) return savedUserPasswordCredentials[0];
return null;
};
const singleCredential = getSingleCredential();
if (singleCredential) {
if (!selectedCredentials) {
@ -311,6 +358,7 @@ export const CredentialsInput: FC<{
} else {
const selectedCreds = savedApiKeys
.concat(savedOAuthCredentials)
.concat(savedUserPasswordCredentials)
.find((c) => c.id == newValue)!;
onSelectCredentials({
@ -349,6 +397,13 @@ export const CredentialsInput: FC<{
{credentials.title}
</SelectItem>
))}
{savedUserPasswordCredentials.map((credentials, index) => (
<SelectItem key={index} value={credentials.id}>
<ProviderIcon className="mr-2 inline h-4 w-4" />
<IconUserPlus className="mr-1.5 inline" />
{credentials.title}
</SelectItem>
))}
<SelectSeparator />
{supportsOAuth2 && (
<SelectItem value="sign-in">
@ -362,6 +417,12 @@ export const CredentialsInput: FC<{
Add new API key
</SelectItem>
)}
{supportsUserPassword && (
<SelectItem value="add-user-password">
<IconUserPlus className="mr-1.5 inline" />
Add new user password
</SelectItem>
)}
</SelectContent>
</Select>
{modals}
@ -508,6 +569,130 @@ export const APIKeyCredentialsModal: FC<{
);
};
export const UserPasswordCredentialsModal: FC<{
credentialsFieldName: string;
open: boolean;
onClose: () => void;
onCredentialsCreate: (creds: CredentialsMetaInput) => void;
}> = ({ credentialsFieldName, open, onClose, onCredentialsCreate }) => {
const credentials = useCredentials(credentialsFieldName);
const formSchema = z.object({
username: z.string().min(1, "Username is required"),
password: z.string().min(1, "Password is required"),
title: z.string().min(1, "Name is required"),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
username: "",
password: "",
title: "",
},
});
if (
!credentials ||
credentials.isLoading ||
!credentials.supportsUserPassword
) {
return null;
}
const { schema, provider, providerName, createUserPasswordCredentials } =
credentials;
async function onSubmit(values: z.infer<typeof formSchema>) {
const newCredentials = await createUserPasswordCredentials({
username: values.username,
password: values.password,
title: values.title,
});
onCredentialsCreate({
provider,
id: newCredentials.id,
type: "user_password",
title: newCredentials.title,
});
}
return (
<Dialog
open={open}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
Add new username & password for {providerName}
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input
type="text"
placeholder="Enter username..."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter password..."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
type="text"
placeholder="Enter a name for this user login..."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
Save & use this user login
</Button>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
export const OAuth2FlowWaitingModal: FC<{
open: boolean;
onClose: () => void;

View File

@ -5,6 +5,7 @@ import {
CredentialsMetaResponse,
CredentialsProviderName,
PROVIDER_NAMES,
UserPasswordCredentials,
} from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { createContext, useCallback, useEffect, useState } from "react";
@ -20,10 +21,13 @@ const providerDisplayNames: Record<CredentialsProviderName, string> = {
discord: "Discord",
d_id: "D-ID",
e2b: "E2B",
exa: "Exa",
fal: "FAL",
github: "GitHub",
google: "Google",
google_maps: "Google Maps",
groq: "Groq",
hubspot: "Hubspot",
ideogram: "Ideogram",
jina: "Jina",
linear: "Linear",
@ -36,13 +40,12 @@ const providerDisplayNames: Record<CredentialsProviderName, string> = {
open_router: "Open Router",
pinecone: "Pinecone",
slant3d: "Slant3D",
smtp: "SMTP",
reddit: "Reddit",
replicate: "Replicate",
fal: "FAL",
revid: "Rev.ID",
twitter: "Twitter",
unreal_speech: "Unreal Speech",
exa: "Exa",
hubspot: "Hubspot",
} as const;
// --8<-- [end:CredentialsProviderNames]
@ -51,11 +54,17 @@ type APIKeyCredentialsCreatable = Omit<
"id" | "provider" | "type"
>;
type UserPasswordCredentialsCreatable = Omit<
UserPasswordCredentials,
"id" | "provider" | "type"
>;
export type CredentialsProviderData = {
provider: CredentialsProviderName;
providerName: string;
savedApiKeys: CredentialsMetaResponse[];
savedOAuthCredentials: CredentialsMetaResponse[];
savedUserPasswordCredentials: CredentialsMetaResponse[];
oAuthCallback: (
code: string,
state_token: string,
@ -63,6 +72,9 @@ export type CredentialsProviderData = {
createAPIKeyCredentials: (
credentials: APIKeyCredentialsCreatable,
) => Promise<CredentialsMetaResponse>;
createUserPasswordCredentials: (
credentials: UserPasswordCredentialsCreatable,
) => Promise<CredentialsMetaResponse>;
deleteCredentials: (
id: string,
force?: boolean,
@ -107,6 +119,11 @@ export default function CredentialsProvider({
...updatedProvider.savedOAuthCredentials,
credentials,
];
} else if (credentials.type === "user_password") {
updatedProvider.savedUserPasswordCredentials = [
...updatedProvider.savedUserPasswordCredentials,
credentials,
];
}
return {
@ -148,6 +165,22 @@ export default function CredentialsProvider({
[api, addCredentials],
);
/** Wraps `BackendAPI.createUserPasswordCredentials`, and adds the result to the internal credentials store. */
const createUserPasswordCredentials = useCallback(
async (
provider: CredentialsProviderName,
credentials: UserPasswordCredentialsCreatable,
): Promise<CredentialsMetaResponse> => {
const credsMeta = await api.createUserPasswordCredentials({
provider,
...credentials,
});
addCredentials(provider, credsMeta);
return credsMeta;
},
[api, addCredentials],
);
/** Wraps `BackendAPI.deleteCredentials`, and removes the credentials from the internal store. */
const deleteCredentials = useCallback(
async (
@ -172,7 +205,10 @@ export default function CredentialsProvider({
updatedProvider.savedOAuthCredentials.filter(
(cred) => cred.id !== id,
);
updatedProvider.savedUserPasswordCredentials =
updatedProvider.savedUserPasswordCredentials.filter(
(cred) => cred.id !== id,
);
return {
...prev,
[provider]: updatedProvider,
@ -191,12 +227,18 @@ export default function CredentialsProvider({
const credentialsByProvider = response.reduce(
(acc, cred) => {
if (!acc[cred.provider]) {
acc[cred.provider] = { oauthCreds: [], apiKeys: [] };
acc[cred.provider] = {
oauthCreds: [],
apiKeys: [],
userPasswordCreds: [],
};
}
if (cred.type === "oauth2") {
acc[cred.provider].oauthCreds.push(cred);
} else if (cred.type === "api_key") {
acc[cred.provider].apiKeys.push(cred);
} else if (cred.type === "user_password") {
acc[cred.provider].userPasswordCreds.push(cred);
}
return acc;
},
@ -205,6 +247,7 @@ export default function CredentialsProvider({
{
oauthCreds: CredentialsMetaResponse[];
apiKeys: CredentialsMetaResponse[];
userPasswordCreds: CredentialsMetaResponse[];
}
>,
);
@ -221,6 +264,8 @@ export default function CredentialsProvider({
savedApiKeys: credentialsByProvider[provider]?.apiKeys ?? [],
savedOAuthCredentials:
credentialsByProvider[provider]?.oauthCreds ?? [],
savedUserPasswordCredentials:
credentialsByProvider[provider]?.userPasswordCreds ?? [],
oAuthCallback: (code: string, state_token: string) =>
oAuthCallback(
provider as CredentialsProviderName,
@ -234,6 +279,13 @@ export default function CredentialsProvider({
provider as CredentialsProviderName,
credentials,
),
createUserPasswordCredentials: (
credentials: UserPasswordCredentialsCreatable,
) =>
createUserPasswordCredentials(
provider as CredentialsProviderName,
credentials,
),
deleteCredentials: (id: string, force: boolean = false) =>
deleteCredentials(
provider as CredentialsProviderName,
@ -246,7 +298,13 @@ export default function CredentialsProvider({
}));
});
});
}, [api, createAPIKeyCredentials, deleteCredentials, oAuthCallback]);
}, [
api,
createAPIKeyCredentials,
createUserPasswordCredentials,
deleteCredentials,
oAuthCallback,
]);
return (
<CredentialsProvidersContext.Provider value={providers}>

View File

@ -17,12 +17,14 @@ export type CredentialsData =
schema: BlockIOCredentialsSubSchema;
supportsApiKey: boolean;
supportsOAuth2: boolean;
supportsUserPassword: boolean;
isLoading: true;
}
| (CredentialsProviderData & {
schema: BlockIOCredentialsSubSchema;
supportsApiKey: boolean;
supportsOAuth2: boolean;
supportsUserPassword: boolean;
isLoading: false;
});
@ -72,6 +74,8 @@ export default function useCredentials(
const supportsApiKey =
credentialsSchema.credentials_types.includes("api_key");
const supportsOAuth2 = credentialsSchema.credentials_types.includes("oauth2");
const supportsUserPassword =
credentialsSchema.credentials_types.includes("user_password");
// No provider means maybe it's still loading
if (!provider) {
@ -93,13 +97,17 @@ export default function useCredentials(
)
: provider.savedOAuthCredentials;
const savedUserPasswordCredentials = provider.savedUserPasswordCredentials;
return {
...provider,
provider: providerName,
schema: credentialsSchema,
supportsApiKey,
supportsOAuth2,
supportsUserPassword,
savedOAuthCredentials,
savedUserPasswordCredentials,
isLoading: false,
};
}

View File

@ -15,7 +15,6 @@ import {
GraphUpdateable,
NodeExecutionResult,
MyAgentsResponse,
OAuth2Credentials,
ProfileDetails,
User,
StoreAgentsResponse,
@ -29,6 +28,8 @@ import {
StoreReview,
ScheduleCreatable,
Schedule,
UserPasswordCredentials,
Credentials,
APIKeyPermission,
CreateAPIKeyResponse,
APIKey,
@ -203,7 +204,17 @@ export default class BackendAPI {
return this._request(
"POST",
`/integrations/${credentials.provider}/credentials`,
credentials,
{ ...credentials, type: "api_key" },
);
}
createUserPasswordCredentials(
credentials: Omit<UserPasswordCredentials, "id" | "type">,
): Promise<UserPasswordCredentials> {
return this._request(
"POST",
`/integrations/${credentials.provider}/credentials`,
{ ...credentials, type: "user_password" },
);
}
@ -215,10 +226,7 @@ export default class BackendAPI {
);
}
getCredentials(
provider: string,
id: string,
): Promise<APIKeyCredentials | OAuth2Credentials> {
getCredentials(provider: string, id: string): Promise<Credentials> {
return this._get(`/integrations/${provider}/credentials/${id}`);
}

View File

@ -97,7 +97,12 @@ export type BlockIOBooleanSubSchema = BlockIOSubSchemaMeta & {
default?: boolean;
};
export type CredentialsType = "api_key" | "oauth2";
export type CredentialsType = "api_key" | "oauth2" | "user_password";
export type Credentials =
| APIKeyCredentials
| OAuth2Credentials
| UserPasswordCredentials;
// --8<-- [start:BlockIOCredentialsSubSchema]
export const PROVIDER_NAMES = {
@ -105,10 +110,13 @@ export const PROVIDER_NAMES = {
D_ID: "d_id",
DISCORD: "discord",
E2B: "e2b",
EXA: "exa",
FAL: "fal",
GITHUB: "github",
GOOGLE: "google",
GOOGLE_MAPS: "google_maps",
GROQ: "groq",
HUBSPOT: "hubspot",
IDEOGRAM: "ideogram",
JINA: "jina",
LINEAR: "linear",
@ -121,13 +129,12 @@ export const PROVIDER_NAMES = {
OPEN_ROUTER: "open_router",
PINECONE: "pinecone",
SLANT3D: "slant3d",
SMTP: "smtp",
TWITTER: "twitter",
REPLICATE: "replicate",
FAL: "fal",
REDDIT: "reddit",
REVID: "revid",
UNREAL_SPEECH: "unreal_speech",
EXA: "exa",
HUBSPOT: "hubspot",
TWITTER: "twitter",
} as const;
// --8<-- [end:BlockIOCredentialsSubSchema]
@ -323,8 +330,15 @@ export type APIKeyCredentials = BaseCredentials & {
expires_at?: number;
};
export type UserPasswordCredentials = BaseCredentials & {
type: "user_password";
title: string;
username: string;
password: string;
};
/* Mirror of backend/data/integrations.py:Webhook */
type Webhook = {
export type Webhook = {
id: string;
url: string;
provider: CredentialsProviderName;

View File

@ -257,13 +257,13 @@ response = requests.post(
)
```
or use the shortcut `credentials.bearer()`:
or use the shortcut `credentials.auth_header()`:
```python
# credentials: APIKeyCredentials | OAuth2Credentials
response = requests.post(
url,
headers={"Authorization": credentials.bearer()},
headers={"Authorization": credentials.auth_header()},
)
```