Skip to content

Commit 8880eca

Browse files
committed
Merge remote-tracking branch 'origin/staging' into codex/durable-agent-memory
2 parents dcebd6b + 6aabeb3 commit 8880eca

24 files changed

Lines changed: 1229 additions & 461 deletions

File tree

‎apps/docs/content/docs/search/slack.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,15 @@ Open **Settings → Sources → Add source** and select **Slack**. Complete **Se
5656

5757
### Configure an app in Slack
5858

59-
Select **Install Sim Search** to open setup. In **Create Slack app**, select **Create app** and choose the target workspace. Sim supplies a manifest with the required scopes, redirects, events, and interactivity URL. Keep **Token Rotation** disabled.
59+
Select **Install Sim Search** to open setup. In **Create Slack app**, select **Create app**, choose the target workspace, and complete Slack's app creation and installation flow. Sim supplies a manifest with the required scopes, redirects, events, and interactivity URL. Keep **Token Rotation** disabled and complete any required Slack administrator approval.
6060

6161
You can also open this wizard from **Settings → Sim Search in Slack → Set up**.
6262

6363
Return to Sim and select **Continue**. In **Slack app credentials**, paste **Client ID**, **Client Secret**, and **Signing Secret** from the new app’s **Basic Information → App Credentials**:
6464

6565
<Image className="mx-auto h-auto w-full max-w-md" src="/static/search/slack-setup.png" alt="Sim Search in Slack setup with placeholders for Client ID, Client Secret, and Signing Secret" width={515} height={375} />
6666

67-
Select **Continue**, then **Install in Slack**. Approve the installation in Slack. Sim saves the bot connection and opens **Settings → Sim Search in Slack**. Complete any required Slack administrator approval before continuing.
67+
Select **Continue**. In **Connect installed Slack app**, paste the **Bot User OAuth Token** from the app's **OAuth & Permissions** page, then select **Connect app**. If Slack requests updated permissions, approve them there first. Sim validates and saves the existing bot connection without starting another installation.
6868

6969
</Step>
7070
<Step>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { connectCustomSlackSearchContract } from '@/lib/api/contracts/knowledge/slack'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
9+
import { connectCustomSlackSearch } from '@/lib/knowledge/application/slack-search/setup'
10+
11+
export const POST = defineInternalJsonRoute({
12+
contract: connectCustomSlackSearchContract,
13+
auth: internalSessionAuth,
14+
operation: knowledgeOperations.connectCustomSlackInstallation,
15+
rateLimit: internalRateLimits.user({ bucketName: 'slack-search-settings' }),
16+
errorPolicy: internalOrchestrationErrorPolicy,
17+
mapInput: ({ body }) => body,
18+
useCase: connectCustomSlackSearch,
19+
})

‎apps/sim/app/api/knowledge/slack/setup/route.test.ts‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@
22
import { authMockFns, createMockRequest } from '@sim/testing'
33
import { beforeEach, describe, expect, it, vi } from 'vitest'
44

