Skip to content

Commit 6d8c34f

Browse files
authored
feat(search): connect multiple accounts per integration (#9716)
* feat(search): connect multiple accounts per integration Key managed OAuth credentials on the provider's subject and tenant instead of one row per enrollment and option, so a member can connect several accounts of any OAuth integration. Reconnect stays pinned to its account, disconnecting frees the slot, and invitation links only refresh the existing account. * feat(search): identify managed MCP accounts to allow several per connector Read the signed-in account through each connector's documented identity tool during OAuth completion and key connections on it, sharing one set of account rules with OAuth providers. Notion, Granola, Fireflies, Coda, and HubSpot now allow several accounts; connectors with no identity tool stay at one. * fix(search): harden multiple accounts per integration Enforce the account limit when reconnecting a disconnected account, keep GitHub setup working when one linked account fails, and surface reconnect prompts over disconnected accounts. Identify existing managed MCP connections from their stored tokens so adding an account does not first require reconnecting. * improvement(search): polish several-account UI and refresh Search on connect Show accounts as accounts, surface Reconnect beside an account that needs it, keep each disconnect clear about which account it removes, and hide Add account in a single account's view. Connecting from any surface now refreshes what Search can reach. Add browser coverage for several accounts and tighten the tests. * fix(search): name per-account Reconnect buttons for assistive tech The design system Button dropped aria-label, so several Reconnect buttons on one page read the same. Restore the browser check for an app with connections unavailable and shrink the test-types baseline after removing a duplicate test. * fix(search): make the MCP identity migration replayable and close review gaps Replace the managed MCP check in one atomic statement so an interrupted migration can retry. Refuse to adopt an identity another connection holds, write the Search receipt only for flows that read it, keep expired accounts labeled as such, and refresh Search's account list on disconnect. * fix(search): only read an MCP account email from text when it is labeled An address in a non-JSON identity response now needs a label naming it as the signed-in account, so an unrelated address is never stored as the identity, and markdown, punctuation, and punycode domains no longer distort it. * fix(search): keep existing connections unchanged and collapse to one migration Combine the account migrations into one. Reuse a disconnected connection's id, let a lone account reconnect when its identity tool fails, keep Find on the first connected account, and refresh an expired account added again. Group People list MCP summaries, tell same-email accounts apart, and version MCP attempts so an old instance can't overwrite tokens during a deploy. * fix(search): add the count to the People MCP summary type * fix(search): keep a leading underscore in a labeled MCP account email
1 parent ab2a5c0 commit 6d8c34f

65 files changed

Lines changed: 34402 additions & 259 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.

‎apps/docs/content/docs/search/connect-your-account.mdx‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,25 @@ After your admin adds a source in **Settings → Sources**, connect your own acc
1010
## Connect and verify
1111

1212
1. Join the Sim organization through its invitation or SSO. Use the verified email required by the provider connection.
13-
2. Open **Integrations**, select the app, and choose **Connect** in its header.
14-
3. Complete authorization in the new tab. If the popup is blocked, allow it and select **Connect** again.
13+
2. Open **Integrations**, select the app, and choose the app's button under **Connect your account**.
14+
3. Complete authorization in the new tab. If the popup is blocked, allow it and select the button again.
1515
4. Return to **Search** or **Search → Assistant** and search for content your account can open. Read a result and follow its source link.
1616

1717
<Image
1818
src="/static/search/connect-account-details.png"
19-
alt="Coda integration details with Connect in the resource header and no account connected"
19+
alt="Coda integration details with a connect button and no account connected"
2020
width={1440}
2121
height={1000}
2222
variant="overview"
2323
/>
2424

25-
Your organization currently supports one connected account per provider. Open the app to see that account's permissions and connection actions.
25+
To connect another account of the same app, such as Google Drive for two different work accounts, open the app and choose **Add account**, then pick the other account when the provider asks. You can connect up to five accounts per app, and Search looks across all of them. Open an account to see its tool permissions when the app supports them; each account's menu has **Reconnect** and **Disconnect**.
26+
27+
- Slack accounts must belong to your organization's connected workspace.
28+
- Zoom, Lucid, and Databricks allow one account, because Sim cannot yet tell their accounts apart.
29+
- If the provider signs you straight back into a connected account, sign in to the other account with the provider first, then choose **Add account** again.
30+
31+
Reconnecting keeps the same account. To use a different one, add it as a new account or disconnect the old one first. If Sim asks you to reconnect before adding another account, it could not confirm which account an existing connection belongs to. Reconnect that one, then add the other. Your organization and the provider must allow each account to authorize Sim.
2632

2733
The connection is usable when authorization completes; no initial indexing run is required. If a provider is missing, ask an admin to add it in **Sources**. `Not configured` means its app or deployment setup needs an admin's attention.
2834

@@ -33,7 +39,7 @@ You do not choose repositories, channels, labels, or an indexing method during p
3339
| Source setup | Personal connection |
3440
| --- | --- |
3541
| **Member accounts** | Connect your own provider account. |
36-
| **GitHub App** | Connect GitHub once per Sim organization. Each selected repository must be readable by you and the installation. |
42+
| **GitHub App** | Connect one or more GitHub accounts. Each selected repository must be readable by you and the installation. |
3743
| Google service account: Gmail, Calendar, Drive | Connect each corresponding Google service. The source verifies your identity and resource access through delegation. |
3844
| Confluence service account | Connect Confluence. Both your account and the service connection must read the selected content. |
3945
| Coda | Connect personal Coda MCP. Service mode also verifies the parent document with its REST token. |
@@ -46,7 +52,7 @@ Creating Jira sprints requires the Jira sprint permission. If your account was c
4652

4753
## Tool permissions
4854

49-
Open the app in **Integrations** and select your account. Tool permissions appear in its details when the app has supported Assistant tools available to you. Hover over or focus the info button beside a tool to read its description. Set a preference for each operation or use the **Read-only tools** and **Write tools** group controls:
55+
Open the app in **Integrations**. If you connected several accounts, select one. Tool permissions appear in its details when the app has supported Assistant tools available to you. Hover over or focus the info button beside a tool to read its description. Set a preference for each operation or use the **Read-only tools** and **Write tools** group controls:
5056

5157
| Preference | Behavior |
5258
| --- | --- |

‎apps/sim/app/api/credential-groups/oauth-callback.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ export async function handleCredentialGroupOAuthCallback({
139139
: error instanceof CredentialGroupOAuthError && error.statusCode === 409
140140
? 'configuration_changed'
141141
: 'failed'
142+
if (error instanceof CredentialGroupOAuthError && error.failure) status = error.failure
142143
if (identityFailure) {
143144
switch (identityFailure.reason) {
144145
case 'email_unverified':

‎apps/sim/app/api/mcp/oauth/callback/route.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from '@/lib/credential-groups/mcp-oauth-state'
1919
import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version'
2020
import type { CredentialGroupOAuthFailure } from '@/lib/credential-groups/oauth-completion'
21+
import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter'
2122
import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit'
2223
import {
2324
assertSafeOauthServerUrl,
@@ -162,7 +163,9 @@ async function completeManagedMcpCallback(params: {
162163
})
163164
} catch (error) {
164165
logger.error('Managed MCP OAuth callback failed', error)
165-
return failureRedirect('failed')
166+
return failureRedirect(
167+
error instanceof CredentialGroupOAuthError && error.failure ? error.failure : 'failed'
168+
)
166169
}
167170
}
168171

‎apps/sim/app/credential-groups/enroll/[token]/page.tsx‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ export default async function CredentialGroupEnrollmentPage({
227227
)
228228
const visibleOptions = focusedOption ? [focusedOption] : activeOptions
229229
const focusedConnected =
230-
focusedOption?.connections[0]?.status === 'connected' &&
230+
focusedOption?.connections.some((connection) => connection.status === 'connected') &&
231231
(returnToSearch || connectedOptionId === focusedOption.id)
232232
const connectedOption = connectedOptionId
233233
? activeOptions.find((option) => option.id === connectedOptionId)
@@ -286,29 +286,34 @@ export default async function CredentialGroupEnrollmentPage({
286286
<div className='au:space-y-2'>
287287
{visibleOptions.map((option) => {
288288
const ProviderIcon = getCredentialGroupProviderService(option.provider).icon
289-
const connection = option.connections[0]
289+
const visibleConnections = option.connections.filter(
290+
(connection) => connection.status !== 'revoked'
291+
)
290292
return (
291293
<SettingsResourceRow
292294
key={option.id}
293295
icon={<ProviderIcon />}
294296
title={option.label}
295297
description={
296-
option.connections.length > 0
297-
? option.connections.map((account) => (
298-
<span key={account.email} className='au:block'>
298+
visibleConnections.length > 0
299+
? visibleConnections.map((account) => (
300+
<span key={`${account.email}-${account.grantedAt}`} className='au:block'>
299301
{account.email}
300302
{account.status === 'connected'
301303
? ' — Connected'
302-
: ' — Reconnect required'}
304+
: ' — Reconnect needed'}
303305
</span>
304306
))
305307
: 'Not connected'
306308
}
307309
actions={
308-
focusedConnected ? undefined : (
310+
focusedConnected &&
311+
visibleConnections.every(
312+
(account) => account.status === 'connected'
313+
) ? undefined : (
309314
<OAuthConnectLink
310315
href={`/api/credential-groups/enroll/${encodeURIComponent(token)}/oauth/${encodeURIComponent(option.id)}${focused ? `?returnTo=${returnToSearch ? 'search' : 'accounts'}` : ''}`}
311-
reconnect={Boolean(connection)}
316+
reconnect={visibleConnections.length > 0}
312317
variant={focused ? 'primary' : undefined}
313318
/>
314319
)
@@ -328,7 +333,7 @@ export default async function CredentialGroupEnrollmentPage({
328333
server.connection?.status === 'connected'
329334
? 'Connected'
330335
: server.connection
331-
? 'Reconnect required'
336+
? 'Reconnect needed'
332337
: server.description || 'Not connected'
333338
}
334339
actions={

‎apps/sim/app/o/[organizationId]/integrations/account-actions-menu.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export function AccountActionsMenu({
110110
if (!open && !disconnect.isPending) setSelectedId(null)
111111
}}
112112
title={`Disconnect ${integrationName}`}
113-
text={`Disconnect ${selected?.displayName ?? integrationName} from all ${integrationName} connections in this organization. Workflows using this account will also lose access. You can reconnect later.`}
113+
text={`Disconnect ${selected?.displayName ?? integrationName} from this organization. Workflows using this account will also lose access. You can reconnect later.`}
114114
confirm={{
115115
label: 'Disconnect',
116116
pendingLabel: 'Disconnecting',

‎apps/sim/app/o/[organizationId]/integrations/assistant-account-integrations.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ChipSelect, toast } from '@sim/emcn'
44
import type { SearchIntegrationStatus } from '@/lib/api/contracts/knowledge/search-integrations'
55
import type { OrganizationAccountsSettings } from '@/lib/api/contracts/organization-accounts'
66
import { assistantSearchSourceForProvider } from '@/lib/assistant-actions/providers'
7+
import { CREDENTIAL_GROUP_ACCOUNTS_PER_PROVIDER_LIMIT } from '@/lib/credential-groups/limits'
78
import { assistantAccountOptions } from '@/lib/credential-groups/member-integrations'
89
import {
910
getCredentialGroupProviderId,
@@ -120,7 +121,7 @@ export function AssistantAccountIntegrations({
120121
integrationName={service.name}
121122
accounts={accounts}
122123
onConnect={
123-
accounts.length === 0
124+
accounts.length < CREDENTIAL_GROUP_ACCOUNTS_PER_PROVIDER_LIMIT
124125
? () => connect.mutate({ organizationId, optionId: option.id }, { onError })
125126
: undefined
126127
}

‎apps/sim/blocks/blocks/credential.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export const CredentialBlock: BlockConfig = {
2828
- Use the Provider filter to narrow results to specific services (e.g. Gmail, Slack).
2929
- Use "List Organization Accounts" with Providers selected and Email blank to discover all accessible accounts for those integrations.
3030
- Organization lists return one page at a time. While hasMore is true, pass nextCursor as Cursor with the same filters to get every matching account.
31-
- "Find Organization Account" requires an exact enrollment email and provider, and fails unless exactly one active account matches.
31+
- "Find Organization Account" requires an exact enrollment email and provider, and returns that person's first connected account for the provider, or fails if there is none. Use "List Organization Accounts" to reach their other accounts.
3232
- Outputs contain account identities and credential references, never secret values.
3333
- To switch credentials across environments, replace the single Credential block rather than updating every downstream block.
3434
`,

‎apps/sim/ee/credential-groups/components/organization-person-connections.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export function OrganizationPersonConnections({ person }: OrganizationPersonConn
4949
.map((connection) => ({
5050
key: `mcp:${connection.mcpServerId}`,
5151
name: connection.name,
52-
count: 1,
52+
count: connection.count,
5353
icon: undefined,
5454
})),
5555
]

‎apps/sim/executor/handlers/credential/handler.test.ts‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,23 +66,36 @@ describe('Credential organization operations', () => {
6666
workspaceId: 'child-workspace',
6767
email: account.email,
6868
credentialProviderIds: ['google-email'],
69-
limit: 2,
69+
limit: 1,
7070
cursor: undefined,
7171
},
7272
})
7373
})
74-
it.each([0, 2])('rejects an ambiguous or missing account (%i matches)', async (count) => {
74+
it('rejects a missing account', async () => {
75+
mocks.oauth.mockResolvedValue({ credentials: [], hasMore: false })
76+
await expect(
77+
handler.execute(ctx, block, {
78+
operation: 'find_organization_account',
79+
email: account.email,
80+
organizationProvider: 'google-email',
81+
})
82+
).rejects.toThrow('No organization account')
83+
})
84+
it('keeps returning the first connected account when a person adds another', async () => {
7585
mocks.oauth.mockResolvedValue({
76-
credentials: Array.from({ length: count }, () => account),
86+
credentials: [
87+
{ ...account, credentialId: 'first', accountEmail: 'first@example.com' },
88+
{ ...account, credentialId: 'added', accountEmail: account.email },
89+
],
7790
hasMore: false,
7891
})
79-
await expect(
80-
handler.execute(ctx, block, {
92+
expect(
93+
await handler.execute(ctx, block, {
8194
operation: 'find_organization_account',
8295
email: account.email,
8396
organizationProvider: 'google-email',
8497
})
85-
).rejects.toThrow('Expected exactly one')
98+
).toMatchObject({ credentialId: 'first' })
8699
})
87100
it('returns the person’s MCP credential separately from the shared server', async () => {
88101
const connection = {
@@ -103,7 +116,7 @@ describe('Credential organization operations', () => {
103116
input: expect.objectContaining({
104117
connectorId: 'fireflies',
105118
email: account.email,
106-
limit: 2,
119+
limit: 1,
107120
}),
108121
})
109122
)

‎apps/sim/executor/handlers/credential/handler.ts‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,18 +111,19 @@ export class CredentialBlockHandler implements BlockHandler {
111111
credentialProviderIds: find
112112
? [requireString(inputs.organizationProvider, 'Provider')]
113113
: parseStringList(inputs.organizationProviders, 'Providers'),
114-
limit: find ? 2 : parseLimit(inputs.limit),
114+
limit: find ? 1 : parseLimit(inputs.limit),
115115
cursor: find ? undefined : parseOptionalString(inputs.cursor, 'Cursor'),
116116
},
117117
})
118118
if (!find) {
119119
return { ...result, emails: result.credentials.map((account) => account.accountEmail) }
120120
}
121-
if (result.credentials.length !== 1 || result.hasMore)
121+
const [first] = result.credentials
122+
if (!first)
122123
throw new Error(
123-
`Expected exactly one organization account; found ${result.credentials.length}${result.hasMore ? '+' : ''}. Check the email, provider, and connection status.`
124+
'No organization account found. Check the email, provider, and connection status.'
124125
)
125-
return omit(result.credentials[0]!, ['accountEmail'])
126+
return omit(first, ['accountEmail'])
126127
}
127128
case 'find_organization_mcp_connection':
128129
case 'list_organization_mcp_connections': {
@@ -137,16 +138,17 @@ export class CredentialBlockHandler implements BlockHandler {
137138
connectorId: find
138139
? requireString(inputs.mcpProvider, 'MCP provider')
139140
: parseOptionalString(inputs.mcpProvider, 'MCP provider'),
140-
limit: find ? 2 : parseLimit(inputs.limit),
141+
limit: find ? 1 : parseLimit(inputs.limit),
141142
cursor: find ? undefined : parseOptionalString(inputs.cursor, 'Cursor'),
142143
},
143144
})
144145
if (!find) return result
145-
if (result.mcpConnections.length !== 1 || result.hasMore)
146+
const [first] = result.mcpConnections
147+
if (!first)
146148
throw new Error(
147-
`Expected exactly one organization MCP connection; found ${result.mcpConnections.length}${result.hasMore ? '+' : ''}. Check the email, provider, and connection status.`
149+
'No organization MCP connection found. Check the email, provider, and connection status.'
148150
)
149-
return result.mcpConnections[0]!
151+
return first
150152
}
151153
default:
152154
throw new Error(`Unsupported Credential operation: ${operation}`)

0 commit comments

Comments
 (0)