Skip to content

Commit edfc88e

Browse files
committed
fix(search): preserve live onboarding and Slack scope policies
1 parent 898bfc9 commit edfc88e

17 files changed

Lines changed: 144 additions & 82 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,6 @@ jobs:
222222
platforms: linux/amd64
223223
tags: ${{ steps.login-ecr.outputs.registry }}/${{ steps.ecr-repo.outputs.name }}:${{ github.sha }}-dev
224224
build-args: |
225-
SIM_SEARCH_LIVE_DEFAULT=true
226225
MSHIP_PLAN_MODE_DEFAULT=true
227226
max-cache-size-mb: ${{ matrix.cache_mb }}
228227

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

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@ import Link from 'next/link'
77
import { HomeSection } from '@/components/home/home-section'
88
import { OAUTH_SEARCH_READ_SCOPE, oauthScopeSatisfies } from '@/lib/auth/oauth-provider'
99
import { organizationRoutes } from '@/lib/navigation/paths'
10+
import {
11+
liveSearchProviderForCredential,
12+
supportsLiveSearchMode,
13+
} from '@/lib/sim-search/live/provider-catalog'
1014
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
1115
import { useAuthorizedApps } from '@/hooks/queries/oauth-provider'
1216
import { useOrganizationAccounts } from '@/hooks/queries/organization-accounts'
17+
import { useSearchIntegrations } from '@/hooks/queries/search-integrations'
1318

1419
type StepId = 'connect-integration' | 'connect-sim-search'
1520