5-
const mocks = vi.hoisted(() => ({ prepare: vi.fn(), start: vi.fn() }))
5+
const mocks = vi.hoisted(() => ({ prepare: vi.fn(), start: vi.fn(), connect: vi.fn() }))
66
vi.mock('@/lib/knowledge/application/slack-search/setup', async () => {
77
const { knowledgeOperations } = await import('@/lib/knowledge/application/operations')
88
return {
9+
connectCustomSlackSearch: {
10+
operation: knowledgeOperations.connectCustomSlackInstallation,
11+
execute: mocks.connect,
12+
},
913
prepareSlackSearchSetup: {
1014
operation: knowledgeOperations.prepareSlackInstallation,
1115
execute: mocks.prepare,
@@ -19,9 +23,15 @@ vi.mock('@/lib/knowledge/application/slack-search/setup', async () => {
1923

2024
import { OrchestrationError } from '@/lib/core/orchestration/types'
2125
import { POST as start } from '@/app/api/knowledge/slack/oauth/route'
26+
import { POST as connect } from '@/app/api/knowledge/slack/setup/connect/route'
2227
import { POST as prepare } from '@/app/api/knowledge/slack/setup/route'
2328

24-
const input = { organizationId: 'organization-1', name: 'Sim Search', description: 'Search' }
29+
const input = {
30+
organizationId: 'organization-1',
31+
name: 'Sim Search',
32+
description: 'Search',
33+
botToken: 'xoxb-existing',
34+
}
2535

2636
beforeEach(() => {
2737
vi.clearAllMocks()
@@ -34,6 +44,7 @@ beforeEach(() => {
3444
describe.each([
3545
['prepare', prepare, mocks.prepare],
3646
['OAuth', start, mocks.start],
47+
['connect installed app', connect, mocks.connect],
3748
] as const)('Slack %s route errors', (_name, route, execute) => {
3849
it('returns application validation errors', async () => {
3950
execute.mockRejectedValue(
@@ -59,3 +70,23 @@ describe.each([
5970
expect(execute).not.toHaveBeenCalled()
6071
})
6172
})
73+
74+
it('connects an installed app with the current session and returns no install URL', async () => {
75+
mocks.connect.mockResolvedValueOnce({ organizationId: input.organizationId })
76+
const response = await connect(createMockRequest('POST', input))
77+
expect(response.status).toBe(200)
78+
expect(await response.json()).toMatchObject({ organizationId: input.organizationId })
79+
expect(mocks.connect).toHaveBeenCalledWith(
80+
expect.objectContaining({
81+
principal: { kind: 'session', userId: 'admin', sessionId: 'session' },
82+
input,
83+
})
84+
)
85+
expect(mocks.start).not.toHaveBeenCalled()
86+
})
87+
88+
it.each(['', ' ', undefined])('requires an existing bot token: %s', async (botToken) => {
89+
const response = await connect(createMockRequest('POST', { ...input, botToken }))
90+
expect(response.status).toBe(400)
91+
expect(mocks.connect).not.toHaveBeenCalled()
92+
})

‎apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.test.tsx‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
1212
configure: vi.fn(),
1313
remove: vi.fn(),
1414
install: vi.fn(),
15+
connect: vi.fn(),
1516
refetch: vi.fn(),
1617
copy: vi.fn(),
1718
removeError: null as Error | null,
@@ -25,6 +26,7 @@ vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({
2526
useOrganizationContext: mocks.context,
2627
}))
2728
vi.mock('@/hooks/queries/slack-search', () => ({
29+
useConnectCustomSlackSearch: () => ({ mutate: mocks.connect, isPending: false, reset: vi.fn() }),
2830
useSlackSearchInstallations: mocks.list,
2931
useSlackSearchManifest: mocks.manifest,
3032
useConfigureSlackSearch: () => ({ mutate: mocks.configure, isPending: false }),
@@ -395,16 +397,26 @@ describe('Slack Search settings and shared wizard', () => {
395397
document.querySelectorAll('input[placeholder="Leave blank to keep the saved value"]')
396398
).toHaveLength(3)
397399
await click('Continue')
398-
await click('Reconnect in Slack')
399-
expect(mocks.install).toHaveBeenCalledWith(
400+
expect(button('Connect app')).toBeDisabled()
401+
await act(async () => {
402+
const input = document.querySelector<HTMLInputElement>('input[placeholder="xoxb-..."]')!
403+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(
404+
input,
405+
'xoxb-installed'
406+
)
407+
input.dispatchEvent(new Event('input', { bubbles: true }))
408+
})
409+
await click('Connect app')
410+
expect(mocks.connect).toHaveBeenCalledWith(
400411
expect.objectContaining({
401412
installationId: 'installation-1',
402413
organizationId: 'org-1',
403414
name: 'Sim Search',
404415
}),
405416
expect.any(Object)
406417
)
407-
expect(mocks.install.mock.calls[0][0]).not.toHaveProperty('clientSecret')
418+
expect(mocks.connect.mock.calls[0][0]).not.toHaveProperty('clientSecret')
419+
expect(mocks.install).not.toHaveBeenCalled()
408420
})
409421

410422
it('keeps the update action available when clipboard access fails', async () => {

‎apps/sim/components/integrations/slack-search-setup-wizard.tsx‎

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ import {
1616
SLACK_SEARCH_DEFAULT_DESCRIPTION,
1717
SLACK_SEARCH_DEFAULT_NAME,
1818
} from '@/lib/slack-search/manifest'
19-
import { useSlackSearchManifest, useStartSlackSearchOAuth } from '@/hooks/queries/slack-search'
19+
import {
20+
useConnectCustomSlackSearch,
21+
useSlackSearchManifest,
22+
useStartSlackSearchOAuth,
23+
} from '@/hooks/queries/slack-search'
2024

2125
interface SlackSearchSetupWizardProps {
2226
organizationId: string
@@ -40,14 +44,16 @@ export function SlackSearchSetupWizard({
4044
const description = SLACK_SEARCH_DEFAULT_DESCRIPTION
4145
const prepare = useSlackSearchManifest(organizationId, name)
4246
const oauth = useStartSlackSearchOAuth()
43-
const [step, setStep] = useState<'manifest' | 'credentials' | 'install'>('manifest')
47+
const connect = useConnectCustomSlackSearch()
48+
const [step, setStep] = useState<'manifest' | 'credentials' | 'token'>('manifest')
4449
const [clientId, setClientId] = useState('')
4550
const [clientSecret, setClientSecret] = useState('')
4651
const [signingSecret, setSigningSecret] = useState('')
52+
const [botToken, setBotToken] = useState('')
4753
const [configurationCopied, setConfigurationCopied] = useState(false)
4854
const [copyError, setCopyError] = useState<Error | null>(null)
49-
const error = prepare.error ?? oauth.error ?? copyError
50-
const busy = oauth.isPending
55+
const error = prepare.error ?? oauth.error ?? connect.error ?? copyError
56+
const busy = oauth.isPending || connect.isPending
5157
const configuredAppId = appId ?? prepare.data?.existingApp?.appId
5258

5359
async function copyConfiguration() {
@@ -83,20 +89,21 @@ export function SlackSearchSetupWizard({
8389
if (step === 'manifest') {
8490
setStep('credentials')
8591
} else if (step === 'credentials') {
86-
setStep('install')
92+
setStep('token')
8793
} else {
88-
oauth.mutate(
94+
connect.mutate(
8995
{
9096
organizationId,
9197
installationId,
9298
name,
9399
description,
100+
botToken: botToken.trim(),
94101
...(clientId.trim() ? { clientId: clientId.trim() } : {}),
95102
...(clientSecret.trim() ? { clientSecret: clientSecret.trim() } : {}),
96103
...(signingSecret.trim() ? { signingSecret: signingSecret.trim() } : {}),
97104
},
98105
{
99-
onSuccess: ({ authorizationUrl }) => window.location.assign(authorizationUrl),
106+
onSuccess: onClose,
100107
}
101108
)
102109
}
@@ -196,9 +203,7 @@ export function SlackSearchSetupWizard({
196203
: 'Create Slack app'
197204
: step === 'credentials'
198205
? 'Slack app credentials'
199-
: installationId
200-
? 'Reconnect in Slack'
201-
: 'Install in Slack'
206+
: 'Connect installed Slack app'
202207

203208
return (
204209
<ChipModal
@@ -220,7 +225,7 @@ export function SlackSearchSetupWizard({
220225
? configurationCopied
221226
? 'Configuration copied. In Slack, replace the JSON under App Manifest and save.'
222227
: 'Copy the configuration, then replace the JSON under App Manifest in Slack.'
223-
: 'Create the app in Slack, then return here to add its credentials.'}
228+
: 'Create and install the app in Slack, then return here to add its credentials.'}
224229
</p>
225230
)}
226231
{step === 'credentials' && (
@@ -268,13 +273,22 @@ export function SlackSearchSetupWizard({
268273
/>
269274
</>
270275
)}
271-
{step === 'install' && (
272-
<p className='px-2 text-[var(--text-secondary)] text-sm'>
273-
{installationId
274-
? 'Approve the updated permissions for'
275-
: 'Choose your workspace and approve'}{' '}
276-
{name} in Slack.
277-
</p>
276+
{step === 'token' && (
277+
<>
278+
<p className='px-2 text-[var(--text-secondary)] text-sm'>
279+
Copy the Bot User OAuth Token from OAuth &amp; Permissions in your installed Slack
280+
app. If Slack requests updated permissions, approve them there first.
281+
</p>
282+
<ChipModalField
283+
type='input'
284+
title='Bot User OAuth Token'
285+
value={botToken}
286+
onChange={setBotToken}
287+
inputType='password'
288+
placeholder='xoxb-...'
289+
required
290+
/>
291+
</>
278292
)}
279293
<ChipModalError>{error?.message}</ChipModalError>
280294
</ChipModalBody>
@@ -319,19 +333,22 @@ export function SlackSearchSetupWizard({
319333
disabled: busy,
320334
onClick: () => {
321335
oauth.reset()
322-
setStep(step === 'install' ? 'credentials' : 'manifest')
336+
connect.reset()
337+
setStep(step === 'token' ? 'credentials' : 'manifest')
323338
},
324339
}
325340
}
326341
primaryAction={{
327-
label: busy ? 'Connecting…' : step === 'install' ? title : 'Continue',
342+
label: busy ? 'Connecting…' : step === 'token' ? 'Connect app' : 'Continue',
328343
onClick: advance,
329344
disabled:
330345
busy ||
331346
(step === 'manifest'
332347
? Boolean(configuredAppId && !configurationCopied)
333-
: !installationId &&
334-
(!clientId.trim() || !clientSecret.trim() || !signingSecret.trim())),
348+
: step === 'token'
349+
? !botToken.trim()
350+
: !installationId &&
351+
(!clientId.trim() || !clientSecret.trim() || !signingSecret.trim())),
335352
}}
336353
/>
337354
</ChipModal>

‎apps/sim/ee/credential-groups/components/slack-managed-users-access.test.tsx‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
1515
refetchApps: vi.fn(),
1616
manifest: vi.fn(),
1717
install: vi.fn(),
18+
connect: vi.fn(),
1819
accounts: vi.fn(),
1920
refetchAccounts: vi.fn(),
2021
}))
@@ -31,6 +32,7 @@ vi.mock('@/hooks/queries/scoped-credentials', () => ({
3132
}))
3233

3334
vi.mock('@/hooks/queries/slack-search', () => ({
35+
useConnectCustomSlackSearch: () => ({ mutate: mocks.connect, isPending: false, reset: vi.fn() }),
3436
useSlackSearchInstallations: mocks.apps,
3537
useSlackSearchManifest: mocks.manifest,
3638
useStartSlackSearchOAuth: () => ({ mutate: mocks.install, isPending: false, reset: vi.fn() }),
@@ -389,6 +391,61 @@ describe('Slack member access selection', () => {
389391
expect(mocks.onOpenChange).not.toHaveBeenCalled()
390392
})
391393

394+
it('finishes manifest setup using the installed bot token without installing the app again', async () => {
395+
await render(undefined, [], 'org-1')
396+
await clickButton('Install Sim Search')
397+
expect(document.querySelector('a')?.href).toBe('https://api.slack.com/apps')
398+
await clickButton('Continue')
399+
await fill('Paste your Slack app’s client ID', 'fixture-client')
400+
await fill('Paste your Slack app’s client secret', 'fixture-secret')
401+
await fill('Paste your Slack app’s signing secret', 'fixture-signing')
402+
await clickButton('Continue')
403+
expect(document.body.textContent).toContain('Connect installed Slack app')
404+
expect(document.body.textContent).not.toContain('Install in Slack')
405+
await fill('xoxb-...', 'xoxb-already-installed')
406+
await clickButton('Connect app')
407+
expect(mocks.connect).toHaveBeenCalledExactlyOnceWith(
408+
{
409+
organizationId: 'org-1',
410+
installationId: undefined,
411+
name: 'Sim Search',
412+
description: expect.any(String),
413+
clientId: 'fixture-client',
414+
clientSecret: 'fixture-secret',
415+
signingSecret: 'fixture-signing',
416+
botToken: 'xoxb-already-installed',
417+
},
418+
expect.any(Object)
419+
)
420+
/** The mutation refreshes installations before closing the nested wizard. */
421+
mocks.apps.mockReturnValue({
422+
isSuccess: true,
423+
isPending: false,
424+
error: null,
425+
data: {
426+
installations: [
427+
{
428+
id: 'installed',
429+
appId: 'A1',
430+
teamId: 'T1',
431+
teamName: 'Test workspace',
432+
appKind: 'custom',
433+
enabled: true,
434+
needsValidation: false,
435+
},
436+
],
437+
bots: [],
438+
},
439+
})
440+
await act(async () => mocks.connect.mock.calls[0][1].onSuccess())
441+
expect(document.body.textContent).toContain('Installed in Test workspace')
442+
expect(document.body.textContent).not.toContain('Install Sim Search first')
443+
expect(document.body.textContent).not.toContain('Connect installed Slack app')
444+
expect(mocks.install).not.toHaveBeenCalled()
445+
expect(mocks.start).not.toHaveBeenCalled()
446+
expect(window.open).not.toHaveBeenCalled()
447+
})
448+
392449
it('waits for the installed app lookup instead of offering a duplicate installation', async () => {
393450
mocks.apps.mockReturnValue({ isPending: true, isSuccess: false, data: undefined, error: null })
394451
await render(undefined, [], 'org-1')

‎apps/sim/hooks/queries/organization-accounts.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ import {
2424
useRevokeOrganizationAccountEnrollment,
2525
useUpdateOrganizationAccounts,
2626
} from '@/hooks/queries/organization-accounts'
27-
import { slackSearchKeys } from '@/hooks/queries/slack-search'
2827
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
2928
import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys'
3029
import { selectorKeys, selectorQueryRoots } from '@/hooks/queries/utils/selector-keys'
30+
import { slackSearchKeys } from '@/hooks/queries/utils/slack-search-keys'
3131

3232
describe('personal account disconnect', () => {
3333
it.each([true, false])(

‎apps/sim/hooks/queries/organization-accounts.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,11 @@ import {
3939
updateOrganizationAccountWorkspaceAccessContract,
4040
} from '@/lib/api/contracts/organization-accounts'
4141
import { personalCredentialKeys } from '@/hooks/queries/personal-credentials'
42-
import { slackSearchKeys } from '@/hooks/queries/slack-search'
4342
import { mcpKeys } from '@/hooks/queries/utils/mcp-keys'
4443
import { resetOrganizationSearchAccess } from '@/hooks/queries/utils/reset-organization-search-access'
4544
import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys'
4645
import { invalidateSelectorQueries } from '@/hooks/queries/utils/selector-keys'
46+
import { slackSearchKeys } from '@/hooks/queries/utils/slack-search-keys'
4747

4848
export const ORGANIZATION_ACCOUNTS_STALE_TIME = 30_000
4949

0 commit comments

Comments
 (0)