From 905cca5e19d83d9ce01473c1eb22bcb0d32e7d02 Mon Sep 17 00:00:00 2001 From: Daryl Lim <5508348+daryllimyt@users.noreply.github.com> Date: Tue, 26 May 2026 15:49:51 -0400 Subject: [PATCH] feat(integrations): add catalog-backed connections --- ...2a3d4_consolidated_integrations_phase_1.py | 87 ++ ...ill_secret_namespaces_into_integrations.py | 66 ++ .../[workspaceId]/integrations/page.tsx | 800 +++++------------- frontend/src/client/schemas.gen.ts | 446 ++++++++++ frontend/src/client/services.gen.ts | 143 ++++ frontend/src/client/types.gen.ts | 219 +++++ .../components/integrations/catalog-card.tsx | 80 ++ .../integrations/configure-dialog.tsx | 575 +++++++++++++ .../integrations/connect-dialog.tsx | 658 ++++++++++++++ .../integrations/integration-detail-panel.tsx | 361 ++++++++ .../src/lib/hooks/integrations-catalog.ts | 481 +++++++++++ packages/tracecat-admin/tracecat_admin/cli.py | 4 + .../tracecat_admin/commands/integrations.py | 54 ++ tests/unit/test_integrations_catalog_auth.py | 305 +++++++ tests/unit/test_validation_service_secrets.py | 89 ++ tracecat/api/app.py | 4 + tracecat/db/models.py | 88 +- tracecat/integrations/catalog/__init__.py | 6 + tracecat/integrations/catalog/router.py | 163 ++++ tracecat/integrations/catalog/schemas.py | 96 +++ tracecat/integrations/catalog/seed.py | 155 ++++ tracecat/integrations/catalog/service.py | 751 ++++++++++++++++ tracecat/integrations/enums.py | 22 + tracecat/secrets/service.py | 13 +- tracecat/validation/service.py | 31 +- 25 files changed, 5085 insertions(+), 612 deletions(-) create mode 100644 alembic/versions/b7e1c9f2a3d4_consolidated_integrations_phase_1.py create mode 100644 alembic/versions/c8f2d1e4a5b6_backfill_secret_namespaces_into_integrations.py create mode 100644 frontend/src/components/integrations/catalog-card.tsx create mode 100644 frontend/src/components/integrations/configure-dialog.tsx create mode 100644 frontend/src/components/integrations/connect-dialog.tsx create mode 100644 frontend/src/components/integrations/integration-detail-panel.tsx create mode 100644 frontend/src/lib/hooks/integrations-catalog.ts create mode 100644 packages/tracecat-admin/tracecat_admin/commands/integrations.py create mode 100644 tests/unit/test_integrations_catalog_auth.py create mode 100644 tests/unit/test_validation_service_secrets.py create mode 100644 tracecat/integrations/catalog/__init__.py create mode 100644 tracecat/integrations/catalog/router.py create mode 100644 tracecat/integrations/catalog/schemas.py create mode 100644 tracecat/integrations/catalog/seed.py create mode 100644 tracecat/integrations/catalog/service.py diff --git a/alembic/versions/b7e1c9f2a3d4_consolidated_integrations_phase_1.py b/alembic/versions/b7e1c9f2a3d4_consolidated_integrations_phase_1.py new file mode 100644 index 0000000000..2318bd5bf6 --- /dev/null +++ b/alembic/versions/b7e1c9f2a3d4_consolidated_integrations_phase_1.py @@ -0,0 +1,87 @@ +"""consolidated integrations catalog + +Revision ID: b7e1c9f2a3d4 +Revises: a3d7c9e8b4f2 +Create Date: 2026-05-25 21:00:00.000000 + +Adds the consolidated Integrations catalog model: an integration catalog table. + +Additive only -- no existing tables are dropped. Secret, OAuthIntegration, +WorkspaceOAuthProvider, and MCPIntegration storage continue to drive credential +and MCP behavior. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "b7e1c9f2a3d4" +down_revision: str | None = "a3d7c9e8b4f2" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +INTEGRATION_SOURCE_VALUES = ("platform", "workspace") + + +def upgrade() -> None: + # --- enums --------------------------------------------------------- + sa.Enum(*INTEGRATION_SOURCE_VALUES, name="integrationsource").create(op.get_bind()) + + # --- integration --------------------------------------------------- + op.create_table( + "integration", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("workspace_id", sa.UUID(), nullable=True), + sa.Column("namespace", sa.String(), nullable=False), + sa.Column("display_name", sa.String(), nullable=False), + sa.Column("description", sa.String(), nullable=True), + sa.Column("icon_url", sa.String(), nullable=True), + sa.Column( + "source", + postgresql.ENUM( + *INTEGRATION_SOURCE_VALUES, + name="integrationsource", + create_type=False, + ), + nullable=False, + ), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["workspace_id"], + ["workspace.id"], + name=op.f("fk_integration_workspace_id_workspace"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_integration")), + sa.UniqueConstraint( + "workspace_id", + "namespace", + name="uq_integration_workspace_namespace", + ), + ) + op.create_index(op.f("ix_integration_id"), "integration", ["id"], unique=True) + op.create_index("ix_integration_namespace", "integration", ["namespace"]) + + +def downgrade() -> None: + op.drop_index("ix_integration_namespace", table_name="integration") + op.drop_index(op.f("ix_integration_id"), table_name="integration") + op.drop_table("integration") + + sa.Enum(name="integrationsource").drop(op.get_bind()) diff --git a/alembic/versions/c8f2d1e4a5b6_backfill_secret_namespaces_into_integrations.py b/alembic/versions/c8f2d1e4a5b6_backfill_secret_namespaces_into_integrations.py new file mode 100644 index 0000000000..8a660f6329 --- /dev/null +++ b/alembic/versions/c8f2d1e4a5b6_backfill_secret_namespaces_into_integrations.py @@ -0,0 +1,66 @@ +"""backfill secret namespaces into integrations + +Revision ID: c8f2d1e4a5b6 +Revises: b7e1c9f2a3d4 +Create Date: 2026-05-25 23:00:00.000000 + +For secret names that don't already have an Integration row, a workspace-scoped +``Integration`` is created on demand so the Integrations catalog can project +legacy static credentials without moving credential storage. + +Additive only -- the legacy ``secret`` table remains the source of truth for +static/API-key credentials. +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "c8f2d1e4a5b6" +down_revision: str | None = "b7e1c9f2a3d4" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Backfill workspace-scoped Integration rows for every Secret name that + # doesn't already have a catalog entry. Source=workspace so we can tell + # these apart from the platform-seeded providers. + op.execute( + """ + INSERT INTO integration ( + id, workspace_id, namespace, display_name, description, + source, created_at, updated_at + ) + SELECT + gen_random_uuid(), + s.workspace_id, + s.name, + INITCAP(REPLACE(s.name, '_', ' ')), + 'Backfilled from legacy credential.', + 'workspace', + NOW(), + NOW() + FROM ( + SELECT DISTINCT workspace_id, name + FROM secret + ) AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM integration i + WHERE i.namespace = s.name + AND ( + i.workspace_id IS NULL + OR i.workspace_id = s.workspace_id + ) + ) + """ + ) + + +def downgrade() -> None: + # This migration only adds catalog rows for pre-existing secret names. + # Leave them in place on downgrade; deleting them could hide credentials + # from the catalog after a downgrade/upgrade loop. + pass diff --git a/frontend/src/app/workspaces/[workspaceId]/integrations/page.tsx b/frontend/src/app/workspaces/[workspaceId]/integrations/page.tsx index e91c203e6e..d47489925f 100644 --- a/frontend/src/app/workspaces/[workspaceId]/integrations/page.tsx +++ b/frontend/src/app/workspaces/[workspaceId]/integrations/page.tsx @@ -1,643 +1,261 @@ "use client" -import { ChevronRight, Loader2, RotateCcw, SquareAsterisk } from "lucide-react" +import { Star, UserSquare } from "lucide-react" import { useRouter, useSearchParams } from "next/navigation" -import { useCallback, useEffect, useMemo, useRef, useState } from "react" -import type { IntegrationStatus, OAuthGrantType } from "@/client" +import { useCallback, useMemo, useState } from "react" +import type { CatalogIntegrationRead } from "@/client" import { useScopeCheck } from "@/components/auth/scope-guard" -import { ConfirmDestructiveDialog } from "@/components/confirm-destructive-dialog" -import { ProviderIcon } from "@/components/icons" import { - type ConnectionFilter, - IntegrationsHeader, - type IntegrationTypeFilter, -} from "@/components/integrations/integrations-header" -import { OAuthIntegrationDetailsDialog } from "@/components/integrations/oauth-integration-details-dialog" -import { OAuthIntegrationDialog } from "@/components/integrations/oauth-integration-dialog" + CatalogHeader, + type CatalogHeaderPillOption, +} from "@/components/catalog/catalog-header" +import { CatalogCard } from "@/components/integrations/catalog-card" +import { IntegrationDetailPanel } from "@/components/integrations/integration-detail-panel" import { CenteredSpinner } from "@/components/loading/spinner" -import { Button } from "@/components/ui/button" -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible" -import { - Item, - ItemActions, - ItemContent, - ItemMedia, - ItemTitle, -} from "@/components/ui/item" -import { ScrollArea } from "@/components/ui/scroll-area" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip" -import { - useConnectProvider, - useDisconnectProvider, - useTestProvider, -} from "@/hooks/use-integration-actions" -import { useIntegrations } from "@/lib/hooks" -import { isMcpProvider } from "@/lib/integrations" -import { cn } from "@/lib/utils" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { useListIntegrationCatalog } from "@/lib/hooks/integrations-catalog" import { useWorkspaceId } from "@/providers/workspace-id" -type IntegrationItem = { - type: "oauth" - id: string - name: string - description: string - enabled: boolean - integration_status: IntegrationStatus - grant_type: OAuthGrantType - requires_config: boolean -} - -const displayStatus = (status: IntegrationStatus) => - status === "configured" ? "not_configured" : status +type CatalogPillFilter = "built_by_me" +type CatalogCardIntent = "connect" | "configure" | "open" -type IntegrationSectionType = "oauth" | "custom_oauth" - -const integrationTypeLabels = { - oauth: "OAuth", - custom_oauth: "Custom OAuth", -} as const - -const integrationSectionOrder: IntegrationSectionType[] = [ - "oauth", - "custom_oauth", +const PILL_FILTERS: Array> = [ + { value: "built_by_me", label: "Built by me", icon: UserSquare }, ] -const integrationSectionTitles: Record = { - oauth: "OAuth", - custom_oauth: "Custom OAuth", +const POPULAR_NAMESPACES = new Set([ + "github", + "slack", + "google_gmail", + "google_drive", + "microsoft_graph", + "servicenow", +]) + +const INTEGRATION_ID_PARAM = "integrationId" + +function matchesFilters( + integration: CatalogIntegrationRead, + activeFilters: CatalogPillFilter[] +): boolean { + if (activeFilters.length === 0) return true + return activeFilters.every((filter) => { + switch (filter) { + case "built_by_me": + return integration.source === "workspace" + default: + return true + } + }) } -function getIntegrationStatus(item: IntegrationItem): IntegrationStatus { - return displayStatus(item.integration_status) -} +function catalogCardState(integration: CatalogIntegrationRead): { + ctaIntent: CatalogCardIntent + isConnected: boolean +} { + const authOptions = integration.auth_options ?? [] + const isConnected = authOptions.some( + (option) => option.status === "connected" + ) + const needsConfiguration = authOptions.some( + (option) => + option.requires_config === true && option.status === "not_configured" + ) + const hasReadyConnectableOption = authOptions.some( + (option) => + option.enabled !== false && + (option.auth_method === "static_kv" || + (option.auth_method === "oauth_auth_code" && + !( + option.requires_config === true && + option.status === "not_configured" + ))) + ) -function getIntegrationDisplayType( - item: IntegrationItem -): IntegrationSectionType { - if (item.id.startsWith("custom_")) { - return "custom_oauth" + if (!isConnected && hasReadyConnectableOption) { + return { ctaIntent: "connect", isConnected } + } + if (needsConfiguration) { + return { + ctaIntent: "configure", + isConnected, + } } - return item.type + return { ctaIntent: "open", isConnected } } export default function IntegrationsPage() { const workspaceId = useWorkspaceId() - const canReadIntegrations = useScopeCheck("integration:read") - const canUpdateIntegrations = useScopeCheck("integration:update") - const canMutateIntegrations = canUpdateIntegrations === true const router = useRouter() const searchParams = useSearchParams() + const canRead = useScopeCheck("integration:read") + const [searchQuery, setSearchQuery] = useState("") - const [typeFilters, setTypeFilters] = useState([]) - const [connectionFilter, setConnectionFilter] = - useState("all") - const [activeOAuthProvider, setActiveOAuthProvider] = useState<{ - providerId: string - grantType: OAuthGrantType - } | null>(null) - const [detailsProvider, setDetailsProvider] = useState<{ - providerId: string - grantType: OAuthGrantType - } | null>(null) - const [disconnectTarget, setDisconnectTarget] = useState<{ - providerId: string - grantType: OAuthGrantType - name: string - } | null>(null) - const [expandedSections, setExpandedSections] = useState< - Record - >({ - oauth: false, - custom_oauth: false, - }) - const lastHandledConnectRef = useRef(null) + const [activeFilters, setActiveFilters] = useState([]) - const { providers, providersIsLoading, providersError } = - useIntegrations(workspaceId) + const { catalog, catalogIsLoading, catalogError } = useListIntegrationCatalog( + workspaceId, + { + search: searchQuery || null, + } + ) - function handleTypeFilterToggle(filter: IntegrationTypeFilter) { - setTypeFilters((prev) => + const togglePillFilter = useCallback((filter: CatalogPillFilter) => { + setActiveFilters((prev) => prev.includes(filter) ? prev.filter((value) => value !== filter) : [...prev, filter] ) - } - - const connectProviderMutation = useConnectProvider(workspaceId) - const disconnectProviderMutation = useDisconnectProvider(workspaceId) - const testConnectionMutation = useTestProvider(workspaceId) - - const allIntegrations = useMemo(() => { - return ( - providers - ?.filter((provider) => !isMcpProvider(provider.id)) - .map((provider) => ({ - type: "oauth" as const, - id: provider.id, - name: provider.name, - description: provider.description, - enabled: provider.enabled, - integration_status: provider.integration_status, - grant_type: provider.grant_type, - requires_config: provider.requires_config, - })) ?? [] - ) - }, [providers]) - - const filteredIntegrations = useMemo(() => { - const q = searchQuery.toLowerCase() - const filtered = allIntegrations.filter((item) => { - const matchesSearch = - item.name.toLowerCase().includes(q) || - (item.description ?? "").toLowerCase().includes(q) - const matchesType = - typeFilters.length === 0 || - typeFilters.some((filter) => { - if (filter === "custom_oauth") { - return item.id.startsWith("custom_") - } - return item.type === filter - }) - const status = getIntegrationStatus(item) - const matchesConnection = - connectionFilter === "all" - ? true - : connectionFilter === "connected" - ? status === "connected" - : status !== "connected" - - return matchesSearch && matchesType && matchesConnection - }) - - return [...filtered].sort((a, b) => { - const aStatus = getIntegrationStatus(a) - const bStatus = getIntegrationStatus(b) - - const statusOrder: Record = { - connected: 0, - not_configured: 1, - configured: 1, - } - - const aOrder = statusOrder[aStatus] ?? 1 - const bOrder = statusOrder[bStatus] ?? 1 - - if (aOrder !== bOrder) { - return aOrder - bOrder - } - - if (a.enabled !== b.enabled) { - return a.enabled ? -1 : 1 - } - - return a.name.localeCompare(b.name) - }) - }, [allIntegrations, connectionFilter, searchQuery, typeFilters]) - - const sectionedIntegrations = useMemo(() => { - const groupedIntegrations: Record< - IntegrationSectionType, - IntegrationItem[] - > = { - oauth: [], - custom_oauth: [], - } - - for (const item of filteredIntegrations) { - groupedIntegrations[getIntegrationDisplayType(item)].push(item) + }, []) + + const filteredCatalog = useMemo(() => { + if (!catalog) return [] + return catalog + .filter((integration) => matchesFilters(integration, activeFilters)) + .sort((a, b) => a.display_name.localeCompare(b.display_name)) + }, [catalog, activeFilters]) + + const { popular, rest } = useMemo(() => { + const showPopular = searchQuery.trim() === "" && activeFilters.length === 0 + if (!showPopular) { + return { popular: [] as CatalogIntegrationRead[], rest: filteredCatalog } } - - return integrationSectionOrder - .map((sectionType) => ({ - sectionType, - title: integrationSectionTitles[sectionType], - items: groupedIntegrations[sectionType], - })) - .filter( - (section) => - section.items.length > 0 || section.sectionType === "custom_oauth" - ) - }, [filteredIntegrations]) - - const connectParam = searchParams?.get("connect") - const connectGrantType = searchParams?.get( - "grant_type" - ) as OAuthGrantType | null - - useEffect(() => { - if (!connectParam) { - lastHandledConnectRef.current = null + const popularList: CatalogIntegrationRead[] = [] + const restList: CatalogIntegrationRead[] = [] + for (const integration of filteredCatalog) { + if (POPULAR_NAMESPACES.has(integration.namespace)) { + popularList.push(integration) + } else { + restList.push(integration) + } } - }, [connectParam]) + return { popular: popularList, rest: restList } + }, [filteredCatalog, searchQuery, activeFilters]) - const clearConnectParams = useCallback(() => { - const params = new URLSearchParams(searchParams?.toString() || "") - params.delete("connect") - params.delete("grant_type") - const query = params.toString() - router.replace( - `/workspaces/${workspaceId}/integrations${query ? `?${query}` : ""}` - ) - }, [router, searchParams, workspaceId]) + const selectedIntegrationId = searchParams?.get(INTEGRATION_ID_PARAM) ?? null - const setConnectParams = useCallback( - (providerId: string, grantType: OAuthGrantType) => { + const updateSelection = useCallback( + (next: string | null) => { const params = new URLSearchParams(searchParams?.toString() || "") - params.set("connect", providerId) - params.set("grant_type", grantType) + if (next) { + params.set(INTEGRATION_ID_PARAM, next) + } else { + params.delete(INTEGRATION_ID_PARAM) + } + const query = params.toString() router.replace( - `/workspaces/${workspaceId}/integrations?${params.toString()}` + `/workspaces/${workspaceId}/integrations${query ? `?${query}` : ""}` ) }, [router, searchParams, workspaceId] ) - const handleOpenOAuthModal = useCallback( - (providerId: string, grantType: OAuthGrantType) => { - setActiveOAuthProvider({ providerId, grantType }) - setConnectParams(providerId, grantType) - }, - [setConnectParams] - ) - - const handleOAuthDialogOpenChange = useCallback( - (nextOpen: boolean) => { - if (!nextOpen) { - setActiveOAuthProvider(null) - clearConnectParams() - } - }, - [clearConnectParams] - ) - - const handleDirectConnect = useCallback( - (providerId: string, grantType: OAuthGrantType) => { - if (grantType === "authorization_code") { - connectProviderMutation.mutate({ providerId }) - } else { - testConnectionMutation.mutate({ providerId, grantType }) - } - }, - [connectProviderMutation, testConnectionMutation] - ) - - useEffect(() => { - if (!canMutateIntegrations) { - return - } - if (!connectParam || !providers) { - return - } - - const handleKey = `${connectParam}:${connectGrantType ?? ""}` - if (lastHandledConnectRef.current === handleKey) { - return - } - - const provider = providers.find( - (item) => - item.id === connectParam && - (connectGrantType == null || item.grant_type === connectGrantType) + if (canRead !== true) { + return ( +
+ + Access denied + + You do not have permission to view integrations. + + +
) - if (!provider) { - lastHandledConnectRef.current = handleKey - return - } - - lastHandledConnectRef.current = handleKey - - if (provider.requires_config) { - handleOpenOAuthModal(provider.id, provider.grant_type) - return - } - - handleDirectConnect(provider.id, provider.grant_type) - clearConnectParams() - }, [ - canMutateIntegrations, - clearConnectParams, - connectGrantType, - connectParam, - handleDirectConnect, - handleOpenOAuthModal, - providers, - ]) - - const handleReconnect = useCallback( - (providerId: string, grantType: OAuthGrantType) => { - handleDirectConnect(providerId, grantType) - }, - [handleDirectConnect] - ) - - if ( - canReadIntegrations === undefined || - canUpdateIntegrations === undefined || - providersIsLoading - ) { - return - } - - if (!canReadIntegrations) { - return null - } - if (providersError) { - return
Error: {providersError.message}
} return ( -
- + searchQuery={searchQuery} onSearchChange={setSearchQuery} - typeFilters={typeFilters} - onTypeFilterToggle={handleTypeFilterToggle} - connectionFilter={connectionFilter} - onConnectionFilterChange={setConnectionFilter} - displayIntegrationCount={filteredIntegrations.length} + searchPlaceholder="Search integrations..." + pillFilters={PILL_FILTERS} + activePillFilters={activeFilters} + onPillFilterToggle={togglePillFilter} + displayCount={filteredCatalog.length} + countLabel="integrations" /> - {/* Integrations List */} - -
- {sectionedIntegrations.map((section) => { - const isExpanded = expandedSections[section.sectionType] ?? false - return ( - - setExpandedSections((prev) => ({ - ...prev, - [section.sectionType]: nextOpen, - })) - } - > -
- - - - -
- {section.items.map((item) => { - const status = getIntegrationStatus(item) - const isConnected = - item.integration_status === "connected" - const isConfigured = status === "connected" - const isClickable = - isConnected || - (canMutateIntegrations && - item.requires_config && - item.enabled) - const isDisabled = !item.enabled - const showConnect = - canMutateIntegrations && !isConnected - const showDisconnect = - canMutateIntegrations && isConnected - const isConnecting = - (connectProviderMutation.isPending && - connectProviderMutation.variables?.providerId === - item.id) || - (testConnectionMutation.isPending && - testConnectionMutation.variables?.providerId === - item.id) - const isDisconnecting = - disconnectProviderMutation.isPending && - disconnectProviderMutation.variables?.providerId === - item.id - const displayType = getIntegrationDisplayType(item) - const typeLabel = integrationTypeLabels[displayType] - - return ( - { - if (isConnected) { - setDetailsProvider({ - providerId: item.id, - grantType: item.grant_type, - }) - return - } - if (item.requires_config && item.enabled) { - if (!canMutateIntegrations) { - return - } - handleOpenOAuthModal(item.id, item.grant_type) - } - }} - > - - - - - - - - - - {item.name} - - - -

{item.name}

-
-
-
- - {typeLabel} - -
-
- - {isConfigured ? ( - - - - - - - - -

Configured

-
-
-
- ) : null} - {isConnected && canMutateIntegrations && ( - - - - - - -

Reconnect

-
-
-
- )} - {showConnect && ( - <> - - - )} - {showDisconnect && ( - - )} -
-
- ) - })} -
-
-
-
- ) - })} -
- {filteredIntegrations.length === 0 && ( -
-

- No integrations found matching your criteria. -

-
- )} -
- {activeOAuthProvider && ( - - )} - {detailsProvider && ( - { - if (!nextOpen) { - setDetailsProvider(null) - } - }} - providerId={detailsProvider.providerId} - grantType={detailsProvider.grantType} - canUpdate={canMutateIntegrations} - /> - )} - { - if (!next) setDisconnectTarget(null) - }} - confirmPhrase={disconnectTarget?.name ?? ""} - title="Disconnect integration" - description={ - disconnectTarget ? ( +
+
+

+ Browse and connect integrations available to this workspace. +

+ + {catalogError ? ( + + Failed to load integrations + + {String(catalogError.body?.detail ?? catalogError.message)} + + + ) : null} + + {catalogIsLoading ? ( + + ) : ( <> - Are you sure you want to disconnect from{" "} - {disconnectTarget.name}? + {popular.length > 0 ? ( +
+
+ +

+ Popular in workspace +

+
+
+ {popular.map((integration) => { + const cardState = catalogCardState(integration) + return ( + updateSelection(integration.id)} + /> + ) + })} +
+
+ ) : null} + +
+ {popular.length > 0 ? ( +

+ All integrations +

+ ) : null} + {rest.length === 0 ? ( +

+ No integrations match your filters. +

+ ) : ( +
+ {rest.map((integration) => { + const cardState = catalogCardState(integration) + return ( + updateSelection(integration.id)} + /> + ) + })} +
+ )} +
- ) : null - } - confirmLabel="Disconnect" - isPending={disconnectProviderMutation.isPending} - onConfirm={async () => { - if (!disconnectTarget) return - await disconnectProviderMutation.mutateAsync({ - providerId: disconnectTarget.providerId, - grantType: disconnectTarget.grantType, - }) - setDisconnectTarget(null) - }} + )} +
+
+ + updateSelection(null)} />
) diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index f8550ed32a..06886046c2 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -8838,6 +8838,433 @@ export const $CaseViewedEventRead = { description: "Event for when a case is viewed.", } as const +export const $CatalogAuthOption = { + properties: { + auth_method: { + $ref: "#/components/schemas/ConnectionAuthMethod", + }, + label: { + type: "string", + title: "Label", + }, + description: { + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], + title: "Description", + }, + provider_id: { + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], + title: "Provider Id", + }, + grant_type: { + anyOf: [ + { + $ref: "#/components/schemas/OAuthGrantType", + }, + { + type: "null", + }, + ], + }, + requires_config: { + type: "boolean", + title: "Requires Config", + default: false, + }, + enabled: { + type: "boolean", + title: "Enabled", + default: true, + }, + status: { + anyOf: [ + { + $ref: "#/components/schemas/IntegrationStatus", + }, + { + type: "null", + }, + ], + }, + fields: { + items: { + $ref: "#/components/schemas/CatalogCredentialField", + }, + type: "array", + title: "Fields", + }, + }, + type: "object", + required: ["auth_method", "label"], + title: "CatalogAuthOption", + description: "Supported authentication path for a catalog integration.", +} as const + +export const $CatalogConnectionRead = { + properties: { + id: { + type: "string", + format: "uuid", + title: "Id", + }, + integration_id: { + type: "string", + format: "uuid", + title: "Integration Id", + }, + workspace_id: { + type: "string", + format: "uuid", + title: "Workspace Id", + }, + user_id: { + anyOf: [ + { + type: "string", + format: "uuid", + }, + { + type: "null", + }, + ], + title: "User Id", + }, + auth_method: { + $ref: "#/components/schemas/ConnectionAuthMethod", + }, + label: { + type: "string", + title: "Label", + }, + expires_at: { + anyOf: [ + { + type: "string", + format: "date-time", + }, + { + type: "null", + }, + ], + title: "Expires At", + }, + scope: { + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], + title: "Scope", + }, + metadata_: { + additionalProperties: true, + type: "object", + title: "Metadata", + }, + created_at: { + type: "string", + format: "date-time", + title: "Created At", + }, + updated_at: { + type: "string", + format: "date-time", + title: "Updated At", + }, + is_expired: { + type: "boolean", + title: "Is Expired", + default: false, + }, + }, + type: "object", + required: [ + "id", + "integration_id", + "workspace_id", + "user_id", + "auth_method", + "label", + "expires_at", + "scope", + "created_at", + "updated_at", + ], + title: "CatalogConnectionRead", + description: "A user/workspace authenticated binding to an integration.", +} as const + +export const $CatalogCredentialField = { + properties: { + key: { + type: "string", + title: "Key", + }, + label: { + type: "string", + title: "Label", + }, + required: { + type: "boolean", + title: "Required", + default: true, + }, + secret: { + type: "boolean", + title: "Secret", + default: true, + }, + multiline: { + type: "boolean", + title: "Multiline", + default: false, + }, + placeholder: { + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], + title: "Placeholder", + }, + description: { + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], + title: "Description", + }, + }, + type: "object", + required: ["key", "label"], + title: "CatalogCredentialField", + description: + "Credential field required by a static/auth configuration option.", +} as const + +export const $CatalogIntegrationDetail = { + properties: { + id: { + type: "string", + format: "uuid", + title: "Id", + }, + workspace_id: { + anyOf: [ + { + type: "string", + format: "uuid", + }, + { + type: "null", + }, + ], + title: "Workspace Id", + }, + namespace: { + type: "string", + title: "Namespace", + }, + display_name: { + type: "string", + title: "Display Name", + }, + description: { + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], + title: "Description", + }, + icon_url: { + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], + title: "Icon Url", + }, + source: { + $ref: "#/components/schemas/IntegrationSource", + }, + auth_options: { + items: { + $ref: "#/components/schemas/CatalogAuthOption", + }, + type: "array", + title: "Auth Options", + }, + created_at: { + type: "string", + format: "date-time", + title: "Created At", + }, + updated_at: { + type: "string", + format: "date-time", + title: "Updated At", + }, + connections: { + items: { + $ref: "#/components/schemas/CatalogConnectionRead", + }, + type: "array", + title: "Connections", + }, + }, + type: "object", + required: [ + "id", + "workspace_id", + "namespace", + "display_name", + "source", + "created_at", + "updated_at", + ], + title: "CatalogIntegrationDetail", + description: "Integration row enriched with related connections.", +} as const + +export const $CatalogIntegrationRead = { + properties: { + id: { + type: "string", + format: "uuid", + title: "Id", + }, + workspace_id: { + anyOf: [ + { + type: "string", + format: "uuid", + }, + { + type: "null", + }, + ], + title: "Workspace Id", + }, + namespace: { + type: "string", + title: "Namespace", + }, + display_name: { + type: "string", + title: "Display Name", + }, + description: { + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], + title: "Description", + }, + icon_url: { + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], + title: "Icon Url", + }, + source: { + $ref: "#/components/schemas/IntegrationSource", + }, + auth_options: { + items: { + $ref: "#/components/schemas/CatalogAuthOption", + }, + type: "array", + title: "Auth Options", + }, + created_at: { + type: "string", + format: "date-time", + title: "Created At", + }, + updated_at: { + type: "string", + format: "date-time", + title: "Updated At", + }, + }, + type: "object", + required: [ + "id", + "workspace_id", + "namespace", + "display_name", + "source", + "created_at", + "updated_at", + ], + title: "CatalogIntegrationRead", + description: "Catalog row for an integration.", +} as const + +export const $CatalogStaticKVConnectionCreate = { + properties: { + auth_method: { + $ref: "#/components/schemas/ConnectionAuthMethod", + default: "static_kv", + }, + environment: { + type: "string", + title: "Environment", + default: "default", + }, + keys: { + additionalProperties: { + type: "string", + }, + type: "object", + title: "Keys", + }, + }, + type: "object", + required: ["keys"], + title: "CatalogStaticKVConnectionCreate", + description: "Connection backed by an arbitrary key-value blob.", +} as const + export const $ChannelType = { type: "string", enum: ["slack"], @@ -9829,6 +10256,18 @@ export const $CommentUpdatedEventRead = { description: "Event for when a top-level comment is updated.", } as const +export const $ConnectionAuthMethod = { + type: "string", + enum: [ + "oauth_auth_code", + "oauth_client_credentials", + "service_account", + "static_kv", + ], + title: "ConnectionAuthMethod", + description: "Auth method for catalog connection projections.", +} as const + export const $ContinueRunRequest = { properties: { kind: { @@ -14130,6 +14569,13 @@ export const $IntegrationReadMinimal = { description: "Response model for user integration.", } as const +export const $IntegrationSource = { + type: "string", + enum: ["platform", "workspace"], + title: "IntegrationSource", + description: "Origin of a catalog integration.", +} as const + export const $IntegrationStatus = { type: "string", enum: ["not_configured", "configured", "connected"], diff --git a/frontend/src/client/services.gen.ts b/frontend/src/client/services.gen.ts index 3e57837079..f01bf0affe 100644 --- a/frontend/src/client/services.gen.ts +++ b/frontend/src/client/services.gen.ts @@ -414,6 +414,16 @@ import type { InboxGetPendingCountResponse, InboxListItemsData, InboxListItemsResponse, + IntegrationsCatalogCreateConnectionData, + IntegrationsCatalogCreateConnectionResponse, + IntegrationsCatalogDeleteConnectionData, + IntegrationsCatalogDeleteConnectionResponse, + IntegrationsCatalogGetCatalogEntryData, + IntegrationsCatalogGetCatalogEntryResponse, + IntegrationsCatalogListCatalogData, + IntegrationsCatalogListCatalogResponse, + IntegrationsCatalogListConnectionsData, + IntegrationsCatalogListConnectionsResponse, IntegrationsConnectProviderData, IntegrationsConnectProviderResponse, IntegrationsDeleteIntegrationData, @@ -11092,6 +11102,139 @@ export const integrationsOauthCallback = ( }) } +/** + * List Catalog + * List integrations visible to this workspace. + * + * Includes platform-shipped (``workspace_id IS NULL``) rows plus + * workspace-authored rows for the caller's workspace. + * @param data The data for the request. + * @param data.workspaceId + * @param data.source + * @param data.search + * @returns CatalogIntegrationRead Successful Response + * @throws ApiError + */ +export const integrationsCatalogListCatalog = ( + data: IntegrationsCatalogListCatalogData +): CancelablePromise => { + return __request(OpenAPI, { + method: "GET", + url: "/workspaces/{workspace_id}/integrations/catalog", + path: { + workspace_id: data.workspaceId, + }, + query: { + source: data.source, + search: data.search, + }, + errors: { + 422: "Validation Error", + }, + }) +} + +/** + * Get Catalog Entry + * Get an integration with its connections. + * @param data The data for the request. + * @param data.integrationId + * @param data.workspaceId + * @returns CatalogIntegrationDetail Successful Response + * @throws ApiError + */ +export const integrationsCatalogGetCatalogEntry = ( + data: IntegrationsCatalogGetCatalogEntryData +): CancelablePromise => { + return __request(OpenAPI, { + method: "GET", + url: "/workspaces/{workspace_id}/integrations/catalog/{integration_id}", + path: { + integration_id: data.integrationId, + workspace_id: data.workspaceId, + }, + errors: { + 422: "Validation Error", + }, + }) +} + +/** + * List Connections + * @param data The data for the request. + * @param data.integrationId + * @param data.workspaceId + * @returns CatalogConnectionRead Successful Response + * @throws ApiError + */ +export const integrationsCatalogListConnections = ( + data: IntegrationsCatalogListConnectionsData +): CancelablePromise => { + return __request(OpenAPI, { + method: "GET", + url: "/workspaces/{workspace_id}/integrations/catalog/{integration_id}/connections", + path: { + integration_id: data.integrationId, + workspace_id: data.workspaceId, + }, + errors: { + 422: "Validation Error", + }, + }) +} + +/** + * Create Connection + * Create a static key-value connection for registry credentials. + * @param data The data for the request. + * @param data.integrationId + * @param data.workspaceId + * @param data.requestBody + * @returns CatalogConnectionRead Successful Response + * @throws ApiError + */ +export const integrationsCatalogCreateConnection = ( + data: IntegrationsCatalogCreateConnectionData +): CancelablePromise => { + return __request(OpenAPI, { + method: "POST", + url: "/workspaces/{workspace_id}/integrations/catalog/{integration_id}/connections", + path: { + integration_id: data.integrationId, + workspace_id: data.workspaceId, + }, + body: data.requestBody, + mediaType: "application/json", + errors: { + 422: "Validation Error", + }, + }) +} + +/** + * Delete Connection + * @param data The data for the request. + * @param data.connectionId + * @param data.workspaceId + * @returns void Successful Response + * @throws ApiError + */ +export const integrationsCatalogDeleteConnection = ( + data: IntegrationsCatalogDeleteConnectionData +): CancelablePromise => { + return __request(OpenAPI, { + method: "DELETE", + url: "/workspaces/{workspace_id}/integrations/connections/{connection_id}", + path: { + connection_id: data.connectionId, + workspace_id: data.workspaceId, + }, + errors: { + 422: "Validation Error", + }, + }) +} + /** * List Integrations * List all integrations for the current user. diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 9a3d5cfb01..6a48fe9902 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -2331,6 +2331,98 @@ export type CaseViewedEventRead = { created_at: string } +/** + * Supported authentication path for a catalog integration. + */ +export type CatalogAuthOption = { + auth_method: ConnectionAuthMethod + label: string + description?: string | null + provider_id?: string | null + grant_type?: OAuthGrantType | null + requires_config?: boolean + enabled?: boolean + status?: IntegrationStatus | null + fields?: Array +} + +/** + * A user/workspace authenticated binding to an integration. + */ +export type CatalogConnectionRead = { + id: string + integration_id: string + workspace_id: string + user_id: string | null + auth_method: ConnectionAuthMethod + label: string + expires_at: string | null + scope: string | null + metadata_?: { + [key: string]: unknown + } + created_at: string + updated_at: string + is_expired?: boolean +} + +/** + * Credential field required by a static/auth configuration option. + */ +export type CatalogCredentialField = { + key: string + label: string + required?: boolean + secret?: boolean + multiline?: boolean + placeholder?: string | null + description?: string | null +} + +/** + * Integration row enriched with related connections. + */ +export type CatalogIntegrationDetail = { + id: string + workspace_id: string | null + namespace: string + display_name: string + description?: string | null + icon_url?: string | null + source: IntegrationSource + auth_options?: Array + created_at: string + updated_at: string + connections?: Array +} + +/** + * Catalog row for an integration. + */ +export type CatalogIntegrationRead = { + id: string + workspace_id: string | null + namespace: string + display_name: string + description?: string | null + icon_url?: string | null + source: IntegrationSource + auth_options?: Array + created_at: string + updated_at: string +} + +/** + * Connection backed by an arbitrary key-value blob. + */ +export type CatalogStaticKVConnectionCreate = { + auth_method?: ConnectionAuthMethod + environment?: string + keys: { + [key: string]: string + } +} + /** * Supported external channel types. */ @@ -2740,6 +2832,15 @@ export type CommentUpdatedEventRead = { created_at: string } +/** + * Auth method for catalog connection projections. + */ +export type ConnectionAuthMethod = + | "oauth_auth_code" + | "oauth_client_credentials" + | "service_account" + | "static_kv" + /** * Payload to continue a CE run after collecting approvals. */ @@ -4340,6 +4441,11 @@ export type IntegrationReadMinimal = { is_expired: boolean } +/** + * Origin of a catalog integration. + */ +export type IntegrationSource = "platform" | "workspace" + /** * Status of an integration. */ @@ -12189,6 +12295,46 @@ export type IntegrationsOauthCallbackData = { export type IntegrationsOauthCallbackResponse = IntegrationOAuthCallback +export type IntegrationsCatalogListCatalogData = { + search?: string | null + source?: IntegrationSource | null + workspaceId: string +} + +export type IntegrationsCatalogListCatalogResponse = + Array + +export type IntegrationsCatalogGetCatalogEntryData = { + integrationId: string + workspaceId: string +} + +export type IntegrationsCatalogGetCatalogEntryResponse = + CatalogIntegrationDetail + +export type IntegrationsCatalogListConnectionsData = { + integrationId: string + workspaceId: string +} + +export type IntegrationsCatalogListConnectionsResponse = + Array + +export type IntegrationsCatalogCreateConnectionData = { + integrationId: string + requestBody: CatalogStaticKVConnectionCreate + workspaceId: string +} + +export type IntegrationsCatalogCreateConnectionResponse = CatalogConnectionRead + +export type IntegrationsCatalogDeleteConnectionData = { + connectionId: string + workspaceId: string +} + +export type IntegrationsCatalogDeleteConnectionResponse = void + export type IntegrationsListIntegrationsData = { workspaceId: string } @@ -18129,6 +18275,79 @@ export type $OpenApiTs = { } } } + "/workspaces/{workspace_id}/integrations/catalog": { + get: { + req: IntegrationsCatalogListCatalogData + res: { + /** + * Successful Response + */ + 200: Array + /** + * Validation Error + */ + 422: HTTPValidationError + } + } + } + "/workspaces/{workspace_id}/integrations/catalog/{integration_id}": { + get: { + req: IntegrationsCatalogGetCatalogEntryData + res: { + /** + * Successful Response + */ + 200: CatalogIntegrationDetail + /** + * Validation Error + */ + 422: HTTPValidationError + } + } + } + "/workspaces/{workspace_id}/integrations/catalog/{integration_id}/connections": { + get: { + req: IntegrationsCatalogListConnectionsData + res: { + /** + * Successful Response + */ + 200: Array + /** + * Validation Error + */ + 422: HTTPValidationError + } + } + post: { + req: IntegrationsCatalogCreateConnectionData + res: { + /** + * Successful Response + */ + 201: CatalogConnectionRead + /** + * Validation Error + */ + 422: HTTPValidationError + } + } + } + "/workspaces/{workspace_id}/integrations/connections/{connection_id}": { + delete: { + req: IntegrationsCatalogDeleteConnectionData + res: { + /** + * Successful Response + */ + 204: void + /** + * Validation Error + */ + 422: HTTPValidationError + } + } + } "/workspaces/{workspace_id}/integrations": { get: { req: IntegrationsListIntegrationsData diff --git a/frontend/src/components/integrations/catalog-card.tsx b/frontend/src/components/integrations/catalog-card.tsx new file mode 100644 index 0000000000..6be06a7921 --- /dev/null +++ b/frontend/src/components/integrations/catalog-card.tsx @@ -0,0 +1,80 @@ +"use client" + +import type { CatalogIntegrationRead } from "@/client" +import { ProviderIcon } from "@/components/icons" +import { Button } from "@/components/ui/button" +import { Card } from "@/components/ui/card" + +type CtaIntent = "connect" | "configure" | "open" + +interface CatalogCardProps { + integration: CatalogIntegrationRead + ctaIntent?: CtaIntent + isConnected?: boolean + onSelect: () => void +} + +const ctaLabels: Record = { + connect: "Connect", + configure: "Configure", + open: "Open", +} + +function catalogStatusLabel( + integration: CatalogIntegrationRead, + isConnected: boolean +): string | null { + if (isConnected) return "Connected" + if (integration.source === "workspace") return "Built by me" + return null +} + +export function CatalogCard({ + integration, + ctaIntent = "open", + isConnected = false, + onSelect, +}: CatalogCardProps) { + const statusLabel = catalogStatusLabel(integration, isConnected) + + return ( + +
+ + +
+ +
+
+

+ {integration.display_name} +

+ {statusLabel ? ( + + {statusLabel} + + ) : null} +
+

+ {integration.description ?? "No description"} +

+
+
+ ) +} diff --git a/frontend/src/components/integrations/configure-dialog.tsx b/frontend/src/components/integrations/configure-dialog.tsx new file mode 100644 index 0000000000..b87a209917 --- /dev/null +++ b/frontend/src/components/integrations/configure-dialog.tsx @@ -0,0 +1,575 @@ +"use client" + +import { Check, ChevronDown, ChevronRight, Copy, Loader2 } from "lucide-react" +import { useEffect, useMemo, useState } from "react" +import type { CatalogAuthOption } from "@/client" +import { MultiTagCommandInput } from "@/components/tags-input" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { toast } from "@/components/ui/use-toast" +import { + useDeleteProviderAuthConfig, + useProviderAuthConfig, + useTestProviderAuthConnection, + useUpdateProviderAuthConfig, +} from "@/lib/hooks/integrations-catalog" +import { cn } from "@/lib/utils" + +interface ConfigureDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + workspaceId: string + integrationId: string + displayName: string + authOptions: CatalogAuthOption[] + defaultAuthOption?: CatalogAuthOption | null +} + +function optionKey(option: CatalogAuthOption): string { + return [ + option.auth_method, + option.provider_id ?? "unknown", + option.grant_type ?? "none", + ].join(":") +} + +function isConfigurableOption(option: CatalogAuthOption): boolean { + return Boolean( + option.enabled !== false && + option.provider_id && + option.grant_type && + option.requires_config + ) +} + +function optionStatusLabel(option: CatalogAuthOption): string | null { + if (option.status === "connected") { + return "Connected" + } + if (option.status === "configured") { + return "Configured" + } + return null +} + +function fallbackRedirectUrl(): string { + if (typeof window === "undefined") { + return "/integrations/callback" + } + return `${window.location.origin}/integrations/callback` +} + +export function ConfigureDialog({ + open, + onOpenChange, + workspaceId, + integrationId, + displayName, + authOptions, + defaultAuthOption, +}: ConfigureDialogProps) { + const configurableOptions = useMemo( + () => authOptions.filter(isConfigurableOption), + [authOptions] + ) + const defaultKey = useMemo(() => { + const preferred = defaultAuthOption + ? configurableOptions.find( + (option) => optionKey(option) === optionKey(defaultAuthOption) + ) + : null + const fallback = preferred ?? configurableOptions[0] + return fallback ? optionKey(fallback) : "" + }, [configurableOptions, defaultAuthOption]) + const [selectedKey, setSelectedKey] = useState(defaultKey) + + useEffect(() => { + if (open) { + setSelectedKey(defaultKey) + } + }, [defaultKey, open]) + + const selectedOption = + configurableOptions.find((option) => optionKey(option) === selectedKey) ?? + configurableOptions[0] + + return ( + + + + Configure {displayName} + + Save the provider credentials used by this workspace. + + + {configurableOptions.length === 0 || !selectedOption ? ( +
+

+ This integration does not require workspace configuration. +

+ + + +
+ ) : configurableOptions.length === 1 ? ( + onOpenChange(false)} + /> + ) : ( +
+
+ {configurableOptions.map((option) => { + const key = optionKey(option) + const selected = selectedKey === key + const statusLabel = optionStatusLabel(option) + return ( + + ) + })} +
+ onOpenChange(false)} + /> +
+ )} +
+
+ ) +} + +function ProviderConfigForm({ + open, + workspaceId, + integrationId, + authOption, + onClose, +}: { + open: boolean + workspaceId: string + integrationId: string + authOption: CatalogAuthOption + onClose: () => void +}) { + const providerId = authOption.provider_id! + const grantType = authOption.grant_type! + const isServiceAccount = authOption.auth_method === "service_account" + const canTest = + authOption.auth_method === "oauth_client_credentials" || isServiceAccount + const { provider, providerIsLoading, integration, integrationIsLoading } = + useProviderAuthConfig(workspaceId, authOption, open) + const { updateProviderAuthConfig, updateProviderAuthConfigIsPending } = + useUpdateProviderAuthConfig({ + workspaceId, + integrationId, + providerId, + grantType, + }) + const { deleteProviderAuthConfig, deleteProviderAuthConfigIsPending } = + useDeleteProviderAuthConfig({ + workspaceId, + integrationId, + providerId, + grantType, + }) + const { testProviderAuthConnection, testProviderAuthConnectionIsPending } = + useTestProviderAuthConnection({ + workspaceId, + integrationId, + providerId, + grantType, + }) + + const [clientId, setClientId] = useState("") + const [clientSecret, setClientSecret] = useState("") + const [serviceAccountJson, setServiceAccountJson] = useState("") + const [scopes, setScopes] = useState([]) + const [authEndpoint, setAuthEndpoint] = useState("") + const [tokenEndpoint, setTokenEndpoint] = useState("") + const [advancedOpen, setAdvancedOpen] = useState(false) + const [pendingDelete, setPendingDelete] = useState(false) + + useEffect(() => { + setClientId("") + setClientSecret("") + setServiceAccountJson("") + setScopes(integration?.requested_scopes ?? provider?.scopes.default ?? []) + setAuthEndpoint( + integration?.authorization_endpoint ?? + provider?.default_authorization_endpoint ?? + "" + ) + setTokenEndpoint( + integration?.token_endpoint ?? provider?.default_token_endpoint ?? "" + ) + setAdvancedOpen(false) + }, [authOption, integration, provider]) + + const redirectUrl = provider?.redirect_uri ?? fallbackRedirectUrl() + const hasExistingConfig = Boolean(integration) + const hasStoredClientId = Boolean(integration?.client_id) + const canSave = isServiceAccount + ? hasExistingConfig || Boolean(serviceAccountJson.trim()) + : hasExistingConfig || + (Boolean(clientId.trim()) && Boolean(clientSecret.trim())) + + const save = async () => { + let nextClientId = clientId.trim() || null + let nextClientSecret = clientSecret.trim() || null + + if (isServiceAccount) { + const trimmedJson = serviceAccountJson.trim() + nextClientId = null + nextClientSecret = trimmedJson || null + if (trimmedJson) { + const derived = deriveServiceAccountClientId(trimmedJson) + if (!derived) return + nextClientId = derived + } + } + + await updateProviderAuthConfig({ + client_id: nextClientId, + client_secret: nextClientSecret, + scopes, + authorization_endpoint: authEndpoint.trim() || null, + token_endpoint: tokenEndpoint.trim() || null, + }) + setClientId("") + setClientSecret("") + setServiceAccountJson("") + } + + const confirmDelete = async () => { + setPendingDelete(false) + await deleteProviderAuthConfig() + onClose() + } + + if (providerIsLoading || integrationIsLoading) { + return ( +
+ +
+ ) + } + + return ( +
+ {authOption.auth_method === "oauth_auth_code" ? ( + + ) : null} + +
+ + +
+ + {isServiceAccount ? ( +
+ +