diff --git a/apps/api/src/integration-platform/controllers/connections.controller.ts b/apps/api/src/integration-platform/controllers/connections.controller.ts index e318dce508..f605b8e05e 100644 --- a/apps/api/src/integration-platform/controllers/connections.controller.ts +++ b/apps/api/src/integration-platform/controllers/connections.controller.ts @@ -262,7 +262,7 @@ export class ConnectionsController { } /** - * List connections for an organization + * List connections for an organization (excludes soft-deleted/disconnected) */ @Get() @RequirePermission('integration', 'read') @@ -270,20 +270,22 @@ export class ConnectionsController { const connections = await this.connectionService.getOrganizationConnections(organizationId); - return connections.map((c) => ({ - id: c.id, - providerId: c.providerId, - providerSlug: (c as any).provider?.slug, - providerName: (c as any).provider?.name, - status: c.status, - authStrategy: c.authStrategy, - lastSyncAt: c.lastSyncAt, - nextSyncAt: c.nextSyncAt, - errorMessage: c.errorMessage, - variables: c.variables, - metadata: c.metadata, - createdAt: c.createdAt, - })); + return connections + .filter((c) => c.status !== 'disconnected') + .map((c) => ({ + id: c.id, + providerId: c.providerId, + providerSlug: (c as any).provider?.slug, + providerName: (c as any).provider?.name, + status: c.status, + authStrategy: c.authStrategy, + lastSyncAt: c.lastSyncAt, + nextSyncAt: c.nextSyncAt, + errorMessage: c.errorMessage, + variables: c.variables, + metadata: c.metadata, + createdAt: c.createdAt, + })); } /** @@ -1178,6 +1180,10 @@ export class ConnectionsController { if (Array.isArray(mergedCredentials.regions)) { metaUpdates.regions = mergedCredentials.regions; } + // Mark cloud credential updates as reconnections so reconnect banners clear + if (manifest.category === 'Cloud') { + metaUpdates.reconnectedAt = new Date().toISOString(); + } if (Object.keys(metaUpdates).length > 0) { const existingMeta = (connection.metadata as Record) ?? {}; diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ProviderTabs.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ProviderTabs.tsx index b414cbd5ba..0df49c479e 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ProviderTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ProviderTabs.tsx @@ -23,11 +23,13 @@ interface ProviderTabsProps { onConnectionTabChange: (providerType: string, connectionId: string) => void; onRunScan: (connectionId?: string) => Promise; onAddConnection: (providerType: string) => void; + onReconnect: (providerType: string) => void; onConfigure: (provider: Provider) => void; needsConfiguration: (provider: Provider) => boolean; requiresReconnect: (provider: Provider) => boolean; canRunScan?: boolean; canAddConnection?: boolean; + isReconnecting?: boolean; orgId: string; } @@ -235,11 +237,13 @@ export function ProviderTabs({ onConnectionTabChange, onRunScan, onAddConnection, + onReconnect, onConfigure, needsConfiguration, requiresReconnect, canRunScan, canAddConnection, + isReconnecting, orgId, }: ProviderTabsProps) { return ( @@ -328,7 +332,12 @@ export function ProviderTabs({

{canAddConnection !== false && ( - )} diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/TestsLayout.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/TestsLayout.tsx index 228e505441..43b2fc85bf 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/TestsLayout.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/TestsLayout.tsx @@ -2,6 +2,7 @@ import { ConnectIntegrationDialog } from '@/components/integrations/ConnectIntegrationDialog'; import { useApi } from '@/hooks/use-api'; +import { useIntegrationMutations } from '@/hooks/use-integration-platform'; import { usePermissions } from '@/hooks/use-permissions'; import { ManageIntegrationDialog } from '@/components/integrations/ManageIntegrationDialog'; import { CLOUD_RECONNECT_CUTOFF_LABEL, requiresCloudReconnect } from '@/lib/cloud-reconnect-policy'; @@ -10,6 +11,7 @@ import { Add, Settings } from '@trycompai/design-system/icons'; import { useSearchParams, useRouter } from 'next/navigation'; import { useCallback, useMemo, useState } from 'react'; import { toast } from 'sonner'; +import { mutate as globalMutate } from 'swr'; import { isCloudProviderSlug } from '../constants'; import type { Finding, Provider } from '../types'; import { CloudSettingsModal } from './CloudSettingsModal'; @@ -51,9 +53,11 @@ export function TestsLayout({ initialFindings, initialProviders, orgId }: TestsL const canRunScan = hasPermission('integration', 'update'); const canCreateIntegration = hasPermission('integration', 'create'); const api = useApi(); + const { deleteConnection } = useIntegrationMutations(); const [showSettings, setShowSettings] = useState(false); const [viewingResults, setViewingResults] = useState(true); const [isScanning, setIsScanning] = useState(false); + const [isReconnecting, setIsReconnecting] = useState(false); const searchParams = useSearchParams(); const router = useRouter(); const [activeProviderTab, setActiveProviderTabState] = useState( @@ -217,6 +221,37 @@ export function TestsLayout({ initialFindings, initialProviders, orgId }: TestsL setViewingResults(true); }; + const handleReconnect = async (providerType: string) => { + const providerName = PROVIDER_NAME[providerType] || providerType.toUpperCase(); + if ( + !confirm( + `This will disconnect all your ${providerName} connections and redirect you to set up a fresh connection. Continue?`, + ) + ) { + return; + } + + setIsReconnecting(true); + try { + const connections = providerGroups[providerType] || []; + await Promise.all( + connections.map((connection) => + connection.isLegacy + ? api.delete(`/v1/cloud-security/legacy/${connection.id}`) + : deleteConnection(connection.id), + ), + ); + await Promise.all([mutateProviders(), mutateFindings()]); + // Clear integration connections cache so the target page doesn't flash stale data + await globalMutate(['integration-connections', orgId]); + router.push(`/${orgId}/integrations/${providerType}`); + } catch { + toast.error('Failed to disconnect connections. Please try again.'); + } finally { + setIsReconnecting(false); + } + }; + if (connectedProviders.length === 0 || !viewingResults) { return ( diff --git a/apps/app/src/components/integrations/ConnectIntegrationDialog.tsx b/apps/app/src/components/integrations/ConnectIntegrationDialog.tsx index 04fecf40a8..5a493f955e 100644 --- a/apps/app/src/components/integrations/ConnectIntegrationDialog.tsx +++ b/apps/app/src/components/integrations/ConnectIntegrationDialog.tsx @@ -95,11 +95,11 @@ export function ConnectIntegrationDialog({ // since hooks return [] as fallback, not undefined const isDataLoading = isProvidersLoading || isConnectionsLoading; - // Filter connections for this specific integration + // Filter connections for this specific integration (exclude soft-deleted) const existingConnections: ExistingConnection[] = useMemo(() => { if (!allConnections) return []; return allConnections - .filter((conn) => conn.providerSlug === integrationId) + .filter((conn) => conn.providerSlug === integrationId && conn.status !== 'disconnected') .map((conn) => { const metadata = (conn.metadata || {}) as Record; return {