Skip to content

Commit a50f02c

Browse files
feat(search): add personal integration inventory and connection cards (#7711)
* feat(search): add personal integration inventory and connection cards * feat(slack): stream Search tool progress * fix(tests): isolate PitchBook error handling from network
1 parent 78e14b2 commit a50f02c

56 files changed

Lines changed: 2690 additions & 180 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {
2+
connectPersonalSearchIntegrationContract,
3+
listPersonalSearchIntegrationsContract,
4+
} from '@/lib/api/contracts/knowledge/personal-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 { connectPersonalSearchIntegration } from '@/lib/knowledge/application/connect-personal-search-integration'
12+
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
13+
import { listPersonalSearchIntegrations } from '@/lib/knowledge/application/personal-search-integrations'
14+
15+
export const GET = defineInternalJsonRoute({
16+
contract: listPersonalSearchIntegrationsContract,
17+
auth: internalSessionAuth,
18+
operation: knowledgeOperations.listPersonalSearchIntegrations,
19+
rateLimit: internalRateLimits.user({ bucketName: 'knowledge.search.personal-integrations.list' }),
20+
errorPolicy: internalKnowledgeErrorPolicies.connectors,
21+
mapInput: ({ query }) => query,
22+
useCase: listPersonalSearchIntegrations,
23+
present: (data) => ({ success: true as const, data }),
24+
})
25+
26+
export const POST = defineInternalJsonRoute({
27+
contract: connectPersonalSearchIntegrationContract,
28+
auth: internalSessionAuth,
29+
operation: knowledgeOperations.connectPersonalSearchIntegration,
30+
rateLimit: internalRateLimits.user({
31+
bucketName: 'knowledge.search.personal-integrations.connect',
32+
}),
33+
errorPolicy: internalKnowledgeErrorPolicies.connectAccount,
34+
mapInput: ({ body }) => body,
35+
useCase: connectPersonalSearchIntegration,
36+
present: (data) => ({ success: true as const, data }),
37+
})

apps/sim/app/o/[organizationId]/home/organization-home.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
66
import { Composer } from '@/app/o/[organizationId]/home/components/composer'
77
import { GetStarted } from '@/app/o/[organizationId]/home/components/get-started'
88
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
9+
import { SearchIntegrationConnection } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection'
910
import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components/mothership-chat'
1011
import { useChat } from '@/app/workspace/[workspaceId]/home/hooks/use-chat'
1112
import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats'
@@ -79,6 +80,7 @@ function OrganizationHomeContent({ userName, chatId }: OrganizationHomeProps) {
7980
<div className='flex h-full min-h-0 flex-col bg-[var(--bg)]'>
8081
{hasChat ? (
8182
<MothershipChat
83+
SearchConnectionComponent={SearchIntegrationConnection}
8284
messages={chat.messages}
8385
isSending={chat.isSending}
8486
isReconnecting={chat.isReconnecting}

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useMemo } from 'react'
44
import type { ResourceScope } from '@/lib/core/resource-scope'
5+
import type { SearchConnectionTarget } from '@/lib/knowledge/search/connection-target'
56
import { connectorDisplayName } from '@/lib/sim-search/connectors'
67
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
78
import { OrganizationPage } from '@/app/o/[organizationId]/components/organization-page'
@@ -10,6 +11,7 @@ import { ConnectAccountOptions } from '@/app/o/[organizationId]/integrations/con
1011
import { DisconnectAccountMenu } from '@/app/o/[organizationId]/integrations/disconnect-account-menu'
1112
import { SlackSearchActions } from '@/app/o/[organizationId]/integrations/slack-search-actions'
1213
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
14+
import { SearchIntegrationConnection } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection'
1315
import { SearchSourcePagination } from '@/app/workspace/[workspaceId]/search/components/search-source-pagination'
1416
import { SearchSourceRow } from '@/app/workspace/[workspaceId]/search/components/search-source-row'
1517
import { SettingsQueryErrorState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
@@ -22,11 +24,15 @@ import { useMemberEnrollment } from '@/hooks/use-member-enrollment'
2224
import { useDesktopOAuthConnectListener, useOAuthReturnRouter } from '@/hooks/use-oauth-return'
2325

2426
interface OrganizationIntegrationsProps {
27+
connectionRequest?: { target: SearchConnectionTarget; userId: string }
2528
slackOnboarding?: { token: string; userId: string }
2629
}
2730

2831
/** The viewer's Search connections and ready integrations they can connect personally. */
29-
export function OrganizationIntegrations({ slackOnboarding }: OrganizationIntegrationsProps = {}) {
32+
export function OrganizationIntegrations({
33+
slackOnboarding,
34+
connectionRequest,
35+
}: OrganizationIntegrationsProps = {}) {
3036
useOAuthReturnRouter()
3137
useDesktopOAuthConnectListener()
3238
const { organization, searchAccess } = useOrganizationContext()
@@ -68,6 +74,13 @@ export function OrganizationIntegrations({ slackOnboarding }: OrganizationIntegr
6874
)
6975
}
7076
>
77+
{connectionRequest && (
78+
<SearchIntegrationConnection
79+
organizationId={organization.id}
80+
{...connectionRequest}
81+
controlId='integrations-link'
82+
/>
83+
)}
7184
<div className={RESOURCE_LIST_STACK}>
7285
{sources.isError && !sources.isFetchNextPageError ? (
7386
<SettingsQueryErrorState

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,41 @@ beforeEach(() => {
3232
})
3333

3434
describe('integrations page Slack context', () => {
35+
it('preserves a requested connection across login and validates it in the existing organization page', async () => {
36+
const selected = {
37+
...props,
38+
searchParams: Promise.resolve({
39+
connectorType: 'gmail',
40+
connectorId: 'source',
41+
credentialId: 'account',
42+
}),
43+
}
44+
const page = await OrganizationIntegrationsPage(selected)
45+
expect(page.props.connectionRequest).toMatchObject({
46+
userId: 'viewer',
47+
target: {
48+
type: 'link',
49+
connectorType: 'gmail',
50+
connectorId: 'source',
51+
credentialId: 'account',
52+
},
53+
})
54+
authMockFns.mockGetSession.mockResolvedValue(null)
55+
await expect(OrganizationIntegrationsPage(selected)).rejects.toThrow('Redirect')
56+
expect(mocks.redirect).toHaveBeenCalledWith(
57+
`/login?callbackUrl=${encodeURIComponent('/o/organization-a/integrations?connectorType=gmail&connectorId=source&credentialId=account')}`
58+
)
59+
})
60+
it('rejects unknown providers and reconnects without a source', async () => {
61+
for (const query of [
62+
{ connectorType: 'invented' },
63+
{ connectorType: 'gmail', credentialId: 'account' },
64+
]) {
65+
await expect(
66+
OrganizationIntegrationsPage({ ...props, searchParams: Promise.resolve(query) })
67+
).rejects.toThrow('Not found')
68+
}
69+
})
3570
it('preserves the source page and Slack question context through login', async () => {
3671
authMockFns.mockGetSession.mockResolvedValue(null)
3772
await expect(OrganizationIntegrationsPage(props)).rejects.toThrow('Redirect')

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

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,17 @@ import type { Metadata } from 'next'
22
import { notFound, redirect } from 'next/navigation'
33
import { slackSearchOnboardingInputSchema } from '@/lib/api/contracts/knowledge/slack'
44
import { getSession } from '@/lib/auth'
5+
import {
6+
searchConnectionPath,
7+
searchConnectionTargetSchema,
8+
} from '@/lib/knowledge/search/connection-target'
59
import { organizationRoutes } from '@/lib/navigation/paths'
610
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
11+
import { SEARCH_CONNECTORS } from '@/lib/sim-search/connectors'
712
import { slackSearchIntegrationsPath } from '@/lib/slack-search/onboarding'
813
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
914
import { OrganizationIntegrations } from '@/app/o/[organizationId]/integrations/integrations'
15+
import { loadIntegrationConnectionParams } from '@/app/o/[organizationId]/integrations/search-params'
1016

1117
export const metadata: Metadata = {
1218
title: 'Integrations',
@@ -15,15 +21,35 @@ export const metadata: Metadata = {
1521

1622
interface OrganizationIntegrationsPageProps {
1723
params: Promise<{ organizationId: string }>
18-
searchParams: Promise<{ slack?: string | string[] }>
24+
searchParams: Promise<{
25+
slack?: string | string[]
26+
connectorType?: string | string[]
27+
connectorId?: string | string[]
28+
credentialId?: string | string[]
29+
}>
1930
}
2031

2132
export default async function OrganizationIntegrationsPage({
2233
params,
2334
searchParams,
2435
}: OrganizationIntegrationsPageProps) {
2536
const { organizationId } = await params
26-
const { slack } = await searchParams
37+
const query = await searchParams
38+
const { slack } = query
39+
const selection = loadIntegrationConnectionParams(query)
40+
const connector = SEARCH_CONNECTORS.find((entry) => entry.type === selection.connectorType)
41+
const requested =
42+
selection.connectorType || selection.connectorId || selection.credentialId
43+
? searchConnectionTargetSchema.safeParse({
44+
type: 'link',
45+
provider: connector?.providerId,
46+
connectorType: selection.connectorType,
47+
...(selection.connectorId ? { connectorId: selection.connectorId } : {}),
48+
...(selection.credentialId ? { credentialId: selection.credentialId } : {}),
49+
})
50+
: undefined
51+
if (requested && !requested.success) notFound()
52+
const connectionTarget = requested?.data
2753
const context =
2854
slack === undefined ? undefined : slackSearchOnboardingInputSchema.safeParse({ token: slack })
2955
if (context && !context.success) notFound()
@@ -32,16 +58,21 @@ export default async function OrganizationIntegrationsPage({
3258
if (!session?.user)
3359
redirect(
3460
buildAuthCrossLink('/login', {
35-
callbackUrl: slackToken
36-
? slackSearchIntegrationsPath(organizationId, slackToken)
37-
: organizationRoutes(organizationId).integrations,
61+
callbackUrl: connectionTarget
62+
? searchConnectionPath(organizationId, connectionTarget)
63+
: slackToken
64+
? slackSearchIntegrationsPath(organizationId, slackToken)
65+
: organizationRoutes(organizationId).integrations,
3866
isInviteFlow: false,
3967
})
4068
)
4169
const organizationContext = await getOrganizationSurfaceContext(organizationId, session.user.id)
4270
if (!organizationContext?.searchAccess.memberScoped) notFound()
4371
return (
4472
<OrganizationIntegrations
73+
connectionRequest={
74+
connectionTarget ? { target: connectionTarget, userId: session.user.id } : undefined
75+
}
4576
slackOnboarding={slackToken ? { token: slackToken, userId: session.user.id } : undefined}
4677
/>
4778
)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { createLoader, parseAsString } from 'nuqs/server'
2+
3+
export const integrationConnectionParams = {
4+
connectorType: parseAsString.withDefault(''),
5+
connectorId: parseAsString.withDefault(''),
6+
credentialId: parseAsString.withDefault(''),
7+
}
8+
9+
export const loadIntegrationConnectionParams = createLoader(integrationConnectionParams)

apps/sim/app/workspace/[workspaceId]/home/components/chat-surface-context/chat-surface-context.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client'
22

33
import {
4+
type ComponentType,
45
createContext,
56
type ReactNode,
67
useCallback,
@@ -10,6 +11,7 @@ import {
1011
useRef,
1112
} from 'react'
1213
import { noop } from '@sim/utils/helpers'
14+
import type { SearchIntegrationConnectionProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection'
1315
import type { WorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/types'
1416
import type { ChatContext } from '@/stores/panel'
1517

@@ -20,6 +22,7 @@ import type { ChatContext } from '@/stores/panel'
2022
* consume them without relaying through every intermediate component.
2123
*/
2224
interface ChatSurfaceContextValue {
25+
SearchConnectionComponent?: ComponentType<SearchIntegrationConnectionProps>
2326
/** Resolved id of the chat backing this surface, if one exists yet. */
2427
chatId?: string
2528
/** Id of the user interacting with this surface. */
@@ -44,6 +47,7 @@ const ChatSurfaceContext = createContext<ChatSurfaceContextValue>({
4447
})
4548

4649
interface ChatSurfaceProviderProps {
50+
SearchConnectionComponent?: ComponentType<SearchIntegrationConnectionProps>
4751
chatId?: string
4852
userId?: string
4953
onContextAdd?: (context: ChatContext) => void
@@ -59,6 +63,7 @@ interface ChatSurfaceProviderProps {
5963
* not re-render when a parent re-creates a handler.
6064
*/
6165
export function ChatSurfaceProvider({
66+
SearchConnectionComponent,
6267
chatId,
6368
userId,
6469
onContextAdd,
@@ -88,13 +93,21 @@ export function ChatSurfaceProvider({
8893

8994
const value = useMemo<ChatSurfaceContextValue>(
9095
() => ({
96+
SearchConnectionComponent,
9197
chatId,
9298
userId,
9399
onContextAdd: stableOnContextAdd,
94100
onContextRemove: stableOnContextRemove,
95101
onWorkspaceResourceSelect: stableOnWorkspaceResourceSelect,
96102
}),
97-
[chatId, userId, stableOnContextAdd, stableOnContextRemove, stableOnWorkspaceResourceSelect]
103+
[
104+
SearchConnectionComponent,
105+
chatId,
106+
userId,
107+
stableOnContextAdd,
108+
stableOnContextRemove,
109+
stableOnWorkspaceResourceSelect,
110+
]
98111
)
99112

100113
return <ChatSurfaceContext.Provider value={value}>{children}</ChatSurfaceContext.Provider>

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/interaction-card.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ export const InteractionCardInputRow = forwardRef<HTMLInputElement, InteractionC
9090
InteractionCardInputRow.displayName = 'InteractionCardInputRow'
9191

9292
interface InteractionCardActionRowProps {
93+
divided?: boolean
94+
trailing?: ReactNode
9395
label: string
9496
leading?: ReactNode
9597
disabled?: boolean
@@ -100,6 +102,8 @@ interface InteractionCardActionRowProps {
100102
export function InteractionCardActionRow({
101103
label,
102104
leading,
105+
divided = true,
106+
trailing,
103107
disabled = false,
104108
onClick,
105109
}: InteractionCardActionRowProps) {
@@ -110,7 +114,8 @@ export function InteractionCardActionRow({
110114
onClick={onClick}
111115
className={cn(
112116
INTERACTION_CARD_ROW_CLASSES,
113-
'border-t',
117+
'w-full',
118+
divided && 'border-t',
114119
disabled ? 'cursor-not-allowed' : 'hover-hover:bg-[var(--surface-5)]'
115120
)}
116121
>
@@ -123,7 +128,7 @@ export function InteractionCardActionRow({
123128
>
124129
{label}
125130
</span>
126-
<ArrowRight className='size-[16px] shrink-0 text-[var(--text-icon)]' />
131+
{trailing ?? <ArrowRight className='size-[16px] shrink-0 text-[var(--text-icon)]' />}
127132
</button>
128133
)
129134
}

0 commit comments

Comments
 (0)