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 } : {}),