Skip to content
Merged
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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ Build order: **Tools** → **Block** → **Icon** → optional **Trigger**, star
- **Tool IDs are `snake_case`** (`service_action`), registered in `tools/registry.ts`; blocks register in `blocks/registry-maps.ts` (`BLOCK_REGISTRY` + `BLOCK_META_REGISTRY`, alphabetically).
- **Type coercions go in `tools.config.params`** (runs at execution, after variable resolution), never in `tools.config.tool` (runs at serialization, where `Number()` destroys dynamic `<Block.output>` references).

A surface that lists integrations to a viewer (a catalog, picker, or `@` menu) reads `useIntegrationAccess` (`hooks/use-integration-access.ts`): the one verdict across permission group, deployment allowlist, and deployment OAuth configuration, which fails closed until the viewer's policy loads. Never map over a raw catalog (`INTEGRATIONS`, `listIntegrations()`, `INTEGRATION_METADATA`) there; `check:integration-access` fails the build.

Remaining block/tool/trigger rules: `.claude/rules/sim-integrations.md`. Canvas sentences: `apps/sim/blocks/AGENTS.md`.

## Tables
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ Build order: **Tools** → **Block** → **Icon** → optional **Trigger**, star
- **Tool IDs are `snake_case`** (`service_action`), registered in `tools/registry.ts`; blocks register in `blocks/registry-maps.ts` (`BLOCK_REGISTRY` + `BLOCK_META_REGISTRY`, alphabetically).
- **Type coercions go in `tools.config.params`** (runs at execution, after variable resolution), never in `tools.config.tool` (runs at serialization, where `Number()` destroys dynamic `<Block.output>` references).

A surface that lists integrations to a viewer (a catalog, picker, or `@` menu) reads `useIntegrationAccess` (`hooks/use-integration-access.ts`): the one verdict across permission group, deployment allowlist, and deployment OAuth configuration, which fails closed until the viewer's policy loads. Never map over a raw catalog (`INTEGRATIONS`, `listIntegrations()`, `INTEGRATION_METADATA`) there; `check:integration-access` fails the build.

Remaining block/tool/trigger rules: `.claude/rules/sim-integrations.md`. Canvas sentences: `apps/sim/blocks/AGENTS.md`.

## Tables
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useMemo } from 'react'
import { isIntegrationUsable } from '@/lib/integrations/access'
import type { MentionMenuData } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-store'
import { listIntegrations } from '@/blocks/integration-matcher'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
Expand All @@ -7,6 +8,7 @@ import { useTablesList } from '@/hooks/queries/tables'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders'
import { useWorkspaceFilePickerOptions } from '@/hooks/queries/workspace-files'
import { useIntegrationAccess } from '@/hooks/use-integration-access'
import { mentionIcon } from './mention-icon'
import type { MentionItem } from './types'

Expand Down Expand Up @@ -44,18 +46,21 @@ export function useMarkdownMentions(
const knowledgeBases = useKnowledgeBasesQuery(ws, { enabled: active })
const workflows = useWorkflows(ws)
const skills = useSkills(wsStr)
const { getAccess: getIntegrationAccess } = useIntegrationAccess(workspaceId, { enabled: active })

// The integration registry is static — materialize it once rather than on every resource refetch.
// Rebuilt only when the access policy changes, never on a resource refetch.
const integrationItems = useMemo<MentionItem[]>(() => {
if (!active) return []
return listIntegrations().map((integration) => ({
kind: 'integration',
id: integration.blockType,
label: integration.name,
group: 'Integrations',
icon: mentionIcon('integration', integration.blockType),
}))
}, [active])
return listIntegrations()
.filter((integration) => isIntegrationUsable(getIntegrationAccess(integration.blockType)))
.map((integration) => ({
kind: 'integration',
id: integration.blockType,
label: integration.name,
group: 'Integrations',
icon: mentionIcon('integration', integration.blockType),
}))
}, [active, getIntegrationAccess])

