Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/docs/content/docs/search/slack.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,15 @@ Open **Settings → Sources → Add source** and select **Slack**. Complete **Se

### Configure an app in Slack

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.
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.

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

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**:

<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} />

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.
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.

</Step>
<Step>
Expand Down
19 changes: 19 additions & 0 deletions apps/sim/app/api/knowledge/slack/setup/connect/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { connectCustomSlackSearchContract } from '@/lib/api/contracts/knowledge/slack'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
import { connectCustomSlackSearch } from '@/lib/knowledge/application/slack-search/setup'

export const POST = defineInternalJsonRoute({
contract: connectCustomSlackSearchContract,
auth: internalSessionAuth,
operation: knowledgeOperations.connectCustomSlackInstallation,
rateLimit: internalRateLimits.user({ bucketName: 'slack-search-settings' }),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ body }) => body,
useCase: connectCustomSlackSearch,
})
35 changes: 33 additions & 2 deletions apps/sim/app/api/knowledge/slack/setup/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@
import { authMockFns, createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({ prepare: vi.fn(), start: vi.fn() }))
const mocks = vi.hoisted(() => ({ prepare: vi.fn(), start: vi.fn(), connect: vi.fn() }))
vi.mock('@/lib/knowledge/application/slack-search/setup', async () => {
const { knowledgeOperations } = await import('@/lib/knowledge/application/operations')
return {
connectCustomSlackSearch: {
operation: knowledgeOperations.connectCustomSlackInstallation,
execute: mocks.connect,
},
prepareSlackSearchSetup: {
operation: knowledgeOperations.prepareSlackInstallation,
execute: mocks.prepare,
Expand All @@ -19,9 +23,15 @@ vi.mock('@/lib/knowledge/application/slack-search/setup', async () => {

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

const input = { organizationId: 'organization-1', name: 'Sim Search', description: 'Search' }
const input = {
organizationId: 'organization-1',
name: 'Sim Search',
description: 'Search',
botToken: 'xoxb-existing',
}

beforeEach(() => {
vi.clearAllMocks()
Expand All @@ -34,6 +44,7 @@ beforeEach(() => {
describe.each([
['prepare', prepare, mocks.prepare],
['OAuth', start, mocks.start],
['connect installed app', connect, mocks.connect],
] as const)('Slack %s route errors', (_name, route, execute) => {
it('returns application validation errors', async () => {
execute.mockRejectedValue(
Expand All @@ -59,3 +70,23 @@ describe.each([
expect(execute).not.toHaveBeenCalled()
})
})

it('connects an installed app with the current session and returns no install URL', async () => {
mocks.connect.mockResolvedValueOnce({ organizationId: input.organizationId })
const response = await connect(createMockRequest('POST', input))
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ organizationId: input.organizationId })
expect(mocks.connect).toHaveBeenCalledWith(
expect.objectContaining({
principal: { kind: 'session', userId: 'admin', sessionId: 'session' },
input,
})
)
expect(mocks.start).not.toHaveBeenCalled()
})

it.each(['', ' ', undefined])('requires an existing bot token: %s', async (botToken) => {
const response = await connect(createMockRequest('POST', { ...input, botToken }))
expect(response.status).toBe(400)
expect(mocks.connect).not.toHaveBeenCalled()
})
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
configure: vi.fn(),
remove: vi.fn(),
install: vi.fn(),
connect: vi.fn(),
refetch: vi.fn(),
copy: vi.fn(),
removeError: null as Error | null,
Expand All @@ -25,6 +26,7 @@ vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({
useOrganizationContext: mocks.context,
}))
vi.mock('@/hooks/queries/slack-search', () => ({
useConnectCustomSlackSearch: () => ({ mutate: mocks.connect, isPending: false, reset: vi.fn() }),
useSlackSearchInstallations: mocks.list,
useSlackSearchManifest: mocks.manifest,
useConfigureSlackSearch: () => ({ mutate: mocks.configure, isPending: false }),
Expand Down Expand Up @@ -395,16 +397,26 @@ describe('Slack Search settings and shared wizard', () => {
document.querySelectorAll('input[placeholder="Leave blank to keep the saved value"]')
).toHaveLength(3)
await click('Continue')
await click('Reconnect in Slack')
expect(mocks.install).toHaveBeenCalledWith(
expect(button('Connect app')).toBeDisabled()
await act(async () => {
const input = document.querySelector<HTMLInputElement>('input[placeholder="xoxb-..."]')!
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(
input,
'xoxb-installed'
)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
await click('Connect app')
expect(mocks.connect).toHaveBeenCalledWith(
expect.objectContaining({
installationId: 'installation-1',
organizationId: 'org-1',
name: 'Sim Search',
}),
expect.any(Object)
)
expect(mocks.install.mock.calls[0][0]).not.toHaveProperty('clientSecret')
expect(mocks.connect.mock.calls[0][0]).not.toHaveProperty('clientSecret')
expect(mocks.install).not.toHaveBeenCalled()
})

it('keeps the update action available when clipboard access fails', async () => {
Expand Down
61 changes: 39 additions & 22 deletions apps/sim/components/integrations/slack-search-setup-wizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ import {
SLACK_SEARCH_DEFAULT_DESCRIPTION,
SLACK_SEARCH_DEFAULT_NAME,
} from '@/lib/slack-search/manifest'
import { useSlackSearchManifest, useStartSlackSearchOAuth } from '@/hooks/queries/slack-search'
import {
useConnectCustomSlackSearch,
useSlackSearchManifest,
useStartSlackSearchOAuth,
} from '@/hooks/queries/slack-search'

interface SlackSearchSetupWizardProps {
organizationId: string
Expand All @@ -40,14 +44,16 @@ export function SlackSearchSetupWizard({
const description = SLACK_SEARCH_DEFAULT_DESCRIPTION
const prepare = useSlackSearchManifest(organizationId, name)
const oauth = useStartSlackSearchOAuth()
const [step, setStep] = useState<'manifest' | 'credentials' | 'install'>('manifest')
const connect = useConnectCustomSlackSearch()
const [step, setStep] = useState<'manifest' | 'credentials' | 'token'>('manifest')
const [clientId, setClientId] = useState('')
const [clientSecret, setClientSecret] = useState('')
const [signingSecret, setSigningSecret] = useState('')
const [botToken, setBotToken] = useState('')
const [configurationCopied, setConfigurationCopied] = useState(false)
const [copyError, setCopyError] = useState<Error | null>(null)
const error = prepare.error ?? oauth.error ?? copyError
const busy = oauth.isPending
const error = prepare.error ?? oauth.error ?? connect.error ?? copyError
const busy = oauth.isPending || connect.isPending
const configuredAppId = appId ?? prepare.data?.existingApp?.appId

async function copyConfiguration() {
Expand Down Expand Up @@ -83,20 +89,21 @@ export function SlackSearchSetupWizard({
if (step === 'manifest') {
setStep('credentials')
} else if (step === 'credentials') {
setStep('install')
setStep('token')
} else {
oauth.mutate(
connect.mutate(
{
organizationId,
installationId,
name,
description,
botToken: botToken.trim(),
...(clientId.trim() ? { clientId: clientId.trim() } : {}),
...(clientSecret.trim() ? { clientSecret: clientSecret.trim() } : {}),
...(signingSecret.trim() ? { signingSecret: signingSecret.trim() } : {}),
},
{
onSuccess: ({ authorizationUrl }) => window.location.assign(authorizationUrl),
onSuccess: onClose,
}
)
}
Expand Down Expand Up @@ -196,9 +203,7 @@ export function SlackSearchSetupWizard({
: 'Create Slack app'
: step === 'credentials'
? 'Slack app credentials'
: installationId
? 'Reconnect in Slack'
: 'Install in Slack'
: 'Connect installed Slack app'

return (
<ChipModal
Expand All @@ -220,7 +225,7 @@ export function SlackSearchSetupWizard({
? configurationCopied
? 'Configuration copied. In Slack, replace the JSON under App Manifest and save.'
: 'Copy the configuration, then replace the JSON under App Manifest in Slack.'
: 'Create the app in Slack, then return here to add its credentials.'}
: 'Create and install the app in Slack, then return here to add its credentials.'}
</p>
)}
{step === 'credentials' && (
Expand Down Expand Up @@ -268,13 +273,22 @@ export function SlackSearchSetupWizard({
/>
</>
)}
{step === 'install' && (
<p className='px-2 text-[var(--text-secondary)] text-sm'>
{installationId
? 'Approve the updated permissions for'
: 'Choose your workspace and approve'}{' '}
{name} in Slack.
</p>
{step === 'token' && (
<>
<p className='px-2 text-[var(--text-secondary)] text-sm'>
Copy the Bot User OAuth Token from OAuth &amp; Permissions in your installed Slack
app. If Slack requests updated permissions, approve them there first.
</p>
<ChipModalField
type='input'
title='Bot User OAuth Token'
value={botToken}
onChange={setBotToken}
inputType='password'
placeholder='xoxb-...'
required
/>
</>
)}
<ChipModalError>{error?.message}</ChipModalError>
</ChipModalBody>
Expand Down Expand Up @@ -319,19 +333,22 @@ export function SlackSearchSetupWizard({
disabled: busy,
onClick: () => {
oauth.reset()
setStep(step === 'install' ? 'credentials' : 'manifest')
connect.reset()
setStep(step === 'token' ? 'credentials' : 'manifest')
},
}
}
primaryAction={{
label: busy ? 'Connecting…' : step === 'install' ? title : 'Continue',
label: busy ? 'Connecting…' : step === 'token' ? 'Connect app' : 'Continue',
onClick: advance,
disabled:
busy ||
(step === 'manifest'
? Boolean(configuredAppId && !configurationCopied)
: !installationId &&
(!clientId.trim() || !clientSecret.trim() || !signingSecret.trim())),
: step === 'token'
? !botToken.trim()
: !installationId &&
(!clientId.trim() || !clientSecret.trim() || !signingSecret.trim())),
}}
/>
</ChipModal>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
refetchApps: vi.fn(),
manifest: vi.fn(),
install: vi.fn(),
connect: vi.fn(),
accounts: vi.fn(),
refetchAccounts: vi.fn(),
}))
Expand All @@ -31,6 +32,7 @@ vi.mock('@/hooks/queries/scoped-credentials', () => ({
}))

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

it('finishes manifest setup using the installed bot token without installing the app again', async () => {
await render(undefined, [], 'org-1')
await clickButton('Install Sim Search')
expect(document.querySelector('a')?.href).toBe('https://api.slack.com/apps')
await clickButton('Continue')
await fill('Paste your Slack app’s client ID', 'fixture-client')
await fill('Paste your Slack app’s client secret', 'fixture-secret')
await fill('Paste your Slack app’s signing secret', 'fixture-signing')
await clickButton('Continue')
expect(document.body.textContent).toContain('Connect installed Slack app')
expect(document.body.textContent).not.toContain('Install in Slack')
await fill('xoxb-...', 'xoxb-already-installed')
await clickButton('Connect app')
expect(mocks.connect).toHaveBeenCalledExactlyOnceWith(
{
organizationId: 'org-1',
installationId: undefined,
name: 'Sim Search',
description: expect.any(String),
clientId: 'fixture-client',
clientSecret: 'fixture-secret',
signingSecret: 'fixture-signing',
botToken: 'xoxb-already-installed',
},
expect.any(Object)
)
/** The mutation refreshes installations before closing the nested wizard. */
mocks.apps.mockReturnValue({
isSuccess: true,
isPending: false,
error: null,
data: {
installations: [
{
id: 'installed',
appId: 'A1',
teamId: 'T1',
teamName: 'Test workspace',
appKind: 'custom',
enabled: true,
needsValidation: false,
},
],
bots: [],
},
})
await act(async () => mocks.connect.mock.calls[0][1].onSuccess())
expect(document.body.textContent).toContain('Installed in Test workspace')
expect(document.body.textContent).not.toContain('Install Sim Search first')
expect(document.body.textContent).not.toContain('Connect installed Slack app')
expect(mocks.install).not.toHaveBeenCalled()
expect(mocks.start).not.toHaveBeenCalled()
expect(window.open).not.toHaveBeenCalled()
})

it('waits for the installed app lookup instead of offering a duplicate installation', async () => {
mocks.apps.mockReturnValue({ isPending: true, isSuccess: false, data: undefined, error: null })
await render(undefined, [], 'org-1')
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/hooks/queries/organization-accounts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ import {
useRevokeOrganizationAccountEnrollment,
useUpdateOrganizationAccounts,
} from '@/hooks/queries/organization-accounts'
import { slackSearchKeys } from '@/hooks/queries/slack-search'
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys'
import { selectorKeys, selectorQueryRoots } from '@/hooks/queries/utils/selector-keys'
import { slackSearchKeys } from '@/hooks/queries/utils/slack-search-keys'

describe('personal account disconnect', () => {
it.each([true, false])(
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/hooks/queries/organization-accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ import {
updateOrganizationAccountWorkspaceAccessContract,
} from '@/lib/api/contracts/organization-accounts'
import { personalCredentialKeys } from '@/hooks/queries/personal-credentials'
import { slackSearchKeys } from '@/hooks/queries/slack-search'
import { mcpKeys } from '@/hooks/queries/utils/mcp-keys'
import { resetOrganizationSearchAccess } from '@/hooks/queries/utils/reset-organization-search-access'
import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys'
import { invalidateSelectorQueries } from '@/hooks/queries/utils/selector-keys'
import { slackSearchKeys } from '@/hooks/queries/utils/slack-search-keys'

export const ORGANIZATION_ACCOUNTS_STALE_TIME = 30_000

Expand Down
Loading
Loading