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
2 changes: 2 additions & 0 deletions apps/docs/components/ui/icon-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ import {
OktaIcon,
OnePasswordIcon,
OpenAIIcon,
OracleIcon,
OutlookIcon,
PackageSearchIcon,
PagerDutyIcon,
Expand Down Expand Up @@ -480,6 +481,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
onedrive: MicrosoftOneDriveIcon,
onepassword: OnePasswordIcon,
openai: OpenAIIcon,
oracle_fusion_scm: OracleIcon,
outlook: OutlookIcon,
pagerduty: PagerDutyIcon,
parallel_ai: ParallelIcon,
Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/integrations/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@
"onedrive",
"onepassword",
"openai",
"oracle_fusion_scm",
"outlook",
"pagerduty",
"parallel_ai",
Expand Down
1,009 changes: 1,009 additions & 0 deletions apps/docs/content/docs/integrations/oracle_fusion_scm.mdx

Large diffs are not rendered by default.

18 changes: 12 additions & 6 deletions apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ function getRelatedSlugs(
const AUTH_STEP: Record<AuthType, (name: string) => string> = {
oauth: (name) =>
`Connect your ${name} account with one-click OAuth, with no credentials to copy.`,
'service-account': (name) =>
`Select or add a reusable ${name} service-account credential in your workspace.`,
'api-key': (name) =>
`Paste your ${name} API key to authenticate. You can find it in your ${name} account settings.`,
none: () => 'No authentication is needed, so the block works as soon as you drop it in.',
Expand Down Expand Up @@ -501,9 +503,11 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
that AI agents can use inside Sim&apos;s visual workflow builder.{' '}
{authType === 'oauth'
? `${name} connects with one-click OAuth.`
: authType === 'api-key'
? `${name} connects with an API key.`
: `${name} requires no authentication.`}{' '}
: authType === 'service-account'
? `${name} connects with a reusable service-account credential.`
: authType === 'api-key'
? `${name} connects with an API key.`
: `${name} requires no authentication.`}{' '}
Free to start at sim.ai.
</p>

Expand Down Expand Up @@ -656,9 +660,11 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
body:
authType === 'oauth'
? `Open your workspace, drag ${articleFor(name)} ${name} block onto the workflow builder, and connect your account with one-click OAuth.`
: authType === 'api-key'
? `Open your workspace, drag ${articleFor(name)} ${name} block onto the workflow builder, and paste in your ${name} API key.`
: `Open your workspace, drag ${articleFor(name)} ${name} block onto the workflow builder. No authentication is needed.`,
: authType === 'service-account'
? `Open your workspace, drag ${articleFor(name)} ${name} block onto the workflow builder, and select or add a reusable service-account credential.`
: authType === 'api-key'
? `Open your workspace, drag ${articleFor(name)} ${name} block onto the workflow builder, and paste in your ${name} API key.`
: `Open your workspace, drag ${articleFor(name)} ${name} block onto the workflow builder. No authentication is needed.`,
},
{
step: '03',
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/(landing)/integrations/(shell)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const allIntegrations = INTEGRATIONS
const integrationSummaries = allIntegrations.map(toIntegrationSummary)
const INTEGRATION_COUNT = allIntegrations.length
const OAUTH_COUNT = allIntegrations.filter((i) => i.authType === 'oauth').length
const SERVICE_ACCOUNT_COUNT = allIntegrations.filter((i) => i.authType === 'service-account').length
const TRIGGER_INTEGRATION_COUNT = allIntegrations.filter((i) => i.triggerCount > 0).length
const TOTAL_TOOL_COUNT = allIntegrations.reduce((sum, i) => sum + i.operationCount, 0)

Expand All @@ -31,7 +32,7 @@ const TOTAL_TOOL_COUNT = allIntegrations.reduce((sum, i) => sum + i.operationCou
const CATALOG_FAQS: FAQItem[] = [
{
question: 'How do integrations work in Sim?',
answer: `Each integration is a block you drag onto Sim's workflow builder. Together, Sim's ${INTEGRATION_COUNT} integrations expose ${TOTAL_TOOL_COUNT}+ tools that AI agents can call. ${OAUTH_COUNT} connect with one-click OAuth, and the rest use an API key or no authentication at all. Wire blocks together, add an AI agent block for reasoning, and run.`,
answer: `Each integration is a block you drag onto Sim's workflow builder. Together, Sim's ${INTEGRATION_COUNT} integrations expose ${TOTAL_TOOL_COUNT}+ tools that AI agents can call. ${OAUTH_COUNT} connect with one-click OAuth, ${SERVICE_ACCOUNT_COUNT} use reusable service-account credentials, and the rest are configured directly in the block with an API key, other connection details, or no credentials as applicable. Wire blocks together, add an AI agent block for reasoning, and run.`,
},
{
question: 'Are Sim integrations free to use?',
Expand Down
7 changes: 6 additions & 1 deletion apps/sim/app/api/settings/allowed-integrations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ export const GET = withRouteHandler(async () => {
return NextResponse.json({
allowedIntegrations: getAllowedIntegrationsFromEnv(),
integrationAvailability: getIntegrationAvailability().map(
({ type, state, oauthAvailable }) => ({ type, state, oauthAvailable })
({ type, state, oauthAvailable, serviceAccountAvailable }) => ({
type,
state,
oauthAvailable,
serviceAccountAvailable,
})
),
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ 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 { credentialsState, mockCaptureEvent, modeState, oauthServicesState } = vi.hoisted(() => ({
credentialsState: { data: [] as unknown[] },
mockCaptureEvent: vi.fn(),
/** The URL `mode` param as the nuqs mock serves it; `set` is the live setter once mounted. */
modeState: { initial: 'build', set: (_next: string) => {} },
oauthServicesState: { data: [] as unknown[] },
}))

vi.mock('nuqs', async () => {
Expand All @@ -30,10 +32,10 @@ vi.mock('@/lib/posthog/client', () => ({ captureEvent: mockCaptureEvent }))
vi.mock('@sim/utils/random', () => ({ randomFloat: () => 0 }))

vi.mock('@/hooks/queries/credentials', () => ({
useWorkspaceCredentials: () => ({ data: [] }),
useWorkspaceCredentials: () => ({ data: credentialsState.data }),
}))
vi.mock('@/hooks/queries/oauth/oauth-connections', () => ({
useOAuthConnections: () => ({ data: [] }),
useOAuthConnections: () => ({ data: oauthServicesState.data }),
}))
vi.mock('@/hooks/queries/tables', () => ({
useTablesList: () => ({ data: [] }),
Expand Down Expand Up @@ -114,6 +116,8 @@ beforeEach(() => {
onSelectPrompt.mockClear()
mockCaptureEvent.mockClear()
modeState.initial = 'build'
credentialsState.data = []
oauthServicesState.data = []
})

afterEach(() => {
Expand Down Expand Up @@ -150,4 +154,36 @@ describe('SuggestedActions', () => {
expect(document.querySelector('[data-testid="search-sources"]')).not.toBeNull()
expect(rows()).toHaveLength(0)
})

it('does not offer a service-account-only integration through the OAuth modal', () => {
const OracleIcon = () => null
credentialsState.data = [
{
id: 'existing-oauth',
type: 'oauth',
providerId: 'slack',
},
]
oauthServicesState.data = [
{
id: 'oracle_fusion_scm',
name: 'Oracle Fusion Cloud SCM',
description: 'Read Oracle Fusion Cloud SCM data.',
providerId: 'oracle_fusion_scm',
icon: OracleIcon,
baseProviderIcon: OracleIcon,
scopes: [],
authType: 'service_account',
serviceAccountProviderId: 'oracle-fusion-service-account',
isConnected: false,
},
]

mount()

expect(rows().map((row) => row.textContent)).not.toContain(
'Integrate with Oracle Fusion Cloud SCM'
)
expect(document.querySelector('[data-testid="connect-modal"]')).toBeNull()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ function toIntegrationAction(service: ServiceInfo, slug: string): Action {
*/
function computeActions(services: readonly ServiceInfo[], signals: Signals): Action[] {
const connectCandidates = services.flatMap((s) => {
if ((s.authType ?? 'oauth') !== 'oauth') return []
if (signals.connectedProviders.has(s.providerId)) return []
const slug = SLUG_BY_LOWER_NAME.get(s.name.toLowerCase())
return slug ? [{ service: s, slug }] : []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/comp
import {
CONNECT_MODE,
resolveAvailableConnectMode,
resolveOAuthAvailability,
} from '@/app/workspace/[workspaceId]/integrations/connect-route'
import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration'
import {
Expand Down Expand Up @@ -70,7 +71,9 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
const oauthService = resolveOAuthServiceForIntegration(integration)
const { integrationAvailability, isLoading: permissionConfigLoading } = usePermissionConfig()
const availability = integrationAvailability.get(integration.type.toLowerCase())
const oauthAvailable = Boolean(oauthService) && (availability?.oauthAvailable ?? true)
const oauthAvailable =
Boolean(oauthService) &&
resolveOAuthAvailability(availability?.oauthAvailable, integration.authType)
const [oauthOpen, setOAuthOpen] = useState(false)

const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
CONNECT_MODE,
resolveAvailableConnectMode,
resolveOAuthAvailability,
} from '@/app/workspace/[workspaceId]/integrations/connect-route'

describe('resolveAvailableConnectMode', () => {
Expand Down Expand Up @@ -36,4 +37,11 @@ describe('resolveAvailableConnectMode', () => {
})
).toBe(CONNECT_MODE.oauth)
})

it('uses the catalog auth type until deployment availability hydrates', () => {
expect(resolveOAuthAvailability(undefined, 'service-account')).toBe(false)
expect(resolveOAuthAvailability(undefined, 'oauth')).toBe(true)
expect(resolveOAuthAvailability(true, 'service-account')).toBe(true)
expect(resolveOAuthAvailability(false, 'oauth')).toBe(false)
})
})
10 changes: 10 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/integrations/connect-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* the detail page's `?connect=oauth|service-account` query handler.
*/

import type { AuthType } from '@/lib/integrations/types'

export const CONNECT_QUERY_PARAM = 'connect' as const

export const CONNECT_MODE = {
Expand All @@ -29,3 +31,11 @@ export function resolveAvailableConnectMode(
}
return null
}

/** Prevents service-account-only integrations from advertising OAuth before availability loads. */
export function resolveOAuthAvailability(
deployedOAuthAvailable: boolean | undefined,
catalogAuthType: AuthType
): boolean {
return deployedOAuthAvailable ?? catalogAuthType === 'oauth'
}
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,11 @@ export function Integrations() {
name={integration.name}
description={integration.description}
icon={Icon}
unavailable={integration.authType === 'oauth' && deploymentUnavailable}
unavailable={
(integration.authType === 'oauth' ||
integration.authType === 'service-account') &&
deploymentUnavailable
}
/>
)
})}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route'
import { resolveIntegrationSearchConnectMode } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/integration-search-items'

describe('resolveIntegrationSearchConnectMode', () => {
it('opens the service-account picker for a ready service-account-only integration', () => {
expect(
resolveIntegrationSearchConnectMode(
{
oauthAvailable: false,
serviceAccountAvailable: true,
},
'service-account'
)
).toBe(CONNECT_MODE.serviceAccount)
})

it('prefers OAuth when both stored-credential paths are available', () => {
expect(
resolveIntegrationSearchConnectMode(
{
oauthAvailable: true,
serviceAccountAvailable: true,
},
'oauth'
)
).toBe(CONNECT_MODE.oauth)
})

it('does not request a connection dialog when neither path is available', () => {
expect(
resolveIntegrationSearchConnectMode(
{
oauthAvailable: false,
serviceAccountAvailable: false,
},
'oauth'
)
).toBeNull()
})

it('uses the catalog auth type before deployment availability hydrates', () => {
expect(resolveIntegrationSearchConnectMode(null, 'service-account')).toBe(
CONNECT_MODE.serviceAccount
)
expect(resolveIntegrationSearchConnectMode(null, 'oauth')).toBe(CONNECT_MODE.oauth)
})
})
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ComponentType } from 'react'
import { blockTypeToIconMap, INTEGRATIONS, resolveCredentialDisplay } from '@/lib/integrations'
import type { IntegrationAvailability } from '@/lib/integrations/availability'
import {
CONNECT_MODE,
CONNECT_QUERY_PARAM,
Expand All @@ -10,6 +11,19 @@ import type { WorkspaceCredential } from '@/hooks/queries/credentials'
/** Fallback brand color for credentials whose integration metadata cannot be resolved. */
const FALLBACK_BG_COLOR = '#6B7280'

/** Selects the credential dialog that a catalog search result should open. */
export function resolveIntegrationSearchConnectMode(
availability: Pick<IntegrationAvailability, 'oauthAvailable' | 'serviceAccountAvailable'> | null,
authType: 'oauth' | 'service-account'
): (typeof CONNECT_MODE)[keyof typeof CONNECT_MODE] | null {
if (!availability) {
return authType === 'service-account' ? CONNECT_MODE.serviceAccount : CONNECT_MODE.oauth
}
if (availability.oauthAvailable) return CONNECT_MODE.oauth
if (availability.serviceAccountAvailable) return CONNECT_MODE.serviceAccount
return null
}

/**
* Module-level base array of resolvable integrations (entries without a
* registered icon are dropped, matching the catalog's `if (!Icon) return null`
Expand Down Expand Up @@ -41,19 +55,24 @@ 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.
* Stored-credential integrations link directly to the detail page with a connect mode so the
* matching modal auto-opens (via the detail page's `useEffect` on `CONNECT_QUERY_PARAM`). Other
* integrations link to the plain detail page.
*/
export function buildIntegrationSearchItems(
workspaceId: string,
isBlockAllowed: (blockType: string) => boolean = () => true,
getConnectMode: (
blockType: string
) => (typeof CONNECT_MODE)[keyof typeof CONNECT_MODE] | null = () => CONNECT_MODE.oauth
blockType: string,
authType: 'oauth' | 'service-account'
) => (typeof CONNECT_MODE)[keyof typeof CONNECT_MODE] | null = (_blockType, authType) =>
authType === 'service-account' ? CONNECT_MODE.serviceAccount : CONNECT_MODE.oauth
): IntegrationSearchItem[] {
return INTEGRATION_BASES.filter((base) => isBlockAllowed(base.blockType)).map((base) => {
const connectMode = base.authType === 'oauth' ? getConnectMode(base.blockType) : null
const connectMode =
base.authType === 'oauth' || base.authType === 'service-account'
? getConnectMode(base.blockType, base.authType)
: null
const connectSuffix = connectMode ? `?${CONNECT_QUERY_PARAM}=${connectMode}` : ''
return {
id: base.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ import { isChatEnabled, isHosted, isStatusNoticePreviewEnabled } from '@/lib/cor
import { isMacPlatform } from '@/lib/core/utils/platform'
import { buildFolderTree, getFolderPathNames } from '@/lib/folders/tree'
import { captureEvent } from '@/lib/posthog/client'
import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
Expand Down Expand Up @@ -71,6 +70,7 @@ import {
import {
buildConnectedAccountSearchItems,
buildIntegrationSearchItems,
resolveIntegrationSearchConnectMode,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/integration-search-items'
import type {
LogItem,
Expand Down Expand Up @@ -1100,12 +1100,9 @@ export const Sidebar = memo(function Sidebar({
() =>
permissionConfig.hideIntegrationsTab
? []
: buildIntegrationSearchItems(workspaceId, isBlockAllowed, (blockType) => {
: buildIntegrationSearchItems(workspaceId, isBlockAllowed, (blockType, authType) => {
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
return null
return resolveIntegrationSearchConnectMode(availability ?? null, authType)
}),
[workspaceId, permissionConfig.hideIntegrationsTab, isBlockAllowed, integrationAvailability]
)
Expand Down
Loading