const items = useMemo(() => {
if (!active) return NO_MENTION_ITEMS
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useMemo, useSyncExternalStore } from 'react'
import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport'
import { subscribeDesktopPreferences } from '@/lib/desktop'
import { isIntegrationUsable } from '@/lib/integrations/access'
import { isTerminalAvailable } from '@/lib/terminal/transport'
import type { AvailableItem } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree'
import {
Expand All @@ -21,6 +22,7 @@ import { useWorkflowTests } from '@/hooks/queries/workflow-tests'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders'
import { useWorkspaceFilePickerOptions } from '@/hooks/queries/workspace-files'
import { useIntegrationAccess } from '@/hooks/use-integration-access'

/**
* Placeholder id for the Browser launcher row. It never names a resource: the
Expand Down Expand Up @@ -178,6 +180,23 @@ export function useAvailableResources(
const { data: tasks, isPending: tasksPending } = useMothershipChats(workspaceId, {
enabled: enabled && Boolean(workspaceId),
})
const integrationsIncluded = !excludeTypes?.includes('integration')
const integrationAccess = useIntegrationAccess(workspaceId, {
enabled: enabled && integrationsIncluded,
})
const { getAccess: getIntegrationAccess } = integrationAccess

/**
* Only integrations the workspace can use now: the server drops a mention of
* one its policy restricts or whose OAuth this deployment has not configured.
*/
const usableIntegrations = useMemo(
() =>
listIntegrationsByPopularity().filter((integration) =>
isIntegrationUsable(getIntegrationAccess(integration.blockType))
),
[getIntegrationAccess]
)
const { data: logsData, isPending: logsPending } = useLogsList(
workspaceId,
LOG_DROPDOWN_FILTERS,
Expand Down Expand Up @@ -208,7 +227,8 @@ export function useAvailableResources(
(!excludeTypes?.includes('knowledgebase') && knowledgeBaseFoldersPending))) ||
fileFoldersPending ||
tasksPending ||
logsPending)
logsPending ||
(integrationsIncluded && integrationAccess.isPending))

