Skip to content

Commit 9bcb8fd

Browse files
committed
feat(search): enforce organization integration approvals
Persist admin approval independently of integration setup, wire the existing settings controls, and allow members to connect approved integrations. Enforce approval through shared search access checks and preserve indexed data for reapproval. Fix organization scope handling in member sync and connector sync analytics. Include migration and coverage for approval authorization, member setup, and PostgreSQL access predicates.
1 parent cedf551 commit 9bcb8fd

36 files changed

Lines changed: 26315 additions & 246 deletions
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import {
2+
listSearchIntegrationsContract,
3+
updateSearchIntegrationContract,
4+
} from '@/lib/api/contracts/knowledge/search-integrations'
5+
import {
6+
defineInternalJsonRoute,
7+
internalRateLimits,
8+
internalSessionAuth,
9+
} from '@/lib/api/server/routes'
10+
import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies'
11+
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
12+
import {
13+
approveSearchIntegration,
14+
listSearchIntegrations,
15+
} from '@/lib/knowledge/application/search-integrations'
16+
17+
export const GET = defineInternalJsonRoute({
18+
contract: listSearchIntegrationsContract,
19+
auth: internalSessionAuth,
20+
operation: knowledgeOperations.listSearchIntegrations,
21+
rateLimit: internalRateLimits.user({ bucketName: 'knowledge.search.integrations.list' }),
22+
errorPolicy: internalKnowledgeErrorPolicies.connectors,
23+
mapInput: ({ query }) => query,
24+
useCase: listSearchIntegrations,
25+
present: (data) => ({ success: true as const, data }),
26+
})
27+
28+
export const PUT = defineInternalJsonRoute({
29+
contract: updateSearchIntegrationContract,
30+
auth: internalSessionAuth,
31+
operation: knowledgeOperations.approveSearchIntegration,
32+
rateLimit: internalRateLimits.user({ bucketName: 'knowledge.search.integrations.approve' }),
33+
errorPolicy: internalKnowledgeErrorPolicies.connectors,
34+
mapInput: ({ body }) => body,
35+
useCase: approveSearchIntegration,
36+
present: ({ connectorType, approved }) => ({
37+
success: true as const,
38+
data: { connectorType, approved },
39+
}),
40+
})

apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,22 @@ import type { SearchSourceSummary } from '@/lib/api/contracts/knowledge/connecto
77
const mocks = vi.hoisted(() => ({
88
context: vi.fn(),
99
sources: vi.fn(),
10+
integrations: vi.fn(),
1011
filters: vi.fn(),
1112
setSource: vi.fn(),
1213
connect: vi.fn(),
1314
}))
1415

