Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2478,11 +2478,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,13 +5,14 @@ import { INTEGRATION_METADATA } from '@sim/deployment-config/integration-metadat
import { ArrowRight, cn, OverflowText } 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 { HomeSection } from '@/components/home/home-section'
import { GmailIcon, SlackIcon } from '@/components/icons'
import {
resolveOAuthServiceForIntegration,
resolveOAuthServiceForSlug,
resolveServiceAccountServiceForIntegration,
} from '@/lib/integrations/oauth-service'
import { captureEvent } from '@/lib/posthog/client'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
Expand All @@ -21,6 +22,10 @@ import type {
OAuthConnectTarget,
} from '@/app/workspace/[workspaceId]/home/components/suggested-actions/types'
import { weightedSample } from '@/app/workspace/[workspaceId]/home/components/suggested-actions/weighted-sample'
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 @@ -240,6 +245,7 @@ interface SuggestedActionsProps {
export function SuggestedActions({ onSelectPrompt, organizationId }: SuggestedActionsProps) {
const params = useParams<{ workspaceId?: string }>()
const workspaceId = organizationId ? undefined : params.workspaceId
const router = useRouter()
const posthog = usePostHog()

const { data: credentials = EMPTY_CREDENTIALS } = useWorkspaceCredentials({
Expand Down Expand Up @@ -312,7 +318,23 @@ export function SuggestedActions({ onSelectPrompt, organizationId }: SuggestedAc
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 = INTEGRATION_METADATA.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,186 @@
/**
* @vitest-environment jsdom
*/

import { act } from 'react'
import { deploymentShapeMock } from '@sim/testing/mocks/deployment-shape.mock'
import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock'
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 } = vi.hoisted(() => ({
/** `null` stands for an availability answer that has not arrived. */
availabilityState: {
availability: null as { state: string; oauthAvailable: boolean } | null,
isLoading: false,
},
}))

vi.mock('next/navigation', () => nextNavigationMock)
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', () => deploymentShapeMock)
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,
})
)

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(() => {
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