const groups = useMemo(() => {
if (!enabled) return NO_RESOURCE_GROUPS
Expand Down Expand Up @@ -279,7 +299,7 @@ export function useAvailableResources(
},
{
type: 'integration' as const,
items: listIntegrationsByPopularity().map((integration) => ({
items: usableIntegrations.map((integration) => ({
id: integration.blockType,
name: integration.name,
iconComponent: integration.icon,
Expand Down Expand Up @@ -355,6 +375,7 @@ export function useAvailableResources(
knowledgeBases,
tasks,
logs,
usableIntegrations,
excludeTypes,
dashboardsEnabled,
testsEnabled,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,32 @@ describe('organization mention inventory', () => {
expect(result.groups[1].items).toEqual([{ id: 'slack', name: 'Slack' }])
expect(result.isHydrating).toBe(false)
})
it('offers an integration only when every workspace policy permits it', () => {
const withIntegrations = (ids: string[]) => ({
...inventory,
groups: [{ type: 'integration' as const, items: ids.map((id) => ({ id, name: id })) }],
})
const result = mergeOrganizationResourceInventories(
[
{ id: 'sales', name: 'Sales' },
{ id: 'finance', name: 'Finance' },
],
{ sales: withIntegrations(['slack', 'gmail_v2']), finance: withIntegrations(['gmail_v2']) }
)
expect(result.groups).toEqual([
{ type: 'integration', items: [{ id: 'gmail_v2', name: 'gmail_v2' }] },
])
})
it('offers no integration while a workspace policy has not arrived', () => {
const result = mergeOrganizationResourceInventories(
[
{ id: 'sales', name: 'Sales' },
{ id: 'finance', name: 'Finance' },
],
{ sales: inventory }
)
expect(result.groups.find((group) => group.type === 'integration')?.items ?? []).toEqual([])
})
it('keeps mention confirmation pending until every accessible workspace settles', () => {
expect(
mergeOrganizationResourceInventories([{ id: 'sales', name: 'Sales' }], {}).isHydrating
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,12 @@ export function mergeOrganizationResourceInventories(
const byType = new Map<string, AvailableResources['groups'][number]>()
const structureFolders: AvailableResources['structureFolders'] = { table: [], knowledgebase: [] }
let isHydrating = false
let isAnyInventoryMissing = false
for (const workspace of workspaces) {
const inventory = inventories[workspace.id]
if (!inventory) {
isHydrating = true
isAnyInventoryMissing = true
continue
}
isHydrating ||= inventory.isHydrating
Expand All @@ -51,8 +53,15 @@ export function mergeOrganizationResourceInventories(
})
for (const group of inventory.groups) {
if (group.type === 'browser' || group.type === 'terminal') continue
// Integration definitions are global and do not belong to a workspace.
if (group.type === 'integration' && byType.has(group.type)) continue
// Integrations are not workspace-owned, but a permission group can target
// individual workspaces, so one is offered only where every workspace permits it,
// and a workspace whose inventory has not arrived permits nothing yet.
const integrations = group.type === 'integration' ? byType.get(group.type) : undefined
Comment thread
waleedlatif1 marked this conversation as resolved.
if (integrations) {
const permittedHere = new Set(group.items.map((item) => item.id))
integrations.items = integrations.items.filter((item) => permittedHere.has(item.id))
continue
}
let target = byType.get(group.type)
if (!target) {
target = { type: group.type, items: [] }
Expand All @@ -64,5 +73,7 @@ export function mergeOrganizationResourceInventories(
structureFolders.table.push(...inventory.structureFolders.table.map(owned))
structureFolders.knowledgebase.push(...inventory.structureFolders.knowledgebase.map(owned))
}
const integrations = byType.get('integration')
if (integrations && isAnyInventoryMissing) integrations.items = []
return { groups, structureFolders, isHydrating }
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
'use client'

import { useEffect, useMemo, useRef, useState } from 'react'
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
import { Chip, ChipDropdown, ChipLink, cn } from '@sim/emcn'
import { ArrowLeft, Plus } from '@sim/emcn/icons'
import { useRouter } from 'next/navigation'
import { useQueryState } from 'nuqs'
import { EmptyState } from '@/components/empty-state/empty-state'
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
import {
Expand All @@ -13,9 +14,11 @@ import {
resolveCredentialDisplay,
resolveOAuthServiceForIntegration,
} from '@/lib/integrations'
import { isIntegrationPermitted } from '@/lib/integrations/access'
import { credentialProviderMatchesService } from '@/lib/oauth'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
import { RESOURCE_TILE_BASE } from '@/app/workspace/[workspaceId]/components/resource-tile'
import { IntegrationBlockDetailFallback } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail-fallback'
import { IntegrationSkillsSection } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section'
import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params'
import { ConnectPersonalTokenModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-personal-token-modal'
Expand All @@ -42,7 +45,10 @@ import {
getTemplatesForBlock,
type ScopedBlockTemplate,
} from '@/blocks/registry'
import { useWorkspaceAccessRequestFeatures } from '@/ee/access-requests/components/permission-access-boundary'
import { RequestAccessAction } from '@/ee/access-requests/components/request-access-action'
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
import { useIntegrationAccess } from '@/hooks/use-integration-access'
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
import { usePermissionConfig } from '@/hooks/use-permission-config'

Expand All @@ -60,7 +66,84 @@ interface IntegrationBlockDetailProps {
workspaceId: string
}

/**
* Integration detail page, shown only when the viewer's access policy permits
* the integration (an unconfigured one included, which the page presents as
* unavailable). A direct link to a restricted one renders an access-required
* state instead of the connect actions, and nothing renders until the policy
* has loaded.
*/
export function IntegrationBlockDetail({ integration, workspaceId }: IntegrationBlockDetailProps) {
const { getAccess, availabilityError, refetchAvailability } = useIntegrationAccess(workspaceId)
const accessRequests = useWorkspaceAccessRequestFeatures()
const access = getAccess(integration.type)

if (availabilityError) {
return (
<IntegrationAccessNotice
workspaceId={workspaceId}
title='Unable to check access'
description={availabilityError.message}
action={<Chip onClick={() => void refetchAvailability()}>Try again</Chip>}
/>
)
}
if (access === undefined) return <IntegrationBlockDetailFallback workspaceId={workspaceId} />
if (isIntegrationPermitted(access)) {
return <IntegrationBlockDetailContent integration={integration} workspaceId={workspaceId} />
}

const requestable = access === 'requestable' && accessRequests.data?.enabled === true

return (
<IntegrationAccessNotice
workspaceId={workspaceId}
title='Access required'
description={
requestable
? 'Ask an administrator for access.'
: 'Your organization restricts this integration.'
}
action={
requestable ? (
<RequestAccessAction
scope={{ kind: 'workspace', workspaceId }}
target={{ kind: 'integration', id: integration.type }}
label={integration.name}
variant='primary'
/>
) : undefined
}
/>
)
}

interface IntegrationAccessNoticeProps {
workspaceId: string
title: string
description: string
action?: ReactNode
}

function IntegrationAccessNotice({
workspaceId,
title,
description,
action,
}: IntegrationAccessNoticeProps) {
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className={PAGE_HEADER_BAR}>
<ChipLink href={`/workspace/${workspaceId}/integrations`} leftIcon={ArrowLeft}>
Integrations
</ChipLink>
</div>
<EmptyState title={title} description={description} action={action} />
</div>
)
}

function IntegrationBlockDetailContent({ integration, workspaceId }: IntegrationBlockDetailProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null)
useOAuthReturnRouter()
const router = useRouter()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,17 @@ export function IntegrationTile({ blockType, icon: Icon, framed = false }: Integ
)
}

interface IntegrationsShowcaseProps {
/** Tiles whose block type fails this check are left out of the grid. */
isTileVisible: (blockType: string) => boolean
}

/**
* Decorative integrations grid: a notched, masked grid background populated
* with a curated set of brand tiles. The bottom-right notch leaves room for an
* "Explore in chat" CTA rendered by `ShowcaseWithExplore`.
*/
export function IntegrationsShowcase() {
export function IntegrationsShowcase({ isTileVisible }: IntegrationsShowcaseProps) {
return (
<div aria-hidden className='relative h-[144px] w-full overflow-hidden rounded-xl'>
<div
Expand All @@ -110,7 +115,7 @@ export function IntegrationsShowcase() {
<div className='absolute inset-0 grid translate-x-[0.5px] translate-y-[0.5px] grid-cols-[repeat(auto-fill,48px)] grid-rows-[repeat(auto-fill,48px)]'>
{SHOWCASE_TILES.map((tile) => {
const block = getBlock(tile.id)
if (!block) return null
if (!block || !isTileVisible(tile.id)) return null
return (
<div
key={tile.id}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import { Chip } from '@sim/emcn'
import { ArrowRight } from '@sim/emcn/icons'
import { useParams, useRouter } from 'next/navigation'
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
import { isIntegrationUsable } from '@/lib/integrations/access'
import { IntegrationsShowcase } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import { storeCuratedPrompt } from '@/blocks/integration-matcher'
import { useIntegrationAccess } from '@/hooks/use-integration-access'

interface ShowcaseWithExploreProps {
/**
Expand All @@ -27,10 +29,13 @@ export function ShowcaseWithExplore({ prompt }: ShowcaseWithExploreProps) {
const router = useRouter()
const { chatEnabled } = useDeploymentShape()
const workspaceId = (params?.workspaceId as string) || ''
const { getAccess } = useIntegrationAccess(workspaceId)

return (
<div className='relative'>
<IntegrationsShowcase />
<IntegrationsShowcase
isTileVisible={(blockType) => isIntegrationUsable(getAccess(blockType))}
/>
{chatEnabled && (
<Chip
active
Expand Down
Loading
Loading