Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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: [] }),
Expand Down Expand Up @@ -112,7 +119,10 @@ function rows(): HTMLButtonElement[] {
beforeEach(() => {
onSelectPrompt.mockClear()
mockCaptureEvent.mockClear()
mockRouterPush.mockClear()
modeState.initial = 'build'
connectionState.credentials = []
connectionState.services = []
})

afterEach(() => {
Expand All @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 = () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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: () => <div data-testid='oauth-modal' />,
}))
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(<IntegrationBlockDetail integration={integration} workspaceId='workspace-1' />)
)
}

/**
* 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')
})
})
Loading
Loading