Skip to content

Commit 428a945

Browse files
fix(search): correct onboarding and live citations (#8542)
* fix(search): correct onboarding and live citations * fix(search): clear onboarding readiness after credential disconnect
1 parent ec1a997 commit 428a945

12 files changed

Lines changed: 444 additions & 119 deletions

File tree

‎apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ function StepMark({ complete }: { complete: boolean }) {
7171
* The organization home's onboarding list under the composer. Same chrome as
7272
* the workspace home's suggested actions: a hover-revealed disclosure header
7373
* over hairline-separated rows. Each step leads to the page that completes it,
74-
* and reads as done from the organization's real state: a connected account and an OAuth app authorized to use Search.
74+
* and reads as done from the organization's real state: a configured integration and an OAuth app authorized to use Search.
7575
*/
7676
export function GetStarted() {
7777
const { organization, viewer, connectedAccountsAvailable } = useOrganizationContext()
@@ -142,7 +142,15 @@ export function GetStarted() {
142142
'connect-sim-search': routes.settingsSection('search-mcp'),
143143
}
144144
const completed: Record<StepId, boolean> = {
145-
'connect-integration': Boolean(hasSearchConnection),
145+
'connect-integration': Boolean(
146+
hasSearchConnection ||
147+
integrations?.some(
148+
(integration) =>
149+
integration.approved &&
150+
integration.available !== false &&
151+
integration.configuredServiceSource
152+
)
153+
),
146154
'connect-sim-search': hasSearchAuthorization,
147155
}
148156
const steps = STEPS.filter((step) =>

‎apps/sim/lib/api/contracts/knowledge/search-integrations.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export type SearchIntegrationApproval = z.output<typeof searchIntegrationApprova
1313

1414
export const searchIntegrationStatusSchema = searchIntegrationApprovalSchema.extend({
1515
available: z.boolean().optional(),
16+
configuredServiceSource: z.boolean().optional(),
1617
})
1718
export type SearchIntegrationStatus = z.output<typeof searchIntegrationStatusSchema>
1819

‎apps/sim/lib/knowledge/__integration__/search-mcp-setup.integration.ts‎

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {
33
credential,
44
credentialGroup,
55
credentialGroupEnrollment,
6+
knowledgeBase,
7+
knowledgeConnector,
68
mcpServers,
79
member,
810
organization,
@@ -30,11 +32,16 @@ import {
3032
import { getCredentialGroup } from '@/lib/credential-groups/service'
3133
import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes'
3234
import { createOrganizationAccountsGroup } from '@/lib/credential-groups/workspace-accounts'
35+
import { deleteConnectionCredential } from '@/lib/credentials/deletion'
3336
import { acquireAdvisoryXactLock, tryAcquireAdvisoryXactLock } from '@/lib/db/advisory-locks'
3437
import {
3538
approveSearchIntegration,
3639
listSearchIntegrations,
3740
} from '@/lib/knowledge/application/search-integrations'
41+
import {
42+
GITHUB_INSTALLATION_PROVIDER_ID,
43+
type GitHubInstallationBinding,
44+
} from '@/lib/oauth/github-installation-types'
3845
import { defaultLiveSearchPolicy } from '@/lib/sim-search/live/policy-schema'
3946
import { SLACK_RTS_USER_SCOPES } from '@/lib/sim-search/live/scopes'
4047

@@ -247,6 +254,217 @@ describe('atomic organization live Search MCP setup', () => {
247254
}
248255
)
249256

257+
async function seedServiceSource(provider: 'google_drive' | 'github' | 'gitlab') {
258+
const knowledgeBaseId = generateId()
259+
const connectorId = generateId()
260+
const credentialId = generateId()
261+
const installation = {
262+
type: 'github_app_installation',
263+
version: 1,
264+
appId: '1',
265+
appClientId: 'fixture-github-app',
266+
installationId: '21',
267+
accountId: '11',
268+
accountType: 'Organization',
269+
accountLogin: 'fixture-owner',
270+
repositorySelection: 'selected',
271+
} satisfies GitHubInstallationBinding
272+
const encryptedInstallation =
273+
provider === 'github' ? await encryptSecret(JSON.stringify(installation)) : undefined
274+
await db.insert(knowledgeBase).values({
275+
id: knowledgeBaseId,
276+
userId: ids.owner,
277+
organizationId: ids.organization,
278+
isSearchIndex: true,
279+
name: 'Service source fixture',
280+
})
281+
await db.insert(credential).values({
282+
id: credentialId,
283+
organizationId: ids.organization,
284+
type: 'service_account',
285+
providerId: provider === 'github' ? GITHUB_INSTALLATION_PROVIDER_ID : 'google-drive',
286+
...(encryptedInstallation
287+
? {
288+
encryptedServiceAccountKey: encryptedInstallation.encrypted,
289+
providerSubjectId: installation.installationId,
290+
providerTenantId: installation.accountId,
291+
authorizationAppId: installation.appClientId,
292+
}
293+
: {}),
294+
displayName: 'Service source fixture',
295+
createdBy: ids.owner,
296+
})
297+
await db.insert(knowledgeConnector).values({
298+
id: connectorId,
299+
knowledgeBaseId,
300+
connectorType: provider,
301+
credentialId,
302+
encryptedApiKey: provider === 'gitlab' ? 'synthetic-encrypted-key' : null,
303+
sourceConfig:
304+
provider === 'github'
305+
? { repository: 'fixture-owner/repository', githubRepositoryId: '101' }
306+
: {},
307+
accessMode: provider === 'github' ? 'members' : 'admin',
308+
status: 'active',
309+
})
310+
await db.insert(organizationSearchIntegration).values({
311+
organizationId: ids.organization,
312+
connectorType: provider,
313+
approved: true,
314+
})
315+
await db
316+
.update(organization)
317+
.set({
318+
metadata: {
319+
liveSearchPolicies: {
320+
[provider]: {
321+
...defaultLiveSearchPolicy(provider),
322+
accessMode: 'service_account',
323+
...(provider === 'google_drive' ? { sourceId: connectorId } : {}),
324+
},
325+
},
326+
},
327+
})
328+
.where(eq(organization.id, ids.organization))
329+
return { knowledgeBaseId, connectorId, credentialId }
330+
}
331+
332+
async function integrationStatus(provider: string) {
333+
const data = await listSearchIntegrations.execute({
334+
principal: createSessionPrincipal({ userId: ids.member, sessionId: generateId() }),
335+
input: { organizationId: ids.organization },
336+
})
337+
return listSearchIntegrationsContract.response.schema
338+
.parse({ success: true, data })
339+
.data.find((entry) => entry.connectorType === provider)
340+
}
341+
342+
it.each(['google_drive', 'github', 'gitlab'] as const)(
343+
'reports a configured %s service source without requiring a member account',
344+
async (provider) => {
345+
const source = await seedServiceSource(provider)
346+
expect((await snapshot()).groups).toEqual([])
347+
expect(await integrationStatus(provider)).toMatchObject({ configuredServiceSource: true })
348+
await db
349+
.update(knowledgeConnector)
350+
.set({ status: 'disabled' })
351+
.where(eq(knowledgeConnector.id, source.connectorId))
352+
expect(await integrationStatus(provider)).toMatchObject({ configuredServiceSource: false })
353+
await db
354+
.update(knowledgeConnector)
355+
.set({ status: 'active', archivedAt: new Date() })
356+
.where(eq(knowledgeConnector.id, source.connectorId))
357+
expect(await integrationStatus(provider)).toMatchObject({ configuredServiceSource: false })
358+
await db
359+
.update(knowledgeConnector)
360+
.set({ archivedAt: null })
361+
.where(eq(knowledgeConnector.id, source.connectorId))
362+
await db
363+
.update(knowledgeBase)
364+
.set({ deletedAt: new Date() })
365+
.where(eq(knowledgeBase.id, source.knowledgeBaseId))
366+
expect(await integrationStatus(provider)).toMatchObject({ configuredServiceSource: false })
367+
await db
368+
.update(knowledgeBase)
369+
.set({ deletedAt: null })
370+
.where(eq(knowledgeBase.id, source.knowledgeBaseId))
371+
await db
372+
.update(organizationSearchIntegration)
373+
.set({ approved: false })
374+
.where(eq(organizationSearchIntegration.organizationId, ids.organization))
375+
expect(await integrationStatus(provider)).toMatchObject({ configuredServiceSource: false })
376+
}
377+
)
378+
379+
it('stops reporting a service source as configured after its credential is deleted', async () => {
380+
const source = await seedServiceSource('google_drive')
381+
expect(await integrationStatus('google_drive')).toMatchObject({ configuredServiceSource: true })
382+
await deleteConnectionCredential({
383+
credentialId: source.credentialId,
384+
organizationId: ids.organization,
385+
reason: 'user_delete',
386+
})
387+
expect(await integrationStatus('google_drive')).toMatchObject({
388+
configuredServiceSource: false,
389+
})
390+
})
391+
392+
it('requires the selected service source to belong to this organization and provider', async () => {
393+
const source = await seedServiceSource('google_drive')
394+
const otherOrganizationId = generateId()
395+
await db.insert(organization).values({
396+
id: otherOrganizationId,
397+
name: 'Other service fixture',
398+
slug: otherOrganizationId,
399+
})
400+
try {
401+
await db
402+
.update(knowledgeBase)
403+
.set({ organizationId: otherOrganizationId })
404+
.where(eq(knowledgeBase.id, source.knowledgeBaseId))
405+
expect(await integrationStatus('google_drive')).toMatchObject({
406+
configuredServiceSource: false,
407+
})
408+
await db
409+
.update(knowledgeBase)
410+
.set({ organizationId: ids.organization })
411+
.where(eq(knowledgeBase.id, source.knowledgeBaseId))
412+
await db
413+
.update(knowledgeConnector)
414+
.set({ connectorType: 'confluence' })
415+
.where(eq(knowledgeConnector.id, source.connectorId))
416+
expect(await integrationStatus('google_drive')).toMatchObject({
417+
configuredServiceSource: false,
418+
})
419+
await db
420+
.update(knowledgeConnector)
421+
.set({ connectorType: 'google_drive' })
422+
.where(eq(knowledgeConnector.id, source.connectorId))
423+
await db
424+
.update(organization)
425+
.set({
426+
metadata: {
427+
liveSearchPolicies: {
428+
google_drive: {
429+
...defaultLiveSearchPolicy(),
430+
accessMode: 'service_account',
431+
sourceId: generateId(),
432+
},
433+
},
434+
},
435+
})
436+
.where(eq(organization.id, ids.organization))
437+
expect(await integrationStatus('google_drive')).toMatchObject({
438+
configuredServiceSource: false,
439+
})
440+
} finally {
441+
await db.delete(organization).where(eq(organization.id, otherOrganizationId))
442+
}
443+
})
444+
445+
it('does not count a GitHub member source without its active installation credential', async () => {
446+
const source = await seedServiceSource('github')
447+
await db
448+
.update(credential)
449+
.set({ revokedAt: new Date() })
450+
.where(eq(credential.id, source.credentialId))
451+
expect(await integrationStatus('github')).toMatchObject({ configuredServiceSource: false })
452+
await db
453+
.update(credential)
454+
.set({ revokedAt: null })
455+
.where(eq(credential.id, source.credentialId))
456+
await db
457+
.update(knowledgeConnector)
458+
.set({ memberSyncStatus: 'disabled' })
459+
.where(eq(knowledgeConnector.id, source.connectorId))
460+
expect(await integrationStatus('github')).toMatchObject({ configuredServiceSource: false })
461+
await db
462+
.update(knowledgeConnector)
463+
.set({ memberSyncStatus: 'idle', sourceConfig: {} })
464+
.where(eq(knowledgeConnector.id, source.connectorId))
465+
expect(await integrationStatus('github')).toMatchObject({ configuredServiceSource: false })
466+
})
467+
250468
it('keeps disabled Zoom approvals visible and removable without permitting reapproval', async () => {
251469
const connectorType = 'zoom'
252470
await db.insert(organizationSearchIntegration).values({

0 commit comments

Comments
 (0)