Compare commits

...

3 Commits

Author SHA1 Message Date
Lluis Agusti
383e22da19 chore: wip 2026-01-13 20:04:51 +07:00
Lluis Agusti
8957ecb099 Merge remote-tracking branch 'origin/dev' into fix/run-modal-layout-fixes 2026-01-13 15:37:53 +07:00
Lluis Agusti
d2305d047d chore: wip 2026-01-13 15:37:38 +07:00
40 changed files with 1772 additions and 320 deletions

View File

@@ -108,6 +108,9 @@ class CredentialsMetaResponse(BaseModel):
host: str | None = Field( host: str | None = Field(
default=None, description="Host pattern for host-scoped credentials" default=None, description="Host pattern for host-scoped credentials"
) )
is_system: bool = Field(
default=False, description="Whether this is a system-managed credential"
)
@router.post("/{provider}/callback", summary="Exchange OAuth code for tokens") @router.post("/{provider}/callback", summary="Exchange OAuth code for tokens")
@@ -175,6 +178,8 @@ async def callback(
f"Successfully processed OAuth callback for user {user_id} " f"Successfully processed OAuth callback for user {user_id} "
f"and provider {provider.value}" f"and provider {provider.value}"
) )
from backend.integrations.credentials_store import is_system_credential
return CredentialsMetaResponse( return CredentialsMetaResponse(
id=credentials.id, id=credentials.id,
provider=credentials.provider, provider=credentials.provider,
@@ -185,6 +190,7 @@ async def callback(
host=( host=(
credentials.host if isinstance(credentials, HostScopedCredentials) else None credentials.host if isinstance(credentials, HostScopedCredentials) else None
), ),
is_system=is_system_credential(credentials.id),
) )
@@ -192,6 +198,8 @@ async def callback(
async def list_credentials( async def list_credentials(
user_id: Annotated[str, Security(get_user_id)], user_id: Annotated[str, Security(get_user_id)],
) -> list[CredentialsMetaResponse]: ) -> list[CredentialsMetaResponse]:
from backend.integrations.credentials_store import is_system_credential
credentials = await creds_manager.store.get_all_creds(user_id) credentials = await creds_manager.store.get_all_creds(user_id)
return [ return [
CredentialsMetaResponse( CredentialsMetaResponse(
@@ -202,6 +210,7 @@ async def list_credentials(
scopes=cred.scopes if isinstance(cred, OAuth2Credentials) else None, scopes=cred.scopes if isinstance(cred, OAuth2Credentials) else None,
username=cred.username if isinstance(cred, OAuth2Credentials) else None, username=cred.username if isinstance(cred, OAuth2Credentials) else None,
host=cred.host if isinstance(cred, HostScopedCredentials) else None, host=cred.host if isinstance(cred, HostScopedCredentials) else None,
is_system=is_system_credential(cred.id),
) )
for cred in credentials for cred in credentials
] ]
@@ -214,6 +223,8 @@ async def list_credentials_by_provider(
], ],
user_id: Annotated[str, Security(get_user_id)], user_id: Annotated[str, Security(get_user_id)],
) -> list[CredentialsMetaResponse]: ) -> list[CredentialsMetaResponse]:
from backend.integrations.credentials_store import is_system_credential
credentials = await creds_manager.store.get_creds_by_provider(user_id, provider) credentials = await creds_manager.store.get_creds_by_provider(user_id, provider)
return [ return [
CredentialsMetaResponse( CredentialsMetaResponse(
@@ -224,6 +235,7 @@ async def list_credentials_by_provider(
scopes=cred.scopes if isinstance(cred, OAuth2Credentials) else None, scopes=cred.scopes if isinstance(cred, OAuth2Credentials) else None,
username=cred.username if isinstance(cred, OAuth2Credentials) else None, username=cred.username if isinstance(cred, OAuth2Credentials) else None,
host=cred.host if isinstance(cred, HostScopedCredentials) else None, host=cred.host if isinstance(cred, HostScopedCredentials) else None,
is_system=is_system_credential(cred.id),
) )
for cred in credentials for cred in credentials
] ]

View File

@@ -245,6 +245,13 @@ DEFAULT_CREDENTIALS = [
webshare_proxy_credentials, webshare_proxy_credentials,
] ]
SYSTEM_CREDENTIAL_IDS = {cred.id for cred in DEFAULT_CREDENTIALS}
def is_system_credential(credential_id: str) -> bool:
"""Check if a credential ID belongs to a system-managed credential."""
return credential_id in SYSTEM_CREDENTIAL_IDS
class IntegrationCredentialsStore: class IntegrationCredentialsStore:
def __init__(self): def __init__(self):

View File

@@ -3,6 +3,13 @@ import { withSentryConfig } from "@sentry/nextjs";
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
productionBrowserSourceMaps: true, productionBrowserSourceMaps: true,
// Externalize OpenTelemetry packages to fix Turbopack HMR issues
serverExternalPackages: [
"@opentelemetry/instrumentation",
"@opentelemetry/sdk-node",
"import-in-the-middle",
"require-in-the-middle",
],
experimental: { experimental: {
serverActions: { serverActions: {
bodySizeLimit: "256mb", bodySizeLimit: "256mb",

View File

@@ -117,6 +117,7 @@
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "4.1.2", "@chromatic-com/storybook": "4.1.2",
"@opentelemetry/instrumentation": "0.209.0",
"@playwright/test": "1.56.1", "@playwright/test": "1.56.1",
"@storybook/addon-a11y": "9.1.5", "@storybook/addon-a11y": "9.1.5",
"@storybook/addon-docs": "9.1.5", "@storybook/addon-docs": "9.1.5",
@@ -140,6 +141,7 @@
"eslint": "8.57.1", "eslint": "8.57.1",
"eslint-config-next": "15.5.7", "eslint-config-next": "15.5.7",
"eslint-plugin-storybook": "9.1.5", "eslint-plugin-storybook": "9.1.5",
"import-in-the-middle": "2.0.2",
"msw": "2.11.6", "msw": "2.11.6",
"msw-storybook-addon": "2.0.6", "msw-storybook-addon": "2.0.6",
"orval": "7.13.0", "orval": "7.13.0",
@@ -147,7 +149,7 @@
"postcss": "8.5.6", "postcss": "8.5.6",
"prettier": "3.6.2", "prettier": "3.6.2",
"prettier-plugin-tailwindcss": "0.7.1", "prettier-plugin-tailwindcss": "0.7.1",
"require-in-the-middle": "7.5.2", "require-in-the-middle": "8.0.1",
"storybook": "9.1.5", "storybook": "9.1.5",
"tailwindcss": "3.4.17", "tailwindcss": "3.4.17",
"typescript": "5.9.3" "typescript": "5.9.3"

View File

@@ -270,6 +270,9 @@ importers:
'@chromatic-com/storybook': '@chromatic-com/storybook':
specifier: 4.1.2 specifier: 4.1.2
version: 4.1.2(storybook@9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2)) version: 4.1.2(storybook@9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2))
'@opentelemetry/instrumentation':
specifier: 0.209.0
version: 0.209.0(@opentelemetry/api@1.9.0)
'@playwright/test': '@playwright/test':
specifier: 1.56.1 specifier: 1.56.1
version: 1.56.1 version: 1.56.1
@@ -339,6 +342,9 @@ importers:
eslint-plugin-storybook: eslint-plugin-storybook:
specifier: 9.1.5 specifier: 9.1.5
version: 9.1.5(eslint@8.57.1)(storybook@9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2))(typescript@5.9.3) version: 9.1.5(eslint@8.57.1)(storybook@9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2))(typescript@5.9.3)
import-in-the-middle:
specifier: 2.0.2
version: 2.0.2
msw: msw:
specifier: 2.11.6 specifier: 2.11.6
version: 2.11.6(@types/node@24.10.0)(typescript@5.9.3) version: 2.11.6(@types/node@24.10.0)(typescript@5.9.3)
@@ -361,8 +367,8 @@ importers:
specifier: 0.7.1 specifier: 0.7.1
version: 0.7.1(prettier@3.6.2) version: 0.7.1(prettier@3.6.2)
require-in-the-middle: require-in-the-middle:
specifier: 7.5.2 specifier: 8.0.1
version: 7.5.2 version: 8.0.1
storybook: storybook:
specifier: 9.1.5 specifier: 9.1.5
version: 9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2) version: 9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2)
@@ -1547,6 +1553,10 @@ packages:
resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==} resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
'@opentelemetry/api-logs@0.209.0':
resolution: {integrity: sha512-xomnUNi7TiAGtOgs0tb54LyrjRZLu9shJGGwkcN7NgtiPYOpNnKLkRJtzZvTjD/w6knSZH9sFZcUSUovYOPg6A==}
engines: {node: '>=8.0.0'}
'@opentelemetry/api@1.9.0': '@opentelemetry/api@1.9.0':
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
@@ -1701,6 +1711,12 @@ packages:
peerDependencies: peerDependencies:
'@opentelemetry/api': ^1.3.0 '@opentelemetry/api': ^1.3.0
'@opentelemetry/instrumentation@0.209.0':
resolution: {integrity: sha512-Cwe863ojTCnFlxVuuhG7s6ODkAOzKsAEthKAcI4MDRYz1OmGWYnmSl4X2pbyS+hBxVTdvfZePfoEA01IjqcEyw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/redis-common@0.38.2': '@opentelemetry/redis-common@0.38.2':
resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==} resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==}
engines: {node: ^18.19.0 || >=20.6.0} engines: {node: ^18.19.0 || >=20.6.0}
@@ -4957,8 +4973,8 @@ packages:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
import-in-the-middle@2.0.1: import-in-the-middle@2.0.2:
resolution: {integrity: sha512-bruMpJ7xz+9jwGzrwEhWgvRrlKRYCRDBrfU+ur3FcasYXLJDxTruJ//8g2Noj+QFyRBeqbpj8Bhn4Fbw6HjvhA==} resolution: {integrity: sha512-qet/hkGt3EbNGVtbDfPu0BM+tCqBS8wT1SYrstPaDKoWtshsC6licOemz7DVtpBEyvDNzo8UTBf9/GwWuSDZ9w==}
imurmurhash@0.1.4: imurmurhash@0.1.4:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
@@ -6502,10 +6518,6 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
require-in-the-middle@7.5.2:
resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==}
engines: {node: '>=8.6.0'}
require-in-the-middle@8.0.1: require-in-the-middle@8.0.1:
resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==}
engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'}
@@ -8720,6 +8732,10 @@ snapshots:
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs@0.209.0':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api@1.9.0': {} '@opentelemetry/api@1.9.0': {}
'@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0)': '@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0)':
@@ -8920,7 +8936,16 @@ snapshots:
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.208.0 '@opentelemetry/api-logs': 0.208.0
import-in-the-middle: 2.0.1 import-in-the-middle: 2.0.2
require-in-the-middle: 8.0.1
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation@0.209.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.209.0
import-in-the-middle: 2.0.2
require-in-the-middle: 8.0.1 require-in-the-middle: 8.0.1
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -9100,7 +9125,7 @@ snapshots:
'@prisma/instrumentation@6.19.0(@opentelemetry/api@1.9.0)': '@prisma/instrumentation@6.19.0(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -9944,7 +9969,7 @@ snapshots:
'@opentelemetry/semantic-conventions': 1.38.0 '@opentelemetry/semantic-conventions': 1.38.0
'@sentry/core': 10.27.0 '@sentry/core': 10.27.0
'@sentry/opentelemetry': 10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0) '@sentry/opentelemetry': 10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)
import-in-the-middle: 2.0.1 import-in-the-middle: 2.0.2
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -9983,7 +10008,7 @@ snapshots:
'@sentry/core': 10.27.0 '@sentry/core': 10.27.0
'@sentry/node-core': 10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0) '@sentry/node-core': 10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)
'@sentry/opentelemetry': 10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0) '@sentry/opentelemetry': 10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)
import-in-the-middle: 2.0.1 import-in-the-middle: 2.0.2
minimatch: 9.0.5 minimatch: 9.0.5
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -12792,7 +12817,7 @@ snapshots:
parent-module: 1.0.1 parent-module: 1.0.1
resolve-from: 4.0.0 resolve-from: 4.0.0
import-in-the-middle@2.0.1: import-in-the-middle@2.0.2:
dependencies: dependencies:
acorn: 8.15.0 acorn: 8.15.0
acorn-import-attributes: 1.9.5(acorn@8.15.0) acorn-import-attributes: 1.9.5(acorn@8.15.0)
@@ -14631,14 +14656,6 @@ snapshots:
require-from-string@2.0.2: {} require-from-string@2.0.2: {}
require-in-the-middle@7.5.2:
dependencies:
debug: 4.4.3
module-details-from-path: 1.0.4
resolve: 1.22.11
transitivePeerDependencies:
- supports-color
require-in-the-middle@8.0.1: require-in-the-middle@8.0.1:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3

