Skip to content

Commit 951bf66

Browse files
committed
feat(integrations): offer requestable integrations in the catalog and gate @ mentions by policy
1 parent 379bd5a commit 951bf66

6 files changed

Lines changed: 149 additions & 44 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources.ts‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { useMemo, useSyncExternalStore } from 'react'
22
import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport'
33
import { subscribeDesktopPreferences } from '@/lib/desktop'
4+
import { isBlockTypePermittedByAllowlist } from '@/lib/permission-groups/block-access'
5+
import { intersectAccessControlAllowlists } from '@/lib/permission-groups/integration-allowlist'
46
import { isTerminalAvailable } from '@/lib/terminal/transport'
57
import type { AvailableItem } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree'
68
import {
@@ -10,9 +12,14 @@ import {
1012
import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types'
1113
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
1214
import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
13-
import { listIntegrationsByPopularity } from '@/blocks/integration-matcher'
15+
import {
16+
type IntegrationDescriptor,
17+
listIntegrationsByPopularity,
18+
} from '@/blocks/integration-matcher'
19+
import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups'
1420
import { useWorkspaceDashboard } from '@/hooks/queries/dashboards'
1521
import { useFolders } from '@/hooks/queries/folders'
22+
import { useIntegrationAvailability } from '@/hooks/queries/integration-availability'
1623
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
1724
import { useLogsList } from '@/hooks/queries/logs'
1825
import { useMothershipChats } from '@/hooks/queries/mothership-chats'
@@ -93,6 +100,7 @@ interface UseAvailableResourcesOptions {
93100

94101
/** Stable identity for the disabled result, so downstream memos never bust. */
95102
const NO_RESOURCE_GROUPS: AvailableItemsByType[] = []
103+
const NO_INTEGRATIONS: readonly IntegrationDescriptor[] = []
96104

97105
const LOG_DROPDOWN_LIMIT = 50
98106

@@ -178,6 +186,29 @@ export function useAvailableResources(
178186
const { data: tasks, isPending: tasksPending } = useMothershipChats(workspaceId, {
179187
enabled: enabled && Boolean(workspaceId),
180188
})
189+
const integrationsIncluded = !excludeTypes?.includes('integration')
190+
const { data: permissionConfig, isPending: permissionConfigPending } = useUserPermissionConfig(
191+
enabled && integrationsIncluded && workspaceId ? workspaceId : undefined
192+
)
193+
const { data: integrationAvailability, isPending: integrationAvailabilityPending } =
194+
useIntegrationAvailability()
195+
196+
/**
197+
* Integrations this workspace's policy permits: the viewer's permission group
198+
* intersected with the deployment allowlist. Fails closed — nothing is offered
199+
* until both have loaded — because the server drops a mention of an
200+
* integration either policy disallows.
201+
*/
202+
const permittedIntegrations = useMemo(() => {
203+
if (!permissionConfig || !integrationAvailability) return NO_INTEGRATIONS
204+
const allowlist = intersectAccessControlAllowlists(
205+
permissionConfig.config?.allowedIntegrations ?? null,
206+
integrationAvailability.allowedIntegrations
207+
)
208+
return listIntegrationsByPopularity().filter((integration) =>
209+
isBlockTypePermittedByAllowlist(allowlist, integration.blockType)
210+
)
211+
}, [permissionConfig, integrationAvailability])
181212
const { data: logsData, isPending: logsPending } = useLogsList(
182213
workspaceId,
183214
LOG_DROPDOWN_FILTERS,
@@ -208,7 +239,8 @@ export function useAvailableResources(
208239
(!excludeTypes?.includes('knowledgebase') && knowledgeBaseFoldersPending))) ||
209240
fileFoldersPending ||
210241
tasksPending ||
211-
logsPending)
242+
logsPending ||
243+
(integrationsIncluded && (permissionConfigPending || integrationAvailabilityPending)))
212244

213245
const groups = useMemo(() => {
214246
if (!enabled) return NO_RESOURCE_GROUPS
@@ -279,7 +311,7 @@ export function useAvailableResources(
279311
},
280312
{
281313
type: 'integration' as const,
282-
items: listIntegrationsByPopularity().map((integration) => ({
314+
items: permittedIntegrations.map((integration) => ({
283315
id: integration.blockType,
284316
name: integration.name,
285317
iconComponent: integration.icon,
@@ -355,6 +387,7 @@ export function useAvailableResources(
355387
knowledgeBases,
356388
tasks,
357389
logs,
390+
permittedIntegrations,
358391
excludeTypes,
359392
dashboardsEnabled,
360393
testsEnabled,

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/organization-resource-inventory.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,22 @@ describe('organization mention inventory', () => {
3333
expect(result.groups[1].items).toEqual([{ id: 'slack', name: 'Slack' }])
3434
expect(result.isHydrating).toBe(false)
3535
})
36+
it('offers an integration only when every workspace policy permits it', () => {
37+
const withIntegrations = (ids: string[]) => ({
38+
...inventory,
39+
groups: [{ type: 'integration' as const, items: ids.map((id) => ({ id, name: id })) }],
40+
})
41+
const result = mergeOrganizationResourceInventories(
42+
[
43+
{ id: 'sales', name: 'Sales' },
44+
{ id: 'finance', name: 'Finance' },
45+
],
46+
{ sales: withIntegrations(['slack', 'gmail_v2']), finance: withIntegrations(['gmail_v2']) }
47+
)
48+
expect(result.groups).toEqual([
49+
{ type: 'integration', items: [{ id: 'gmail_v2', name: 'gmail_v2' }] },
50+
])
51+
})
3652
it('keeps mention confirmation pending until every accessible workspace settles', () => {
3753
expect(
3854
mergeOrganizationResourceInventories([{ id: 'sales', name: 'Sales' }], {}).isHydrating

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/organization-resource-inventory.tsx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,14 @@ export function mergeOrganizationResourceInventories(
5151
})
5252
for (const group of inventory.groups) {
5353
if (group.type === 'browser' || group.type === 'terminal') continue
54-
// Integration definitions are global and do not belong to a workspace.
55-
if (group.type === 'integration' && byType.has(group.type)) continue
54+
// Integrations are not workspace-owned, but a permission group can target
55+
// individual workspaces, so one is offered only where every workspace permits it.
56+
const integrations = group.type === 'integration' ? byType.get(group.type) : undefined
57+
if (integrations) {
58+
const permittedHere = new Set(group.items.map((item) => item.id))
59+
integrations.items = integrations.items.filter((item) => permittedHere.has(item.id))
60+
continue
61+
}
5662
let target = byType.get(group.type)
5763
if (!target) {
5864
target = { type: group.type, items: [] }

‎apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx‎

Lines changed: 67 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { type ComponentType, useCallback, useMemo, useRef } from 'react'
3+
import { type ComponentType, useCallback, useMemo, useRef, useState } from 'react'
44
import {
55
ChevronDown,
66
ChipInput,
@@ -11,6 +11,7 @@ import {
1111
DropdownMenuTrigger,
1212
Search,
1313
} from '@sim/emcn'
14+
import { Lock } from '@sim/emcn/icons'
1415
import { useParams } from 'next/navigation'
1516
import { useQueryStates } from 'nuqs'
1617
import {
@@ -37,7 +38,11 @@ import {
3738
SettingsQueryErrorState,
3839
} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
3940
import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
40-
import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary'
41+
import {
42+
PermissionAccessBoundary,
43+
useWorkspaceAccessRequestFeatures,
44+
} from '@/ee/access-requests/components/permission-access-boundary'
45+
import { RequestAccessModal } from '@/ee/access-requests/components/request-access-action'
4146
import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials'
4247
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
4348
import { usePermissionConfig } from '@/hooks/use-permission-config'
@@ -74,6 +79,8 @@ interface IntegrationItemProps {
7479
description?: string | null
7580
icon: ComponentType<{ className?: string }>
7681
unavailable?: boolean
82+
/** Set for an integration the viewer's policy restricts but lets them request. */
83+
onRequestAccess?: () => void
7784
}
7885

7986
function IntegrationItem({
@@ -84,7 +91,9 @@ function IntegrationItem({
8491
description,
8592
icon: Icon,
8693
unavailable = false,
94+
onRequestAccess,
8795
}: IntegrationItemProps) {
96+
const restricted = onRequestAccess !== undefined
8897
return (
8998
<SettingsResourceRow
9099
iconVariant='custom'
@@ -95,9 +104,15 @@ function IntegrationItem({
95104
? 'Unavailable in this deployment. Contact your administrator.'
96105
: description || undefined
97106
}
98-
href={`/workspace/${workspaceId}/integrations/${slug}`}
99-
clickLabel={`Open ${name}`}
100-
navigable={!unavailable}
107+
badge={
108+
restricted ? (
109+
<Lock className='size-[14px] text-[var(--text-icon)]' aria-hidden />
110+
) : undefined
111+
}
112+
href={restricted ? undefined : `/workspace/${workspaceId}/integrations/${slug}`}
113+
onClick={onRequestAccess}
114+
clickLabel={restricted ? `Request access to ${name}` : `Open ${name}`}
115+
navigable={!unavailable && !restricted}
101116
disabled={unavailable}
102117
/>
103118
)
@@ -156,11 +171,14 @@ function IntegrationsContent() {
156171
const {
157172
integrationAvailability,
158173
isBlockPermitted,
174+
isBlockRequestable,
159175
isIntegrationAvailabilityReady,
160176
isIntegrationAvailabilityFetching,
161177
integrationAvailabilityError,
162178
refetchIntegrationAvailability,
163179
} = usePermissionConfig()
180+
const accessRequestsEnabled = useWorkspaceAccessRequestFeatures().data?.enabled === true
181+
const [requestedIntegration, setRequestedIntegration] = useState<Integration | null>(null)
164182

165183
const [{ category: urlCategory, search: urlSearchTerm }, setIntegrationFilters] = useQueryStates(
166184
integrationsParsers,
@@ -218,29 +236,33 @@ function IntegrationsContent() {
218236
}, [oauthCredentials])
219237

220238
/**
221-
* The catalog lists nothing until the deployment allowlist has loaded, so a
222-
* pending or failed availability request never shows an integration the
223-
* deployment's `ALLOWED_INTEGRATIONS` excludes.
239+
* The catalog lists what the viewer's integration policy permits, plus — when
240+
* access requests are on — what it restricts but lets them request, which
241+
* renders as a locked row. It lists nothing until the deployment allowlist has
242+
* loaded, so a pending or failed availability request never shows an
243+
* integration the deployment's `ALLOWED_INTEGRATIONS` excludes.
224244
*/
225-
const permittedFeatured = useMemo(
226-
() =>
227-
isIntegrationAvailabilityReady
228-
? FEATURED_INTEGRATIONS.filter((integration) => isBlockPermitted(integration.type))
229-
: [],
230-
[isIntegrationAvailabilityReady, isBlockPermitted]
245+
const isListed = useMemo(
246+
() => (blockType: string) =>
247+
isIntegrationAvailabilityReady &&
248+
(isBlockPermitted(blockType) || (accessRequestsEnabled && isBlockRequestable(blockType))),
249+
[isIntegrationAvailabilityReady, isBlockPermitted, accessRequestsEnabled, isBlockRequestable]
250+
)
251+
252+
const listedFeatured = useMemo(
253+
() => FEATURED_INTEGRATIONS.filter((integration) => isListed(integration.type)),
254+
[isListed]
231255
)
232256

233-
const permittedCategorySections = useMemo(
257+
const listedCategorySections = useMemo(
234258
() =>
235-
isIntegrationAvailabilityReady
236-
? ALL_CATEGORY_SECTIONS.flatMap((section) => {
237-
const integrations = section.integrations.filter((integration) =>
238-
isBlockPermitted(integration.type)
239-
)
240-
return integrations.length > 0 ? [{ label: section.label, integrations }] : []
241-
})
242-
: [],
243-
[isIntegrationAvailabilityReady, isBlockPermitted]
259+
ALL_CATEGORY_SECTIONS.flatMap((section) => {
260+
const integrations = section.integrations.filter((integration) =>
261+
isListed(integration.type)
262+
)
263+
return integrations.length > 0 ? [{ label: section.label, integrations }] : []
264+
}),
265+
[isListed]
244266
)
245267

246268
const setSelectedCategory = useCallback(
@@ -253,8 +275,8 @@ function IntegrationsContent() {
253275
const categoryOptions = [
254276
ALL_CATEGORY,
255277
...(connectedItems.length > 0 ? [CONNECTED_LABEL] : []),
256-
...(permittedFeatured.length > 0 ? [FEATURED_LABEL] : []),
257-
...permittedCategorySections.map((section) => section.label),
278+
...(listedFeatured.length > 0 ? [FEATURED_LABEL] : []),
279+
...listedCategorySections.map((section) => section.label),
258280
]
259281

260282
/**
@@ -282,7 +304,7 @@ function IntegrationsContent() {
282304
integration.description.toLowerCase().includes(normalizedSearch)
283305

284306
if (isFeaturedSelected) {
285-
const items = permittedFeatured.filter(matchesSearch)
307+
const items = listedFeatured.filter(matchesSearch)
286308
return items.length > 0 ? [{ label: FEATURED_LABEL, integrations: items }] : []
287309
}
288310

@@ -291,11 +313,11 @@ function IntegrationsContent() {
291313

292314
// Featured is a curated home-row pin: hide it during search so results
293315
// are not duplicated between the Featured section and the category list.
294-
const featured = normalizedSearch ? [] : permittedFeatured.filter(matchesCategory)
316+
const featured = normalizedSearch ? [] : listedFeatured.filter(matchesCategory)
295317
const featuredSection =
296318
featured.length > 0 ? [{ label: FEATURED_LABEL, integrations: featured }] : []
297319

298-
const categorySections = permittedCategorySections
320+
const categorySections = listedCategorySections
299321
.filter((section) => isAllCategorySelected || section.label === selectedCategory)
300322
.map((section) => ({
301323
label: section.label,
@@ -305,8 +327,8 @@ function IntegrationsContent() {
305327

306328
return [...featuredSection, ...categorySections]
307329
}, [
308-
permittedFeatured,
309-
permittedCategorySections,
330+
listedFeatured,
331+
listedCategorySections,
310332
isAllCategorySelected,
311333
isConnectedSelected,
312334
isFeaturedSelected,
@@ -349,6 +371,15 @@ function IntegrationsContent() {
349371
return (
350372
<div className='flex h-full flex-col bg-[var(--bg)]'>
351373
<IntegrationTabsHeader active='integrations' workspaceId={workspaceId} />
374+
{requestedIntegration && accessRequestsEnabled && (
375+
<RequestAccessModal
376+
key={requestedIntegration.type}
377+
scope={{ kind: 'workspace', workspaceId }}
378+
target={{ kind: 'integration', id: requestedIntegration.type }}
379+
label={requestedIntegration.name}
380+
onClose={() => setRequestedIntegration(null)}
381+
/>
382+
)}
352383
<div
353384
ref={scrollContainerRef}
354385
className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'
@@ -429,6 +460,11 @@ function IntegrationsContent() {
429460
description={integration.description}
430461
icon={Icon}
431462
unavailable={integration.authType === 'oauth' && deploymentUnavailable}
463+
onRequestAccess={
464+
isBlockPermitted(integration.type)
465+
? undefined
466+
: () => setRequestedIntegration(integration)
467+
}
432468
/>
433469
)
434470
})}

‎apps/sim/hooks/use-permission-config.ts‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import {
88
isDeploymentGatedIntegrationType,
99
resolveIntegrationAvailabilityStateForVisibility,
1010
} from '@/lib/integrations/availability'
11-
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
11+
import {
12+
isBlockTypeAccessControlExempt,
13+
isBlockTypePermittedByAllowlist,
14+
} from '@/lib/permission-groups/block-access'
1215
import {
1316
DEFAULT_PERMISSION_GROUP_CONFIG,
1417
type PermissionGroupConfig,
@@ -146,13 +149,11 @@ export function usePermissionConfig(): PermissionConfigResult {
146149
[envAllowlistData?.oauthServiceAvailability]
147150
)
148151

149-
const isBlockPermitted = useMemo(() => {
150-
return (blockType: string) => {
151-
if (isBlockTypeAccessControlExempt(blockType)) return true
152-
if (allowedAccessControlTypes === null) return true
153-
return allowedAccessControlTypes.has(resolveAccessControlBlockType(blockType.toLowerCase()))
154-
}
155-
}, [allowedAccessControlTypes])
152+
const isBlockPermitted = useMemo(
153+
() => (blockType: string) =>
154+
isBlockTypePermittedByAllowlist(allowedAccessControlTypes, blockType),
155+
[allowedAccessControlTypes]
156+
)
156157

157158
const isBlockAllowed = useMemo(() => {
158159
return (blockType: string) => {

‎apps/sim/lib/permission-groups/block-access.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,19 @@ export function isBlockTypeAccessControlExempt(blockType: string): boolean {
4343
return successor === blockType || successor === UNIVERSAL_ENTRY_POINT
4444
}
4545

46+
/**
47+
* Whether a resolved integration allowlist permits `blockType`. The allowlist is
48+
* in the successor-resolved vocabulary `toAccessControlAllowlist` and
49+
* `intersectAccessControlAllowlists` produce; `null` is unrestricted.
50+
*/
51+
export function isBlockTypePermittedByAllowlist(
52+
allowlist: ReadonlySet<string> | null,
53+
blockType: string
54+
): boolean {
55+
if (allowlist === null || isBlockTypeAccessControlExempt(blockType)) return true
56+
return allowlist.has(resolveAccessControlBlockType(blockType.toLowerCase()))
57+
}
58+
4659
/**
4760
* Whether `blockType` is a row in the Access Control editor's allowlist
4861
* universe — the set the editor materializes an allowlist from and compares

0 commit comments

Comments
 (0)