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]/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.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')
+ })
+})
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..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
@@ -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,22 +96,39 @@ 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'
+ /**
+ * 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 || !permissionConfigLoading
const hasServiceAccount =
serviceAccountDeploymentAvailable &&
Boolean(serviceAccountTarget) &&
@@ -147,28 +166,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)
@@ -180,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 (
@@ -191,7 +226,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
setPersonalTokenOpen(true)}>
Add personal token
- ) : oauthService ? (
+ ) : oauthService || serviceAccountService ? (
connectOptions.length > 1 ? (
) : (
- Unavailable
+ connectFallback
)
- ) : chatEnabled ? (
-
- Add to Sim
-
- ) : null}
+ ) : (
+ connectFallback
+ )}
{personalTokenAvailable && (
@@ -247,7 +280,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/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 1d82af1f806..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'
@@ -21,7 +22,16 @@ const INTEGRATION_BASES: readonly {
icon: ComponentType<{ className?: string }>
bgColor: string
slug: string
- authType: string
+ /**
+ * 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.
+ */
+ catalogConnectMode: ConnectMode | null
blockType: string
}[] = INTEGRATIONS.flatMap((integration) => {
const icon = blockTypeToIconMap[integration.type]
@@ -33,7 +43,11 @@ const INTEGRATION_BASES: readonly {
icon,
bgColor: integration.bgColor,
slug: integration.slug,
- authType: integration.authType,
+ catalogConnectMode: integration.oauthServiceId
+ ? CONNECT_MODE.oauth
+ : integration.serviceAccountServiceId
+ ? CONNECT_MODE.serviceAccount
+ : null,
blockType: integration.type,
},
]
@@ -41,19 +55,27 @@ 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.
+ *
+ * `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.authType === 'oauth' ? 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 36b376cba74..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,11 +1042,28 @@ 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
- if (availability?.oauthAvailable) return CONNECT_MODE.oauth
- if (availability?.state === 'limited') return CONNECT_MODE.serviceAccount
+ /**
+ * 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
+ * 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]
diff --git a/apps/sim/lib/integrations/availability.server.test.ts b/apps/sim/lib/integrations/availability.server.test.ts
index f782f80b24d..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,6 +55,31 @@ function availabilityFor(
}
describe('integration availability', () => {
+ 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([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`
+ )
+ 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 +226,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..fd98aefb43a 100644
--- a/apps/sim/lib/integrations/credential-visibility.server.test.ts
+++ b/apps/sim/lib/integrations/credential-visibility.server.test.ts
@@ -71,6 +71,56 @@ describe('integration credential visibility', () => {
])
})
+ /**
+ * `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,
+ providerId,
+ serviceAccountProviderId: providerId,
+ name: serviceId,
+ description: serviceId,
+ baseProvider: serviceId,
+ authType: 'service_account',
+ }
+ getIntegrationAvailabilityMock.mockReturnValue([
+ 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([blockType]), previewTagged: new Set() }
+ : null,
+ oauthServices: [service],
+ })
+ expect(
+ visible([blockType]).isCredentialVisible({ providerId, type: 'service_account' })
+ ).toBe(true)
+ expect(visible(['jira']).isCredentialVisible({ providerId, type: 'service_account' })).toBe(
+ false
+ )
+ expect(
+ visible([blockType], true).isCredentialVisible({ providerId, type: 'service_account' })
+ ).toBe(false)
+ getBlockMock.mockImplementation((type: string) => ({ type, preview: true }))
+ expect(
+ visible([blockType]).isCredentialVisible({ providerId, type: 'service_account' })
+ ).toBe(false)
+ }
+ )
+
it('applies the integration allowlist to OAuth and service-account credentials', () => {
const visibility = createIntegrationCredentialVisibility({
allowedIntegrationTypes: new Set(['slack_v2']),
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..4faed1559b4 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,58 @@ describe('resolveOAuthServiceForSlug', () => {
})
describe('resolveServiceAccountIntegration', () => {
+ it.each(['netsuite', 'snowflake', 'harmonic', 'claude-platform'])(
+ '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)
+ }
+ )
+
+ /**
+ * 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')!
+ 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..c90f9f13a8a 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
@@ -68,6 +89,7 @@ export interface ServiceAccountIntegrationMatch {
slug: string
serviceAccountProviderId: ServiceAccountProviderId
serviceName: string
+ serviceIcon: ComponentType<{ className?: string }>
providerId: string
}
@@ -93,18 +115,19 @@ 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 [
{
slug: integration.slug,
serviceAccountProviderId: match.serviceAccountProviderId,
serviceName: integration.name,
+ serviceIcon: match.serviceIcon,
providerId: match.providerId,
},
]
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..5d735bb47ee 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,54 @@ import {
parsePropertiesContent,
} from './generate-docs'
+describe('integration credential relationships', () => {
+ 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/${blockFile}.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 } : {}),