From f826f6096dc2ed795b783d88df8028e6e7c4a75c Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 8 Sep 2026 16:27:22 -0700 Subject: [PATCH 1/6] fix(integrations): expose stored service-account catalog connections --- .../[block]/integration-block-detail.tsx | 62 ++++++++++--------- .../application/provider-catalog.test.ts | 32 ++++++++++ .../integrations/availability.server.test.ts | 36 ++++++++--- .../integrations/credential-display.test.ts | 21 ++++--- .../credential-visibility.server.test.ts | 39 ++++++++++++ apps/sim/lib/integrations/index.ts | 1 + .../lib/integrations/oauth-service.test.ts | 29 +++++++++ apps/sim/lib/integrations/oauth-service.ts | 25 +++++++- apps/sim/lib/integrations/types.ts | 2 + .../src/integration-availability.ts | 35 +++++++++-- .../deployment-config/src/integrations.json | 4 ++ .../service-account-providers.generated.ts | 4 ++ scripts/check-integration-catalog.ts | 13 +++- scripts/generate-deployment-config.ts | 22 +++++-- scripts/generate-docs.test.ts | 38 ++++++++++++ scripts/generate-docs.ts | 29 ++++++++- 16 files changed, 332 insertions(+), 60 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index af31043f573..39502a80684 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -12,6 +12,7 @@ import { type Integration, resolveCredentialDisplay, resolveOAuthServiceForIntegration, + resolveServiceAccountServiceForIntegration, } from '@/lib/integrations' import { credentialProviderMatchesService } from '@/lib/oauth' import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' @@ -69,6 +70,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration const matchingTemplates = getTemplatesForBlock(integration.type) const suggestedSkills = getSuggestedSkillsForBlock(integration.type) const oauthService = resolveOAuthServiceForIntegration(integration) + const serviceAccountService = resolveServiceAccountServiceForIntegration(integration) const { integrationAvailability, isLoading: permissionConfigLoading } = usePermissionConfig() const { chatEnabled } = useDeploymentShape() const availability = integrationAvailability.get(integration.type.toLowerCase()) @@ -94,19 +96,20 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration const connectedCredentials = useMemo(() => { if (integration.type === 'gitlab') return credentials.filter((c) => c.type === 'personal_token' && c.providerId === 'gitlab') - if (!oauthService) return [] + const credentialService = oauthService ?? serviceAccountService + if (!credentialService) return [] return credentials.filter( (c) => (c.type === 'oauth' || c.type === 'service_account') && c.providerId && - credentialProviderMatchesService(c.providerId, oauthService) + credentialProviderMatchesService(c.providerId, credentialService) ) - }, [credentials, oauthService, integration.type]) + }, [credentials, oauthService, serviceAccountService, integration.type]) const [serviceAccountOpen, setServiceAccountOpen] = useState(false) const serviceAccountTarget = useServiceAccountConnectTarget({ - serviceAccountProviderId: oauthService?.serviceAccountProviderId, - serviceName: oauthService?.serviceName, - serviceIcon: oauthService?.serviceIcon, + serviceAccountProviderId: serviceAccountService?.serviceAccountProviderId, + serviceName: serviceAccountService?.serviceName, + serviceIcon: serviceAccountService?.serviceIcon, }) const serviceAccountDeploymentAvailable = availability?.state === 'ready' || availability?.state === 'limited' @@ -147,28 +150,29 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration setConnectMode, ]) - const connectOptions = oauthService - ? [ - ...(oauthAvailable - ? [ - { - value: CONNECT_MODE.oauth, - label: 'Connect with OAuth', - icon: oauthService.serviceIcon, - }, - ] - : []), - ...(hasServiceAccount - ? [ - { - value: CONNECT_MODE.serviceAccount, - label: serviceAccountConnectLabel, - icon: serviceAccountTarget?.serviceIcon ?? oauthService.serviceIcon, - }, - ] - : []), - ] - : [] + const connectOptions = + oauthService || serviceAccountService + ? [ + ...(oauthAvailable && oauthService + ? [ + { + value: CONNECT_MODE.oauth, + label: 'Connect with OAuth', + icon: oauthService.serviceIcon, + }, + ] + : []), + ...(hasServiceAccount + ? [ + { + value: CONNECT_MODE.serviceAccount, + label: serviceAccountConnectLabel, + icon: serviceAccountTarget?.serviceIcon ?? serviceAccountService?.serviceIcon, + }, + ] + : []), + ] + : [] const handleSelectConnectOption = (value: string) => { if (value === CONNECT_MODE.oauth) setOAuthOpen(true) @@ -191,7 +195,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration setPersonalTokenOpen(true)}> Add personal token - ) : oauthService ? ( + ) : oauthService || serviceAccountService ? ( connectOptions.length > 1 ? ( { + it.each(['netsuite', 'snowflake', 'harmonic'])( + 'projects %s as a stored service-account provider without an OAuth connection', + async (serviceId) => { + const providerId = `${serviceId}-service-account` + mocks.getAllOAuthServices.mockReturnValue([ + { + serviceId, + providerId, + serviceAccountProviderId: providerId, + name: serviceId, + description: serviceId, + baseProvider: serviceId, + authType: 'service_account', + }, + ]) + const isCredentialVisible = vi.fn(() => true) + mocks.createVisibility.mockReturnValue({ + isCredentialVisible, + isOAuthServiceVisible: () => false, + }) + const catalog = await listCredentialProviderCatalog(personalPrincipal, context) + expect(catalog).toHaveLength(1) + expect(catalog[0]).toMatchObject({ type: 'service_account', providerId, available: true }) + expect(isCredentialVisible).toHaveBeenCalledWith({ providerId, type: 'service_account' }) + expect(requireAvailableServiceAccountCredentialProvider(catalog, providerId)).toBe(catalog[0]) + isCredentialVisible.mockReturnValue(false) + const restricted = await listCredentialProviderCatalog(personalPrincipal, context) + expect(() => + requireAvailableServiceAccountCredentialProvider(restricted, providerId) + ).toThrow('unavailable') + } + ) beforeEach(() => { vi.clearAllMocks() mocks.getAllOAuthServices.mockReturnValue(services) diff --git a/apps/sim/lib/integrations/availability.server.test.ts b/apps/sim/lib/integrations/availability.server.test.ts index f782f80b24d..1edee77194a 100644 --- a/apps/sim/lib/integrations/availability.server.test.ts +++ b/apps/sim/lib/integrations/availability.server.test.ts @@ -41,6 +41,24 @@ function availabilityFor( } describe('integration availability', () => { + it.each(['netsuite', 'snowflake', 'harmonic'])( + 'makes %s stored credentials available without configuring an OAuth client', + (serviceId) => { + expect(availabilityFor(serviceId)).toMatchObject({ + state: 'ready', + oauthAvailable: false, + serviceAccountAvailable: true, + missingFields: [], + }) + expect(getIntegrationTypesForOAuthServiceId(serviceId)).toEqual([serviceId]) + expect(isOAuthServiceAllowedByIntegrationTypes(serviceId, new Set([serviceId]))).toBe(true) + expect(isOAuthServiceAllowedByIntegrationTypes(serviceId, new Set(['jira']))).toBe(false) + expect(SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID[serviceId]?.providerId).toBe( + `${serviceId}-service-account` + ) + expect(CREDENTIAL_CONFIGURED_OAUTH_SERVICE_IDS).not.toContain(serviceId) + } + ) it('does not infer GitHub repository OAuth readiness from its API-key workflow block', () => { expect(availabilityFor('github_v2')).toMatchObject({ state: 'ready', oauthAvailable: false }) expect( @@ -187,24 +205,26 @@ describe('integration availability', () => { }) it('keeps service-account metadata in parity with canonical OAuth services', () => { - const oauthServiceIds = [ + const credentialServiceIds = [ ...new Set( - integrations.flatMap((integration) => - integration.authType === 'oauth' && integration.oauthServiceId - ? [integration.oauthServiceId] - : [] - ) + integrations.flatMap((integration) => { + const serviceId = integration.serviceAccountServiceId ?? integration.oauthServiceId + return serviceId ? [serviceId] : [] + }) ), ] const expectedServiceAccountIds: Record = {} const expectedCredentialConfiguredServiceIds: string[] = [] - for (const oauthServiceId of oauthServiceIds) { + for (const oauthServiceId of credentialServiceIds) { const canonical = getServiceConfigByServiceId(oauthServiceId) if (!canonical) throw new Error(`Missing canonical OAuth service ${oauthServiceId}`) const projected = SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID[oauthServiceId] expect(projected?.providerId, oauthServiceId).toBe(canonical.serviceAccountProviderId) - if (canonical.clientConfiguration) { + if ( + canonical.clientConfiguration && + integrations.some((integration) => integration.oauthServiceId === oauthServiceId) + ) { expectedCredentialConfiguredServiceIds.push(oauthServiceId) } if (canonical.serviceAccountProviderId) { diff --git a/apps/sim/lib/integrations/credential-display.test.ts b/apps/sim/lib/integrations/credential-display.test.ts index 470b293640a..f395c31e30c 100644 --- a/apps/sim/lib/integrations/credential-display.test.ts +++ b/apps/sim/lib/integrations/credential-display.test.ts @@ -15,7 +15,10 @@ import { isFamilyServiceAccount, resolveCredentialDisplay, } from '@/lib/integrations/credential-display' -import { resolveOAuthServiceForIntegration } from '@/lib/integrations/oauth-service' +import { + resolveOAuthServiceForIntegration, + resolveServiceAccountServiceForIntegration, +} from '@/lib/integrations/oauth-service' import type { Integration } from '@/lib/integrations/types' import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' import { credentialProviderMatchesService } from '@/lib/oauth/utils' @@ -47,7 +50,7 @@ const EXPECTED_COVERAGE: Record = { 'attio-service-account': ['attio'], 'box-service-account': ['box'], 'calcom-service-account': ['cal-com'], - 'claude-platform-service-account': [], + 'claude-platform-service-account': ['claude-managed-agents'], 'clickup-service-account': ['clickup'], 'google-service-account': [ 'gmail', @@ -64,21 +67,17 @@ const EXPECTED_COVERAGE: Record = { 'google-tasks', 'google-vault', ], - 'harmonic-service-account': [], + 'harmonic-service-account': ['harmonic'], 'hubspot-service-account': ['hubspot'], 'linear-service-account': ['linear'], 'monday-service-account': ['monday'], 'notion-service-account': ['notion'], - // NetSuite remains an API-key catalog integration, like Snowflake, while its - // block uses the shared reusable-credential selector. - 'netsuite-service-account': [], + 'netsuite-service-account': ['oracle-netsuite'], 'pipedrive-service-account': ['pipedrive'], 'salesforce-service-account': ['salesforce'], 'shopify-service-account': ['shopify'], 'slack-custom-bot': ['slack'], - // Snowflake's catalog entry is api-key (there is no Snowflake OAuth client), - // so its credential is offered on the block rather than an integration page. - 'snowflake-service-account': [], + 'snowflake-service-account': ['snowflake'], 'trello-service-account': ['trello'], 'wealthbox-service-account': ['wealthbox'], 'webflow-service-account': ['webflow'], @@ -169,7 +168,9 @@ describe('service-account coverage', () => { const covered = new Set(getIntegrationsForCredentialProvider(providerId).map((i) => i.slug)) for (const integration of INTEGRATIONS) { - const service = resolveOAuthServiceForIntegration(integration) + const service = + resolveOAuthServiceForIntegration(integration) ?? + resolveServiceAccountServiceForIntegration(integration) if (!service) continue expect( credentialProviderMatchesService(providerId, service), diff --git a/apps/sim/lib/integrations/credential-visibility.server.test.ts b/apps/sim/lib/integrations/credential-visibility.server.test.ts index 30f9e271bed..f5dcfa54537 100644 --- a/apps/sim/lib/integrations/credential-visibility.server.test.ts +++ b/apps/sim/lib/integrations/credential-visibility.server.test.ts @@ -56,6 +56,45 @@ function availability( } describe('integration credential visibility', () => { + it.each(['netsuite', 'snowflake', 'harmonic'])( + 'applies allowlists and block visibility to %s stored credentials', + (serviceId) => { + const providerId = `${serviceId}-service-account` + const service: OAuthServiceMetadata = { + serviceId, + providerId, + serviceAccountProviderId: providerId, + name: serviceId, + description: serviceId, + baseProvider: serviceId, + authType: 'service_account', + } + getIntegrationAvailabilityMock.mockReturnValue([ + availability(serviceId, 'ready', { oauthAvailable: false, serviceAccountAvailable: true }), + ]) + const visible = (allowed: string[], hidden = false) => + createIntegrationCredentialVisibility({ + allowedIntegrationTypes: new Set(allowed), + blockVisibility: hidden + ? { revealed: new Set(), disabled: new Set([serviceId]), previewTagged: new Set() } + : null, + oauthServices: [service], + }) + expect( + visible([serviceId]).isCredentialVisible({ providerId, type: 'service_account' }) + ).toBe(true) + expect(visible(['jira']).isCredentialVisible({ providerId, type: 'service_account' })).toBe( + false + ) + expect( + visible([serviceId], true).isCredentialVisible({ providerId, type: 'service_account' }) + ).toBe(false) + getBlockMock.mockReturnValue({ type: serviceId, preview: true }) + expect( + visible([serviceId]).isCredentialVisible({ providerId, type: 'service_account' }) + ).toBe(false) + } + ) beforeEach(() => { vi.clearAllMocks() getBlockMock.mockImplementation((type: string) => ({ type })) diff --git a/apps/sim/lib/integrations/index.ts b/apps/sim/lib/integrations/index.ts index 463096463bc..3a5f47e20a5 100644 --- a/apps/sim/lib/integrations/index.ts +++ b/apps/sim/lib/integrations/index.ts @@ -68,6 +68,7 @@ export { type OAuthServiceMatch, resolveOAuthServiceForIntegration, resolveOAuthServiceForSlug, + resolveServiceAccountServiceForIntegration, } from '@/lib/integrations/oauth-service' export type { AuthType, FAQItem, Integration, IntegrationSummary } from '@/lib/integrations/types' export type { BlockMeta, BlockTemplate } from '@/blocks/types' diff --git a/apps/sim/lib/integrations/oauth-service.test.ts b/apps/sim/lib/integrations/oauth-service.test.ts index 22a70eceef3..4a251204447 100644 --- a/apps/sim/lib/integrations/oauth-service.test.ts +++ b/apps/sim/lib/integrations/oauth-service.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest' import { resolveOAuthServiceForSlug, resolveServiceAccountIntegration, + resolveServiceAccountServiceForIntegration, } from '@/lib/integrations/oauth-service' import type { Integration } from '@/lib/integrations/types' @@ -136,6 +137,34 @@ describe('resolveOAuthServiceForSlug', () => { }) describe('resolveServiceAccountIntegration', () => { + it.each(['netsuite', 'snowflake', 'harmonic'])( + 'resolves %s without offering OAuth', + (serviceId) => { + const integration = INTEGRATIONS.find((entry) => entry.serviceAccountServiceId === serviceId)! + expect(integration).toBeDefined() + expect(integration.authType).toBe('api-key') + expect(resolveOAuthServiceForSlug(integration.slug)).toBeNull() + expect( + resolveServiceAccountServiceForIntegration(integration)?.serviceAccountProviderId + ).toBe(`${serviceId}-service-account`) + expect(resolveServiceAccountIntegration(serviceId)?.slug).toBe(integration.slug) + } + ) + + it('only offers the OAuth fallback when the canonical service supports stored accounts', () => { + const jira = INTEGRATIONS.find((entry) => entry.slug === 'jira')! + const x = INTEGRATIONS.find((entry) => entry.type === 'x')! + expect(resolveServiceAccountServiceForIntegration(jira)?.serviceAccountProviderId).toBe( + 'atlassian-service-account' + ) + expect(resolveServiceAccountServiceForIntegration(x)).toBeNull() + expect( + resolveServiceAccountServiceForIntegration({ + ...x, + serviceAccountServiceId: 'unknown-service', + }) + ).toBeNull() + }) it.concurrent('keeps a named service instead of collapsing to the family default', () => { // Every Google integration issues the same google-service-account // credential, so a fuzzy matcher can silently answer Drive for all of diff --git a/apps/sim/lib/integrations/oauth-service.ts b/apps/sim/lib/integrations/oauth-service.ts index 977d0845eab..6bfc59c5116 100644 --- a/apps/sim/lib/integrations/oauth-service.ts +++ b/apps/sim/lib/integrations/oauth-service.ts @@ -49,6 +49,27 @@ export function resolveOAuthServiceForIntegration( } } +interface ServiceAccountServiceMatch extends Omit { + serviceAccountProviderId: ServiceAccountProviderId +} + +/** Resolves a stored service-account connection independently of the OAuth catalog marker. */ +export function resolveServiceAccountServiceForIntegration( + integration: Integration +): ServiceAccountServiceMatch | null { + const serviceId = integration.serviceAccountServiceId ?? integration.oauthServiceId + if (!serviceId) return null + const service = getServiceConfigByServiceId(serviceId) + const serviceAccountProviderId = asServiceAccountProviderId(service?.serviceAccountProviderId) + if (!service || !serviceAccountProviderId) return null + return { + providerId: service.providerId, + serviceName: service.name, + serviceIcon: service.icon as ComponentType<{ className?: string }>, + serviceAccountProviderId, + } +} + /** * Resolves the integration entry for a catalog slug, then derives its OAuth * service match. Returns `null` when the slug is unknown or the matching @@ -93,12 +114,12 @@ export const CANONICAL_SERVICE_ACCOUNT_SLUGS: Readonly> = /** * Every integration that offers a service-account flow, in catalog order. - * Built once — `resolveOAuthServiceForIntegration` walks `OAUTH_PROVIDERS` per + * Built once — `resolveServiceAccountServiceForIntegration` walks `OAUTH_PROVIDERS` per * entry, which is wasted work to repeat on each lookup. */ const SERVICE_ACCOUNT_INTEGRATIONS: readonly ServiceAccountIntegrationMatch[] = INTEGRATIONS_DATA.flatMap((integration) => { - const match = resolveOAuthServiceForIntegration(integration) + const match = resolveServiceAccountServiceForIntegration(integration) if (!match?.serviceAccountProviderId) return [] return [ { diff --git a/apps/sim/lib/integrations/types.ts b/apps/sim/lib/integrations/types.ts index b1df1a9d59b..3f936418adf 100644 --- a/apps/sim/lib/integrations/types.ts +++ b/apps/sim/lib/integrations/types.ts @@ -63,6 +63,8 @@ export interface Integration { * `OAUTH_PROVIDERS`). Present exactly when `authType` is `'oauth'`. */ oauthServiceId?: string + /** Canonical stored service-account service for a non-OAuth integration. */ + serviceAccountServiceId?: string /** Hand-authored landing content baked in at generation time (see `landing-content.ts`). */ landingContent?: IntegrationLandingContent } diff --git a/packages/deployment-config/src/integration-availability.ts b/packages/deployment-config/src/integration-availability.ts index 9ef1718ecb1..b6cd3e3afcf 100644 --- a/packages/deployment-config/src/integration-availability.ts +++ b/packages/deployment-config/src/integration-availability.ts @@ -23,13 +23,16 @@ interface DeploymentIntegration { name: string authType: 'oauth' | 'api-key' | 'none' oauthServiceId?: string + serviceAccountServiceId?: string } const integrations = integrationsJson.integrations as readonly DeploymentIntegration[] const credentialConfiguredOAuthServiceIds = new Set(CREDENTIAL_CONFIGURED_OAUTH_SERVICE_IDS) const deploymentGatedIntegrationTypes = new Set( integrations - .filter((integration) => integration.authType === 'oauth') + .filter( + (integration) => integration.authType === 'oauth' || integration.serviceAccountServiceId + ) .map((integration) => integration.type.toLowerCase()) ) const integrationTypesByOAuthServiceId = new Map() @@ -37,8 +40,9 @@ const integrationTypesByOAuthServiceId = new Map() integrationTypesByOAuthServiceId.set('github-repositories', ['github_v2']) const previewServiceAccountProvidersByIntegrationType = new Map() for (const integration of integrations) { - if (integration.authType !== 'oauth' || !integration.oauthServiceId) continue - const serviceId = integration.oauthServiceId.toLowerCase() + const credentialServiceId = integration.serviceAccountServiceId ?? integration.oauthServiceId + if (!credentialServiceId) continue + const serviceId = credentialServiceId.toLowerCase() const current = integrationTypesByOAuthServiceId.get(serviceId) ?? [] const integrationType = integration.type.toLowerCase() integrationTypesByOAuthServiceId.set(serviceId, [...current, integrationType]) @@ -52,7 +56,7 @@ export function isDeploymentGatedIntegrationType(blockType: string): boolean { return deploymentGatedIntegrationTypes.has(blockType.toLowerCase()) } -/** Returns the generated integration block types authenticated by one OAuth service entry. */ +/** Returns block types using a canonical credential service, including stored service accounts. */ export function getIntegrationTypesForOAuthServiceId(serviceId: string): readonly string[] { return integrationTypesByOAuthServiceId.get(serviceId.toLowerCase()) ?? [] } @@ -144,6 +148,29 @@ export function resolveIntegrationAvailability( return resolveOAuthIntegrationAvailability(integration, values) } + if (integration.serviceAccountServiceId) { + const serviceAccount = getServiceAccountMetadata(integration.serviceAccountServiceId) + if (!serviceAccount) { + throw new Error(`Integration ${integration.slug} is missing service-account metadata`) + } + const capabilityId = resolveOAuthClientCapabilityId(integration.serviceAccountServiceId) + const serviceAccountAvailable = + serviceAccount.deploymentRequirement !== 'preview-gated' && + (serviceAccount.deploymentRequirement !== 'oauth-client' || + Boolean( + capabilityId && inspectOAuthClientCapability(capabilityId, values).state === 'ready' + )) + return { + type: integration.type, + slug: integration.slug, + name: integration.name, + state: serviceAccountAvailable ? 'ready' : 'unavailable', + oauthAvailable: false, + serviceAccountAvailable, + missingFields: [], + } + } + return { type: integration.type, slug: integration.slug, diff --git a/packages/deployment-config/src/integrations.json b/packages/deployment-config/src/integrations.json index 8803700661a..468ef3bb00b 100644 --- a/packages/deployment-config/src/integrations.json +++ b/packages/deployment-config/src/integrations.json @@ -4353,6 +4353,7 @@ "triggers": [], "triggerCount": 0, "authType": "api-key", + "serviceAccountServiceId": "claude-platform", "category": "tools", "integrationType": "ai", "tags": ["agentic", "llm"] @@ -11178,6 +11179,7 @@ "triggers": [], "triggerCount": 0, "authType": "api-key", + "serviceAccountServiceId": "harmonic", "category": "tools", "integrationType": "sales", "tags": ["enrichment", "automation", "agentic"] @@ -16625,6 +16627,7 @@ "triggers": [], "triggerCount": 0, "authType": "api-key", + "serviceAccountServiceId": "netsuite", "category": "tools", "integrationType": "commerce", "tags": ["automation", "data-analytics", "payments"] @@ -22788,6 +22791,7 @@ "triggers": [], "triggerCount": 0, "authType": "api-key", + "serviceAccountServiceId": "snowflake", "category": "tools", "integrationType": "databases", "tags": ["data-warehouse", "data-analytics", "cloud"] diff --git a/packages/deployment-config/src/service-account-providers.generated.ts b/packages/deployment-config/src/service-account-providers.generated.ts index 5084ee16254..ab5e00c216b 100644 --- a/packages/deployment-config/src/service-account-providers.generated.ts +++ b/packages/deployment-config/src/service-account-providers.generated.ts @@ -8,6 +8,7 @@ export const SERVICE_ACCOUNT_PROVIDER_BY_OAUTH_SERVICE_ID = { attio: 'attio-service-account', box: 'box-service-account', calcom: 'calcom-service-account', + 'claude-platform': 'claude-platform-service-account', clickup: 'clickup-service-account', confluence: 'atlassian-service-account', gmail: 'google-service-account', @@ -22,15 +23,18 @@ export const SERVICE_ACCOUNT_PROVIDER_BY_OAUTH_SERVICE_ID = { 'google-sheets': 'google-service-account', 'google-tasks': 'google-service-account', 'google-vault': 'google-service-account', + harmonic: 'harmonic-service-account', hubspot: 'hubspot-service-account', jira: 'atlassian-service-account', linear: 'linear-service-account', monday: 'monday-service-account', + netsuite: 'netsuite-service-account', notion: 'notion-service-account', pipedrive: 'pipedrive-service-account', salesforce: 'salesforce-service-account', shopify: 'shopify-service-account', slack: 'slack-custom-bot', + snowflake: 'snowflake-service-account', trello: 'trello-service-account', wealthbox: 'wealthbox-service-account', webflow: 'webflow-service-account', diff --git a/scripts/check-integration-catalog.ts b/scripts/check-integration-catalog.ts index 754df89cab7..f0940653db9 100644 --- a/scripts/check-integration-catalog.ts +++ b/scripts/check-integration-catalog.ts @@ -8,6 +8,7 @@ import { stripVersionSuffix } from '@sim/utils/string' */ import { BLOCK_REGISTRY } from '../apps/sim/blocks/registry-maps' import { AuthMode, type BlockConfig } from '../apps/sim/blocks/types' +import { getServiceConfigByServiceId } from '../apps/sim/lib/oauth/utils' import integrationsJson from '../packages/deployment-config/src/integrations.json' import { DOCS_ORIGIN, DOCS_OUTPUT_PATH, defaultIntegrationDocsUrl } from './generate-docs' @@ -21,6 +22,7 @@ interface CatalogEntry { integrationType: string authType: CatalogAuthType oauthServiceId?: string + serviceAccountServiceId?: string } function resolveAuthType(block: BlockConfig): CatalogAuthType { @@ -55,7 +57,14 @@ function expectedEntry(block: BlockConfig): CatalogEntry { throw new Error(`Integration block "${block.type}" is missing integrationType`) } const authType = resolveAuthType(block) - const oauthServiceId = authType === 'oauth' ? resolveOAuthServiceId(block) : undefined + const credentialServiceId = resolveOAuthServiceId(block) + const oauthServiceId = authType === 'oauth' ? credentialServiceId : undefined + const serviceAccountServiceId = + authType !== 'oauth' && + credentialServiceId && + getServiceConfigByServiceId(credentialServiceId)?.serviceAccountProviderId + ? credentialServiceId + : undefined if (authType === 'oauth' && !oauthServiceId) { throw new Error(`OAuth integration block "${block.type}" is missing an OAuth service ID`) } @@ -70,6 +79,7 @@ function expectedEntry(block: BlockConfig): CatalogEntry { integrationType: block.integrationType, authType, ...(oauthServiceId ? { oauthServiceId } : {}), + ...(serviceAccountServiceId ? { serviceAccountServiceId } : {}), } } @@ -158,6 +168,7 @@ function verifyIntegrationCatalog(): void { 'integrationType', 'authType', 'oauthServiceId', + 'serviceAccountServiceId', ] as const) { if (generated[field] !== entry[field]) { issues.push(`"${type}" has stale ${field}`) diff --git a/scripts/generate-deployment-config.ts b/scripts/generate-deployment-config.ts index 8ffb008edae..90cc9c9ac60 100644 --- a/scripts/generate-deployment-config.ts +++ b/scripts/generate-deployment-config.ts @@ -21,6 +21,7 @@ import { formatGeneratedSource } from './format-generated-source' interface DeploymentIntegration { authType: 'oauth' | 'api-key' | 'none' oauthServiceId?: string + serviceAccountServiceId?: string } const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) @@ -52,14 +53,25 @@ function buildOAuthDeploymentFacts(): CanonicalOAuthDeploymentFacts { } const catalogServiceIds = new Set() + const oauthServiceIds = new Set() for (const integration of integrationsJson.integrations as readonly DeploymentIntegration[]) { - if (integration.authType !== 'oauth') continue - if (!integration.oauthServiceId) { + if (integration.authType === 'oauth' && !integration.oauthServiceId) { throw new Error( 'Generated integration catalog contains an OAuth entry without oauthServiceId' ) } - catalogServiceIds.add(integration.oauthServiceId) + if (integration.authType === 'oauth' && integration.oauthServiceId) { + catalogServiceIds.add(integration.oauthServiceId) + oauthServiceIds.add(integration.oauthServiceId) + } + if (integration.serviceAccountServiceId) { + if (!canonicalServices.get(integration.serviceAccountServiceId)?.serviceAccountProviderId) { + throw new Error( + `Integration catalog references a service without a service-account provider: ${integration.serviceAccountServiceId}` + ) + } + catalogServiceIds.add(integration.serviceAccountServiceId) + } } const providers = new Map() @@ -69,7 +81,9 @@ function buildOAuthDeploymentFacts(): CanonicalOAuthDeploymentFacts { throw new Error(`Integration catalog references unknown OAuth service: ${serviceId}`) } const service = canonicalServices.get(serviceId) - if (service?.credentialConfigured) credentialConfiguredOAuthServiceIds.push(serviceId) + if (oauthServiceIds.has(serviceId) && service?.credentialConfigured) { + credentialConfiguredOAuthServiceIds.push(serviceId) + } const providerId = service?.serviceAccountProviderId if (providerId) providers.set(serviceId, providerId) } diff --git a/scripts/generate-docs.test.ts b/scripts/generate-docs.test.ts index f4fa9ed57f9..9d0796484ad 100644 --- a/scripts/generate-docs.test.ts +++ b/scripts/generate-docs.test.ts @@ -9,6 +9,7 @@ import { extractAllBlockConfigs, extractBlockSuppliedParamIds, extractInheritedBlockCategory, + extractIntegrationCredentialServices, extractToolInfo, extractUserSettableParamIds, generateIconMappings, @@ -17,6 +18,43 @@ import { parsePropertiesContent, } from './generate-docs' +describe('integration credential relationships', () => { + const serviceAccountServiceIds = new Set(['netsuite', 'snowflake', 'harmonic', 'jira']) + it.each(['netsuite', 'snowflake', 'harmonic'])( + 'projects %s stored credentials without changing its auth marker', + (serviceId) => { + const source = fs.readFileSync( + path.join(__dirname, `../apps/sim/blocks/blocks/${serviceId}.ts`), + 'utf8' + ) + expect(extractIntegrationCredentialServices(source, serviceAccountServiceIds)).toEqual({ + serviceAccountServiceId: serviceId, + }) + } + ) + + it('preserves the OAuth relationship and ignores unrelated selector services', () => { + expect( + extractIntegrationCredentialServices( + `{ authMode: AuthMode.OAuth, subBlocks: [{ type: 'oauth-input', serviceId: 'jira' }] }`, + serviceAccountServiceIds + ) + ).toEqual({ oauthServiceId: 'jira' }) + expect( + extractIntegrationCredentialServices( + `{ authMode: AuthMode.ApiKey, subBlocks: [{ type: 'selector-input', serviceId: 'netsuite' }] }`, + serviceAccountServiceIds + ) + ).toEqual({}) + expect( + extractIntegrationCredentialServices( + `{ authMode: AuthMode.ApiKey, subBlocks: [{ type: 'oauth-input', serviceId: 'x' }] }`, + serviceAccountServiceIds + ) + ).toEqual({}) + }) +}) + describe('documentation editor icon metadata', () => { it('keeps core icons and inherited categories out of the integration catalog', async () => { const { docs, visible, coreBlockTypes } = await generateIconMappings() diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 1874ea0c561..2888f4a06cc 100755 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -411,6 +411,7 @@ interface IntegrationEntry { triggerCount: number authType: 'oauth' | 'api-key' | 'none' oauthServiceId?: string + serviceAccountServiceId?: string category: BlockCategory integrationType: IntegrationType tags?: string[] @@ -1681,6 +1682,20 @@ function extractOAuthServiceId(blockContent: string): string | undefined { return /serviceId\s*:\s*['"]([^'"]+)['"]/.exec(subBlockContent)?.[1] } +/** Keeps stored service-account ownership independent of the catalog's OAuth marker. */ +export function extractIntegrationCredentialServices( + blockContent: string, + serviceAccountServiceIds: ReadonlySet +): { + oauthServiceId?: string + serviceAccountServiceId?: string +} { + const serviceId = extractOAuthServiceId(blockContent) + if (!serviceId) return {} + if (extractAuthType(blockContent) === 'oauth') return { oauthServiceId: serviceId } + return serviceAccountServiceIds.has(serviceId) ? { serviceAccountServiceId: serviceId } : {} +} + /** * Extract the list of trigger IDs from the block's `triggers.available` array. * Handles blocks that declare `triggers: { enabled: true, available: [...] }`. @@ -1852,6 +1867,12 @@ ${mappingEntries} * Applies the same visibility filters as the docs generation pipeline. */ async function writeIntegrationsJson(iconMapping: Record): Promise { + const { getAllOAuthServices } = await import('../apps/sim/lib/oauth/utils') + const serviceAccountServiceIds = new Set( + getAllOAuthServices() + .filter((service) => service.serviceAccountProviderId) + .map((service) => service.serviceId) + ) try { if (!fs.existsSync(INTEGRATIONS_DATA_PATH)) { fs.mkdirSync(INTEGRATIONS_DATA_PATH, { recursive: true }) @@ -1963,7 +1984,11 @@ async function writeIntegrationsJson(iconMapping: Record): Prom .replace(/^-|-$/g, '') const authType = extractAuthType(fileContent) - const oauthServiceId = authType === 'oauth' ? extractOAuthServiceId(fileContent) : undefined + const credentialServices = extractIntegrationCredentialServices( + fileContent, + serviceAccountServiceIds + ) + const { oauthServiceId } = credentialServices // OAuth integrations resolve their connect UI through the service id // (see `resolveOAuthServiceForIntegration`), so fail loudly rather than // shipping a catalog entry that silently falls back to the API-key path. @@ -1988,7 +2013,7 @@ async function writeIntegrationsJson(iconMapping: Record): Prom triggers, triggerCount: triggers.length, authType, - ...(oauthServiceId ? { oauthServiceId } : {}), + ...credentialServices, category: 'tools', integrationType, ...(config.tags ? { tags: config.tags } : {}), From 261f9926e25e7fb1484399b44f03a0f066faa0fb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 8 Sep 2026 20:04:38 -0700 Subject: [PATCH 2/6] fix(integrations): carry the service-account icon on the catalog match `ServiceAccountIntegrationMatch` named a service-account integration but left its icon to a second `resolveOAuthServiceForSlug` lookup, which is null for a stored-credential integration whose catalog entry is not `oauth`. The chat's inline connect control read the icon from that lookup, so `useServiceAccountConnectTarget` saw an undefined icon and rendered nothing for exactly the four integrations this branch exposes. Resolving the icon once, where the match is built, removes the second lookup and the class of bug with it. Also: - Offer the service-account connect control while deployment availability is still unknown, matching `oauthAvailable` directly above it. A pessimistic default rendered a disabled "Unavailable" verdict for the whole permission-config load on an integration whose only path is a stored service account. - Drop the `listCredentialProviderCatalog` case that mocked `createIntegrationCredentialVisibility` wholesale: it asserted catalog wiring that did not change and passed without the fix. The projection and both `requireAvailableServiceAccountCredentialProvider` branches are already covered in this file. - Move the new visibility case below the `beforeEach` that configures it, and set the block mock through `mockImplementation` like its neighbours instead of relying on hook ordering to undo a `mockReturnValue`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V9K4apTYamQFpRYT3tcjVQ --- .../components/special-tags/special-tags.tsx | 3 +- .../[block]/integration-block-detail.tsx | 9 +++++- .../application/provider-catalog.test.ts | 32 ------------------- .../credential-visibility.server.test.ts | 31 +++++++++--------- apps/sim/lib/integrations/oauth-service.ts | 2 ++ 5 files changed, 27 insertions(+), 50 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 0330603d98c..dae16305a33 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -2365,11 +2365,10 @@ function ServiceAccountConnectDisplay({ () => (data.provider ? resolveServiceAccountIntegration(data.provider) : null), [data.provider] ) - const service = useMemo(() => (match ? resolveOAuthServiceForSlug(match.slug) : null), [match]) const target = useServiceAccountConnectTarget({ serviceAccountProviderId: match?.serviceAccountProviderId, serviceName: match?.serviceName, - serviceIcon: service?.serviceIcon, + serviceIcon: match?.serviceIcon, }) // A credentialId reconnects (rotates the secret on) that existing service diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index 39502a80684..85c40868a02 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -111,8 +111,15 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration serviceName: serviceAccountService?.serviceName, serviceIcon: serviceAccountService?.serviceIcon, }) + /** + * Unknown availability offers the connect control rather than withholding it, + * matching `oauthAvailable` above. The deployment answer arrives with the + * permission config, and on an integration whose only path is a stored + * service account a pessimistic default renders a disabled "Unavailable" + * verdict for the whole load — a false negative, not a neutral placeholder. + */ const serviceAccountDeploymentAvailable = - availability?.state === 'ready' || availability?.state === 'limited' + availability === undefined || availability.state === 'ready' || availability.state === 'limited' const hasServiceAccount = serviceAccountDeploymentAvailable && Boolean(serviceAccountTarget) && diff --git a/apps/sim/lib/credentials/application/provider-catalog.test.ts b/apps/sim/lib/credentials/application/provider-catalog.test.ts index 4b90ab08c67..0e7488f75b6 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.test.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.test.ts @@ -107,38 +107,6 @@ const services = [ ] describe('listCredentialProviderCatalog', () => { - it.each(['netsuite', 'snowflake', 'harmonic'])( - 'projects %s as a stored service-account provider without an OAuth connection', - async (serviceId) => { - const providerId = `${serviceId}-service-account` - mocks.getAllOAuthServices.mockReturnValue([ - { - serviceId, - providerId, - serviceAccountProviderId: providerId, - name: serviceId, - description: serviceId, - baseProvider: serviceId, - authType: 'service_account', - }, - ]) - const isCredentialVisible = vi.fn(() => true) - mocks.createVisibility.mockReturnValue({ - isCredentialVisible, - isOAuthServiceVisible: () => false, - }) - const catalog = await listCredentialProviderCatalog(personalPrincipal, context) - expect(catalog).toHaveLength(1) - expect(catalog[0]).toMatchObject({ type: 'service_account', providerId, available: true }) - expect(isCredentialVisible).toHaveBeenCalledWith({ providerId, type: 'service_account' }) - expect(requireAvailableServiceAccountCredentialProvider(catalog, providerId)).toBe(catalog[0]) - isCredentialVisible.mockReturnValue(false) - const restricted = await listCredentialProviderCatalog(personalPrincipal, context) - expect(() => - requireAvailableServiceAccountCredentialProvider(restricted, providerId) - ).toThrow('unavailable') - } - ) beforeEach(() => { vi.clearAllMocks() mocks.getAllOAuthServices.mockReturnValue(services) diff --git a/apps/sim/lib/integrations/credential-visibility.server.test.ts b/apps/sim/lib/integrations/credential-visibility.server.test.ts index f5dcfa54537..44a976b4c6a 100644 --- a/apps/sim/lib/integrations/credential-visibility.server.test.ts +++ b/apps/sim/lib/integrations/credential-visibility.server.test.ts @@ -56,6 +56,21 @@ function availability( } describe('integration credential visibility', () => { + beforeEach(() => { + vi.clearAllMocks() + getBlockMock.mockImplementation((type: string) => ({ type })) + getIntegrationAvailabilityMock.mockReturnValue([ + availability('notion_v2', 'limited', { + oauthAvailable: false, + serviceAccountAvailable: true, + }), + availability('slack_v2', 'limited', { + oauthAvailable: false, + serviceAccountAvailable: true, + }), + ]) + }) + it.each(['netsuite', 'snowflake', 'harmonic'])( 'applies allowlists and block visibility to %s stored credentials', (serviceId) => { @@ -89,26 +104,12 @@ describe('integration credential visibility', () => { expect( visible([serviceId], true).isCredentialVisible({ providerId, type: 'service_account' }) ).toBe(false) - getBlockMock.mockReturnValue({ type: serviceId, preview: true }) + getBlockMock.mockImplementation((type: string) => ({ type, preview: true })) expect( visible([serviceId]).isCredentialVisible({ providerId, type: 'service_account' }) ).toBe(false) } ) - beforeEach(() => { - vi.clearAllMocks() - getBlockMock.mockImplementation((type: string) => ({ type })) - getIntegrationAvailabilityMock.mockReturnValue([ - availability('notion_v2', 'limited', { - oauthAvailable: false, - serviceAccountAvailable: true, - }), - availability('slack_v2', 'limited', { - oauthAvailable: false, - serviceAccountAvailable: true, - }), - ]) - }) it('applies the integration allowlist to OAuth and service-account credentials', () => { const visibility = createIntegrationCredentialVisibility({ diff --git a/apps/sim/lib/integrations/oauth-service.ts b/apps/sim/lib/integrations/oauth-service.ts index 6bfc59c5116..c90f9f13a8a 100644 --- a/apps/sim/lib/integrations/oauth-service.ts +++ b/apps/sim/lib/integrations/oauth-service.ts @@ -89,6 +89,7 @@ export interface ServiceAccountIntegrationMatch { slug: string serviceAccountProviderId: ServiceAccountProviderId serviceName: string + serviceIcon: ComponentType<{ className?: string }> providerId: string } @@ -126,6 +127,7 @@ const SERVICE_ACCOUNT_INTEGRATIONS: readonly ServiceAccountIntegrationMatch[] = slug: integration.slug, serviceAccountProviderId: match.serviceAccountProviderId, serviceName: integration.name, + serviceIcon: match.serviceIcon, providerId: match.providerId, }, ] From 19120804747da91cf6779c998bbfb7396027a13f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 8 Sep 2026 20:42:49 -0700 Subject: [PATCH 3/6] test(integrations): cover the fourth stored-credential integration Every new case in this branch was parameterized on the service id, which made Claude Platform quietly absent from all of them: its block type is `managed_agent`, so a serviceId-keyed lookup finds no availability entry and no allowlist key. Pair each case with its block type and add the fourth integration, so the authorization tightening and the availability projection are pinned for all four rather than three. Also: - Guard the icon the previous commit added to `ServiceAccountIntegrationMatch`. It had no test, and its absence renders nothing at all rather than a broken chip, so the gap was invisible in exactly the way that produced the bug. - Assert `isDeploymentGatedIntegrationType` for the four, alongside the unconditional `ready` that keeps the gate from hiding them. - Narrow the connect-control's unknown-availability default to the case it was written for. Relaxing it for every integration widened the header control from a chip to a dropdown and back on every OAuth integration that also offers a service account, as the permission config landed. - Read `atlassianProduct` from the service-account match that mounts the modal rather than the OAuth match, which is null for a stored-credential integration. Verified behavior-identical across all 39 integrations that can mount it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V9K4apTYamQFpRYT3tcjVQ --- .../[block]/integration-block-detail.tsx | 21 +++++++----- .../integrations/availability.server.test.ts | 33 +++++++++++++++---- .../credential-visibility.server.test.ts | 26 ++++++++++----- .../lib/integrations/oauth-service.test.ts | 26 ++++++++++++++- scripts/generate-docs.test.ts | 21 +++++++++--- 5 files changed, 99 insertions(+), 28 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index 85c40868a02..fc96065ce8c 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -112,14 +112,17 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration serviceIcon: serviceAccountService?.serviceIcon, }) /** - * Unknown availability offers the connect control rather than withholding it, - * matching `oauthAvailable` above. The deployment answer arrives with the - * permission config, and on an integration whose only path is a stored - * service account a pessimistic default renders a disabled "Unavailable" - * verdict for the whole load — a false negative, not a neutral placeholder. + * Unknown availability offers the connect control only when the stored + * service account is the integration's *only* path, mirroring the optimistic + * default `oauthAvailable` already applies to the OAuth one. There, a + * pessimistic default renders a disabled "Unavailable" verdict for the whole + * permission-config load; here it would instead widen the header control from + * a chip to a dropdown and back as the config lands, so an integration that + * also offers OAuth keeps waiting for the real answer. */ - const serviceAccountDeploymentAvailable = - availability === undefined || availability.state === 'ready' || availability.state === 'limited' + const serviceAccountDeploymentAvailable = availability + ? availability.state === 'ready' || availability.state === 'limited' + : !oauthService const hasServiceAccount = serviceAccountDeploymentAvailable && Boolean(serviceAccountTarget) && @@ -258,7 +261,9 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration onOpenChange={setServiceAccountOpen} workspaceId={workspaceId} serviceAccountProviderId={serviceAccountTarget.serviceAccountProviderId} - atlassianProduct={oauthService?.providerId === 'confluence' ? 'confluence' : 'jira'} + atlassianProduct={ + serviceAccountService?.providerId === 'confluence' ? 'confluence' : 'jira' + } serviceName={serviceAccountTarget.serviceName} serviceIcon={serviceAccountTarget.serviceIcon} /> diff --git a/apps/sim/lib/integrations/availability.server.test.ts b/apps/sim/lib/integrations/availability.server.test.ts index 1edee77194a..c7875b90d9a 100644 --- a/apps/sim/lib/integrations/availability.server.test.ts +++ b/apps/sim/lib/integrations/availability.server.test.ts @@ -13,6 +13,7 @@ import { import { getIntegrationTypesForOAuthServiceId, type IntegrationAvailability, + isDeploymentGatedIntegrationType, isOAuthServiceAllowedByIntegrationTypes, resolveIntegrationAvailability, resolveIntegrationAvailabilityStateForVisibility, @@ -31,6 +32,19 @@ import { getServiceConfigByServiceId } from '@/lib/oauth/utils' const integrations = integrationsJson.integrations as readonly Integration[] +/** + * Integrations whose only credential is a stored service account, each paired + * with the block type the catalog gates it by. The two differ for Claude + * Platform (`claude-platform` / `managed_agent`), so a parameterization keyed + * on the service id alone silently omits it. + */ +const STORED_CREDENTIAL_INTEGRATIONS = [ + { serviceId: 'netsuite', blockType: 'netsuite' }, + { serviceId: 'snowflake', blockType: 'snowflake' }, + { serviceId: 'harmonic', blockType: 'harmonic' }, + { serviceId: 'claude-platform', blockType: 'managed_agent' }, +] + function availabilityFor( type: string, values: Parameters[0] = {} @@ -41,17 +55,24 @@ function availabilityFor( } describe('integration availability', () => { - it.each(['netsuite', 'snowflake', 'harmonic'])( - 'makes %s stored credentials available without configuring an OAuth client', - (serviceId) => { - expect(availabilityFor(serviceId)).toMatchObject({ + it.each(STORED_CREDENTIAL_INTEGRATIONS)( + 'makes $serviceId stored credentials available without configuring an OAuth client', + ({ serviceId, blockType }) => { + expect(availabilityFor(blockType)).toMatchObject({ state: 'ready', oauthAvailable: false, serviceAccountAvailable: true, missingFields: [], }) - expect(getIntegrationTypesForOAuthServiceId(serviceId)).toEqual([serviceId]) - expect(isOAuthServiceAllowedByIntegrationTypes(serviceId, new Set([serviceId]))).toBe(true) + expect(getIntegrationTypesForOAuthServiceId(serviceId)).toEqual([blockType]) + /** + * Joining the deployment-gated set cannot hide these blocks: `isBlockAllowed` + * drops a gated type only at `unavailable`/`misconfigured`, and the `ready` + * asserted above holds for every deployment because none of these services + * carries a `deploymentRequirement`. + */ + expect(isDeploymentGatedIntegrationType(blockType)).toBe(true) + expect(isOAuthServiceAllowedByIntegrationTypes(serviceId, new Set([blockType]))).toBe(true) expect(isOAuthServiceAllowedByIntegrationTypes(serviceId, new Set(['jira']))).toBe(false) expect(SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID[serviceId]?.providerId).toBe( `${serviceId}-service-account` diff --git a/apps/sim/lib/integrations/credential-visibility.server.test.ts b/apps/sim/lib/integrations/credential-visibility.server.test.ts index 44a976b4c6a..fd98aefb43a 100644 --- a/apps/sim/lib/integrations/credential-visibility.server.test.ts +++ b/apps/sim/lib/integrations/credential-visibility.server.test.ts @@ -71,9 +71,19 @@ describe('integration credential visibility', () => { ]) }) - it.each(['netsuite', 'snowflake', 'harmonic'])( - 'applies allowlists and block visibility to %s stored credentials', - (serviceId) => { + /** + * `blockType` is the id the allowlist and the kill switch are keyed by, and it + * differs from the service id for Claude Platform — the real catalog decides + * that mapping here, since `getIntegrationTypesForOAuthServiceId` is not mocked. + */ + it.each([ + { serviceId: 'netsuite', blockType: 'netsuite' }, + { serviceId: 'snowflake', blockType: 'snowflake' }, + { serviceId: 'harmonic', blockType: 'harmonic' }, + { serviceId: 'claude-platform', blockType: 'managed_agent' }, + ])( + 'applies allowlists and block visibility to $serviceId stored credentials', + ({ serviceId, blockType }) => { const providerId = `${serviceId}-service-account` const service: OAuthServiceMetadata = { serviceId, @@ -85,28 +95,28 @@ describe('integration credential visibility', () => { authType: 'service_account', } getIntegrationAvailabilityMock.mockReturnValue([ - availability(serviceId, 'ready', { oauthAvailable: false, serviceAccountAvailable: true }), + availability(blockType, 'ready', { oauthAvailable: false, serviceAccountAvailable: true }), ]) const visible = (allowed: string[], hidden = false) => createIntegrationCredentialVisibility({ allowedIntegrationTypes: new Set(allowed), blockVisibility: hidden - ? { revealed: new Set(), disabled: new Set([serviceId]), previewTagged: new Set() } + ? { revealed: new Set(), disabled: new Set([blockType]), previewTagged: new Set() } : null, oauthServices: [service], }) expect( - visible([serviceId]).isCredentialVisible({ providerId, type: 'service_account' }) + visible([blockType]).isCredentialVisible({ providerId, type: 'service_account' }) ).toBe(true) expect(visible(['jira']).isCredentialVisible({ providerId, type: 'service_account' })).toBe( false ) expect( - visible([serviceId], true).isCredentialVisible({ providerId, type: 'service_account' }) + visible([blockType], true).isCredentialVisible({ providerId, type: 'service_account' }) ).toBe(false) getBlockMock.mockImplementation((type: string) => ({ type, preview: true })) expect( - visible([serviceId]).isCredentialVisible({ providerId, type: 'service_account' }) + visible([blockType]).isCredentialVisible({ providerId, type: 'service_account' }) ).toBe(false) } ) diff --git a/apps/sim/lib/integrations/oauth-service.test.ts b/apps/sim/lib/integrations/oauth-service.test.ts index 4a251204447..4faed1559b4 100644 --- a/apps/sim/lib/integrations/oauth-service.test.ts +++ b/apps/sim/lib/integrations/oauth-service.test.ts @@ -137,7 +137,7 @@ describe('resolveOAuthServiceForSlug', () => { }) describe('resolveServiceAccountIntegration', () => { - it.each(['netsuite', 'snowflake', 'harmonic'])( + it.each(['netsuite', 'snowflake', 'harmonic', 'claude-platform'])( 'resolves %s without offering OAuth', (serviceId) => { const integration = INTEGRATIONS.find((entry) => entry.serviceAccountServiceId === serviceId)! @@ -151,6 +151,30 @@ describe('resolveServiceAccountIntegration', () => { } ) + /** + * The match carries its own icon because the connect control cannot recover + * one for these four: `resolveOAuthServiceForSlug` answers `null` for a + * non-OAuth catalog entry, and a missing icon makes + * `useServiceAccountConnectTarget` return `null` — rendering nothing at all + * rather than a broken chip, which is why the gap was invisible. + */ + it('carries a service icon on every service-account match', () => { + const matches = INTEGRATIONS.map((entry) => ({ + slug: entry.slug, + match: resolveServiceAccountIntegration(entry.slug), + })).filter(({ match }) => match) + expect(matches.length).toBeGreaterThan(0) + for (const { slug, match } of matches) { + expect(typeof match?.serviceIcon, slug).toBe('function') + } + for (const serviceId of ['netsuite', 'snowflake', 'harmonic', 'claude-platform']) { + const integration = INTEGRATIONS.find((entry) => entry.serviceAccountServiceId === serviceId)! + expect(resolveServiceAccountIntegration(integration.slug)?.serviceIcon, serviceId).toBe( + resolveServiceAccountServiceForIntegration(integration)?.serviceIcon + ) + } + }) + it('only offers the OAuth fallback when the canonical service supports stored accounts', () => { const jira = INTEGRATIONS.find((entry) => entry.slug === 'jira')! const x = INTEGRATIONS.find((entry) => entry.type === 'x')! diff --git a/scripts/generate-docs.test.ts b/scripts/generate-docs.test.ts index 9d0796484ad..5d735bb47ee 100644 --- a/scripts/generate-docs.test.ts +++ b/scripts/generate-docs.test.ts @@ -19,12 +19,23 @@ import { } from './generate-docs' describe('integration credential relationships', () => { - const serviceAccountServiceIds = new Set(['netsuite', 'snowflake', 'harmonic', 'jira']) - it.each(['netsuite', 'snowflake', 'harmonic'])( - 'projects %s stored credentials without changing its auth marker', - (serviceId) => { + const serviceAccountServiceIds = new Set([ + 'netsuite', + 'snowflake', + 'harmonic', + 'claude-platform', + 'jira', + ]) + it.each([ + { serviceId: 'netsuite', blockFile: 'netsuite' }, + { serviceId: 'snowflake', blockFile: 'snowflake' }, + { serviceId: 'harmonic', blockFile: 'harmonic' }, + { serviceId: 'claude-platform', blockFile: 'managed_agent' }, + ])( + 'projects $serviceId stored credentials without changing its auth marker', + ({ serviceId, blockFile }) => { const source = fs.readFileSync( - path.join(__dirname, `../apps/sim/blocks/blocks/${serviceId}.ts`), + path.join(__dirname, `../apps/sim/blocks/blocks/${blockFile}.ts`), 'utf8' ) expect(extractIntegrationCredentialServices(source, serviceAccountServiceIds)).toEqual({ From abd020475c433c6b970beb2face2a4033b79dc6e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 8 Sep 2026 20:55:55 -0700 Subject: [PATCH 4/6] fix(integrations): connect stored service accounts from every entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three surfaces decided whether to offer a connect flow by asking whether the integration was `authType: 'oauth'`, which is false for one authenticated by a stored service account. Each failed differently. Suggested actions was the worst of them, and the failure predates this branch: `defineServices` enumerates every OAuth provider, including the four whose `authType` is `service_account`, so "Integrate with Snowflake" was already offered on the home screen — and clicking it resolved no OAuth target and fell off the end of the handler. Snowflake, NetSuite, Harmonic and GitHub all had dead rows. They now hand off to the integration's detail page, carrying the service-account deep link when that is the flow on offer, so the modal still opens in one click. Sidebar search needed both halves fixed to work: the `authType` gate dropped the deep link, and the mode callback matched on the `limited` state an OAuth integration reaches when only its service account is available. An integration whose *only* credential is a service account is plain `ready`, so it fell through to `null`. Gate on having a credential service and test what is actually connectable instead. The detail page reserved "Unavailable" for a verdict it had no business making: an API-key integration still runs on the user's own key, so it now keeps the catalog's ordinary call to action, which also collapses a duplicated fallback branch. The catalog card is deliberately left alone — its `authType === 'oauth'` guard is what stops it from calling a usable block unavailable, and it was the detail page that diverged. Also fail open on a *failed* availability fetch rather than a pending one, per review: while the config is in flight the answer is imminent and an optimistic default would flip the header control's shape, but once both queries settle with nothing the request failed, and withholding the control strands a user who has a valid stored account behind a fetch they cannot retry. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V9K4apTYamQFpRYT3tcjVQ --- .../suggested-actions.test.tsx | 38 +++++++++++++-- .../suggested-actions/suggested-actions.tsx | 26 +++++++++- .../[block]/integration-block-detail.tsx | 47 +++++++++++++------ .../search-modal/integration-search-items.ts | 21 ++++++--- .../w/components/sidebar/sidebar.tsx | 15 +++++- 5 files changed, 120 insertions(+), 27 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.test.tsx index af71779f6b2..91e46b17895 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.test.tsx @@ -5,9 +5,15 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCaptureEvent, modeState } = vi.hoisted(() => ({ +const { mockCaptureEvent, mockRouterPush, modeState, connectionState } = vi.hoisted(() => ({ mockCaptureEvent: vi.fn(), + mockRouterPush: vi.fn(), modeState: { initial: 'build', set: (_next: string) => {} }, + /** Drives the personalized Build list; empty keeps the component on INITIAL_ACTIONS. */ + connectionState: { + credentials: [] as { type: string; providerId: string }[], + services: [] as { providerId: string; name: string; icon: () => null }[], + }, })) vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode', async () => { @@ -23,16 +29,17 @@ vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode', async () vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }), + useRouter: () => ({ push: mockRouterPush }), })) vi.mock('posthog-js/react', () => ({ usePostHog: () => null })) vi.mock('@/lib/posthog/client', () => ({ captureEvent: mockCaptureEvent })) vi.mock('@sim/utils/random', () => ({ randomFloat: () => 0 })) vi.mock('@/hooks/queries/credentials', () => ({ - useWorkspaceCredentials: () => ({ data: [] }), + useWorkspaceCredentials: () => ({ data: connectionState.credentials }), })) vi.mock('@/hooks/queries/oauth/oauth-connections', () => ({ - useOAuthConnections: () => ({ data: [] }), + useOAuthConnections: () => ({ data: connectionState.services }), })) vi.mock('@/hooks/queries/tables', () => ({ useTablesList: () => ({ data: [] }), @@ -112,7 +119,10 @@ function rows(): HTMLButtonElement[] { beforeEach(() => { onSelectPrompt.mockClear() mockCaptureEvent.mockClear() + mockRouterPush.mockClear() modeState.initial = 'build' + connectionState.credentials = [] + connectionState.services = [] }) afterEach(() => { @@ -123,6 +133,28 @@ afterEach(() => { }) describe('SuggestedActions', () => { + /** + * Snowflake authenticates with a stored service account, so the catalog holds + * no OAuth service for its slug and the inline modal cannot open. The row is + * still offered — `defineServices` enumerates every OAuth provider, including + * the service-account ones — so before this handoff the click resolved no + * target and was silently dropped. + */ + it('hands a stored-service-account row to its detail page instead of dropping the click', () => { + connectionState.credentials = [{ type: 'oauth', providerId: 'gmail' }] + connectionState.services = [{ providerId: 'snowflake', name: 'Snowflake', icon: () => null }] + mount() + + const row = rows().find((candidate) => candidate.textContent === 'Integrate with Snowflake') + expect(row).toBeDefined() + act(() => row?.click()) + + expect(mockRouterPush).toHaveBeenCalledWith( + '/workspace/workspace-1/integrations/snowflake?connect=service-account' + ) + expect(container?.querySelector('[data-testid="connect-modal"]')).toBeNull() + }) + it('shows the Build starters by default', () => { mount() diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx index 1035e08e009..289868e3f06 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx @@ -4,13 +4,14 @@ import { useMemo, useState } from 'react' import { ArrowRight, ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn' import { Table } from '@sim/emcn/icons' import { stripVersionSuffix } from '@sim/utils/string' -import { useParams } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { GmailIcon, SlackIcon } from '@/components/icons' import { INTEGRATIONS, resolveOAuthServiceForIntegration, resolveOAuthServiceForSlug, + resolveServiceAccountServiceForIntegration, } from '@/lib/integrations' import { captureEvent } from '@/lib/posthog/client' import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' @@ -23,6 +24,10 @@ import type { import { weightedSample } from '@/app/workspace/[workspaceId]/home/components/suggested-actions/weighted-sample' import { useMothershipMode } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode' import type { MothershipMode } from '@/app/workspace/[workspaceId]/home/search-params' +import { + CONNECT_MODE, + CONNECT_QUERY_PARAM, +} from '@/app/workspace/[workspaceId]/integrations/connect-route' import { BrandIcon } from '@/blocks/brand-icon' import { getAllBlockMeta } from '@/blocks/registry' import type { ModuleTag } from '@/blocks/types' @@ -245,6 +250,7 @@ interface SuggestedActionsProps { export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) { const { workspaceId } = useParams<{ workspaceId: string }>() + const router = useRouter() const posthog = usePostHog() const [mode] = useMothershipMode() const { integrationAvailability } = usePermissionConfig() @@ -328,7 +334,23 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) { return } const target = resolveOAuthServiceForSlug(action.slug) - if (target) setOAuthTarget(target) + if (target) { + setOAuthTarget(target) + return + } + /** + * The row names an integration this surface cannot connect inline: one + * authenticated by a stored service account, or one whose OAuth service the + * catalog does not carry. Both used to drop the click silently. Hand off to + * the detail page instead — with the service-account deep link when that is + * the flow it offers, so the modal still opens in one click. + */ + const integration = INTEGRATIONS.find((entry) => entry.slug === action.slug) + const connectSuffix = + integration && resolveServiceAccountServiceForIntegration(integration) + ? `?${CONNECT_QUERY_PARAM}=${CONNECT_MODE.serviceAccount}` + : '' + router.push(`/workspace/${workspaceId}/integrations/${action.slug}${connectSuffix}`) } const handleToggleExpanded = () => { diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index fc96065ce8c..4af97190c70 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -112,17 +112,23 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration serviceIcon: serviceAccountService?.serviceIcon, }) /** - * Unknown availability offers the connect control only when the stored - * service account is the integration's *only* path, mirroring the optimistic - * default `oauthAvailable` already applies to the OAuth one. There, a - * pessimistic default renders a disabled "Unavailable" verdict for the whole - * permission-config load; here it would instead widen the header control from - * a chip to a dropdown and back as the config lands, so an integration that - * also offers OAuth keeps waiting for the real answer. + * Unknown availability means two different things, and they want opposite + * defaults. + * + * While the permission config is still in flight the answer is imminent, so + * only an integration whose *sole* path is a stored service account offers the + * control — for one that also has OAuth, a `true` here would widen the header + * from a chip to a dropdown and then collapse it again as the config lands. + * + * Once both queries have settled and still produced nothing, the request + * failed, and withholding the control strands a user who has a perfectly good + * stored account behind a fetch they cannot retry. Fail open there, matching + * the `?? true` that `oauthAvailable` above already applies to the OAuth path; + * the server still refuses a provider the deployment does not offer. */ const serviceAccountDeploymentAvailable = availability ? availability.state === 'ready' || availability.state === 'limited' - : !oauthService + : !oauthService || !permissionConfigLoading const hasServiceAccount = serviceAccountDeploymentAvailable && Boolean(serviceAccountTarget) && @@ -194,6 +200,21 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration router.push(`/workspace/${workspaceId}/home`) } + /** + * Shown when no connect flow is on offer. "Unavailable" is a verdict about a + * connection the deployment grants, so it belongs only to an integration with + * an OAuth path. One authenticated by a stored service account still runs on + * the user's own API key, and so keeps the catalog's ordinary call to action — + * the same fallback an integration with no credential service at all gets. + */ + const connectFallback = oauthService ? ( + Unavailable + ) : chatEnabled ? ( + + Add to Sim + + ) : null + return (
@@ -225,13 +246,11 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration {serviceAccountConnectLabel} ) : ( - Unavailable + connectFallback ) - ) : chatEnabled ? ( - - Add to Sim - - ) : null} + ) : ( + connectFallback + )}
{personalTokenAvailable && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/integration-search-items.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/integration-search-items.ts index 1d82af1f806..2d8bfc15651 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/integration-search-items.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/integration-search-items.ts @@ -21,7 +21,13 @@ const INTEGRATION_BASES: readonly { icon: ComponentType<{ className?: string }> bgColor: string slug: string - authType: string + /** + * Whether the detail page has a credential the deep link can pre-open a modal + * for. Read from the catalog's credential services rather than `authType`, + * because an integration can be `api-key` there and still authenticate with a + * stored service account (NetSuite, Snowflake, Harmonic, Claude Platform). + */ + hasCredentialService: boolean blockType: string }[] = INTEGRATIONS.flatMap((integration) => { const icon = blockTypeToIconMap[integration.type] @@ -33,7 +39,9 @@ const INTEGRATION_BASES: readonly { icon, bgColor: integration.bgColor, slug: integration.slug, - authType: integration.authType, + hasCredentialService: Boolean( + integration.oauthServiceId ?? integration.serviceAccountServiceId + ), blockType: integration.type, }, ] @@ -41,9 +49,10 @@ const INTEGRATION_BASES: readonly { /** * Builds the full integration catalog as search items for a given workspace. - * OAuth integrations link directly to the detail page with `?connect=oauth` so - * the connect modal auto-opens (via the detail page's `useEffect` on - * `CONNECT_QUERY_PARAM`). Non-OAuth integrations link to the plain detail page. + * An integration with a credential links to the detail page carrying the connect + * mode `getConnectMode` picks for it, so that modal auto-opens (via the detail + * page's `useEffect` on `CONNECT_QUERY_PARAM`). Everything else — and anything + * with no connect flow currently on offer — links to the plain detail page. */ export function buildIntegrationSearchItems( workspaceId: string, @@ -53,7 +62,7 @@ export function buildIntegrationSearchItems( ) => (typeof CONNECT_MODE)[keyof typeof CONNECT_MODE] | null = () => CONNECT_MODE.oauth ): IntegrationSearchItem[] { return INTEGRATION_BASES.filter((base) => isBlockAllowed(base.blockType)).map((base) => { - const connectMode = base.authType === 'oauth' ? getConnectMode(base.blockType) : null + const connectMode = base.hasCredentialService ? getConnectMode(base.blockType) : null const connectSuffix = connectMode ? `?${CONNECT_QUERY_PARAM}=${connectMode}` : '' return { id: base.id, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 36b376cba74..2f0bbcac3bb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -1045,8 +1045,19 @@ export const Sidebar = memo(function Sidebar() { : buildIntegrationSearchItems(workspaceId, isBlockAllowed, (blockType) => { const availability = integrationAvailability.get(blockType.toLowerCase()) if (!availability) return CONNECT_MODE.oauth - if (availability?.oauthAvailable) return CONNECT_MODE.oauth - if (availability?.state === 'limited') return CONNECT_MODE.serviceAccount + if (availability.oauthAvailable) return CONNECT_MODE.oauth + /** + * Anything still connectable once OAuth is out is the stored + * service account, which is the detail page's own test for offering + * it. Matching only `limited` misses an integration whose *only* + * credential is a service account: it is plain `ready`, and would + * open the detail page with no modal. The caller only asks about + * integrations that have a credential service, so an ordinary + * API-key block never reaches this. + */ + if (availability.state === 'ready' || availability.state === 'limited') { + return CONNECT_MODE.serviceAccount + } return null }), [workspaceId, permissionConfig.hideIntegrationsTab, isBlockAllowed, integrationAvailability] From fe9ed358f44625f38f33ff980fb42a8e8c013a5c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 8 Sep 2026 21:07:47 -0700 Subject: [PATCH 5/6] fix(sidebar): keep the catalog's connect flow when availability is unknown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The search deep link fell back to OAuth whenever deployment availability could not be read — while it loads, and after a failed fetch. That was safe while only OAuth integrations reached the fallback, but this branch also routes stored-service-account ones through it, and they have no OAuth flow for the detail page to open: the link resolved to nothing and cost the search result the one click it exists to save. Carry the catalog's own answer instead. `getConnectMode` now receives it and returns it verbatim when it cannot do better, which also keeps `null` meaning what it meant — the deployment offers no connect flow — rather than doubling as "unknown". Covers the builder, which had no tests: the per-credential-kind deep link, the resolver contract, and the allowlist filter. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V9K4apTYamQFpRYT3tcjVQ --- .../integration-search-items.test.ts | 79 +++++++++++++++++++ .../search-modal/integration-search-items.ts | 37 ++++++--- .../w/components/sidebar/sidebar.tsx | 10 ++- 3 files changed, 112 insertions(+), 14 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/integration-search-items.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/integration-search-items.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/integration-search-items.test.ts new file mode 100644 index 00000000000..a0dbc5bef62 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/integration-search-items.test.ts @@ -0,0 +1,79 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/blocks/registry', () => ({ getAllBlockMeta: () => ({}), getAllBlocks: () => [] })) + +import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route' +import { buildIntegrationSearchItems } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/integration-search-items' + +/** An OAuth integration, a stored-service-account one, and one with no credential. */ +const OAUTH_SLUG = 'airtable' +const SERVICE_ACCOUNT_SLUG = 'claude-managed-agents' +const NO_CREDENTIAL_SLUG = '1password' + +function hrefFor(slug: string, items: ReturnType): string { + const item = items.find((candidate) => candidate.id === slug) + if (!item) throw new Error(`Missing search item for ${slug}`) + return item.href +} + +describe('buildIntegrationSearchItems', () => { + it('deep-links each integration to the connect flow its catalog entry describes', () => { + const items = buildIntegrationSearchItems('workspace-1') + + expect(hrefFor(OAUTH_SLUG, items)).toBe( + `/workspace/workspace-1/integrations/${OAUTH_SLUG}?connect=${CONNECT_MODE.oauth}` + ) + /** + * The default is what the sidebar falls back to while deployment + * availability is unknown. Assuming OAuth here would send this integration + * to a detail page with no OAuth flow to open, and the deep link would + * silently do nothing. + */ + expect(hrefFor(SERVICE_ACCOUNT_SLUG, items)).toBe( + `/workspace/workspace-1/integrations/${SERVICE_ACCOUNT_SLUG}?connect=${CONNECT_MODE.serviceAccount}` + ) + expect(hrefFor(NO_CREDENTIAL_SLUG, items)).toBe( + `/workspace/workspace-1/integrations/${NO_CREDENTIAL_SLUG}` + ) + }) + + it('offers the catalog flow to the resolver and never consults it without a credential', () => { + const seen: Record = {} + const items = buildIntegrationSearchItems( + 'workspace-1', + undefined, + (blockType, catalogMode) => { + seen[blockType] = catalogMode + return catalogMode + } + ) + + expect(seen.airtable).toBe(CONNECT_MODE.oauth) + expect(seen.managed_agent).toBe(CONNECT_MODE.serviceAccount) + expect(seen.onepassword).toBeUndefined() + expect(hrefFor(NO_CREDENTIAL_SLUG, items)).toBe( + `/workspace/workspace-1/integrations/${NO_CREDENTIAL_SLUG}` + ) + }) + + it('drops the deep link when the deployment offers no connect flow', () => { + const items = buildIntegrationSearchItems('workspace-1', undefined, () => null) + + expect(hrefFor(OAUTH_SLUG, items)).toBe(`/workspace/workspace-1/integrations/${OAUTH_SLUG}`) + expect(hrefFor(SERVICE_ACCOUNT_SLUG, items)).toBe( + `/workspace/workspace-1/integrations/${SERVICE_ACCOUNT_SLUG}` + ) + }) + + it('applies the block allowlist', () => { + const items = buildIntegrationSearchItems( + 'workspace-1', + (blockType) => blockType === 'managed_agent' + ) + + expect(items.map((item) => item.id)).toEqual([SERVICE_ACCOUNT_SLUG]) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/integration-search-items.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/integration-search-items.ts index 2d8bfc15651..1ca8ae631b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/integration-search-items.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/integration-search-items.ts @@ -3,6 +3,7 @@ import { blockTypeToIconMap, INTEGRATIONS, resolveCredentialDisplay } from '@/li import { CONNECT_MODE, CONNECT_QUERY_PARAM, + type ConnectMode, } from '@/app/workspace/[workspaceId]/integrations/connect-route' import type { IntegrationSearchItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' import type { WorkspaceCredential } from '@/hooks/queries/credentials' @@ -22,12 +23,15 @@ const INTEGRATION_BASES: readonly { bgColor: string slug: string /** - * Whether the detail page has a credential the deep link can pre-open a modal - * for. Read from the catalog's credential services rather than `authType`, - * because an integration can be `api-key` there and still authenticate with a - * stored service account (NetSuite, Snowflake, Harmonic, Claude Platform). + * The connect flow this integration would offer knowing only the catalog, or + * `null` when it has no credential at all. Derived from the credential + * services rather than `authType`, because an integration can be `api-key` + * there and still authenticate with a stored service account (NetSuite, + * Snowflake, Harmonic, Claude Platform). Used as the deep link while + * deployment availability is unknown, where assuming OAuth would send those + * four to a page that has no OAuth flow to open. */ - hasCredentialService: boolean + catalogConnectMode: ConnectMode | null blockType: string }[] = INTEGRATIONS.flatMap((integration) => { const icon = blockTypeToIconMap[integration.type] @@ -39,9 +43,11 @@ const INTEGRATION_BASES: readonly { icon, bgColor: integration.bgColor, slug: integration.slug, - hasCredentialService: Boolean( - integration.oauthServiceId ?? integration.serviceAccountServiceId - ), + catalogConnectMode: integration.oauthServiceId + ? CONNECT_MODE.oauth + : integration.serviceAccountServiceId + ? CONNECT_MODE.serviceAccount + : null, blockType: integration.type, }, ] @@ -53,16 +59,23 @@ const INTEGRATION_BASES: readonly { * mode `getConnectMode` picks for it, so that modal auto-opens (via the detail * page's `useEffect` on `CONNECT_QUERY_PARAM`). Everything else — and anything * with no connect flow currently on offer — links to the plain detail page. + * + * `getConnectMode` receives the catalog's own answer as its second argument, to + * return verbatim when deployment availability cannot be read; returning `null` + * means the deployment offers no connect flow, which is not the same thing. */ export function buildIntegrationSearchItems( workspaceId: string, isBlockAllowed: (blockType: string) => boolean = () => true, - getConnectMode: ( - blockType: string - ) => (typeof CONNECT_MODE)[keyof typeof CONNECT_MODE] | null = () => CONNECT_MODE.oauth + getConnectMode: (blockType: string, catalogConnectMode: ConnectMode) => ConnectMode | null = ( + _blockType, + catalogConnectMode + ) => catalogConnectMode ): IntegrationSearchItem[] { return INTEGRATION_BASES.filter((base) => isBlockAllowed(base.blockType)).map((base) => { - const connectMode = base.hasCredentialService ? getConnectMode(base.blockType) : null + const connectMode = base.catalogConnectMode + ? getConnectMode(base.blockType, base.catalogConnectMode) + : null const connectSuffix = connectMode ? `?${CONNECT_QUERY_PARAM}=${connectMode}` : '' return { id: base.id, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 2f0bbcac3bb..02761275181 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -1042,9 +1042,15 @@ export const Sidebar = memo(function Sidebar() { () => permissionConfig.hideIntegrationsTab ? [] - : buildIntegrationSearchItems(workspaceId, isBlockAllowed, (blockType) => { + : buildIntegrationSearchItems(workspaceId, isBlockAllowed, (blockType, catalogMode) => { const availability = integrationAvailability.get(blockType.toLowerCase()) - if (!availability) return CONNECT_MODE.oauth + /** + * Availability is unknown while it loads and after a failed fetch, + * so keep the catalog's own flow rather than assuming OAuth — that + * assumption sends a service-account-only integration to a page with + * no OAuth modal to open, costing the search result its one click. + */ + if (!availability) return catalogMode if (availability.oauthAvailable) return CONNECT_MODE.oauth /** * Anything still connectable once OAuth is out is the stored From 5109ab5a0285c8c76f90e1ed4018fc7502b39eb7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 8 Sep 2026 21:49:17 -0700 Subject: [PATCH 6/6] test(integrations): pin the detail page's connect-control decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page this branch exists to fix had no tests, and its header action is the branch's whole user-visible surface: five integration classes crossed with a loading, loaded and failed availability answer, expressed as nested ternaries. Covers the decision for both classes that matter — one whose only credential is a stored service account, and one that also offers OAuth — across all three availability states, plus the two "Unavailable" cases in either direction. The control-kind tag in the helper is load-bearing. A `ChipDropdown` trigger renders the same "Add to Sim" placeholder as the plain chip, so an assertion on label text alone cannot tell one connect option from two, and the case guarding the narrowed unknown-availability default passed against the over-broad version it was written to reject. Tagging on `aria-haspopup` makes it fail. Each of the six cases was checked against a mutation of the behavior it describes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V9K4apTYamQFpRYT3tcjVQ --- .../[block]/integration-block-detail.test.tsx | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.test.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.test.tsx new file mode 100644 index 00000000000..5d4fe2c35b0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.test.tsx @@ -0,0 +1,191 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Integration } from '@/lib/integrations/types' + +const { availabilityState, mockPush } = vi.hoisted(() => ({ + /** `null` stands for an availability answer that has not arrived. */ + availabilityState: { + availability: null as { state: string; oauthAvailable: boolean } | null, + isLoading: false, + }, + mockPush: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }) })) +vi.mock('nuqs', () => ({ useQueryState: () => [null, vi.fn()] })) +vi.mock('@/hooks/use-oauth-return', () => ({ useOAuthReturnRouter: () => {} })) +vi.mock('@/hooks/queries/credentials', () => ({ + useWorkspaceCredentials: () => ({ data: [], isPending: false }), +})) +vi.mock('@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration', () => ({ + useScrollRestoration: () => {}, +})) +vi.mock('@/lib/core/config/deployment-shape', () => ({ + useDeploymentShape: () => ({ chatEnabled: true }), +})) +vi.mock('@/hooks/use-permission-config', () => ({ + usePermissionConfig: () => ({ + integrationAvailability: new Map( + availabilityState.availability + ? [ + ['snowflake', availabilityState.availability], + ['jira', availabilityState.availability], + ] + : [] + ), + isLoading: availabilityState.isLoading, + }), +})) + +/** Heavy leaf sections carry their own coverage; the header is what is under test. */ +vi.mock('@/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section', () => ({ + IntegrationSkillsSection: () => null, +})) +vi.mock('@/app/workspace/[workspaceId]/integrations/components/integration-section', () => ({ + IntegrationSection: () => null, +})) +vi.mock('@/app/workspace/[workspaceId]/integrations/components/integrations-showcase', () => ({ + IntegrationTile: () => null, +})) +vi.mock( + '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section', + () => ({ + SettingsSection: () => null, + }) +) +vi.mock('@/app/workspace/[workspaceId]/components/connect-oauth-modal', () => ({ + ConnectOAuthModal: () =>
, +})) +vi.mock( + '@/app/workspace/[workspaceId]/integrations/components/connect-personal-token-modal', + () => ({ + ConnectPersonalTokenModal: () => null, + }) +) +vi.mock('@/blocks/registry', () => ({ + getTemplatesForBlock: () => [], + getSuggestedSkillsForBlock: () => [], +})) + +import { getServiceAccountConnectNoun } from '@/lib/credentials/service-account-provider-ids' +import { INTEGRATIONS } from '@/lib/integrations' +import { IntegrationBlockDetail } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail' + +/** Snowflake authenticates only with a stored service account; Jira also offers OAuth. */ +const SERVICE_ACCOUNT_ONLY = INTEGRATIONS.find((i) => i.slug === 'snowflake') as Integration +const OAUTH_WITH_SERVICE_ACCOUNT = INTEGRATIONS.find((i) => i.slug === 'jira') as Integration + +/** + * Derived rather than written out: the vendor-accurate noun is owned by + * `getServiceAccountConnectNoun`, so hardcoding it here would make this test + * fail on a copy change that is none of its business. + */ +const STORED_CREDENTIAL_LABEL = `Add ${getServiceAccountConnectNoun('snowflake-service-account')}` + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function mount(integration: Integration) { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => + root?.render() + ) +} + +/** + * The header's primary action, tagged by control kind. The tag matters: a + * `ChipDropdown` trigger renders the same "Add to Sim" placeholder as the plain + * chip, so comparing label text alone cannot tell one connect option from two. + * Radix marks its trigger with `aria-haspopup`; a bare `Chip` carries none. + */ +function headerAction(): string { + const bar = container?.firstElementChild?.firstElementChild + const buttons = Array.from(bar?.querySelectorAll('button') ?? []) + return buttons + .map((b) => `${b.hasAttribute('aria-haspopup') ? 'dropdown' : 'chip'}:${b.textContent?.trim()}`) + .join('|') +} + +beforeEach(() => { + mockPush.mockClear() + availabilityState.availability = { state: 'ready', oauthAvailable: false } + availabilityState.isLoading = false +}) + +afterEach(() => { + if (root) act(() => root?.unmount()) + container?.remove() + root = null + container = null +}) + +describe('IntegrationBlockDetail header action', () => { + it('offers the stored service account for an integration with no OAuth path', () => { + mount(SERVICE_ACCOUNT_ONLY) + + expect(headerAction()).toContain(`chip:${STORED_CREDENTIAL_LABEL}`) + }) + + it('keeps offering it while the availability answer is still in flight', () => { + availabilityState.availability = null + availabilityState.isLoading = true + mount(SERVICE_ACCOUNT_ONLY) + + expect(headerAction()).toContain(`chip:${STORED_CREDENTIAL_LABEL}`) + }) + + /** + * A failed request leaves availability unresolved once loading ends. Hiding the + * control there strands a user who has a valid stored account behind a fetch + * they cannot retry, so it fails open — the server still refuses a provider the + * deployment does not offer. + */ + it('fails open when the availability request settles with no answer', () => { + availabilityState.availability = null + availabilityState.isLoading = false + mount(SERVICE_ACCOUNT_ONLY) + + expect(headerAction()).toContain(`chip:${STORED_CREDENTIAL_LABEL}`) + }) + + /** + * The OAuth path already defaults to available while unknown, so relaxing the + * service-account one too would widen this header from a chip to a dropdown and + * collapse it again as the config lands. + */ + it('does not add a second option to an OAuth integration while loading', () => { + availabilityState.availability = null + availabilityState.isLoading = true + mount(OAUTH_WITH_SERVICE_ACCOUNT) + + expect(headerAction()).toBe('chip:Add to Sim') + }) + + /** + * "Unavailable" is a verdict about a connection the deployment grants. An + * integration authenticated by a stored service account still runs on the + * user's own API key, so it keeps the ordinary call to action instead. + */ + it('never calls a stored-credential integration unavailable', () => { + availabilityState.availability = { state: 'unavailable', oauthAvailable: false } + mount(SERVICE_ACCOUNT_ONLY) + + const action = headerAction() + expect(action).not.toContain('Unavailable') + expect(action).toContain('chip:Add to Sim') + }) + + it('still calls an OAuth integration unavailable when its client is missing', () => { + availabilityState.availability = { state: 'unavailable', oauthAvailable: false } + mount(OAUTH_WITH_SERVICE_ACCOUNT) + + expect(headerAction()).toContain('chip:Unavailable') + }) +})