@@ -69,9 +74,15 @@ function StepMark({ complete }: { complete: boolean }) {
6974
* and reads as done from the organization's real state: a connected account and an OAuth app authorized to use Search.
7075
*/
7176
export function GetStarted() {
72-
const { organization, viewer } = useOrganizationContext()
77+
const { organization, viewer, connectedAccountsAvailable } = useOrganizationContext()
7378
const routes = organizationRoutes(organization.id)
74-
const { data: accounts } = useOrganizationAccounts(organization.id)
79+
const canConnectIntegrations = viewer.canConnectSearchIntegrations && connectedAccountsAvailable
80+
const { data: accounts } = useOrganizationAccounts(
81+
canConnectIntegrations ? organization.id : undefined
82+
)
83+
const { data: integrations } = useSearchIntegrations(organization.id, {
84+
enabled: canConnectIntegrations,
85+
})
7586
const {
7687
data: authorizedApps,
7788
fetchNextPage,
@@ -83,6 +94,46 @@ export function GetStarted() {
8394
authorizedApps?.pages.some((page) =>
8495
page.apps.some((app) => oauthScopeSatisfies(app.scopes, OAUTH_SEARCH_READ_SCOPE))
8596
) ?? false
97+
const approvedProviders = new Set(
98+
integrations
99+
?.filter((integration) => integration.approved && integration.available !== false)
100+
.map((integration) => integration.connectorType)
101+
)
102+
const readyOptions = new Set(
103+
accounts?.credentialGroup?.options
104+
.filter((option) => {
105+
const provider = liveSearchProviderForCredential(option.provider)
106+
return (
107+
option.status === 'active' &&
108+
option.configurationStatus === 'ready' &&
109+
provider &&
110+
supportsLiveSearchMode(provider, 'member') &&
111+
approvedProviders.has(provider)
112+
)
113+
})
114+
.map((option) => option.id)
115+
)
116+
const readyMcpServers = new Set(
117+
accounts?.credentialGroup?.mcpServers
118+
.filter((server) => {
119+
const provider = liveSearchProviderForCredential(`mcp:${server.managedConnectorId}`)
120+
return (
121+
server.enabled &&
122+
provider &&
123+
approvedProviders.has(provider) &&
124+
accounts.availableMcpConnectors.some((id) => id === server.managedConnectorId)
125+
)
126+
})
127+
.map((server) => server.id)
128+
)
129+
const hasSearchConnection =
130+
accounts?.credentialGroup?.status === 'active' &&
131+
(accounts.viewerAccounts?.some(
132+
(account) => account.status === 'active' && readyOptions.has(account.optionId)
133+
) ||
134+
accounts.viewerMcpAccounts?.some(
135+
(account) => account.status === 'active' && readyMcpServers.has(account.mcpServerId)
136+
))
86137

87138
const hrefs: Record<StepId, string> = {
88139
'connect-integration': viewer.isAdmin
@@ -91,13 +142,12 @@ export function GetStarted() {
91142
'connect-sim-search': routes.settingsSection('search-mcp'),
92143
}
93144
const completed: Record<StepId, boolean> = {
94-
'connect-integration': Boolean(
95-
accounts?.viewerAccounts?.some((account) => account.status === 'active') ||
96-
accounts?.viewerMcpAccounts?.some((account) => account.status === 'active')
97-
),
145+
'connect-integration': Boolean(hasSearchConnection),
98146
'connect-sim-search': hasSearchAuthorization,
99147
}
100-
const steps = STEPS.filter((step) => step.id !== 'connect-sim-search' || viewer.canUseSearchMcp)
148+
const steps = STEPS.filter((step) =>
149+
step.id === 'connect-sim-search' ? viewer.canUseSearchMcp : canConnectIntegrations
150+
)
101151

102152
const [expanded, setExpanded] = useState(true)
103153
/**
@@ -132,6 +182,8 @@ export function GetStarted() {
132182
setExpanded((prev) => !prev)
133183
}
134184

185+
if (steps.length === 0) return null
186+
135187
return (
136188
<HomeSection
137189
title='Get started'

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

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -25,31 +25,6 @@ beforeEach(() => {
2525
})
2626

2727
describe('integrations page Slack context', () => {
28-
it('preserves a requested connection across login and validates it in the existing organization page', async () => {
29-
const selected = {
30-
...props,
31-
searchParams: Promise.resolve({
32-
connectorType: 'gmail',
33-
connectorId: 'source',
34-
credentialId: 'account',
35-
}),
36-
}
37-
const page = await OrganizationIntegrationsPage(selected)
38-
expect(page.props.connectionRequest).toMatchObject({
39-
userId: 'viewer',
40-
target: {
41-
type: 'link',
42-
connectorType: 'gmail',
43-
connectorId: 'source',
44-
credentialId: 'account',
45-
},
46-
})
47-
authMockFns.mockGetSession.mockResolvedValue(null)
48-
await expect(OrganizationIntegrationsPage(selected)).rejects.toThrow('NEXT_REDIRECT')
49-
expect(mockRedirect).toHaveBeenCalledWith(
50-
`/login?callbackUrl=${encodeURIComponent('/o/organization-a/integrations?connectorType=gmail&connectorId=source&credentialId=account')}`
51-
)
52-
})
5328
it('rejects unknown providers and reconnects without a source', async () => {
5429
for (const query of [
5530
{ connectorType: 'invented' },

‎apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ export function SearchSourceSetup({
133133
}
134134
const failedQuery = index.isError ? index : null
135135
const initialMode = (type: string) =>
136-
type === 'github' ? ('members' as const) : ('admin' as const)
136+
type === 'github' || type === 'slack' ? ('members' as const) : ('admin' as const)
137137

138138
const selectedAccessMode = selectedType ? initialMode(selectedType) : undefined
139139
const selectedAvailability = selectedMeta
@@ -359,7 +359,7 @@ export function SearchSourceSetup({
359359
isIntegrationAvailabilityReady,
360360
}
361361
)
362-
const available = type === 'github' ? members : central
362+
const available = initialMode(type) === 'members' ? members : central
363363
return (
364364
<SettingsResourceRow
365365
key={type}

‎apps/sim/hooks/queries/search-integrations.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ import { searchIntegrationKeys } from '@/hooks/queries/utils/search-integration-
1212

1313
export const SEARCH_INTEGRATIONS_STALE_TIME = 30_000
1414

15-
export function useSearchIntegrations(organizationId: string) {
15+
export function useSearchIntegrations(organizationId: string, options?: { enabled?: boolean }) {
1616
return useQuery({
1717
queryKey: searchIntegrationKeys.list(organizationId),
18+
enabled: Boolean(organizationId) && (options?.enabled ?? true),
1819
queryFn: async ({ signal }) =>
1920
(await requestJson(listSearchIntegrationsContract, { query: { organizationId }, signal }))
2021
.data,

‎apps/sim/lib/api/contracts/mothership-management-tools.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const examples = {
2626
{ action: 'update', scope: 'account', section: 'profile', changes: { timezone: 'UTC' } },
2727
],
2828
search_sources: [
29-
{ action: 'list', connectorType: 'google_drive', mine: true },
29+
{ action: 'list', connectorType: 'google_drive' },
3030
{ action: 'get', connectorId: 'source-1' },
3131
{ action: 'providers' },
3232
{ action: 'setup', connectorType: 'google_drive', accessMode: 'admin' },

‎apps/sim/lib/credential-groups/slack-managed-user-scopes.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { SLACK_RTS_USER_SCOPES } from '@/lib/sim-search/live/scopes'
2+
13
/**
24
* User-token policy requested and verified by Credential Group Slack OAuth.
35
* This is independent of the custom bot manifest and its configuration UI.
@@ -48,12 +50,13 @@ export const SLACK_CHANNEL_READ_SCOPES = [
4850

4951
export const SLACK_DM_READ_SCOPES = ['im:history', 'im:read', 'mpim:history', 'mpim:read'] as const
5052

51-
/** The shared organization app grants member access for channel and DM indexing. */
53+
/** Explicit Search setup grants channel, DM, and live retrieval permissions together. */
5254
export const SLACK_SEARCH_USER_SCOPES = [
5355
...SLACK_CHANNEL_READ_SCOPES,
5456
...SLACK_DM_READ_SCOPES,
5557
'users:read',
5658
'users:read.email',
59+
...SLACK_RTS_USER_SCOPES,
5760
] as const
5861

5962
/** Existing workflow options retain their scope policy; every user grant must attest identity. */

‎apps/sim/lib/credential-groups/slack-managed-users.test.ts‎

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -199,24 +199,46 @@ describe('Slack managed-user authorization', () => {
199199
existingScopes: undefined,
200200
requestedScopes: SLACK_MANAGED_USER_SCOPES,
201201
scopes: SLACK_SEARCH_USER_SCOPES,
202+
upgradesSearchPolicy: false,
202203
},
203204
{
204205
name: 'legacy workflow pool without explicit scopes',
205206
existing: true,
206207
existingScopes: undefined,
207208
requestedScopes: SLACK_SEARCH_USER_SCOPES,
208209
scopes: SLACK_MANAGED_USER_SCOPES,
210+
upgradesSearchPolicy: false,
209211
},
210212
{
211213
name: 'existing Search pool',
212214
existing: true,
213215
existingScopes: SLACK_SEARCH_USER_SCOPES,
214216
requestedScopes: SLACK_MANAGED_USER_SCOPES,
215217
scopes: SLACK_SEARCH_USER_SCOPES,
218+
upgradesSearchPolicy: false,
219+
},
220+
{
221+
name: 'legacy Search pool',
222+
existing: true,
223+
existingScopes: [
224+
'channels:history',
225+
'channels:read',
226+
'groups:history',
227+
'groups:read',
228+
'im:history',
229+
'im:read',
230+
'mpim:history',
231+
'mpim:read',
232+
'users:read',
233+
'users:read.email',
234+
],
235+
requestedScopes: SLACK_MANAGED_USER_SCOPES,
236+
scopes: SLACK_SEARCH_USER_SCOPES,
237+
upgradesSearchPolicy: true,
216238
},
217239
])(
218-
'verifies an organization $name without replacing its scope policy or disconnecting members',
219-
async ({ existing, existingScopes, requestedScopes, scopes }) => {
240+
'verifies an organization $name with its explicit Search or workflow scope policy',
241+
async ({ existing, existingScopes, requestedScopes, scopes, upgradesSearchPolicy }) => {
220242
const updatedAt = new Date('2026-08-12T00:00:00Z')
221243
const group = {
222244
id: 'group-1',
@@ -232,7 +254,9 @@ describe('Slack managed-user authorization', () => {
232254
required: true,
233255
authorizationAppId: 'slack:A123:T123',
234256
requiredScopes: existingScopes,
235-
scopeVersion: credentialGroupScopePolicyVersion([...scopes]),
257+
scopeVersion: credentialGroupScopePolicyVersion([
258+
...(existingScopes ?? SLACK_MANAGED_USER_SCOPES),
259+
]),
236260
},
237261
]
238262
: [],
@@ -264,7 +288,6 @@ describe('Slack managed-user authorization', () => {
264288
const attempt = await consumeSlackManagedUsersAttempt(created.state)
265289
expect(attempt?.requiredScopes).toEqual([...scopes])
266290
if (!attempt) throw new Error('Expected an organization authorization attempt')
267-
if (!existing) expect(attempt.requiredScopes).toHaveLength(10)
268291

269292
queueTableRows(schemaMock.slackApp, [app])
270293
queueTableRows(schemaMock.credentialGroup, [group])
@@ -301,9 +324,10 @@ describe('Slack managed-user authorization', () => {
301324
options: [expect.objectContaining({ requiredScopes: [...scopes] })],
302325
})
303326
)
304-
expect(dbChainMockFns.set).not.toHaveBeenCalledWith(
305-
expect.objectContaining({ managedOauthStatus: 'needs_reauth' })
306-
)
327+
if (!upgradesSearchPolicy)
328+
expect(dbChainMockFns.set).not.toHaveBeenCalledWith(
329+
expect.objectContaining({ managedOauthStatus: 'needs_reauth' })
330+
)
307331
}
308332
)
309333

‎apps/sim/lib/credential-groups/slack-managed-users.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import {
2424
} from '@/lib/credential-groups/provider-configuration'
2525
import {
2626
resolveSlackManagedUserScopes,
27+
SLACK_CHANNEL_READ_SCOPES,
28+
SLACK_DM_READ_SCOPES,
2729
SLACK_MANAGED_USER_CONFIGURATION_CALLBACK_PATH,
2830
SLACK_SEARCH_USER_SCOPES,
2931
} from '@/lib/credential-groups/slack-managed-user-scopes'
@@ -532,8 +534,19 @@ export async function createSlackManagedUsersAttempt(params: {
532534
clientId = app.clientId
533535
clientSecret = app.clientSecret
534536
appRevision = app.revision
537+
const retiredSearchScopes = new Set([
538+
...SLACK_CHANNEL_READ_SCOPES,
539+
...SLACK_DM_READ_SCOPES,
540+
'users:read',
541+
'users:read.email',
542+
])
543+
const upgradesSearchPolicy =
544+
existingOption?.requiredScopes?.length === retiredSearchScopes.size &&
545+
existingOption.requiredScopes.every((scope) => retiredSearchScopes.has(scope))
535546
requiredScopes = resolveSlackManagedUserScopes(
536-
existingOption ? existingOption.requiredScopes : SLACK_SEARCH_USER_SCOPES
547+
!existingOption || upgradesSearchPolicy
548+
? SLACK_SEARCH_USER_SCOPES
549+
: existingOption.requiredScopes
537550
)
538551
} else {
539552
if (!params.slackBotCredentialId)

‎apps/sim/lib/credential-groups/slack-provider.test.ts‎

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ describe('Slack member scope policy', () => {
3939
clientSecret: 'secret',
4040
appId: 'A1',
4141
teamId: 'T1',
42-
scopes: [...SLACK_MANAGED_USER_SCOPES],
42+
scopes: [...new Set([...SLACK_MANAGED_USER_SCOPES, ...SLACK_SEARCH_USER_SCOPES])],
4343
})
4444
mocks.exchange.mockResolvedValue({
4545
appId: 'A1',
@@ -79,7 +79,7 @@ describe('Slack member scope policy', () => {
7979
}
8080

8181
it.each([{ scopes: SLACK_SEARCH_USER_SCOPES }, { scopes: SLACK_MANAGED_USER_SCOPES }])(
82-
'requests RTS consent while retaining the stored option policy',
82+
'requests exactly the configured permissions without broadening workflow consent',
8383
async ({ scopes }) => {
8484
const current = context(scopes)
8585
const policy = await adapter.getPolicy(current.option, {
@@ -90,17 +90,7 @@ describe('Slack member scope policy', () => {
9090
const url = new URL(
9191
await authorization.buildAuthorizationUrl({ state: 'state', nonce: 'nonce' })
9292
)
93-
expect(url.searchParams.get('user_scope')?.split(',')).toEqual(
94-
expect.arrayContaining([
95-
...scopes,
96-
'search:read.public',
97-
'search:read.private',
98-
'search:read.im',
99-
'search:read.mpim',
100-
'search:read.files',
101-
'files:read',
102-
])
103-
)
93+
expect(url.searchParams.get('user_scope')?.split(',')).toEqual([...scopes])
10494
expect(policy.requiredScopes).toEqual([...scopes])
10595
}
10696
)

0 commit comments

Comments
 (0)