Skip to content

Commit ed05d9c

Browse files
committed
fix(integrations): hide integrations the permission group disallows
1 parent a0f47b1 commit ed05d9c

6 files changed

Lines changed: 123 additions & 25 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Chip, ChipDropdown, ChipLink, cn } from '@sim/emcn'
55
import { ArrowLeft, Plus } from '@sim/emcn/icons'
66
import { useRouter } from 'next/navigation'
77
import { useQueryState } from 'nuqs'
8+
import { EmptyState } from '@/components/empty-state/empty-state'
89
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
910
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
1011
import {
@@ -16,6 +17,7 @@ import {
1617
import { credentialProviderMatchesService } from '@/lib/oauth'
1718
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
1819
import { RESOURCE_TILE_BASE } from '@/app/workspace/[workspaceId]/components/resource-tile'
20+
import { IntegrationBlockDetailFallback } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail-fallback'
1921
import { IntegrationSkillsSection } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section'
2022
import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params'
2123
import { ConnectPersonalTokenModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-personal-token-modal'
@@ -42,6 +44,8 @@ import {
4244
getTemplatesForBlock,
4345
type ScopedBlockTemplate,
4446
} from '@/blocks/registry'
47+
import { useWorkspaceAccessRequestFeatures } from '@/ee/access-requests/components/permission-access-boundary'
48+
import { RequestAccessAction } from '@/ee/access-requests/components/request-access-action'
4549
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
4650
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
4751
import { usePermissionConfig } from '@/hooks/use-permission-config'
@@ -60,7 +64,52 @@ interface IntegrationBlockDetailProps {
6064
workspaceId: string
6165
}
6266

67+
/**
68+
* Integration detail page, shown only when the viewer's integration policy
69+
* permits the integration. A direct link to a restricted one renders an
70+
* access-required state instead of the connect actions.
71+
*/
6372
export function IntegrationBlockDetail({ integration, workspaceId }: IntegrationBlockDetailProps) {
73+
const { isLoading, isBlockPermitted, isBlockRequestable } = usePermissionConfig()
74+
const accessRequests = useWorkspaceAccessRequestFeatures()
75+
76+
if (isLoading) return <IntegrationBlockDetailFallback workspaceId={workspaceId} />
77+
if (isBlockPermitted(integration.type)) {
78+
return <IntegrationBlockDetailContent integration={integration} workspaceId={workspaceId} />
79+
}
80+
81+
const requestable = accessRequests.data?.enabled === true && isBlockRequestable(integration.type)
82+
83+
return (
84+
<div className='flex h-full flex-col bg-[var(--bg)]'>
85+
<div className={PAGE_HEADER_BAR}>
86+
<ChipLink href={`/workspace/${workspaceId}/integrations`} leftIcon={ArrowLeft}>
87+
Integrations
88+
</ChipLink>
89+
</div>
90+
<EmptyState
91+
title='Access required'
92+
description={
93+
requestable
94+
? 'Ask an administrator for access.'
95+
: 'Your organization restricts this integration.'
96+
}
97+
action={
98+
requestable ? (
99+
<RequestAccessAction
100+
scope={{ kind: 'workspace', workspaceId }}
101+
target={{ kind: 'integration', id: integration.type }}
102+
label={integration.name}
103+
variant='primary'
104+
/>
105+
) : undefined
106+
}
107+
/>
108+
</div>
109+
)
110+
}
111+
112+
function IntegrationBlockDetailContent({ integration, workspaceId }: IntegrationBlockDetailProps) {
64113
const scrollContainerRef = useRef<HTMLDivElement>(null)
65114
useOAuthReturnRouter()
66115
const router = useRouter()

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,17 @@ export function IntegrationTile({ blockType, icon: Icon, framed = false }: Integ
8383
)
8484
}
8585

86+
interface IntegrationsShowcaseProps {
87+
/** Tiles whose block type fails this check are left out of the grid. */
88+
isBlockPermitted: (blockType: string) => boolean
89+
}
90+
8691
/**
8792
* Decorative integrations grid: a notched, masked grid background populated
8893
* with a curated set of brand tiles. The bottom-right notch leaves room for an
8994
* "Explore in chat" CTA rendered by `ShowcaseWithExplore`.
9095
*/
91-
export function IntegrationsShowcase() {
96+
export function IntegrationsShowcase({ isBlockPermitted }: IntegrationsShowcaseProps) {
9297
return (
9398
<div aria-hidden className='relative h-[144px] w-full overflow-hidden rounded-xl'>
9499
<div
@@ -110,7 +115,7 @@ export function IntegrationsShowcase() {
110115
<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)]'>
111116
{SHOWCASE_TILES.map((tile) => {
112117
const block = getBlock(tile.id)
113-
if (!block) return null
118+
if (!block || !isBlockPermitted(tile.id)) return null
114119
return (
115120
<div
116121
key={tile.id}

‎apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useParams, useRouter } from 'next/navigation'
66
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
77
import { IntegrationsShowcase } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
88
import { storeCuratedPrompt } from '@/blocks/integration-matcher'
9+
import { usePermissionConfig } from '@/hooks/use-permission-config'
910

1011
interface ShowcaseWithExploreProps {
1112
/**
@@ -26,11 +27,12 @@ export function ShowcaseWithExplore({ prompt }: ShowcaseWithExploreProps) {
2627
const params = useParams()
2728
const router = useRouter()
2829
const { chatEnabled } = useDeploymentShape()
30+
const { isBlockPermitted } = usePermissionConfig()
2931
const workspaceId = (params?.workspaceId as string) || ''
3032

3133
return (
3234
<div className='relative'>
33-
<IntegrationsShowcase />
35+
<IntegrationsShowcase isBlockPermitted={isBlockPermitted} />
3436
{chatEnabled && (
3537
<Chip
3638
active

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

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ function IntegrationsContent() {
150150
const scrollContainerRef = useRef<HTMLDivElement>(null)
151151
const params = useParams()
152152
const workspaceId = (params?.workspaceId as string) || ''
153-
const { integrationAvailability } = usePermissionConfig()
153+
const { integrationAvailability, isBlockPermitted } = usePermissionConfig()
154154

155155
const [{ category: selectedCategory, search: urlSearchTerm }, setIntegrationFilters] =
156156
useQueryStates(integrationsParsers, integrationsUrlKeys)
@@ -205,6 +205,22 @@ function IntegrationsContent() {
205205
})
206206
}, [oauthCredentials])
207207

208+
const permittedFeatured = useMemo(
209+
() => FEATURED_INTEGRATIONS.filter((integration) => isBlockPermitted(integration.type)),
210+
[isBlockPermitted]
211+
)
212+
213+
const permittedCategorySections = useMemo(
214+
() =>
215+
ALL_CATEGORY_SECTIONS.flatMap((section) => {
216+
const integrations = section.integrations.filter((integration) =>
217+
isBlockPermitted(integration.type)
218+
)
219+
return integrations.length > 0 ? [{ label: section.label, integrations }] : []
220+
}),
221+
[isBlockPermitted]
222+
)
223+
208224
const setSelectedCategory = useCallback(
209225
(category: string) => {
210226
setIntegrationFilters({ category })
@@ -215,8 +231,8 @@ function IntegrationsContent() {
215231
const categoryOptions = [
216232
ALL_CATEGORY,
217233
...(connectedItems.length > 0 ? [CONNECTED_LABEL] : []),
218-
FEATURED_LABEL,
219-
...ALL_CATEGORY_SECTIONS.map((section) => section.label),
234+
...(permittedFeatured.length > 0 ? [FEATURED_LABEL] : []),
235+
...permittedCategorySections.map((section) => section.label),
220236
]
221237

222238
const isAllCategorySelected = selectedCategory === ALL_CATEGORY
@@ -234,7 +250,7 @@ function IntegrationsContent() {
234250
integration.description.toLowerCase().includes(normalizedSearch)
235251

236252
if (isFeaturedSelected) {
237-
const items = FEATURED_INTEGRATIONS.filter(matchesSearch)
253+
const items = permittedFeatured.filter(matchesSearch)
238254
return items.length > 0 ? [{ label: FEATURED_LABEL, integrations: items }] : []
239255
}
240256

@@ -243,27 +259,22 @@ function IntegrationsContent() {
243259

244260
// Featured is a curated home-row pin: hide it during search so results
245261
// are not duplicated between the Featured section and the category list.
246-
const featured = normalizedSearch ? [] : FEATURED_INTEGRATIONS.filter(matchesCategory)
262+
const featured = normalizedSearch ? [] : permittedFeatured.filter(matchesCategory)
247263
const featuredSection =
248264
featured.length > 0 ? [{ label: FEATURED_LABEL, integrations: featured }] : []
249265

250-
if (isAllCategorySelected) {
251-
const rest = ALL_CATEGORY_SECTIONS.map((section) => ({
266+
const categorySections = permittedCategorySections
267+
.filter((section) => isAllCategorySelected || section.label === selectedCategory)
268+
.map((section) => ({
252269
label: section.label,
253270
integrations: section.integrations.filter(matchesSearch),
254-
})).filter((section) => section.integrations.length > 0)
255-
return [...featuredSection, ...rest]
256-
}
257-
258-
const integrations = INTEGRATIONS.filter(matchesCategory)
259-
.filter(matchesSearch)
260-
.sort((a, b) => a.name.localeCompare(b.name))
271+
}))
272+
.filter((section) => section.integrations.length > 0)
261273

262-
return [
263-
...featuredSection,
264-
...(integrations.length > 0 ? [{ label: selectedCategory, integrations }] : []),
265-
]
274+
return [...featuredSection, ...categorySections]
266275
}, [
276+
permittedFeatured,
277+
permittedCategorySections,
267278
isAllCategorySelected,
268279
isConnectedSelected,
269280
isFeaturedSelected,

‎apps/sim/hooks/use-permission-config.test.tsx‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,21 @@ describe('usePermissionConfig deployment readiness', () => {
101101
expect(current!.isBlockRequestable('credential_group')).toBe(false)
102102
})
103103

104+
it('judges policy permission independently of deployment OAuth readiness', () => {
105+
mockUseUserPermissionConfig.mockReturnValue({
106+
data: { config: { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedIntegrations: ['airtable'] } },
107+
isLoading: false,
108+
})
109+
queryClient.setQueryData(integrationAvailabilityKeys.environments(), {
110+
...AVAILABILITY,
111+
integrationAvailability: [{ type: 'airtable', state: 'unavailable', oauthAvailable: false }],
112+
})
113+
render()
114+
expect(current!.isBlockAllowed('airtable')).toBe(false)
115+
expect(current!.isBlockPermitted('airtable')).toBe(true)
116+
expect(current!.isBlockPermitted('slack')).toBe(false)
117+
})
118+
104119
it('does not offer a request before deployment availability resolves', () => {
105120
mockUseUserPermissionConfig.mockReturnValue({
106121
data: { config: { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedIntegrations: [] } },

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

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ export interface PermissionConfigResult {
3535
filterBlocks: <T extends { type: string }>(blocks: T[]) => T[]
3636
filterProviders: (providerIds: string[]) => string[]
3737
isBlockAllowed: (blockType: string) => boolean
38+
/**
39+
* Whether the integration policy (permission group allowlist intersected with
40+
* the deployment's `ALLOWED_INTEGRATIONS`) permits a block, regardless of
41+
* whether this deployment has the block's OAuth client configured. Surfaces
42+
* that show an unconfigured integration as unavailable, rather than hiding
43+
* it, filter by this instead of {@link isBlockAllowed}.
44+
*/
45+
isBlockPermitted: (blockType: string) => boolean
3846
/** Presentation-only hint; request creation revalidates the deployed public catalog. */
3947
isBlockRequestable: (blockType: string) => boolean
4048
/**
@@ -138,6 +146,14 @@ export function usePermissionConfig(): PermissionConfigResult {
138146
[envAllowlistData?.oauthServiceAvailability]
139147
)
140148

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])
156+
141157
const isBlockAllowed = useMemo(() => {
142158
return (blockType: string) => {
143159
const normalizedBlockType = blockType.toLowerCase()
@@ -152,11 +168,9 @@ export function usePermissionConfig(): PermissionConfigResult {
152168
) {
153169
return false
154170
}
155-
if (isBlockTypeAccessControlExempt(blockType)) return true
156-
if (allowedAccessControlTypes === null) return true
157-
return allowedAccessControlTypes.has(resolveAccessControlBlockType(normalizedBlockType))
171+
return isBlockPermitted(blockType)
158172
}
159-
}, [hostContext?.features?.credentialGroups, integrationAvailability, allowedAccessControlTypes])
173+
}, [hostContext?.features?.credentialGroups, integrationAvailability, isBlockPermitted])
160174

161175
const isBlockRequestable = useMemo(() => {
162176
const deploymentAllowlist = intersectAccessControlAllowlists(
@@ -241,6 +255,7 @@ export function usePermissionConfig(): PermissionConfigResult {
241255
filterBlocks,
242256
filterProviders,
243257
isBlockAllowed,
258+
isBlockPermitted,
244259
isBlockRequestable,
245260
isModelUsable,
246261
isToolAllowed,
@@ -264,6 +279,7 @@ export function usePermissionConfig(): PermissionConfigResult {
264279
filterBlocks,
265280
filterProviders,
266281
isBlockAllowed,
282+
isBlockPermitted,
267283
isBlockRequestable,
268284
isModelUsable,
269285
isToolAllowed,

0 commit comments

Comments
 (0)