Skip to content

Commit 41fd1c9

Browse files
Merge latest connector-service-account-auth approval changes
Keep upstream Search approvals at migration 0326 and regenerate connected accounts as 0327 with the existing reviewed SQL.
2 parents 8300f8f + 9bcb8fd commit 41fd1c9

39 files changed

Lines changed: 26536 additions & 414 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)