View File

@@ -1,32 +1,38 @@
"use client"; "use client";
import { Button } from "@/components/atoms/Button/Button"; import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/molecules/Alert/Alert";
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs"; import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard"; import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PlusIcon } from "@phosphor-icons/react"; import { PlusIcon } from "@phosphor-icons/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { RunAgentModal } from "./components/modals/RunAgentModal/RunAgentModal";
import { useMarketplaceUpdate } from "./hooks/useMarketplaceUpdate";
import { AgentVersionChangelog } from "./components/AgentVersionChangelog"; import { AgentVersionChangelog } from "./components/AgentVersionChangelog";
import { MarketplaceBanners } from "@/components/contextual/MarketplaceBanners/MarketplaceBanners"; import { AgentSettingsModal } from "./components/modals/AgentSettingsModal/AgentSettingsModal";
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal"; import { RunAgentModal } from "./components/modals/RunAgentModal/RunAgentModal";
import { AgentSettingsButton } from "./components/other/AgentSettingsButton";
import { AgentRunsLoading } from "./components/other/AgentRunsLoading"; import { AgentRunsLoading } from "./components/other/AgentRunsLoading";
import { EmptySchedules } from "./components/other/EmptySchedules"; import { EmptySchedules } from "./components/other/EmptySchedules";
import { EmptyTasks } from "./components/other/EmptyTasks"; import { EmptyTasks } from "./components/other/EmptyTasks";
import { EmptyTemplates } from "./components/other/EmptyTemplates"; import { EmptyTemplates } from "./components/other/EmptyTemplates";
import { EmptyTriggers } from "./components/other/EmptyTriggers"; import { EmptyTriggers } from "./components/other/EmptyTriggers";
import { MarketplaceBanners } from "./components/other/MarketplaceBanners";
import { SectionWrap } from "./components/other/SectionWrap"; import { SectionWrap } from "./components/other/SectionWrap";
import { LoadingSelectedContent } from "./components/selected-views/LoadingSelectedContent"; import { LoadingSelectedContent } from "./components/selected-views/LoadingSelectedContent";
import { SelectedRunView } from "./components/selected-views/SelectedRunView/SelectedRunView"; import { SelectedRunView } from "./components/selected-views/SelectedRunView/SelectedRunView";
import { SelectedScheduleView } from "./components/selected-views/SelectedScheduleView/SelectedScheduleView"; import { SelectedScheduleView } from "./components/selected-views/SelectedScheduleView/SelectedScheduleView";
import { SelectedSettingsView } from "./components/selected-views/SelectedSettingsView/SelectedSettingsView";
import { SelectedTemplateView } from "./components/selected-views/SelectedTemplateView/SelectedTemplateView"; import { SelectedTemplateView } from "./components/selected-views/SelectedTemplateView/SelectedTemplateView";
import { SelectedTriggerView } from "./components/selected-views/SelectedTriggerView/SelectedTriggerView"; import { SelectedTriggerView } from "./components/selected-views/SelectedTriggerView/SelectedTriggerView";
import { SelectedViewLayout } from "./components/selected-views/SelectedViewLayout"; import { SelectedViewLayout } from "./components/selected-views/SelectedViewLayout";
import { SidebarRunsList } from "./components/sidebar/SidebarRunsList/SidebarRunsList"; import { SidebarRunsList } from "./components/sidebar/SidebarRunsList/SidebarRunsList";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "./helpers"; import { AGENT_LIBRARY_SECTION_PADDING_X } from "./helpers";
import { useAgentMissingCredentials } from "./hooks/useAgentMissingCredentials";
import { useMarketplaceUpdate } from "./hooks/useMarketplaceUpdate";
import { useNewAgentLibraryView } from "./useNewAgentLibraryView"; import { useNewAgentLibraryView } from "./useNewAgentLibraryView";
export function NewAgentLibraryView() { export function NewAgentLibraryView() {
@@ -45,7 +51,6 @@ export function NewAgentLibraryView() {
handleSelectRun, handleSelectRun,
handleCountsChange, handleCountsChange,
handleClearSelectedRun, handleClearSelectedRun,
handleSelectSettings,
onRunInitiated, onRunInitiated,
onTriggerSetup, onTriggerSetup,
onScheduleCreated, onScheduleCreated,
@@ -63,6 +68,10 @@ export function NewAgentLibraryView() {
} = useMarketplaceUpdate({ agent }); } = useMarketplaceUpdate({ agent });
const [changelogOpen, setChangelogOpen] = useState(false); const [changelogOpen, setChangelogOpen] = useState(false);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const { hasMissingCredentials, isLoading: isLoadingCredentials } =
useAgentMissingCredentials(agent);
useEffect(() => { useEffect(() => {
if (agent) { if (agent) {
@@ -137,13 +146,33 @@ export function NewAgentLibraryView() {
return ( return (
<> <>
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="mx-6 pt-4"> <div className="mx-6 flex flex-col gap-4 pt-4">
<Breadcrumbs <div className="flex items-center justify-between">
items={[ <Breadcrumbs
{ name: "My Library", link: "/library" }, items={[
{ name: agent.name, link: `/library/agents/${agentId}` }, { name: "My Library", link: "/library" },
]} { name: agent.name, link: `/library/agents/${agentId}` },
/> ]}
/>
<AgentSettingsModal agent={agent} />
</div>
{hasMissingCredentials && !isLoadingCredentials && (
<Alert variant="warning">
<AlertTitle>Missing credentials</AlertTitle>
<AlertDescription>
<Text variant="small" className="text-zinc-800">
This agent requires credentials that are not configured.{" "}
<button
onClick={() => setSettingsModalOpen(true)}
className="font-medium underline hover:no-underline"
>
Configure credentials
</button>{" "}
to run tasks.
</Text>
</AlertDescription>
</Alert>
)}
</div> </div>
<div className="flex min-h-0 flex-1"> <div className="flex min-h-0 flex-1">
<EmptyTasks <EmptyTasks
@@ -154,6 +183,13 @@ export function NewAgentLibraryView() {
/> />
</div> </div>
</div> </div>
{agent && (
<AgentSettingsModal
agent={agent}
controlledOpen={settingsModalOpen}
onOpenChange={setSettingsModalOpen}
/>
)}
{renderPublishAgentModal()} {renderPublishAgentModal()}
{renderVersionChangelog()} {renderVersionChangelog()}
</> </>
@@ -164,37 +200,49 @@ export function NewAgentLibraryView() {
<> <>
<div className="mx-4 grid h-full grid-cols-1 gap-0 pt-3 md:ml-4 md:mr-0 md:gap-4 lg:grid-cols-[25%_70%]"> <div className="mx-4 grid h-full grid-cols-1 gap-0 pt-3 md:ml-4 md:mr-0 md:gap-4 lg:grid-cols-[25%_70%]">
<SectionWrap className="mb-3 block"> <SectionWrap className="mb-3 block">
{hasMissingCredentials && !isLoadingCredentials && (
<div className={cn("mb-4", AGENT_LIBRARY_SECTION_PADDING_X)}>
<Alert variant="warning">
<AlertTitle>Missing credentials</AlertTitle>
<AlertDescription>
<Text variant="small" className="text-zinc-800">
This agent requires credentials that are not configured.{" "}
<button
onClick={() => setSettingsModalOpen(true)}
className="font-medium underline hover:no-underline"
>
Configure credentials
</button>{" "}
to run tasks.
</Text>
</AlertDescription>
</Alert>
</div>
)}
<div <div
className={cn( className={cn(
"border-b border-zinc-100 pb-5", "border-b border-zinc-100 pb-5",
AGENT_LIBRARY_SECTION_PADDING_X, AGENT_LIBRARY_SECTION_PADDING_X,
)} )}
> >
<div className="flex items-center gap-2"> <RunAgentModal
<RunAgentModal triggerSlot={
triggerSlot={ <Button
<Button variant="primary"
variant="primary" size="large"
size="large" className="w-full"
className="flex-1" disabled={isTemplateLoading && activeTab === "templates"}
disabled={isTemplateLoading && activeTab === "templates"} >
> <PlusIcon size={20} /> New task
<PlusIcon size={20} /> New task </Button>
</Button> }
} agent={agent}
agent={agent} onRunCreated={onRunInitiated}
onRunCreated={onRunInitiated} onScheduleCreated={onScheduleCreated}
onScheduleCreated={onScheduleCreated} onTriggerSetup={onTriggerSetup}
onTriggerSetup={onTriggerSetup} initialInputValues={activeTemplate?.inputs}
initialInputValues={activeTemplate?.inputs} initialInputCredentials={activeTemplate?.credentials}
initialInputCredentials={activeTemplate?.credentials} />
/>
<AgentSettingsButton
agent={agent}
onSelectSettings={handleSelectSettings}
selected={activeItem === "settings"}
/>
</div>
</div> </div>
<SidebarRunsList <SidebarRunsList
@@ -208,12 +256,7 @@ export function NewAgentLibraryView() {
</SectionWrap> </SectionWrap>
{activeItem ? ( {activeItem ? (
activeItem === "settings" ? ( activeTab === "scheduled" ? (
<SelectedSettingsView
agent={agent}
onClearSelectedRun={handleClearSelectedRun}
/>
) : activeTab === "scheduled" ? (
<SelectedScheduleView <SelectedScheduleView
agent={agent} agent={agent}
scheduleId={activeItem} scheduleId={activeItem}
@@ -246,8 +289,6 @@ export function NewAgentLibraryView() {
onSelectRun={handleSelectRun} onSelectRun={handleSelectRun}
onClearSelectedRun={handleClearSelectedRun} onClearSelectedRun={handleClearSelectedRun}
banner={renderMarketplaceUpdateBanner()} banner={renderMarketplaceUpdateBanner()}
onSelectSettings={handleSelectSettings}
selectedSettings={activeItem === "settings"}
/> />
) )
) : sidebarLoading ? ( ) : sidebarLoading ? (
@@ -287,6 +328,13 @@ export function NewAgentLibraryView() {
</SelectedViewLayout> </SelectedViewLayout>
)} )}
</div> </div>
{agent && (
<AgentSettingsModal
agent={agent}
controlledOpen={settingsModalOpen}
onOpenChange={setSettingsModalOpen}
/>
)}
{renderPublishAgentModal()} {renderPublishAgentModal()}
{renderVersionChangelog()} {renderVersionChangelog()}
</> </>

View File

@@ -4,6 +4,7 @@ import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Text } from "@/components/atoms/Text/Text"; import { Text } from "@/components/atoms/Text/Text";
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types"; import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { CredentialsInput } from "../CredentialsInputs/CredentialsInputs"; import { CredentialsInput } from "../CredentialsInputs/CredentialsInputs";
import { isSystemCredential } from "../CredentialsInputs/helpers";
import { RunAgentInputs } from "../RunAgentInputs/RunAgentInputs"; import { RunAgentInputs } from "../RunAgentInputs/RunAgentInputs";
import { getAgentCredentialsFields, getAgentInputFields } from "./helpers"; import { getAgentCredentialsFields, getAgentInputFields } from "./helpers";
@@ -71,6 +72,7 @@ export function AgentInputsReadOnly({
{credentialFieldEntries.map(([key, inputSubSchema]) => { {credentialFieldEntries.map(([key, inputSubSchema]) => {
const credential = credentialInputs![key]; const credential = credentialInputs![key];
if (!credential) return null; if (!credential) return null;
if (isSystemCredential(credential)) return null;
return ( return (
<CredentialsInput <CredentialsInput

View File

@@ -0,0 +1,131 @@
"use client";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Button } from "@/components/atoms/Button/Button";
import { Switch } from "@/components/atoms/Switch/Switch";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
import { GearIcon } from "@phosphor-icons/react";
import { useMemo, useState } from "react";
import { useAgentSystemCredentials } from "../../../hooks/useAgentSystemCredentials";
import { SystemCredentialRow } from "../../selected-views/SelectedSettingsView/components/SystemCredentialRow";
interface Props {
agent: LibraryAgent;
controlledOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function AgentSettingsModal({
agent,
controlledOpen,
onOpenChange,
}: Props) {
const [internalIsOpen, setInternalIsOpen] = useState(false);
const isOpen = controlledOpen !== undefined ? controlledOpen : internalIsOpen;
function setIsOpen(open: boolean) {
if (onOpenChange) {
onOpenChange(open);
} else {
setInternalIsOpen(open);
}
}
const { currentSafeMode, isPending, hasHITLBlocks, handleToggle } =
useAgentSafeMode(agent);
const { hasSystemCredentials, systemCredentials } =
useAgentSystemCredentials(agent);
// Only show credential fields that have system credentials
const credentialFieldsWithSystemCreds = useMemo(() => {
return systemCredentials.map((item) => ({
fieldKey: item.key,
schema: item.schema,
systemCredential: item.credential,
}));
}, [systemCredentials]);
const hasAnySettings = hasHITLBlocks || hasSystemCredentials;
return (
<Dialog
controlled={{ isOpen, set: setIsOpen }}
styling={{ maxWidth: "600px", maxHeight: "90vh" }}
title="Agent Settings"
>
{controlledOpen === undefined && (
<Dialog.Trigger>
<Button
variant="ghost"
size="small"
className="m-0 min-w-0 rounded-full p-0 px-1"
aria-label="Agent Settings"
>
<GearIcon size={18} className="text-zinc-600" />
<Text variant="small">Agent Settings</Text>
</Button>
</Dialog.Trigger>
)}
<Dialog.Content>
<div className="space-y-6">
{hasHITLBlocks && (
<div className="flex w-full flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<div className="flex w-full items-start justify-between gap-4">
<div className="flex-1">
<Text variant="large-semibold">Require human approval</Text>
<Text variant="large" className="mt-1 text-zinc-900">
The agent will pause and wait for your review before
continuing
</Text>
</div>
<Switch
checked={currentSafeMode || false}
onCheckedChange={handleToggle}
disabled={isPending}
className="mt-1"
/>
</div>
</div>
)}
{hasSystemCredentials && (
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<div>
<Text variant="large-semibold">System Credentials</Text>
<Text variant="body" className="mt-1 text-muted-foreground">
These credentials are managed by AutoGPT and used by the agent
to access various services. You can switch to your own
credentials if preferred.
</Text>
</div>
<div className="w-full space-y-4">
{credentialFieldsWithSystemCreds.map(
({ fieldKey, schema, systemCredential }) => (
<SystemCredentialRow
key={fieldKey}
credentialKey={fieldKey}
agentId={agent.id.toString()}
schema={schema}
systemCredential={systemCredential}
/>
),
)}
</div>
</div>
)}
{!hasAnySettings && (
<div className="py-6">
<Text variant="body" className="text-muted-foreground">
This agent doesn&apos;t have any configurable settings.
</Text>
</div>
)}
</div>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -10,11 +10,10 @@ import { toDisplayName } from "@/providers/agent-credentials/helper";
import { APIKeyCredentialsModal } from "./components/APIKeyCredentialsModal/APIKeyCredentialsModal"; import { APIKeyCredentialsModal } from "./components/APIKeyCredentialsModal/APIKeyCredentialsModal";
import { CredentialRow } from "./components/CredentialRow/CredentialRow"; import { CredentialRow } from "./components/CredentialRow/CredentialRow";
import { CredentialsSelect } from "./components/CredentialsSelect/CredentialsSelect"; import { CredentialsSelect } from "./components/CredentialsSelect/CredentialsSelect";
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal/DeleteConfirmationModal";
import { HostScopedCredentialsModal } from "./components/HotScopedCredentialsModal/HotScopedCredentialsModal"; import { HostScopedCredentialsModal } from "./components/HotScopedCredentialsModal/HotScopedCredentialsModal";
import { OAuthFlowWaitingModal } from "./components/OAuthWaitingModal/OAuthWaitingModal"; import { OAuthFlowWaitingModal } from "./components/OAuthWaitingModal/OAuthWaitingModal";
import { PasswordCredentialsModal } from "./components/PasswordCredentialsModal/PasswordCredentialsModal"; import { PasswordCredentialsModal } from "./components/PasswordCredentialsModal/PasswordCredentialsModal";
import { getCredentialDisplayName } from "./helpers"; import { isSystemCredential } from "./helpers";
import { import {
CredentialsInputState, CredentialsInputState,
useCredentialsInput, useCredentialsInput,
@@ -37,6 +36,7 @@ type Props = {
isOptional?: boolean; isOptional?: boolean;
showTitle?: boolean; showTitle?: boolean;
variant?: "default" | "node"; variant?: "default" | "node";
allowSystemCredentials?: boolean; // Allow system credentials (for settings only)
}; };
export function CredentialsInput({ export function CredentialsInput({
@@ -50,6 +50,7 @@ export function CredentialsInput({
isOptional = false, isOptional = false,
showTitle = true, showTitle = true,
variant = "default", variant = "default",
allowSystemCredentials = false,
}: Props) { }: Props) {
const hookData = useCredentialsInput({ const hookData = useCredentialsInput({
schema, schema,
@@ -59,6 +60,7 @@ export function CredentialsInput({
onLoaded, onLoaded,
readOnly, readOnly,
isOptional, isOptional,
allowSystemCredentials,
}); });
if (!isLoaded(hookData)) { if (!isLoaded(hookData)) {
@@ -79,21 +81,22 @@ export function CredentialsInput({
isHostScopedCredentialsModalOpen, isHostScopedCredentialsModalOpen,
isOAuth2FlowInProgress, isOAuth2FlowInProgress,
oAuthPopupController, oAuthPopupController,
credentialToDelete,
deleteCredentialsMutation,
actionButtonText, actionButtonText,
setAPICredentialsModalOpen, setAPICredentialsModalOpen,
setUserPasswordCredentialsModalOpen, setUserPasswordCredentialsModalOpen,
setHostScopedCredentialsModalOpen, setHostScopedCredentialsModalOpen,
setCredentialToDelete,
handleActionButtonClick, handleActionButtonClick,
handleCredentialSelect, handleCredentialSelect,
handleDeleteCredential,
handleDeleteConfirm,
} = hookData; } = hookData;
const displayName = toDisplayName(provider); const displayName = toDisplayName(provider);
const hasCredentialsToShow = credentialsToShow.length > 0; const hasCredentialsToShow = credentialsToShow.length > 0;
const selectedCredentialIsSystem =
selectedCredential && isSystemCredential(selectedCredential);
if (readOnly && selectedCredentialIsSystem) {
return null;
}
return ( return (
<div className={cn("mb-6", className)}> <div className={cn("mb-6", className)}>
@@ -137,15 +140,6 @@ export function CredentialsInput({
provider={provider} provider={provider}
displayName={displayName} displayName={displayName}
onSelect={() => handleCredentialSelect(credential.id)} onSelect={() => handleCredentialSelect(credential.id)}
onDelete={() =>
handleDeleteCredential({
id: credential.id,
title: getCredentialDisplayName(
credential,
displayName,
),
})
}
readOnly={readOnly} readOnly={readOnly}
/> />
); );
@@ -229,13 +223,6 @@ export function CredentialsInput({
Error: {oAuthError} Error: {oAuthError}
</Text> </Text>
) : null} ) : null}
<DeleteConfirmationModal
credentialToDelete={credentialToDelete}
isDeleting={deleteCredentialsMutation.isPending}
onClose={() => setCredentialToDelete(null)}
onConfirm={handleDeleteConfirm}
/>
</> </>
)} )}
</div> </div>

View File

@@ -1,11 +1,11 @@
import { Input } from "@/components/atoms/Input/Input";
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { import {
Form, Form,
FormDescription, FormDescription,
FormField, FormField,
} from "@/components/__legacy__/ui/form"; } from "@/components/__legacy__/ui/form";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { import {
BlockIOCredentialsSubSchema, BlockIOCredentialsSubSchema,
CredentialsMetaInput, CredentialsMetaInput,
@@ -60,7 +60,10 @@ export function APIKeyCredentialsModal({
)} )}
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2"> <form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-2 px-2"
>
<FormField <FormField
control={form.control} control={form.control}
name="apiKey" name="apiKey"
@@ -70,8 +73,7 @@ export function APIKeyCredentialsModal({
id="apiKey" id="apiKey"
label="API Key" label="API Key"
type="password" type="password"
placeholder="Enter API key..." placeholder="Enter API Key..."
size="small"
hint={ hint={
schema.credentials_scopes ? ( schema.credentials_scopes ? (
<FormDescription> <FormDescription>
@@ -98,8 +100,7 @@ export function APIKeyCredentialsModal({
id="title" id="title"
label="Name" label="Name"
type="text" type="text"
placeholder="Enter a name for this API key..." placeholder="Enter a name for this API Key..."
size="small"
{...field} {...field}
/> />
)} )}
@@ -113,13 +114,12 @@ export function APIKeyCredentialsModal({
label="Expiration Date" label="Expiration Date"
type="datetime-local" type="datetime-local"
placeholder="Select expiration date..." placeholder="Select expiration date..."
size="small"
{...field} {...field}
/> />
)} )}
/> />
<Button type="submit" size="small" className="min-w-68"> <Button type="submit" className="min-w-68">
Save & use this API key Add API Key
</Button> </Button>
</form> </form>
</Form> </Form>

View File

@@ -26,7 +26,7 @@ type CredentialRowProps = {
provider: string; provider: string;
displayName: string; displayName: string;
onSelect: () => void; onSelect: () => void;
onDelete: () => void; onDelete?: () => void;
readOnly?: boolean; readOnly?: boolean;
showCaret?: boolean; showCaret?: boolean;
asSelectTrigger?: boolean; asSelectTrigger?: boolean;
@@ -100,7 +100,7 @@ export function CredentialRow({
{showCaret && !asSelectTrigger && ( {showCaret && !asSelectTrigger && (
<CaretDown className="h-4 w-4 shrink-0 text-gray-400" /> <CaretDown className="h-4 w-4 shrink-0 text-gray-400" />
)} )}
{!readOnly && !showCaret && !asSelectTrigger && ( {!readOnly && !showCaret && !asSelectTrigger && onDelete && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button

View File

@@ -65,7 +65,7 @@ export function CredentialsSelect({
> >
<SelectTrigger <SelectTrigger
className={cn( className={cn(
"h-auto min-h-12 w-full rounded-medium border-zinc-200 p-0 pr-4 shadow-none", "h-auto min-h-12 w-full rounded-medium p-0 pr-4 shadow-none",
variant === "node" && "overflow-hidden", variant === "node" && "overflow-hidden",
)} )}
> >
@@ -87,6 +87,39 @@ export function CredentialsSelect({
variant={variant} variant={variant}
/> />
</SelectValue> </SelectValue>
) : allowNone ? (
<SelectValue key="__none__" asChild>
<div
className={cn(
"flex items-center gap-3 rounded-medium border border-zinc-200 bg-white p-3 transition-colors",
variant === "node"
? "min-w-0 flex-1 overflow-hidden border-0 bg-transparent"
: "border-0 bg-transparent",
)}
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-zinc-200">
<Text
variant="body"
className="text-xs font-medium text-zinc-500"
>
</Text>
</div>
<div
className={cn(
"flex min-w-0 flex-1 flex-nowrap items-center gap-4",
variant === "node" && "overflow-hidden",
)}
>
<Text
variant="body"
className={cn("tracking-tight text-zinc-500")}
>
None (skip this credential)
</Text>
</div>
</div>
</SelectValue>
) : ( ) : (
<SelectValue key="placeholder" placeholder="Select credential" /> <SelectValue key="placeholder" placeholder="Select credential" />
)} )}

View File

@@ -100,3 +100,29 @@ export function getCredentialDisplayName(
export const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; export const OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
export const MASKED_KEY_LENGTH = 30; export const MASKED_KEY_LENGTH = 30;
export function isSystemCredential(credential: {
title?: string | null;
is_system?: boolean;
}): boolean {
if (credential.is_system === true) return true;
if (!credential.title) return false;
const titleLower = credential.title.toLowerCase();
return (
titleLower.includes("system") ||
titleLower.startsWith("use credits for") ||
titleLower.includes("use credits")
);
}
export function filterSystemCredentials<
T extends { title?: string; is_system?: boolean },
>(credentials: T[]): T[] {
return credentials.filter((cred) => !isSystemCredential(cred));
}
export function getSystemCredentials<
T extends { title?: string; is_system?: boolean },
>(credentials: T[]): T[] {
return credentials.filter((cred) => isSystemCredential(cred));
}

View File

@@ -6,9 +6,11 @@ import {
CredentialsMetaInput, CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types"; } from "@/lib/autogpt-server-api/types";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { import {
filterSystemCredentials,
getActionButtonText, getActionButtonText,
getSystemCredentials,
OAUTH_TIMEOUT_MS, OAUTH_TIMEOUT_MS,
OAuthPopupResultMessage, OAuthPopupResultMessage,
} from "./helpers"; } from "./helpers";
@@ -23,6 +25,7 @@ type Params = {
onLoaded?: (loaded: boolean) => void; onLoaded?: (loaded: boolean) => void;
readOnly?: boolean; readOnly?: boolean;
isOptional?: boolean; isOptional?: boolean;
allowSystemCredentials?: boolean; // Allow system credentials (for settings only)
}; };
export function useCredentialsInput({ export function useCredentialsInput({
@@ -33,6 +36,7 @@ export function useCredentialsInput({
onLoaded, onLoaded,
readOnly = false, readOnly = false,
isOptional = false, isOptional = false,
allowSystemCredentials = false,
}: Params) { }: Params) {
const [isAPICredentialsModalOpen, setAPICredentialsModalOpen] = const [isAPICredentialsModalOpen, setAPICredentialsModalOpen] =
useState(false); useState(false);
@@ -54,6 +58,7 @@ export function useCredentialsInput({
const api = useBackendAPI(); const api = useBackendAPI();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const credentials = useCredentials(schema, siblingInputs); const credentials = useCredentials(schema, siblingInputs);
const hasAttemptedAutoSelect = useRef(false);
const deleteCredentialsMutation = useDeleteV1DeleteCredentials({ const deleteCredentialsMutation = useDeleteV1DeleteCredentials({
mutation: { mutation: {
@@ -82,13 +87,22 @@ export function useCredentialsInput({
useEffect(() => { useEffect(() => {
if (readOnly) return; if (readOnly) return;
if (!credentials || !("savedCredentials" in credentials)) return; if (!credentials || !("savedCredentials" in credentials)) return;
const availableCreds = allowSystemCredentials
? credentials.savedCredentials
: filterSystemCredentials(credentials.savedCredentials);
if ( if (
selectedCredential && selectedCredential &&
!credentials.savedCredentials.some((c) => c.id === selectedCredential.id) !availableCreds.some((c) => c.id === selectedCredential.id)
) { ) {
onSelectCredential(undefined); onSelectCredential(undefined);
} }
}, [credentials, selectedCredential, onSelectCredential, readOnly]); }, [
credentials,
selectedCredential,
onSelectCredential,
readOnly,
allowSystemCredentials,
]);
// The available credential, if there is only one // The available credential, if there is only one
const singleCredential = useMemo(() => { const singleCredential = useMemo(() => {
@@ -96,24 +110,111 @@ export function useCredentialsInput({
return null; return null;
} }
return credentials.savedCredentials.length === 1 const credsToUse = allowSystemCredentials
? credentials.savedCredentials[0] ? credentials.savedCredentials
: null; : filterSystemCredentials(credentials.savedCredentials);
}, [credentials]); return credsToUse.length === 1 ? credsToUse[0] : null;
}, [credentials, allowSystemCredentials]);
// Auto-select the one available credential (only if not optional) // Auto-select the one available credential
// Prioritize system credentials if available
// For system credentials, always auto-select even if optional (they should be used by default)
useEffect(() => { useEffect(() => {
if (readOnly) return; if (readOnly) return;
if (isOptional) return; // Don't auto-select when credential is optional if (!credentials || !("savedCredentials" in credentials)) return;
if (singleCredential && !selectedCredential) {
// Early return if already selected to prevent infinite loops
const currentSelectedId = selectedCredential?.id;
if (currentSelectedId) {
hasAttemptedAutoSelect.current = true;
return;
}
// If selectedCredential is explicitly undefined and isOptional is true,
// don't auto-select - this could mean "None" was explicitly selected
// The parent component should handle setting the initial value
if (selectedCredential === undefined && isOptional) {
// Mark as attempted to prevent auto-selection when "None" is a valid choice
hasAttemptedAutoSelect.current = true;
return;
}
// Only attempt auto-selection once per credential load
if (hasAttemptedAutoSelect.current) return;
const supportedTypes = schema.credentials_types || [];
const requiredScopes = schema.credentials_scopes;
const savedCreds = credentials.savedCredentials;
const systemCreds = getSystemCredentials(savedCreds);
// Filter system credentials by type and scopes (same logic as useCredentials)
const matchingSystemCreds = systemCreds.filter((cred) => {
// Check type match
if (!supportedTypes.includes(cred.type)) {
return false;
}
// For OAuth2 credentials, check scopes
if (
cred.type === "oauth2" &&
requiredScopes &&
requiredScopes.length > 0
) {
const grantedScopes = new Set(cred.scopes || []);
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
grantedScopes,
);
if (!hasAllRequiredScopes) {
return false;
}
}
return true;
});
// First, try to auto-select system credential if available
if (matchingSystemCreds.length === 1) {
const systemCred = matchingSystemCreds[0];
const credProvider = credentials.provider;
hasAttemptedAutoSelect.current = true;
onSelectCredential({
id: systemCred.id,
type: systemCred.type,
provider: credProvider,
title: (systemCred as any).title,
});
return;
}
// Otherwise, auto-select single credential if there's only one (and not optional)
if (!isOptional && singleCredential) {
hasAttemptedAutoSelect.current = true;
onSelectCredential(singleCredential); onSelectCredential(singleCredential);
} }
}, [ }, [
singleCredential, singleCredential?.id, // Only depend on the ID, not the whole object
selectedCredential, selectedCredential?.id, // Only depend on the ID, not the whole object
onSelectCredential,
readOnly, readOnly,
isOptional, isOptional,
credentials,
schema.credentials_types,
schema.credentials_scopes,
// Note: onSelectCredential removed from deps to prevent infinite loops
// It should be stable, but if it's not, the ref will prevent multiple calls
]);
// Reset the ref when credentials change significantly
useEffect(() => {
if (credentials && "savedCredentials" in credentials) {
hasAttemptedAutoSelect.current = false;
}
}, [
credentials && "savedCredentials" in credentials
? credentials.savedCredentials.length
: 0,
credentials && "savedCredentials" in credentials
? credentials.provider
: null,
]); ]);
if ( if (
@@ -137,6 +238,11 @@ export function useCredentialsInput({
oAuthCallback, oAuthCallback,
} = credentials; } = credentials;
// Filter system credentials unless explicitly allowed (for settings)
const filteredCredentials = allowSystemCredentials
? savedCredentials
: filterSystemCredentials(savedCredentials);
async function handleOAuthLogin() { async function handleOAuthLogin() {
setOAuthError(null); setOAuthError(null);
const { login_url, state_token } = await api.oAuthLogin( const { login_url, state_token } = await api.oAuthLogin(
@@ -291,7 +397,7 @@ export function useCredentialsInput({
supportsOAuth2, supportsOAuth2,
supportsUserPassword, supportsUserPassword,
supportsHostScoped, supportsHostScoped,
credentialsToShow: savedCredentials, credentialsToShow: filteredCredentials,
selectedCredential, selectedCredential,
oAuthError, oAuthError,
isAPICredentialsModalOpen, isAPICredentialsModalOpen,
@@ -306,7 +412,7 @@ export function useCredentialsInput({
supportsApiKey, supportsApiKey,
supportsUserPassword, supportsUserPassword,
supportsHostScoped, supportsHostScoped,
savedCredentials.length > 0, filteredCredentials.length > 0,
), ),
setAPICredentialsModalOpen, setAPICredentialsModalOpen,
setUserPasswordCredentialsModalOpen, setUserPasswordCredentialsModalOpen,

View File

@@ -12,7 +12,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip"; } from "@/components/atoms/Tooltip/BaseTooltip";
import { Dialog } from "@/components/molecules/Dialog/Dialog"; import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { ScheduleAgentModal } from "../ScheduleAgentModal/ScheduleAgentModal"; import { ScheduleAgentModal } from "../ScheduleAgentModal/ScheduleAgentModal";
import { ModalHeader } from "./components/ModalHeader/ModalHeader"; import { ModalHeader } from "./components/ModalHeader/ModalHeader";
import { ModalRunSection } from "./components/ModalRunSection/ModalRunSection"; import { ModalRunSection } from "./components/ModalRunSection/ModalRunSection";
@@ -82,6 +82,8 @@ export function RunAgentModal({
}); });
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false); const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
const [hasOverflow, setHasOverflow] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const hasAnySetupFields = const hasAnySetupFields =
Object.keys(agentInputFields || {}).length > 0 || Object.keys(agentInputFields || {}).length > 0 ||
@@ -89,6 +91,43 @@ export function RunAgentModal({
const isTriggerRunType = defaultRunType.includes("trigger"); const isTriggerRunType = defaultRunType.includes("trigger");
useEffect(() => {
if (!isOpen) return;
function checkOverflow() {
if (!contentRef.current) return;
const scrollableParent = contentRef.current
.closest("[data-dialog-content]")
?.querySelector('[class*="overflow-y-auto"]');
if (scrollableParent) {
setHasOverflow(
scrollableParent.scrollHeight > scrollableParent.clientHeight,
);
}
}
const timeoutId = setTimeout(checkOverflow, 100);
const resizeObserver = new ResizeObserver(checkOverflow);
if (contentRef.current) {
const scrollableParent = contentRef.current
.closest("[data-dialog-content]")
?.querySelector('[class*="overflow-y-auto"]');
if (scrollableParent) {
resizeObserver.observe(scrollableParent);
}
}
return () => {
clearTimeout(timeoutId);
resizeObserver.disconnect();
};
}, [
isOpen,
hasAnySetupFields,
agentInputFields,
agentCredentialsInputFields,
]);
function handleInputChange(key: string, value: string) { function handleInputChange(key: string, value: string) {
setInputValues((prev) => ({ setInputValues((prev) => ({
...prev, ...prev,
@@ -134,91 +173,97 @@ export function RunAgentModal({
> >
<Dialog.Trigger>{triggerSlot}</Dialog.Trigger> <Dialog.Trigger>{triggerSlot}</Dialog.Trigger>
<Dialog.Content> <Dialog.Content>
{/* Header */} <div ref={contentRef} className="flex min-h-full flex-col">
<ModalHeader agent={agent} /> <div className="flex-1">
{/* Header */}
<ModalHeader agent={agent} />
{/* Content */} {/* Content */}
{hasAnySetupFields ? ( {hasAnySetupFields ? (
<div className="mt-10"> <div className="mt-10 pb-32">
<RunAgentModalContextProvider <RunAgentModalContextProvider
value={{ value={{
agent, agent,
defaultRunType, defaultRunType,
presetName, presetName,
setPresetName, setPresetName,
presetDescription, presetDescription,
setPresetDescription, setPresetDescription,
inputValues, inputValues,
setInputValue: handleInputChange, setInputValue: handleInputChange,
agentInputFields, agentInputFields,
inputCredentials, inputCredentials,
setInputCredentialsValue: handleCredentialsChange, setInputCredentialsValue: handleCredentialsChange,
agentCredentialsInputFields, agentCredentialsInputFields,
}} }}
> >
<ModalRunSection /> <ModalRunSection />
</RunAgentModalContextProvider> </RunAgentModalContextProvider>
</div>
) : null}
</div> </div>
) : null}
<Dialog.Footer className="mt-6 bg-white pt-4"> <Dialog.Footer
<div className="flex items-center justify-end gap-3"> className={`sticky bottom-0 z-10 bg-white pt-4 ${
{isTriggerRunType ? null : !allRequiredInputsAreSet ? ( hasOverflow
<TooltipProvider> ? "border-t border-neutral-100 shadow-[0_-2px_8px_rgba(0,0,0,0.04)]"
<Tooltip> : ""
<TooltipTrigger asChild> }`}
<span> >
<Button <div className="flex items-center justify-end gap-3">
variant="secondary" {isTriggerRunType ? null : !allRequiredInputsAreSet ? (
onClick={handleOpenScheduleModal} <TooltipProvider>
disabled={ <Tooltip>
isExecuting || <TooltipTrigger asChild>
isSettingUpTrigger || <span>
!allRequiredInputsAreSet <Button
} variant="secondary"
> onClick={handleOpenScheduleModal}
Schedule Task disabled={
</Button> isExecuting ||
</span> isSettingUpTrigger ||
</TooltipTrigger> !allRequiredInputsAreSet
<TooltipContent> }
<p> >
Please set up all required inputs and credentials before Schedule Task
scheduling </Button>
</p> </span>
</TooltipContent> </TooltipTrigger>
</Tooltip> <TooltipContent>
</TooltipProvider> <p>
) : ( Please set up all required inputs and credentials
<Button before scheduling
variant="secondary" </p>
onClick={handleOpenScheduleModal} </TooltipContent>
disabled={ </Tooltip>
isExecuting || </TooltipProvider>
isSettingUpTrigger || ) : (
!allRequiredInputsAreSet <Button
} variant="secondary"
> onClick={handleOpenScheduleModal}
Schedule Task disabled={isExecuting || isSettingUpTrigger}
</Button> >
)} Schedule Task
<RunActions </Button>
defaultRunType={defaultRunType} )}
onRun={handleRun} <RunActions
isExecuting={isExecuting} defaultRunType={defaultRunType}
isSettingUpTrigger={isSettingUpTrigger} onRun={handleRun}
isRunReady={allRequiredInputsAreSet} isExecuting={isExecuting}
isSettingUpTrigger={isSettingUpTrigger}
isRunReady={allRequiredInputsAreSet}
/>
</div>
<ScheduleAgentModal
isOpen={isScheduleModalOpen}
onClose={handleCloseScheduleModal}
agent={agent}
inputValues={inputValues}
inputCredentials={inputCredentials}
onScheduleCreated={handleScheduleCreated}
/> />
</div> </Dialog.Footer>
<ScheduleAgentModal </div>
isOpen={isScheduleModalOpen}
onClose={handleCloseScheduleModal}
agent={agent}
inputValues={inputValues}
inputCredentials={inputCredentials}
onScheduleCreated={handleScheduleCreated}
/>
</Dialog.Footer>
</Dialog.Content> </Dialog.Content>
</Dialog> </Dialog>
</> </>

View File

@@ -1,6 +1,16 @@
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs"; import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import { Input } from "@/components/atoms/Input/Input"; import { Input } from "@/components/atoms/Input/Input";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip"; import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
import { useContext, useMemo } from "react";
import {
NONE_CREDENTIAL_MARKER,
useAgentCredentialPreferencesStore,
} from "../../../../../stores/agentCredentialPreferencesStore";
import {
filterSystemCredentials,
isSystemCredential,
} from "../../../CredentialsInputs/helpers";
import { RunAgentInputs } from "../../../RunAgentInputs/RunAgentInputs"; import { RunAgentInputs } from "../../../RunAgentInputs/RunAgentInputs";
import { useRunAgentModalContext } from "../../context"; import { useRunAgentModalContext } from "../../context";
import { ModalSection } from "../ModalSection/ModalSection"; import { ModalSection } from "../ModalSection/ModalSection";
@@ -22,8 +32,44 @@ export function ModalRunSection() {
agentCredentialsInputFields, agentCredentialsInputFields,
} = useRunAgentModalContext(); } = useRunAgentModalContext();
const allProviders = useContext(CredentialsProvidersContext);
const store = useAgentCredentialPreferencesStore();
const inputFields = Object.entries(agentInputFields || {}); const inputFields = Object.entries(agentInputFields || {});
const credentialFields = Object.entries(agentCredentialsInputFields || {});
// Only show credential fields that have user credentials (NOT system credentials)
// System credentials should only be shown in settings, not in run modal
const credentialFields = useMemo(() => {
if (!allProviders || !agentCredentialsInputFields) return [];
return Object.entries(agentCredentialsInputFields).filter(
([_key, schema]) => {
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
// Check if any provider has user credentials (NOT system credentials)
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const userCreds = filterSystemCredentials(
providerData.savedCredentials,
);
const matchingUserCreds = userCreds.filter((cred: { type: string }) =>
supportedTypes.includes(cred.type),
);
// If there are user credentials available, show this field
if (matchingUserCreds.length > 0) {
return true;
}
}
// Hide the field if only system credentials exist (or no credentials at all)
return false;
},
);
}, [agentCredentialsInputFields, allProviders]);
// Get the list of required credentials from the schema // Get the list of required credentials from the schema
const requiredCredentials = new Set( const requiredCredentials = new Set(
@@ -98,22 +144,113 @@ export function ModalRunSection() {
subtitle="These are the credentials the agent will use to perform this task" subtitle="These are the credentials the agent will use to perform this task"
> >
<div className="space-y-6"> <div className="space-y-6">
{Object.entries(agentCredentialsInputFields || {}).map( {credentialFields
([key, inputSubSchema]) => ( .map(([key, inputSubSchema]) => {
<CredentialsInput const selectedCred = inputCredentials?.[key];
key={key}
schema={ // Check if the selected credential is a system credential
{ ...inputSubSchema, discriminator: undefined } as any // First check the credential object itself, then look it up in providers
let isSystemCredSelected = false;
if (selectedCred) {
// Check if credential object has is_system or title indicates system credential
isSystemCredSelected = isSystemCredential(
selectedCred as { title?: string; is_system?: boolean },
);
// If not detected by title/is_system, check by looking up in providers
if (
!isSystemCredSelected &&
selectedCred.id &&
allProviders
) {
const providerNames =
inputSubSchema.credentials_provider || [];
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const systemCreds = providerData.savedCredentials.filter(
(cred: any) => cred.is_system === true,
);
if (
systemCreds.some(
(cred: any) => cred.id === selectedCred.id,
)
) {
isSystemCredSelected = true;
break;
}
}
} }
selectedCredentials={inputCredentials?.[key]} }
onSelectCredentials={(value) =>
setInputCredentialsValue(key, value) // If a system credential is selected, check if there are user credentials available
// If not, hide this field entirely (it will still be used for execution)
if (isSystemCredSelected) {
const providerNames =
inputSubSchema.credentials_provider || [];
const supportedTypes = inputSubSchema.credentials_types || [];
const hasUserCreds = providerNames.some(
(providerName: string) => {
const providerData = allProviders?.[providerName];
if (!providerData) return false;
const userCreds = filterSystemCredentials(
providerData.savedCredentials,
);
return userCreds.some((cred: { type: string }) =>
supportedTypes.includes(cred.type),
);
},
);
// If no user credentials available, hide the field completely
if (!hasUserCreds) {
return null;
} }
siblingInputs={inputValues} }
isOptional={!requiredCredentials.has(key)}
/> // If a system credential is selected but user creds exist, don't show it in the UI
), // (it will still be used for execution, but user can select a user credential instead)
)} const credToDisplay = isSystemCredSelected
? undefined
: selectedCred;
return (
<CredentialsInput
key={key}
schema={
{ ...inputSubSchema, discriminator: undefined } as any
}
selectedCredentials={credToDisplay}
onSelectCredentials={(value) => {
// When user selects a credential, update the state and save to preferences
setInputCredentialsValue(key, value);
// Save to preferences store
if (value === undefined) {
store.setCredentialPreference(
agent.id.toString(),
key,
NONE_CREDENTIAL_MARKER,
);
} else if (value === null) {
store.setCredentialPreference(
agent.id.toString(),
key,
null,
);
} else {
store.setCredentialPreference(
agent.id.toString(),
key,
value,
);
}
}}
siblingInputs={inputValues}
isOptional={!requiredCredentials.has(key)}
/>
);
})
.filter(Boolean)}
</div> </div>
</ModalSection> </ModalSection>
) : null} ) : null}

View File

@@ -11,9 +11,25 @@ import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset"; import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { useToast } from "@/components/molecules/Toast/use-toast"; import { useToast } from "@/components/molecules/Toast/use-toast";
import { isEmpty } from "@/lib/utils"; import { isEmpty } from "@/lib/utils";
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
import { analytics } from "@/services/analytics"; import { analytics } from "@/services/analytics";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo, useState } from "react"; import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
NONE_CREDENTIAL_MARKER,
useAgentCredentialPreferencesStore,
} from "../../../stores/agentCredentialPreferencesStore";
import {
filterSystemCredentials,
getSystemCredentials,
} from "../CredentialsInputs/helpers";
import { showExecutionErrorToast } from "./errorHelpers"; import { showExecutionErrorToast } from "./errorHelpers";
export type RunVariant = export type RunVariant =
@@ -42,8 +58,10 @@ export function useAgentRunModal(
const [inputCredentials, setInputCredentials] = useState<Record<string, any>>( const [inputCredentials, setInputCredentials] = useState<Record<string, any>>(
callbacks?.initialInputCredentials || {}, callbacks?.initialInputCredentials || {},
); );
const [presetName, setPresetName] = useState<string>(""); const [presetName, setPresetName] = useState<string>("");
const [presetDescription, setPresetDescription] = useState<string>(""); const [presetDescription, setPresetDescription] = useState<string>("");
const hasInitializedSystemCreds = useRef(false);
// Determine the default run type based on agent capabilities // Determine the default run type based on agent capabilities
const defaultRunType: RunVariant = agent.trigger_setup_info const defaultRunType: RunVariant = agent.trigger_setup_info
@@ -58,6 +76,198 @@ export function useAgentRunModal(
setInputCredentials(callbacks?.initialInputCredentials || {}); setInputCredentials(callbacks?.initialInputCredentials || {});
}, [callbacks?.initialInputValues, callbacks?.initialInputCredentials]); }, [callbacks?.initialInputValues, callbacks?.initialInputCredentials]);
const allProviders = useContext(CredentialsProvidersContext);
const store = useAgentCredentialPreferencesStore();
// Initialize credentials from saved preferences or default system credentials
// This ensures credentials are used even when the field is not displayed
useEffect(() => {
if (!allProviders || !agent.credentials_input_schema?.properties) return;
if (callbacks?.initialInputCredentials) {
hasInitializedSystemCreds.current = true;
return; // Don't override if initial credentials provided
}
if (hasInitializedSystemCreds.current) return; // Already initialized
const properties = agent.credentials_input_schema.properties as Record<
string,
any
>;
// Use functional update to get current state and avoid stale closures
setInputCredentials((currentCreds) => {
const credsToAdd: Record<string, any> = {};
for (const [key, schema] of Object.entries(properties)) {
// Skip if already set
if (currentCreds[key]) continue;
// First, check if user has a saved preference
const savedPreference = store.getCredentialPreference(
agent.id.toString(),
key,
);
// Check if "None" was explicitly selected (special marker)
if (savedPreference === NONE_CREDENTIAL_MARKER) {
// User explicitly selected "None" - don't add any credential
continue;
}
if (savedPreference) {
credsToAdd[key] = savedPreference;
continue;
}
// Otherwise, find default system credentials for this field
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
const requiredScopes = schema.credentials_scopes;
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const systemCreds = getSystemCredentials(
providerData.savedCredentials,
);
const matchingSystemCreds = systemCreds.filter((cred) => {
if (!supportedTypes.includes(cred.type)) return false;
// For OAuth2 credentials, check scopes
if (
cred.type === "oauth2" &&
requiredScopes &&
requiredScopes.length > 0
) {
const grantedScopes = new Set(cred.scopes || []);
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
grantedScopes,
);
if (!hasAllRequiredScopes) return false;
}
return true;
});
// If there's exactly one system credential, use it as default
if (matchingSystemCreds.length === 1) {
const systemCred = matchingSystemCreds[0];
credsToAdd[key] = {
id: systemCred.id,
type: systemCred.type,
provider: providerName,
title: systemCred.title,
};
break; // Use first matching provider
}
}
}
// Only update if we found credentials to add
if (Object.keys(credsToAdd).length > 0) {
hasInitializedSystemCreds.current = true;
return {
...currentCreds,
...credsToAdd,
};
}
return currentCreds; // No changes
});
}, [
allProviders,
agent.credentials_input_schema,
agent.id,
store,
callbacks?.initialInputCredentials,
]);
// Sync credentials with preferences store when modal opens
useEffect(() => {
if (!isOpen || !allProviders || !agent.credentials_input_schema?.properties)
return;
if (callbacks?.initialInputCredentials) return; // Don't override if initial credentials provided
const properties = agent.credentials_input_schema.properties as Record<
string,
any
>;
setInputCredentials((currentCreds) => {
const updatedCreds: Record<string, any> = { ...currentCreds };
for (const [key, schema] of Object.entries(properties)) {
const savedPreference = store.getCredentialPreference(
agent.id.toString(),
key,
);
if (savedPreference === NONE_CREDENTIAL_MARKER) {
// User explicitly selected "None" - remove from credentials
delete updatedCreds[key];
} else if (savedPreference) {
// User has a saved preference - use it
updatedCreds[key] = savedPreference;
} else if (!updatedCreds[key]) {
// No preference and no current credential - try to find default system credential
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
const requiredScopes = schema.credentials_scopes;
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const systemCreds = getSystemCredentials(
providerData.savedCredentials,
);
const matchingSystemCreds = systemCreds.filter((cred) => {
if (!supportedTypes.includes(cred.type)) return false;
if (
cred.type === "oauth2" &&
requiredScopes &&
requiredScopes.length > 0
) {
const grantedScopes = new Set(cred.scopes || []);
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
grantedScopes,
);
if (!hasAllRequiredScopes) return false;
}
return true;
});
if (matchingSystemCreds.length === 1) {
const systemCred = matchingSystemCreds[0];
updatedCreds[key] = {
id: systemCred.id,
type: systemCred.type,
provider: providerName,
title: systemCred.title,
};
break;
}
}
}
}
return updatedCreds;
});
}, [
isOpen,
agent.id,
agent.credentials_input_schema,
allProviders,
store,
callbacks?.initialInputCredentials,
]);
// Reset initialization flag when modal closes/opens or agent changes
useEffect(() => {
hasInitializedSystemCreds.current = false;
}, [isOpen, agent.graph_id]);
// API mutations // API mutations
const executeGraphMutation = usePostV1ExecuteGraphAgent({ const executeGraphMutation = usePostV1ExecuteGraphAgent({
mutation: { mutation: {
@@ -169,15 +379,70 @@ export function useAgentRunModal(
(agent.credentials_input_schema?.required as string[]) || [], (agent.credentials_input_schema?.required as string[]) || [],
); );
// Filter out credential fields that only have system credentials available
// System credentials should not be required in the run modal
// Also check if user has a saved preference (including NONE_MARKER)
const requiredCredentialsToCheck = [...requiredCredentials].filter(
(key) => {
// Check if user has a saved preference first
const savedPreference = store.getCredentialPreference(
agent.id.toString(),
key,
);
// If "None" was explicitly selected, don't require it
if (savedPreference === NONE_CREDENTIAL_MARKER) {
return false;
}
// If user has a saved preference, it should be checked
if (savedPreference) {
return true;
}
const schema = agentCredentialsInputFields[key];
if (!schema || !allProviders) return true; // If we can't check, include it
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
// Check if any provider has non-system credentials available
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const userCreds = filterSystemCredentials(
providerData.savedCredentials,
);
const matchingUserCreds = userCreds.filter((cred) =>
supportedTypes.includes(cred.type),
);
// If there are user credentials available, this field should be checked
if (matchingUserCreds.length > 0) {
return true;
}
}
// If only system credentials are available, exclude from required check
return false;
},
);
// Check if required credentials have valid id (not just key existence) // Check if required credentials have valid id (not just key existence)
// A credential is valid only if it has an id field set // A credential is valid only if it has an id field set
const missing = [...requiredCredentials].filter((key) => { const missing = requiredCredentialsToCheck.filter((key) => {
const cred = inputCredentials[key]; const cred = inputCredentials[key];
return !cred || !cred.id; return !cred || !cred.id;
}); });
return [missing.length === 0, missing]; return [missing.length === 0, missing];
}, [agent.credentials_input_schema, inputCredentials]); }, [
agent.credentials_input_schema,
agentCredentialsInputFields,
inputCredentials,
allProviders,
agent.id,
store,
]);
const credentialsRequired = useMemo( const credentialsRequired = useMemo(
() => Object.keys(agentCredentialsInputFields || {}).length > 0, () => Object.keys(agentCredentialsInputFields || {}).length > 0,

View File

@@ -1,37 +1,17 @@
import { Button } from "@/components/atoms/Button/Button"; import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { GearIcon } from "@phosphor-icons/react"; import { GearIcon } from "@phosphor-icons/react";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
interface Props {
agent: LibraryAgent;
onSelectSettings: () => void;
selected?: boolean;
}
export function AgentSettingsButton({
agent,
onSelectSettings,
selected,
}: Props) {
const { hasHITLBlocks } = useAgentSafeMode(agent);
if (!hasHITLBlocks) {
return null;
}
export function AgentSettingsButton() {
return ( return (
<Button <Button
variant={selected ? "secondary" : "ghost"} variant="ghost"
size="small" size="small"
className="m-0 min-w-0 rounded-full p-0 px-1" className="m-0 min-w-0 rounded-full p-0 px-1"
onClick={onSelectSettings}
aria-label="Agent Settings" aria-label="Agent Settings"
> >
<GearIcon <GearIcon size={18} className="text-zinc-600" />
size={18} <Text variant="small">Agent Settings</Text>
className={selected ? "text-zinc-900" : "text-zinc-600"}
/>
</Button> </Button>
); );
} }

View File

@@ -1,3 +1,5 @@
"use client";
import { Text } from "@/components/atoms/Text/Text"; import { Text } from "@/components/atoms/Text/Text";
export function EmptySchedules() { export function EmptySchedules() {

View File

@@ -20,6 +20,7 @@ import { useQueryClient } from "@tanstack/react-query";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { useAgentMissingCredentials } from "../../hooks/useAgentMissingCredentials";
import { RunAgentModal } from "../modals/RunAgentModal/RunAgentModal"; import { RunAgentModal } from "../modals/RunAgentModal/RunAgentModal";
import { RunDetailCard } from "../selected-views/RunDetailCard/RunDetailCard"; import { RunDetailCard } from "../selected-views/RunDetailCard/RunDetailCard";
import { EmptyTasksIllustration } from "./EmptyTasksIllustration"; import { EmptyTasksIllustration } from "./EmptyTasksIllustration";
@@ -44,6 +45,7 @@ export function EmptyTasks({
const [isDeletingAgent, setIsDeletingAgent] = useState(false); const [isDeletingAgent, setIsDeletingAgent] = useState(false);
const { mutateAsync: deleteAgent } = useDeleteV2DeleteLibraryAgent(); const { mutateAsync: deleteAgent } = useDeleteV2DeleteLibraryAgent();
const { hasMissingCredentials } = useAgentMissingCredentials(agent);
async function handleDeleteAgent() { async function handleDeleteAgent() {
if (!agent.id) return; if (!agent.id) return;
@@ -124,6 +126,7 @@ export function EmptyTasks({
variant="primary" variant="primary"
size="large" size="large"
className="inline-flex w-[19.75rem]" className="inline-flex w-[19.75rem]"
disabled={hasMissingCredentials}
> >
Setup your task Setup your task
</Button> </Button>

View File

@@ -1,3 +1,5 @@
"use client";
import { Text } from "@/components/atoms/Text/Text"; import { Text } from "@/components/atoms/Text/Text";
export function EmptyTemplates() { export function EmptyTemplates() {

View File

@@ -1,3 +1,5 @@
"use client";
import { Text } from "@/components/atoms/Text/Text"; import { Text } from "@/components/atoms/Text/Text";
export function EmptyTriggers() { export function EmptyTriggers() {

View File

@@ -3,7 +3,7 @@
import { Button } from "@/components/atoms/Button/Button"; import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text"; import { Text } from "@/components/atoms/Text/Text";
interface MarketplaceBannersProps { interface Props {
hasUpdate?: boolean; hasUpdate?: boolean;
latestVersion?: number; latestVersion?: number;
hasUnpublishedChanges?: boolean; hasUnpublishedChanges?: boolean;
@@ -21,7 +21,7 @@ export function MarketplaceBanners({
isUpdating, isUpdating,
onUpdate, onUpdate,
onPublish, onPublish,
}: MarketplaceBannersProps) { }: Props) {
const renderUpdateBanner = () => { const renderUpdateBanner = () => {
if (hasUpdate && latestVersion) { if (hasUpdate && latestVersion) {
return ( return (

View File

@@ -1,3 +1,5 @@
"use client";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type Props = { type Props = {

View File

@@ -1,22 +1,16 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Skeleton } from "@/components/__legacy__/ui/skeleton"; import { Skeleton } from "@/components/__legacy__/ui/skeleton";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../helpers"; import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../helpers";
import { SelectedViewLayout } from "./SelectedViewLayout"; import { SelectedViewLayout } from "./SelectedViewLayout";
interface Props { interface Props {
agent: LibraryAgent; agent: LibraryAgent;
onSelectSettings?: () => void;
selectedSettings?: boolean;
} }
export function LoadingSelectedContent(props: Props) { export function LoadingSelectedContent(props: Props) {
return ( return (
<SelectedViewLayout <SelectedViewLayout agent={props.agent}>
agent={props.agent}
onSelectSettings={props.onSelectSettings}
selectedSettings={props.selectedSettings}
>
<div <div
className={cn("flex flex-col gap-4", AGENT_LIBRARY_SECTION_PADDING_X)} className={cn("flex flex-col gap-4", AGENT_LIBRARY_SECTION_PADDING_X)}
> >

View File

@@ -33,8 +33,6 @@ interface Props {
onSelectRun?: (id: string) => void; onSelectRun?: (id: string) => void;
onClearSelectedRun?: () => void; onClearSelectedRun?: () => void;
banner?: React.ReactNode; banner?: React.ReactNode;
onSelectSettings?: () => void;
selectedSettings?: boolean;
} }
export function SelectedRunView({ export function SelectedRunView({
@@ -43,8 +41,6 @@ export function SelectedRunView({
onSelectRun, onSelectRun,
onClearSelectedRun, onClearSelectedRun,
banner, banner,
onSelectSettings,
selectedSettings,
}: Props) { }: Props) {
const { run, preset, isLoading, responseError, httpError } = const { run, preset, isLoading, responseError, httpError } =
useSelectedRunView(agent.graph_id, runId); useSelectedRunView(agent.graph_id, runId);
@@ -84,12 +80,7 @@ export function SelectedRunView({
return ( return (
<div className="flex h-full w-full gap-4"> <div className="flex h-full w-full gap-4">
<div className="flex min-h-0 min-w-0 flex-1 flex-col"> <div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SelectedViewLayout <SelectedViewLayout agent={agent} banner={banner}>
agent={agent}
banner={banner}
onSelectSettings={onSelectSettings}
selectedSettings={selectedSettings}
>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<RunDetailHeader agent={agent} run={run} /> <RunDetailHeader agent={agent} run={run} />

View File

@@ -21,8 +21,6 @@ interface Props {
scheduleId: string; scheduleId: string;
onClearSelectedRun?: () => void; onClearSelectedRun?: () => void;
banner?: React.ReactNode; banner?: React.ReactNode;
onSelectSettings?: () => void;
selectedSettings?: boolean;
} }
export function SelectedScheduleView({ export function SelectedScheduleView({
@@ -30,8 +28,6 @@ export function SelectedScheduleView({
scheduleId, scheduleId,
onClearSelectedRun, onClearSelectedRun,
banner, banner,
onSelectSettings,
selectedSettings,
}: Props) { }: Props) {
const { schedule, isLoading, error } = useSelectedScheduleView( const { schedule, isLoading, error } = useSelectedScheduleView(
agent.graph_id, agent.graph_id,
@@ -76,12 +72,7 @@ export function SelectedScheduleView({
return ( return (
<div className="flex h-full w-full gap-4"> <div className="flex h-full w-full gap-4">
<div className="flex min-h-0 min-w-0 flex-1 flex-col"> <div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SelectedViewLayout <SelectedViewLayout agent={agent} banner={banner}>
agent={agent}
banner={banner}
onSelectSettings={onSelectSettings}
selectedSettings={selectedSettings}
>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex w-full flex-col gap-0"> <div className="flex w-full flex-col gap-0">
<RunDetailHeader <RunDetailHeader

View File

@@ -1,11 +1,12 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent"; import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Text } from "@/components/atoms/Text/Text";
import { Switch } from "@/components/atoms/Switch/Switch";
import { Button } from "@/components/atoms/Button/Button"; import { Button } from "@/components/atoms/Button/Button";
import { ArrowLeftIcon } from "@phosphor-icons/react"; import { Switch } from "@/components/atoms/Switch/Switch";
import { Text } from "@/components/atoms/Text/Text";
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode"; import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
import { SelectedViewLayout } from "../SelectedViewLayout"; import { ArrowLeftIcon } from "@phosphor-icons/react";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../../helpers"; import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../../helpers";
import { SelectedViewLayout } from "../SelectedViewLayout";
import { SystemCredentialsSection } from "./components/SystemCredentialsSection";
interface Props { interface Props {
agent: LibraryAgent; agent: LibraryAgent;
@@ -16,8 +17,12 @@ export function SelectedSettingsView({ agent, onClearSelectedRun }: Props) {
const { currentSafeMode, isPending, hasHITLBlocks, handleToggle } = const { currentSafeMode, isPending, hasHITLBlocks, handleToggle } =
useAgentSafeMode(agent); useAgentSafeMode(agent);
const hasCredentialsSchema =
agent.credentials_input_schema &&
Object.keys(agent.credentials_input_schema.properties || {}).length > 0;
return ( return (
<SelectedViewLayout agent={agent} onSelectSettings={() => {}}> <SelectedViewLayout agent={agent}>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
className={`${AGENT_LIBRARY_SECTION_PADDING_X} mb-8 flex items-center gap-3`} className={`${AGENT_LIBRARY_SECTION_PADDING_X} mb-8 flex items-center gap-3`}
@@ -33,15 +38,8 @@ export function SelectedSettingsView({ agent, onClearSelectedRun }: Props) {
<Text variant="h2">Agent Settings</Text> <Text variant="h2">Agent Settings</Text>
</div> </div>
<div className={AGENT_LIBRARY_SECTION_PADDING_X}> <div className={`${AGENT_LIBRARY_SECTION_PADDING_X} space-y-6`}>
{!hasHITLBlocks ? ( {hasHITLBlocks && (
<div className="rounded-xl border border-zinc-100 bg-white p-6">
<Text variant="body" className="text-muted-foreground">
This agent doesn&apos;t have any human-in-the-loop blocks, so
there are no settings to configure.
</Text>
</div>
) : (
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6"> <div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<div className="flex w-full items-start justify-between gap-4"> <div className="flex w-full items-start justify-between gap-4">
<div className="flex-1"> <div className="flex-1">
@@ -60,6 +58,16 @@ export function SelectedSettingsView({ agent, onClearSelectedRun }: Props) {
</div> </div>
</div> </div>
)} )}
{hasCredentialsSchema && <SystemCredentialsSection agent={agent} />}
{!hasHITLBlocks && !hasCredentialsSchema && (
<div className="rounded-xl border border-zinc-100 bg-white p-6">
<Text variant="body" className="text-muted-foreground">
This agent doesn&apos;t have any configurable settings.
</Text>
</div>
)}
</div> </div>
</div> </div>
</SelectedViewLayout> </SelectedViewLayout>

View File

@@ -0,0 +1,99 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
import { CredentialsMetaResponse } from "@/lib/autogpt-server-api/types";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { useEffect, useState } from "react";
import { CredentialsInput } from "../../../../components/modals/CredentialsInputs/CredentialsInputs";
import {
NONE_CREDENTIAL_MARKER,
useAgentCredentialPreferencesStore,
} from "../../../../stores/agentCredentialPreferencesStore";
interface Props {
credentialKey: string;
agentId: string;
schema: any;
systemCredential: CredentialsMetaResponse;
}
export function SystemCredentialRow({
credentialKey,
agentId,
schema,
systemCredential,
}: Props) {
const store = useAgentCredentialPreferencesStore();
// Initialize with saved preference or default to system credential
const savedPreference = store.getCredentialPreference(agentId, credentialKey);
const defaultCredential = {
id: systemCredential.id,
type: systemCredential.type,
provider: systemCredential.provider,
title: systemCredential.title,
};
// If saved preference is the NONE marker, use undefined (which CredentialsInput interprets as "None")
// Otherwise use saved preference or default
const [selectedCredential, setSelectedCredential] = useState<any>(
savedPreference === NONE_CREDENTIAL_MARKER
? undefined
: savedPreference || defaultCredential,
);
// Update when preference changes externally
useEffect(() => {
const preference = store.getCredentialPreference(agentId, credentialKey);
if (preference === NONE_CREDENTIAL_MARKER) {
setSelectedCredential(undefined);
} else if (preference) {
setSelectedCredential(preference);
} else {
setSelectedCredential(defaultCredential);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [credentialKey, agentId]);
const providerName = schema.credentials_provider?.[0] || "";
const displayName = toDisplayName(providerName);
function handleSelectCredentials(value: any) {
setSelectedCredential(value);
// Save preference:
// - undefined = explicitly selected "None" (save NONE_CREDENTIAL_MARKER)
// - null = use default system credential (fallback behavior, save null)
// - credential object = use this specific credential
if (value === undefined) {
// User explicitly selected "None" - save special marker
store.setCredentialPreference(
agentId,
credentialKey,
NONE_CREDENTIAL_MARKER,
);
} else if (value === null) {
// User cleared selection - use default system credential
store.setCredentialPreference(agentId, credentialKey, null);
} else {
// User selected a credential
store.setCredentialPreference(agentId, credentialKey, value);
}
}
return (
<div className="rounded-lg border border-zinc-100 bg-zinc-50/50 px-4 pb-2 pt-4">
<Text variant="body-medium" className="mb-2 ml-2">
{displayName}
</Text>
<CredentialsInput
schema={{ ...schema, discriminator: undefined }}
selectedCredentials={selectedCredential}
onSelectCredentials={handleSelectCredentials}
showTitle={false}
isOptional
allowSystemCredentials={true}
/>
</div>
);
}

View File

@@ -0,0 +1,63 @@
"use client";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Text } from "@/components/atoms/Text/Text";
import { useAgentSystemCredentials } from "../../../../hooks/useAgentSystemCredentials";
import { SystemCredentialRow } from "./SystemCredentialRow";
interface Props {
agent: LibraryAgent;
}
export function SystemCredentialsSection({ agent }: Props) {
const { hasSystemCredentials, systemCredentials, isLoading } =
useAgentSystemCredentials(agent);
if (isLoading) {
return (
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<Text variant="large-semibold">System Credentials</Text>
<Text variant="body" className="text-muted-foreground">
Loading credentials...
</Text>
</div>
);
}
if (!hasSystemCredentials) return null;
// Group by credential field key (from schema) to show one row per field
const credentialsByField = systemCredentials.reduce(
(acc, item) => {
if (!acc[item.key]) {
acc[item.key] = item;
}
return acc;
},
{} as Record<string, (typeof systemCredentials)[0]>,
);
return (
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<div>
<Text variant="large-semibold">System Credentials</Text>
<Text variant="body" className="mt-1 text-muted-foreground">
These credentials are managed by AutoGPT and used by the agent to
access various services. You can switch to your own credentials if
preferred.
</Text>
</div>
<div className="w-full space-y-4">
{Object.entries(credentialsByField).map(([fieldKey, item]) => (
<SystemCredentialRow
key={fieldKey}
credentialKey={fieldKey}
agentId={agent.id.toString()}
schema={item.schema}
systemCredential={item.credential}
/>
))}
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
import { AgentSettingsButton } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/AgentSettingsButton";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent"; import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../helpers"; import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../helpers";
import { AgentSettingsModal } from "../modals/AgentSettingsModal/AgentSettingsModal";
import { SectionWrap } from "../other/SectionWrap"; import { SectionWrap } from "../other/SectionWrap";
interface Props { interface Props {
@@ -9,8 +9,6 @@ interface Props {
children: React.ReactNode; children: React.ReactNode;
banner?: React.ReactNode; banner?: React.ReactNode;
additionalBreadcrumb?: { name: string; link?: string }; additionalBreadcrumb?: { name: string; link?: string };
onSelectSettings?: () => void;
selectedSettings?: boolean;
} }
export function SelectedViewLayout(props: Props) { export function SelectedViewLayout(props: Props) {
@@ -19,8 +17,8 @@ export function SelectedViewLayout(props: Props) {
<div <div
className={`${AGENT_LIBRARY_SECTION_PADDING_X} flex-shrink-0 border-b border-zinc-100 pb-0 lg:pb-4`} className={`${AGENT_LIBRARY_SECTION_PADDING_X} flex-shrink-0 border-b border-zinc-100 pb-0 lg:pb-4`}
> >
{props.banner && <div className="mb-4">{props.banner}</div>} {props.banner}
<div className="relative flex w-fit items-center gap-2"> <div className="relative flex w-full items-center justify-between">
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ name: "My Library", link: "/library" }, { name: "My Library", link: "/library" },
@@ -33,15 +31,9 @@ export function SelectedViewLayout(props: Props) {
: []), : []),
]} ]}
/> />
{props.agent && props.onSelectSettings && ( <div className="absolute right-0">
<div className="absolute -right-8"> <AgentSettingsModal agent={props.agent} />
<AgentSettingsButton </div>
agent={props.agent}
onSelectSettings={props.onSelectSettings}
selected={props.selectedSettings}
/>
</div>
)}
</div> </div>
</div> </div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-visible"> <div className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-visible">

View File

@@ -0,0 +1,109 @@
"use client";
import { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { storage } from "@/services/storage/local-storage";
import { useCallback, useEffect, useState } from "react";
// Special marker to indicate "None" was explicitly selected
export const NONE_CREDENTIAL_MARKER = { __none__: true } as const;
type AgentCredentialPreferences = Record<
string,
CredentialsMetaInput | null | typeof NONE_CREDENTIAL_MARKER
>;
const STORAGE_KEY_PREFIX = "agent_credential_prefs_";
function getStorageKey(agentId: string): string {
return `${STORAGE_KEY_PREFIX}${agentId}`;
}
function loadPreferences(agentId: string): AgentCredentialPreferences {
const key = getStorageKey(agentId);
const stored = storage.get(key as any);
if (!stored) return {};
try {
const parsed = JSON.parse(stored);
// Convert serialized NONE markers back to the constant
const result: AgentCredentialPreferences = {};
for (const [key, value] of Object.entries(parsed)) {
if (
value &&
typeof value === "object" &&
"__none__" in value &&
(value as any).__none__ === true
) {
result[key] = NONE_CREDENTIAL_MARKER;
} else {
result[key] = value as CredentialsMetaInput | null;
}
}
return result;
} catch {
return {};
}
}
function savePreferences(
agentId: string,
preferences: AgentCredentialPreferences,
): void {
const key = getStorageKey(agentId);
storage.set(key as any, JSON.stringify(preferences));
}
export function useAgentCredentialPreferences(agentId: string) {
const [preferences, setPreferences] = useState<AgentCredentialPreferences>(
() => loadPreferences(agentId),
);
useEffect(() => {
const loaded = loadPreferences(agentId);
setPreferences(loaded);
}, [agentId]);
const setCredentialPreference = useCallback(
(
credentialKey: string,
credential: CredentialsMetaInput | null | typeof NONE_CREDENTIAL_MARKER,
) => {
setPreferences((prev) => {
const updated = {
...prev,
[credentialKey]: credential,
};
savePreferences(agentId, updated);
return updated;
});
},
[agentId],
);
const getCredentialPreference = useCallback(
(
credentialKey: string,
): CredentialsMetaInput | null | typeof NONE_CREDENTIAL_MARKER => {
return preferences[credentialKey] ?? null;
},
[preferences],
);
const clearPreference = useCallback(
(credentialKey: string) => {
setPreferences((prev) => {
const updated = { ...prev };
delete updated[credentialKey];
savePreferences(agentId, updated);
return updated;
});
},
[agentId],
);
return {
preferences,
setCredentialPreference,
getCredentialPreference,
clearPreference,
};
}

View File

@@ -0,0 +1,105 @@
"use client";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { useContext, useMemo } from "react";
import { getSystemCredentials } from "../components/modals/CredentialsInputs/helpers";
/**
* Hook to check if an agent is missing required SYSTEM credentials.
* This is only used to block "New Task" buttons.
* User credential validation is handled separately in RunAgentModal.
*/
export function useAgentMissingCredentials(
agent: LibraryAgent | null | undefined,
) {
const allProviders = useContext(CredentialsProvidersContext);
const result = useMemo(() => {
if (
!agent ||
!agent.id ||
!allProviders ||
!agent.credentials_input_schema?.properties
) {
return {
hasMissingCredentials: false,
missingCredentials: [],
isLoading: !allProviders || !agent,
};
}
const properties = agent.credentials_input_schema.properties as Record<
string,
any
>;
const requiredCredentials = new Set(
(agent.credentials_input_schema.required as string[]) || [],
);
const missingCredentials: Array<{
key: string;
providerDisplayName: string;
}> = [];
for (const [key, schema] of Object.entries(properties)) {
const isRequired = requiredCredentials.has(key);
if (!isRequired) continue; // Only check required credentials
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
const requiredScopes = schema.credentials_scopes;
let hasSystemCredential = false;
// Check if any provider has a system credential available
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const systemCreds = getSystemCredentials(providerData.savedCredentials);
const matchingSystemCreds = systemCreds.filter((cred) => {
if (!supportedTypes.includes(cred.type)) return false;
if (
cred.type === "oauth2" &&
requiredScopes &&
requiredScopes.length > 0
) {
const grantedScopes = new Set(cred.scopes || []);
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
grantedScopes,
);
if (!hasAllRequiredScopes) return false;
}
return true;
});
// If there's a system credential available, it's not missing
if (matchingSystemCreds.length > 0) {
hasSystemCredential = true;
break;
}
}
// If no system credential available, mark as missing
if (!hasSystemCredential) {
const providerName = providerNames[0] || "";
missingCredentials.push({
key,
providerDisplayName: toDisplayName(providerName),
});
}
}
return {
hasMissingCredentials: missingCredentials.length > 0,
missingCredentials,
isLoading: false,
};
}, [allProviders, agent?.credentials_input_schema, agent?.id]);
return result;
}

View File

@@ -0,0 +1,130 @@
"use client";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { CredentialsMetaResponse } from "@/lib/autogpt-server-api/types";
import {
CredentialsProviderData,
CredentialsProvidersContext,
} from "@/providers/agent-credentials/credentials-provider";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { useContext, useMemo } from "react";
import {
filterSystemCredentials,
getSystemCredentials,
} from "../components/modals/CredentialsInputs/helpers";
interface SystemCredentialInfo {
key: string;
provider: string;
schema: any;
credential: CredentialsMetaResponse;
}
interface MissingCredentialInfo {
key: string;
provider: string;
providerDisplayName: string;
}
interface UseAgentSystemCredentialsResult {
hasSystemCredentials: boolean;
systemCredentials: SystemCredentialInfo[];
hasMissingSystemCredentials: boolean;
missingSystemCredentials: MissingCredentialInfo[];
isLoading: boolean;
}
export function useAgentSystemCredentials(
agent: LibraryAgent,
): UseAgentSystemCredentialsResult {
const allProviders = useContext(CredentialsProvidersContext);
const result = useMemo(() => {
const empty = {
hasSystemCredentials: false,
systemCredentials: [],
hasMissingSystemCredentials: false,
missingSystemCredentials: [],
isLoading: false,
};
if (!agent.credentials_input_schema?.properties) return empty;
if (!allProviders) return { ...empty, isLoading: true };
const properties = agent.credentials_input_schema.properties as Record<
string,
any
>;
const requiredCredentials = new Set(
(agent.credentials_input_schema.required as string[]) || [],
);
const systemCredentials: SystemCredentialInfo[] = [];
const missingSystemCredentials: MissingCredentialInfo[] = [];
for (const [key, schema] of Object.entries(properties)) {
const providerNames = schema.credentials_provider || [];
const isRequired = requiredCredentials.has(key);
const supportedTypes = schema.credentials_types || [];
for (const providerName of providerNames) {
const providerData: CredentialsProviderData | undefined =
allProviders[providerName];
if (!providerData) {
// Provider not loaded yet - don't mark as missing, wait for load
continue;
}
// Check for system credentials - now backend always returns them with is_system: true
const systemCreds = getSystemCredentials(providerData.savedCredentials);
const userCreds = filterSystemCredentials(
providerData.savedCredentials,
);
const matchingSystemCreds = systemCreds.filter((cred) =>
supportedTypes.includes(cred.type),
);
const matchingUserCreds = userCreds.filter((cred) =>
supportedTypes.includes(cred.type),
);
// Add system credentials if they exist (even if not configured, backend returns them)
for (const cred of matchingSystemCreds) {
systemCredentials.push({
key,
provider: providerName,
schema,
credential: cred,
});
}
// Only mark as missing if it's required AND there are NO credentials available
// (neither system nor user). This is a true "missing" state.
// Note: We don't block based on this anymore since the run modal
// has its own validation (allRequiredInputsAreSet)
if (
isRequired &&
matchingSystemCreds.length === 0 &&
matchingUserCreds.length === 0
) {
missingSystemCredentials.push({
key,
provider: providerName,
providerDisplayName: toDisplayName(providerName),
});
}
}
}
return {
hasSystemCredentials: systemCredentials.length > 0,
systemCredentials,
hasMissingSystemCredentials: missingSystemCredentials.length > 0,
missingSystemCredentials,
isLoading: false,
};
}, [agent.credentials_input_schema, allProviders]);
return result;
}

View File

@@ -0,0 +1,135 @@
import { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { storage } from "@/services/storage/local-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
// Special marker to indicate "None" was explicitly selected
export const NONE_CREDENTIAL_MARKER = { __none__: true } as const;
type CredentialPreference =
| CredentialsMetaInput
| null
| typeof NONE_CREDENTIAL_MARKER;
type AgentCredentialPreferences = Record<string, CredentialPreference>;
interface AgentCredentialPreferencesStore {
preferences: Record<string, AgentCredentialPreferences>; // agentId -> preferences
setCredentialPreference: (
agentId: string,
credentialKey: string,
credential: CredentialPreference,
) => void;
getCredentialPreference: (
agentId: string,
credentialKey: string,
) => CredentialPreference;
clearPreference: (agentId: string, credentialKey: string) => void;
}
const STORAGE_KEY = "agent_credential_preferences";
// Custom storage adapter for localStorage
const customStorage = {
getItem: (name: string): string | null => {
return storage.get(name as any) || null;
},
setItem: (name: string, value: string): void => {
storage.set(name as any, value);
},
removeItem: (name: string): void => {
storage.clean(name as any);
},
};
export const useAgentCredentialPreferencesStore =
create<AgentCredentialPreferencesStore>()(
persist(
(set, get) => ({
preferences: {},
setCredentialPreference: (agentId, credentialKey, credential) => {
set((state) => {
const agentPrefs = state.preferences[agentId] || {};
const updated = {
...state.preferences,
[agentId]: {
...agentPrefs,
[credentialKey]: credential,
},
};
return { preferences: updated };
});
},
getCredentialPreference: (agentId, credentialKey) => {
const state = get();
const pref = state.preferences[agentId]?.[credentialKey];
// Convert serialized NONE marker back to constant
if (
pref &&
typeof pref === "object" &&
"__none__" in pref &&
(pref as any).__none__ === true &&
pref !== NONE_CREDENTIAL_MARKER
) {
return NONE_CREDENTIAL_MARKER;
}
return pref ?? null;
},
clearPreference: (agentId, credentialKey) => {
set((state) => {
const agentPrefs = state.preferences[agentId] || {};
const updated = { ...agentPrefs };
delete updated[credentialKey];
return {
preferences: {
...state.preferences,
[agentId]: updated,
},
};
});
},
}),
{
name: STORAGE_KEY,
storage: createJSONStorage(() => customStorage),
// Transform on rehydrate to convert NONE markers
onRehydrateStorage: () => (state, error) => {
if (error || !state) {
console.error("Failed to rehydrate credential preferences:", error);
return;
}
// Convert serialized NONE markers back to constant
const converted: Record<string, AgentCredentialPreferences> = {};
for (const [agentId, prefs] of Object.entries(
state.preferences || {},
)) {
const convertedPrefs: AgentCredentialPreferences = {};
for (const [key, value] of Object.entries(prefs)) {
if (
value &&
typeof value === "object" &&
"__none__" in value &&
(value as any).__none__ === true &&
value !== NONE_CREDENTIAL_MARKER
) {
convertedPrefs[key] = NONE_CREDENTIAL_MARKER;
} else {
convertedPrefs[key] = value as CredentialPreference;
}
}
converted[agentId] = convertedPrefs;
}
// Update state with converted preferences
if (
Object.keys(converted).length > 0 ||
Object.keys(state.preferences || {}).length > 0
) {
state.preferences = converted;
}
},
},
),
);

View File

@@ -6792,6 +6792,12 @@
"anyOf": [{ "type": "string" }, { "type": "null" }], "anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Host", "title": "Host",
"description": "Host pattern for host-scoped credentials" "description": "Host pattern for host-scoped credentials"
},
"is_system": {
"type": "boolean",
"title": "Is System",
"description": "Whether this is a system-managed credential",
"default": false
} }
}, },
"type": "object", "type": "object",

View File

@@ -20,6 +20,7 @@ export function Button(props: ButtonProps) {
rightIcon, rightIcon,
children, children,
as = "button", as = "button",
asChild: _asChild, // Destructure to prevent passing to DOM
...restProps ...restProps
} = props; } = props;

View File

@@ -49,7 +49,12 @@ export function DrawerWrap({
> >
{title ? ( {title ? (
<Drawer.Title className={drawerStyles.title}>{title}</Drawer.Title> <Drawer.Title className={drawerStyles.title}>{title}</Drawer.Title>
) : null} ) : (
<span className="sr-only">
{/* Title is required for a11y compliance even if not displayed so screen readers can announce it */}
<Drawer.Title>{title}</Drawer.Title>
</span>
)}
{!isForceOpen ? ( {!isForceOpen ? (
title ? ( title ? (

View File

@@ -593,6 +593,7 @@ export type CredentialsMetaResponse = {
scopes?: Array<string>; scopes?: Array<string>;
username?: string; username?: string;
host?: string; host?: string;
is_system?: boolean;
}; };
/* Mirror of backend/server/integrations/router.py:CredentialsDeletionResponse */ /* Mirror of backend/server/integrations/router.py:CredentialsDeletionResponse */

View File

@@ -1,5 +1,4 @@
import { createContext, useCallback, useEffect, useState } from "react"; import { useToastOnFail } from "@/components/molecules/Toast/use-toast";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { import {
APIKeyCredentials, APIKeyCredentials,
CredentialsDeleteNeedConfirmationResponse, CredentialsDeleteNeedConfirmationResponse,
@@ -10,8 +9,9 @@ import {
UserPasswordCredentials, UserPasswordCredentials,
} from "@/lib/autogpt-server-api"; } from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context"; import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { useToastOnFail } from "@/components/molecules/Toast/use-toast"; import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { toDisplayName } from "@/providers/agent-credentials/helper"; import { toDisplayName } from "@/providers/agent-credentials/helper";
import { createContext, useCallback, useEffect, useState } from "react";
type APIKeyCredentialsCreatable = Omit< type APIKeyCredentialsCreatable = Omit<
APIKeyCredentials, APIKeyCredentials,
@@ -72,6 +72,8 @@ export default function CredentialsProvider({
const api = useBackendAPI(); const api = useBackendAPI();
const onFailToast = useToastOnFail(); const onFailToast = useToastOnFail();
console.log("providers", providers);
const addCredentials = useCallback( const addCredentials = useCallback(
( (
provider: CredentialsProviderName, provider: CredentialsProviderName,
@@ -218,17 +220,7 @@ export default function CredentialsProvider({
[api, onFailToast], [api, onFailToast],
); );
// Fetch provider names on mount const loadCredentials = useCallback(() => {
useEffect(() => {
api
.listProviders()
.then((names) => {
setProviderNames(names);
})
.catch(onFailToast("load provider names"));
}, [api, onFailToast]);
useEffect(() => {
if (!isLoggedIn || providerNames.length === 0) { if (!isLoggedIn || providerNames.length === 0) {
if (isLoggedIn == false) setProviders({}); if (isLoggedIn == false) setProviders({});
return; return;
@@ -288,6 +280,20 @@ export default function CredentialsProvider({
onFailToast, onFailToast,
]); ]);
// Fetch provider names on mount
useEffect(() => {
api
.listProviders()
.then((names) => {
setProviderNames(names);
})
.catch(onFailToast("Load provider names"));
}, [api, onFailToast]);
useEffect(() => {
loadCredentials();
}, [loadCredentials]);
return ( return (
<CredentialsProvidersContext.Provider value={providers}> <CredentialsProvidersContext.Provider value={providers}>
{children} {children}