16+
vi.mock('@/hooks/queries/search-integrations', () => ({
17+
useSearchIntegrations: mocks.integrations,
18+
}))
19+
vi.mock('@/hooks/use-permission-config', () => ({
20+
usePermissionConfig: () => ({
21+
integrationAvailability: new Map(),
22+
oauthServiceAvailability: new Map([['google-email', true]]),
23+
isIntegrationAvailabilityReady: true,
24+
}),
25+
}))
1526
vi.mock('nuqs', () => ({
1627
useQueryState: () => [null, mocks.setSource],
1728
parseAsString: { withOptions: () => ({}) },
@@ -45,6 +56,7 @@ vi.mock('@/hooks/use-member-enrollment', () => ({
4556
CONNECTABLE_MEMBERSHIPS: new Set(['invited', 'not_enrolled', 'needs_reauth']),
4657
useMemberEnrollment: () => ({
4758
connect: mocks.connect,
59+
connectSearchSource: mocks.connect,
4860
isAwaiting: () => false,
4961
isPending: false,
5062
error: null,
@@ -97,6 +109,7 @@ describe('organization integrations role and source paths', () => {
97109
viewer: { isAdmin: false },
98110
searchAccess: { memberScoped: true, sourceMirrored: true },
99111
})
112+
mocks.integrations.mockReturnValue({ data: [], isPending: false })
100113
mocks.sources.mockReturnValue({ data: [memberSource, centralSource], isPending: false })
101114
mocks.filters.mockReturnValue({ tab: null, search: '', setSearch: vi.fn() })
102115
container = document.createElement('div')
@@ -131,6 +144,31 @@ describe('organization integrations role and source paths', () => {
131144
expect(mocks.connect).toHaveBeenCalledExactlyOnceWith('search-index', 'member-source')
132145
})
133146

147+
it('offers an approved integration before any source is configured', async () => {
148+
mocks.sources.mockReturnValue({ data: [], isPending: false })
149+
mocks.integrations.mockReturnValue({
150+
data: [{ connectorType: 'gmail', approved: true }],
151+
isPending: false,
152+
})
153+
await render()
154+
expect(document.body.textContent).toContain('Approved')
155+
expect(buttons('Connect account')).toHaveLength(1)
156+
await act(async () => buttons('Connect account')[0].click())
157+
expect(mocks.connect).toHaveBeenCalledWith(
158+
scope,
159+
expect.objectContaining({ type: 'gmail' }),
160+
undefined
161+
)
162+
})
163+
it('withholds connection when an integration is deactivated', async () => {
164+
mocks.sources.mockReturnValue({
165+
data: [{ ...memberSource, approved: false }],
166+
isPending: false,
167+
})
168+
await render()
169+
expect(buttons('Connect account')).toHaveLength(0)
170+
expect(document.body.textContent).toContain('Deactivated by an organization admin')
171+
})
134172
it('shows an organization admin exactly what a member sees, with no setup or management', async () => {
135173
mocks.context.mockReturnValue({
136174
organization: { id: scope.organizationId },

apps/sim/app/o/[organizationId]/integrations/integrations.tsx

Lines changed: 105 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,33 @@
11
'use client'
22

33
import { useMemo } from 'react'
4+
import { Chip } from '@sim/emcn'
45
import type { ResourceScope } from '@/lib/core/resource-scope'
5-
import { connectorDisplayName } from '@/lib/sim-search/connectors'
6+
import {
7+
connectorDisplayName,
8+
getConnectorAccessAvailability,
9+
SEARCH_CONNECTORS,
10+
SEARCH_SOURCE_TYPES,
11+
} from '@/lib/sim-search/connectors'
612
import { OrganizationPage } from '@/app/o/[organizationId]/components/organization-page'
713
import { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page/use-organization-page-filters'
814
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
15+
import { SourceSetupModal } from '@/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal'
16+
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
917
import { SearchSourceRow } from '@/app/workspace/[workspaceId]/search/components/search-source-row'
1018
import {
1119
SettingsEmptyState,
1220
SettingsQueryErrorState,
1321
} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
14-
import { RESOURCE_LIST_STACK } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
22+
import {
23+
RESOURCE_LIST_STACK,
24+
SettingsResourceRow,
25+
} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
1526
import { searchSourceKeys, useSearchSources } from '@/hooks/queries/kb/connectors'
27+
import { useSearchIntegrations } from '@/hooks/queries/search-integrations'
1628
import { useMemberEnrollment } from '@/hooks/use-member-enrollment'
1729
import { useDesktopOAuthConnectListener, useOAuthReturnRouter } from '@/hooks/use-oauth-return'
30+
import { usePermissionConfig } from '@/hooks/use-permission-config'
1831

1932
/** Every source the organization searches, or only the ones the viewer has connected. */
2033
const TABS = [
@@ -33,6 +46,8 @@ export function OrganizationIntegrations() {
3346
const { organization, searchAccess } = useOrganizationContext()
3447
const scope: ResourceScope = { kind: 'organization', organizationId: organization.id }
3548
const sources = useSearchSources(scope)
49+
const integrations = useSearchIntegrations(organization.id)
50+
const availability = usePermissionConfig()
3651
const { tab, search } = useOrganizationPageFilters()
3752
const membershipQueryKeys = useMemo(
3853
() => [searchSourceKeys.list({ kind: 'organization', organizationId: organization.id })],
@@ -59,40 +74,96 @@ export function OrganizationIntegrations() {
5974
.includes(query)
6075
) ?? []
6176

77+
const approvedTypes = new Set(
78+
integrations.data
79+
?.filter((integration) => integration.approved)
80+
.map((integration) => integration.connectorType)
81+
)
82+
const configuredTypes = new Set(sources.data?.map((source) => source.connectorType))
83+
const unconfigured = mineOnly
84+
? []
85+
: SEARCH_SOURCE_TYPES.filter(
86+
([type, meta]) =>
87+
approvedTypes.has(type) &&
88+
!configuredTypes.has(type) &&
89+
meta.name.toLowerCase().includes(query)
90+
)
91+
const failedQuery = sources.isError ? sources : integrations.isError ? integrations : null
92+
6293
return (
6394
<OrganizationPage
6495
title='Integrations'
6596
description='Connect your tools for Sim Search'
6697
tabs={TABS}
6798
>
6899
<div className={RESOURCE_LIST_STACK}>
69-
{sources.isError ? (
100+
{failedQuery ? (
70101
<SettingsQueryErrorState
71-
error={sources.error}
102+
error={failedQuery.error}
72103
fallback='Could not load sources'
73-
isRetrying={sources.isFetching}
74-
onRetry={() => void sources.refetch()}
104+
isRetrying={failedQuery.isFetching}
105+
onRetry={() => void failedQuery.refetch()}
75106
variant='inline'
76107
/>
77-
) : visibleSources.length > 0 ? (
78-
visibleSources.map((source) => (
79-
<SearchSourceRow
80-
key={source.connectorId}
81-
source={source}
82-
scope={scope}
83-
canAdmin={false}
84-
available={
85-
source.accessMode === 'members'
86-
? searchAccess.memberScoped
87-
: searchAccess.sourceMirrored &&
88-
(!source.connectionRequired || searchAccess.memberScoped)
89-
}
90-
waiting={enrollment.isAwaiting(source.connectorId)}
91-
isPending={enrollment.isPending}
92-
onConnect={() => enrollment.connect(source.knowledgeBaseId, source.connectorId)}
93-
/>
94-
))
95-
) : sources.isPending ? null : (
108+
) : visibleSources.length > 0 || unconfigured.length > 0 ? (
109+
<>
110+
{unconfigured.map(([type, meta]) => {
111+
const connector = SEARCH_CONNECTORS.find((item) => item.type === type)
112+
const access = getConnectorAccessAvailability(
113+
meta,
114+
availability.integrationAvailability,
115+
{
116+
memberAccessAvailable: searchAccess.memberScoped,
117+
mirroredAccessAvailable: searchAccess.sourceMirrored,
118+
oauthServiceAvailability: availability.oauthServiceAvailability,
119+
isIntegrationAvailabilityReady: availability.isIntegrationAvailabilityReady,
120+
}
121+
)
122+
const canConnect = connector && access.members
123+
return (
124+
<SettingsResourceRow
125+
key={type}
126+
iconVariant='custom'
127+
icon={<IntegrationTile blockType={type} icon={meta.icon} />}
128+
title={meta.name}
129+
description={
130+
canConnect
131+
? 'Approved · Connect your account to search this source'
132+
: 'Approved · An admin needs to finish source setup'
133+
}
134+
trailing={
135+
canConnect ? (
136+
<Chip
137+
variant='primary'
138+
disabled={enrollment.isPending}
139+
onClick={() => enrollment.connectSearchSource(scope, connector, undefined)}
140+
>
141+
Connect account
142+
</Chip>
143+
) : undefined
144+
}
145+
/>
146+
)
147+
})}
148+
{visibleSources.map((source) => (
149+
<SearchSourceRow
150+
key={source.connectorId}
151+
source={source}
152+
scope={scope}
153+
canAdmin={false}
154+
available={
155+
source.accessMode === 'members'
156+
? searchAccess.memberScoped
157+
: searchAccess.sourceMirrored &&
158+
(!source.connectionRequired || searchAccess.memberScoped)
159+
}
160+
waiting={enrollment.isAwaiting(source.connectorId)}
161+
isPending={enrollment.isPending}
162+
onConnect={() => enrollment.connect(source.knowledgeBaseId, source.connectorId)}
163+
/>
164+
))}
165+
</>
166+
) : sources.isPending || integrations.isPending ? null : (
96167
<SettingsEmptyState variant='inline'>
97168
{query
98169
? 'No matching sources.'
@@ -105,6 +176,15 @@ export function OrganizationIntegrations() {
105176
<p className='text-[var(--text-error)] text-caption'>{enrollment.error}</p>
106177
)}
107178
</div>
179+
{enrollment.setupConnector && (
180+
<SourceSetupModal
181+
connector={enrollment.setupConnector}
182+
onClose={enrollment.closeSetup}
183+
onConnect={(config) =>
184+
enrollment.connectSource(scope, enrollment.setupConnector!.type, config)
185+
}
186+
/>
187+
)}
108188
</OrganizationPage>
109189
)
110190
}

0 commit comments

Comments
 (0)