From fa046d72d2b302f352275c2aaf8189f8f772684d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 24 Sep 2026 16:38:01 -0700 Subject: [PATCH 1/4] fix(slack): make manifest copying direct and order setup steps --- .../connect-slack-bot-modal.tsx | 30 +++- .../slack-setup-wizard.test.tsx | 105 +++++++++++++ .../slack-setup-wizard/slack-setup-wizard.tsx | 140 ++++++------------ .../integrations/slack-app-manifest.test.tsx | 76 ++++++++++ .../integrations/slack-app-manifest.tsx | 59 +++++++- .../slack-search-setup-wizard.tsx | 97 ++++++------ 6 files changed, 342 insertions(+), 165 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.test.tsx create mode 100644 apps/sim/components/integrations/slack-app-manifest.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx index 213c79d793e..d48f6ad6f06 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx @@ -290,12 +290,12 @@ export function ConnectSlackBotModal({ + 0}> + + 0}> - 0}> - - -
Copy your manifest:
+
Copy the manifest for your selected permissions:
@@ -543,7 +543,12 @@ function StepCreate({ manifestJson, reconnect }: StepCreateProps) { workspace.
- Paste your manifest, then click Next → Create. + Select JSON, paste the manifest, then click Next →{' '} + Create. + + + In App Manifest, verify the event Request URL if shown. + You can verify it before connecting the bot.
@@ -576,13 +581,22 @@ function StepSecret({ value, onChange }: SecretStepProps) { ) } -function StepToken({ value, onChange }: SecretStepProps) { +function StepToken({ value, onChange, reconnect }: SecretStepProps & { reconnect: boolean }) { return (
- In Slack, open Install App → Install to Workspace and - authorize. + {reconnect ? ( + <> + Open OAuth & Permissions in your existing Slack app. Reinstall + only if Slack requests it. + + ) : ( + <> + In Slack, open OAuth & Permissions →{' '} + Install to Workspace and approve access. + + )} Copy the Bot User OAuth Token (starts with xoxb-). diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.test.tsx new file mode 100644 index 00000000000..0ac19e0cd56 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.test.tsx @@ -0,0 +1,105 @@ +/** @vitest-environment jsdom */ +import { act, useState } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loading: true, + copy: vi.fn(), +})) +vi.mock('@/hooks/use-webhook-management', () => ({ + useWebhookManagement: () => ({ + webhookUrl: 'https://sim.test/api/webhooks/trigger/block-1', + isLoading: mocks.loading, + }), +})) +vi.mock('@/stores/workflows/registry/store', () => ({ useWorkflowRegistry: () => 'workflow-1' })) +vi.mock('@/stores/workflows/subblock/store', () => ({ + useSubBlockStore: (selector: (state: { workflowValues: object }) => unknown) => + selector({ workflowValues: {} }), +})) +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value', + () => ({ + useSubBlockValue: (_blockId: string, key: string) => + useState(key === 'botDisplayName' ? 'Test workflow bot' : ''), + }) +) + +import { SlackSetupWizard } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard' + +let root: Root +let container: HTMLDivElement +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.stubGlobal('navigator', { clipboard: { writeText: mocks.copy } }) + mocks.loading = true + mocks.copy.mockReset().mockResolvedValue(undefined) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.unstubAllGlobals() +}) +async function render() { + await act(async () => root.render()) +} +function button(name: string) { + const element = [...document.querySelectorAll('button')].find( + (button) => button.textContent?.trim() === name + ) + expect(element).toBeDefined() + return element! +} +async function click(name: string) { + await act(async () => button(name).click()) +} +async function fill(placeholder: string, value: string) { + const input = document.querySelector(`input[placeholder="${placeholder}"]`)! + await act(async () => input.focus()) + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) +} + +it('waits for the webhook URL without requiring an early deployment, then copies the current manifest', async () => { + await render() + await click('Set up Slack app') + await click('Next') + expect(button('Copy manifest')).toBeDisabled() + expect(button('Next')).toBeDisabled() + expect(document.body).toHaveTextContent('Loading the webhook URL') + expect(document.body).not.toHaveTextContent('Deploy once') + mocks.loading = false + await render() + await click('Copy manifest') + const manifest = JSON.parse(mocks.copy.mock.calls[0][0]) + expect(manifest.display_information.name).toBe('Test workflow bot') + expect(manifest.settings.event_subscriptions.request_url).toBe( + 'https://sim.test/api/webhooks/trigger/block-1' + ) +}) + +it('collects the token before the signing secret and retains both when going back', async () => { + mocks.loading = false + await render() + await click('Set up Slack app') + await click('Next') + await click('Next') + expect(button('Next')).toBeDisabled() + await fill('xoxb-...', 'xoxb-test-token') + await click('Next') + expect(button('Next')).toBeDisabled() + await fill('Paste your signing secret', 'test-secret') + await click('Back') + await click('Next') + expect(button('Next')).not.toBeDisabled() + await click('Next') + expect(document.body).toHaveTextContent('save and deploy the workflow with these credentials') + expect(document.body).toHaveTextContent('verify the event Request URL') + expect(document.body).not.toHaveTextContent('automatically') +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.tsx index ccc23a6decf..5c2f574a1c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.tsx @@ -1,9 +1,10 @@ 'use client' import { type ReactNode, useCallback, useMemo, useState } from 'react' -import { Checkbox, cn, Input, Label, SecretInput, Tooltip, Wizard } from '@sim/emcn' -import { Check, ChevronRight, CircleInfo, Clipboard } from '@sim/emcn/icons' +import { Checkbox, Chip, ChipModalField, cn, Label, SecretInput, Tooltip, Wizard } from '@sim/emcn' +import { Check, ChevronRight, CircleInfo } from '@sim/emcn/icons' import { useShallow } from 'zustand/react/shallow' +import { SlackAppManifest } from '@/components/integrations/slack-app-manifest' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useWebhookManagement } from '@/hooks/use-webhook-management' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -52,20 +53,14 @@ export function SlackSetupWizard({ return ( <> - + Set up Slack app + - + - - + + - - + { - if (!controlsDisabled) setSigningSecret(v) + if (!controlsDisabled) setBotToken(v) }} disabled={controlsDisabled} /> - - + { - if (!controlsDisabled) setBotToken(v) + if (!controlsDisabled) setSigningSecret(v) }} disabled={controlsDisabled} /> - + @@ -213,22 +208,14 @@ function StepConfigure({ }: StepConfigureProps) { return (
-
- - onAppNameChange(e.target.value)} - disabled={disabled} - placeholder={DEFAULT_APP_NAME} - className='h-9 text-sm' - /> -
+
{GROUP_ORDER.map((group) => { const items = SLACK_CAPABILITIES.filter((c) => c.group === group) @@ -252,55 +239,22 @@ function StepConfigure({ interface StepCreateProps { manifestJson: string canCopy: boolean + isLoading: boolean } -function StepCreate({ manifestJson, canCopy }: StepCreateProps) { - const [copied, setCopied] = useState(false) - const [copyFailed, setCopyFailed] = useState(false) - - const handleCopy = useCallback(async () => { - if (!canCopy) return - try { - await navigator.clipboard.writeText(manifestJson) - setCopyFailed(false) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } catch { - setCopyFailed(true) - } - }, [canCopy, manifestJson]) - +function StepCreate({ manifestJson, canCopy, isLoading }: StepCreateProps) { return (
-
Copy your manifest:
- - {copyFailed ? ( -

- Couldn't copy manifest — copy it manually from the developer console. + + {!canCopy && ( +

+ {isLoading + ? 'Loading the webhook URL…' + : 'Webhook URL unavailable. Reload the workflow and try again.'}

- ) : null} + )}
Open the{' '} @@ -319,7 +273,8 @@ function StepCreate({ manifestJson, canCopy }: StepCreateProps) { workspace. - Paste your manifest, then click Next → Create. + Select JSON, paste your manifest, then click Next →{' '} + Create.
@@ -369,8 +324,8 @@ function StepToken({ blockId, value, onChange, disabled }: StepTokenProps) {
- In Slack, open Install App → Install to Workspace and - authorize. + In Slack, open OAuth & Permissions →{' '} + Install to Workspace and authorize. Copy the Bot User OAuth Token (starts with xoxb-). @@ -406,19 +361,15 @@ interface SecretFieldProps { */ function SecretField({ id, label, value, onChange, disabled, placeholder }: SecretFieldProps) { return ( -
- + -
+ ) } @@ -431,14 +382,13 @@ function StepDone({ hasSigningSecret, hasBotToken }: StepDoneProps) { return (

- Your Slack app is set up. Save the workflow and Slack will verify the webhook URL - automatically. + Click Done, then save and deploy the workflow with these credentials. In Slack, open App + Manifest and verify the event Request URL before using the trigger.

-

Click Done and save this workflow.

) } diff --git a/apps/sim/components/integrations/slack-app-manifest.test.tsx b/apps/sim/components/integrations/slack-app-manifest.test.tsx new file mode 100644 index 00000000000..0346e1f6cd8 --- /dev/null +++ b/apps/sim/components/integrations/slack-app-manifest.test.tsx @@ -0,0 +1,76 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SlackAppManifest } from '@/components/integrations/slack-app-manifest' +import { createSlackSearchManifest } from '@/lib/slack-search/manifest' + +const writeText = vi.fn() +let root: Root +let container: HTMLDivElement + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + writeText.mockReset().mockResolvedValue(undefined) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.unstubAllGlobals() +}) + +async function render(manifest: string, disabled = false, onCopy = vi.fn()) { + await act(async () => + root.render() + ) +} +async function copy() { + await act(async () => container.querySelector('button')!.click()) +} + +describe('Slack manifest copying', () => { + it('copies the current generated manifest, including its name, scopes, and environment', async () => { + const original = JSON.stringify( + createSlackSearchManifest('First app', 'Search', 'https://first.sim.test') + ) + const updated = JSON.stringify( + createSlackSearchManifest('Second app', 'Search', 'https://second.sim.test', ['files:write']) + ) + await render(original) + await copy() + expect(writeText).toHaveBeenLastCalledWith(original) + expect(container.querySelector('[role="status"]')).toHaveTextContent('Manifest copied') + await render(updated) + expect(container.querySelector('[role="status"]')).toBeNull() + await copy() + expect(writeText).toHaveBeenLastCalledWith(updated) + expect(container.querySelector('button')).toHaveTextContent('Copy manifest') + expect(container.querySelector('details')).not.toHaveAttribute('open') + }) + + it('reports a failed repeat copy without stale success and allows retry', async () => { + const onCopy = vi.fn() + await render('{}', false, onCopy) + await copy() + writeText.mockRejectedValueOnce(new Error('Denied')) + await copy() + expect(container.querySelector('[role="status"]')).toBeNull() + expect(container.querySelector('[role="alert"]')).toHaveTextContent('Allow clipboard access') + expect(onCopy).toHaveBeenCalledTimes(1) + await copy() + expect(container.querySelector('[role="alert"]')).toBeNull() + expect(onCopy).toHaveBeenCalledTimes(2) + }) + + it('does not copy an unavailable or empty manifest', async () => { + await render('{}', true) + await copy() + await render('') + await copy() + expect(writeText).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/components/integrations/slack-app-manifest.tsx b/apps/sim/components/integrations/slack-app-manifest.tsx index 248207eee8c..7197861047e 100644 --- a/apps/sim/components/integrations/slack-app-manifest.tsx +++ b/apps/sim/components/integrations/slack-app-manifest.tsx @@ -1,20 +1,63 @@ 'use client' -import { Code, CopyCodeButton } from '@sim/emcn' +import { useState } from 'react' +import { Chip, Code, useCopyToClipboard } from '@sim/emcn' +import { Check, Clipboard } from '@sim/emcn/icons' interface SlackAppManifestProps { manifest: string + disabled?: boolean + onCopy?: (manifest: string) => void } -/** Shared manifest preview for Slack app setup flows. */ -export function SlackAppManifest({ manifest }: SlackAppManifestProps) { +/** Shared copy action and optional preview for Slack app setup flows. */ +export function SlackAppManifest({ manifest, disabled, onCopy }: SlackAppManifestProps) { + const { copied, copy } = useCopyToClipboard() + const [copiedManifest, setCopiedManifest] = useState(null) + const [copyFailed, setCopyFailed] = useState(false) + const showCopied = copied && copiedManifest === manifest && !copyFailed + + async function copyManifest() { + setCopyFailed(false) + const success = await copy(manifest) + if (!success) { + setCopyFailed(true) + return + } + setCopiedManifest(manifest) + onCopy?.(manifest) + } + return ( -
-
- manifest.json - +
+
+ void copyManifest()} + > + Copy manifest + + {showCopied && ( + + Manifest copied + + )}
- + {copyFailed && ( +

+ Could not copy the manifest. Allow clipboard access and try again. +

+ )} +
+ + View manifest + +
+ +
+
) } diff --git a/apps/sim/components/integrations/slack-search-setup-wizard.tsx b/apps/sim/components/integrations/slack-search-setup-wizard.tsx index bbc10320c88..7c0018d4fd2 100644 --- a/apps/sim/components/integrations/slack-search-setup-wizard.tsx +++ b/apps/sim/components/integrations/slack-search-setup-wizard.tsx @@ -9,9 +9,9 @@ import { ChipModalField, ChipModalFooter, ChipModalHeader, - writeTextToClipboard, } from '@sim/emcn' import { SlackIcon } from '@/components/icons' +import { SlackAppManifest } from '@/components/integrations/slack-app-manifest' import { SLACK_SEARCH_DEFAULT_DESCRIPTION, SLACK_SEARCH_DEFAULT_NAME, @@ -50,25 +50,11 @@ export function SlackSearchSetupWizard({ const [clientSecret, setClientSecret] = useState('') const [signingSecret, setSigningSecret] = useState('') const [botToken, setBotToken] = useState('') - const [configurationCopied, setConfigurationCopied] = useState(false) - const [copyError, setCopyError] = useState(null) - const error = prepare.error ?? oauth.error ?? connect.error ?? copyError + const [copiedManifest, setCopiedManifest] = useState(null) + const error = prepare.error ?? oauth.error ?? connect.error const busy = oauth.isPending || connect.isPending const configuredAppId = appId ?? prepare.data?.existingApp?.appId - async function copyConfiguration() { - if (!prepare.data) throw new Error('Slack app configuration is not ready') - setCopyError(null) - try { - await writeTextToClipboard(prepare.data.manifest) - setConfigurationCopied(true) - } catch { - setCopyError( - new Error('Could not copy the app configuration. Allow clipboard access and try again.') - ) - } - } - const shared = mode ? mode === 'shared' : Boolean( @@ -87,9 +73,9 @@ export function SlackSearchSetupWizard({ function advance() { if (step === 'manifest') { - setStep('credentials') - } else if (step === 'credentials') { setStep('token') + } else if (step === 'token') { + setStep('credentials') } else { connect.mutate( { @@ -203,7 +189,7 @@ export function SlackSearchSetupWizard({ : 'Create Slack app' : step === 'credentials' ? 'Slack app credentials' - : 'Connect installed Slack app' + : 'Install Slack app' return ( {step === 'manifest' && ( -

- {configuredAppId - ? 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 and install the app in Slack, then return here to add its credentials.'} -

+ + +

+ {configuredAppId + ? 'In your Slack app, open App Manifest, replace the JSON, and save changes.' + : 'In Slack, choose Create New App → From a manifest, select your workspace, and paste the JSON. Review it and click Create.'} +

+

+ In App Manifest, verify the event Request URL before continuing. You can verify it + before connecting the app. +

+ + {configuredAppId ? 'Open app settings' : 'Open Slack Apps'} + +
)} {step === 'credentials' && ( <>

- Find these values under Basic Information in your Slack app. + Open Basic Information → App Credentials in the same Slack app. Copy these values, + then connect the app to Sim.

- Copy the Bot User OAuth Token from OAuth & Permissions in your installed Slack - app. If Slack requests updated permissions, approve them there first. + Open OAuth & Permissions in Slack, choose Install to Workspace (or Reinstall to + Workspace), and approve access. Then copy the Bot User OAuth Token below.

void copyConfiguration() }] - : [ - { - custom: ( - - {configuredAppId ? 'Open app settings' : 'Create app'} - - ), - }, - ] - : undefined + : undefined } primaryAdjacentAction={ step === 'manifest' @@ -334,17 +322,18 @@ export function SlackSearchSetupWizard({ onClick: () => { oauth.reset() connect.reset() - setStep(step === 'token' ? 'credentials' : 'manifest') + setStep(step === 'credentials' ? 'token' : 'manifest') }, } } primaryAction={{ - label: busy ? 'Connecting…' : step === 'token' ? 'Connect app' : 'Continue', + label: busy ? 'Connecting…' : step === 'credentials' ? 'Connect app' : 'Continue', onClick: advance, disabled: busy || + Boolean(prepare.error) || (step === 'manifest' - ? Boolean(configuredAppId && !configurationCopied) + ? copiedManifest !== prepare.data.manifest : step === 'token' ? !botToken.trim() : !installationId && From 6f30261f8b4d33cb6a98316d93672d4700aeb8b3 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 24 Sep 2026 18:56:28 -0700 Subject: [PATCH 2/4] fix(slack): preserve manual setup and accessible credential fields --- .../connect-slack-bot-modal.tsx | 13 ++++-- .../slack-setup-wizard.test.tsx | 15 +++++++ .../slack-setup-wizard/slack-setup-wizard.tsx | 42 +++++++------------ .../integrations/slack-app-manifest.test.tsx | 12 ++---- .../integrations/slack-app-manifest.tsx | 4 +- .../slack-search-setup-wizard.tsx | 17 +++----- 6 files changed, 49 insertions(+), 54 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx index d48f6ad6f06..9d4fca19ada 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx @@ -8,7 +8,6 @@ import { type ChipDropdownOption, ChipInput, ChipModalField, - SecretInput, Wizard, } from '@sim/emcn' import { Loader, Plus, Trash } from '@sim/emcn/icons' @@ -616,9 +615,15 @@ interface SecretFieldProps { } function SecretField({ label, value, onChange, placeholder }: SecretFieldProps) { return ( - - - + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.test.tsx index 0ac19e0cd56..85f665eee57 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.test.tsx @@ -91,9 +91,13 @@ it('collects the token before the signing secret and retains both when going bac await click('Next') await click('Next') expect(button('Next')).toBeDisabled() + expect(document.querySelector('input[placeholder="xoxb-..."]')).toHaveAccessibleName('Bot Token') await fill('xoxb-...', 'xoxb-test-token') await click('Next') expect(button('Next')).toBeDisabled() + expect( + document.querySelector('input[placeholder="Paste your signing secret"]') + ).toHaveAccessibleName('Signing Secret') await fill('Paste your signing secret', 'test-secret') await click('Back') await click('Next') @@ -103,3 +107,14 @@ it('collects the token before the signing secret and retains both when going bac expect(document.body).toHaveTextContent('verify the event Request URL') expect(document.body).not.toHaveTextContent('automatically') }) + +it('uses the existing default name when the bot name is cleared', async () => { + mocks.loading = false + await render() + await click('Set up Slack app') + await fill('Sim Workflow Bot', '') + expect(button('Next')).not.toBeDisabled() + await click('Next') + await click('Copy manifest') + expect(JSON.parse(mocks.copy.mock.calls[0][0]).display_information.name).toBe('Sim Workflow Bot') +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.tsx index 5c2f574a1c4..74c11a310c9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.tsx @@ -1,7 +1,7 @@ 'use client' import { type ReactNode, useCallback, useMemo, useState } from 'react' -import { Checkbox, Chip, ChipModalField, cn, Label, SecretInput, Tooltip, Wizard } from '@sim/emcn' +import { Checkbox, Chip, ChipModalField, cn, Label, Tooltip, Wizard } from '@sim/emcn' import { Check, ChevronRight, CircleInfo } from '@sim/emcn/icons' import { useShallow } from 'zustand/react/shallow' import { SlackAppManifest } from '@/components/integrations/slack-app-manifest' @@ -126,7 +126,7 @@ function WizardModal({ blockId, open, onOpenChange, isPreview, disabled }: Wizar size='lg' height={MODAL_HEIGHT_CLASS} > - + { if (!controlsDisabled) setBotToken(v) @@ -152,7 +151,6 @@ function WizardModal({ blockId, open, onOpenChange, isPreview, disabled }: Wizar { if (!controlsDisabled) setSigningSecret(v) @@ -282,13 +280,12 @@ function StepCreate({ manifestJson, canCopy, isLoading }: StepCreateProps) { } interface StepSecretProps { - blockId: string value: string onChange: (next: string) => void disabled: boolean } -function StepSecret({ blockId, value, onChange, disabled }: StepSecretProps) { +function StepSecret({ value, onChange, disabled }: StepSecretProps) { return (
@@ -301,7 +298,6 @@ function StepSecret({ blockId, value, onChange, disabled }: StepSecretProps) { Paste it into the field below. void disabled: boolean } -function StepToken({ blockId, value, onChange, disabled }: StepTokenProps) { +function StepToken({ value, onChange, disabled }: StepTokenProps) { return (
@@ -333,7 +328,6 @@ function StepToken({ blockId, value, onChange, disabled }: StepTokenProps) { Paste it into the field below. void @@ -353,23 +346,18 @@ interface SecretFieldProps { placeholder?: string } -/** - * Label + SecretInput pair used by the signing-secret and bot-token wizard - * steps. The masked-on-blur behavior lives in the emcn `SecretInput` - * primitive; this wrapper just pins the label/input composition the wizard - * reuses twice. - */ -function SecretField({ id, label, value, onChange, disabled, placeholder }: SecretFieldProps) { +function SecretField({ label, value, onChange, disabled, placeholder }: SecretFieldProps) { return ( - - - + ) } diff --git a/apps/sim/components/integrations/slack-app-manifest.test.tsx b/apps/sim/components/integrations/slack-app-manifest.test.tsx index 0346e1f6cd8..9ffd4cf476f 100644 --- a/apps/sim/components/integrations/slack-app-manifest.test.tsx +++ b/apps/sim/components/integrations/slack-app-manifest.test.tsx @@ -23,10 +23,8 @@ afterEach(async () => { vi.unstubAllGlobals() }) -async function render(manifest: string, disabled = false, onCopy = vi.fn()) { - await act(async () => - root.render() - ) +async function render(manifest: string, disabled = false) { + await act(async () => root.render()) } async function copy() { await act(async () => container.querySelector('button')!.click()) @@ -53,17 +51,15 @@ describe('Slack manifest copying', () => { }) it('reports a failed repeat copy without stale success and allows retry', async () => { - const onCopy = vi.fn() - await render('{}', false, onCopy) + await render('{}') await copy() writeText.mockRejectedValueOnce(new Error('Denied')) await copy() expect(container.querySelector('[role="status"]')).toBeNull() expect(container.querySelector('[role="alert"]')).toHaveTextContent('Allow clipboard access') - expect(onCopy).toHaveBeenCalledTimes(1) await copy() expect(container.querySelector('[role="alert"]')).toBeNull() - expect(onCopy).toHaveBeenCalledTimes(2) + expect(writeText).toHaveBeenCalledTimes(3) }) it('does not copy an unavailable or empty manifest', async () => { diff --git a/apps/sim/components/integrations/slack-app-manifest.tsx b/apps/sim/components/integrations/slack-app-manifest.tsx index 7197861047e..4c30b86ca56 100644 --- a/apps/sim/components/integrations/slack-app-manifest.tsx +++ b/apps/sim/components/integrations/slack-app-manifest.tsx @@ -7,11 +7,10 @@ import { Check, Clipboard } from '@sim/emcn/icons' interface SlackAppManifestProps { manifest: string disabled?: boolean - onCopy?: (manifest: string) => void } /** Shared copy action and optional preview for Slack app setup flows. */ -export function SlackAppManifest({ manifest, disabled, onCopy }: SlackAppManifestProps) { +export function SlackAppManifest({ manifest, disabled }: SlackAppManifestProps) { const { copied, copy } = useCopyToClipboard() const [copiedManifest, setCopiedManifest] = useState(null) const [copyFailed, setCopyFailed] = useState(false) @@ -25,7 +24,6 @@ export function SlackAppManifest({ manifest, disabled, onCopy }: SlackAppManifes return } setCopiedManifest(manifest) - onCopy?.(manifest) } return ( diff --git a/apps/sim/components/integrations/slack-search-setup-wizard.tsx b/apps/sim/components/integrations/slack-search-setup-wizard.tsx index 7c0018d4fd2..8f97e516925 100644 --- a/apps/sim/components/integrations/slack-search-setup-wizard.tsx +++ b/apps/sim/components/integrations/slack-search-setup-wizard.tsx @@ -50,7 +50,6 @@ export function SlackSearchSetupWizard({ const [clientSecret, setClientSecret] = useState('') const [signingSecret, setSigningSecret] = useState('') const [botToken, setBotToken] = useState('') - const [copiedManifest, setCopiedManifest] = useState(null) const error = prepare.error ?? oauth.error ?? connect.error const busy = oauth.isPending || connect.isPending const configuredAppId = appId ?? prepare.data?.existingApp?.appId @@ -207,11 +206,7 @@ export function SlackSearchSetupWizard({ {step === 'manifest' && ( - +

{configuredAppId ? 'In your Slack app, open App Manifest, replace the JSON, and save changes.' @@ -332,12 +327,10 @@ export function SlackSearchSetupWizard({ disabled: busy || Boolean(prepare.error) || - (step === 'manifest' - ? copiedManifest !== prepare.data.manifest - : step === 'token' - ? !botToken.trim() - : !installationId && - (!clientId.trim() || !clientSecret.trim() || !signingSecret.trim())), + (step === 'token' && !botToken.trim()) || + (step === 'credentials' && + !installationId && + (!clientId.trim() || !clientSecret.trim() || !signingSecret.trim())), }} /> From 95f756549e9cf81a868b8d9a78e5674f1b4f22a9 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 25 Sep 2026 11:06:54 -0700 Subject: [PATCH 3/4] fix(slack): prefill app creation and validate bot names --- .../connect-slack-bot-modal.tsx | 40 +++++--------- .../slack-setup-wizard.test.tsx | 52 ++----------------- .../slack-setup-wizard/slack-setup-wizard.tsx | 38 ++++++-------- .../integrations/slack-app-manifest.test.tsx | 49 ++++++++--------- .../integrations/slack-app-manifest.tsx | 25 +++++++-- .../slack-search-setup-wizard.tsx | 34 ++++++------ apps/sim/lib/integrations/slack-manifest.ts | 19 +++++++ .../application/slack-search/setup.ts | 3 +- 8 files changed, 116 insertions(+), 144 deletions(-) create mode 100644 apps/sim/lib/integrations/slack-manifest.ts diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx index 9d4fca19ada..2c20e1f68a4 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx @@ -18,6 +18,7 @@ import { SlackIcon } from '@/components/icons' import { SlackAppManifest } from '@/components/integrations/slack-app-manifest' import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope' import { getBaseUrl } from '@/lib/core/utils/urls' +import { buildSlackAppCreationUrl, getSlackAppNameError } from '@/lib/integrations/slack-manifest' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import { useCreateScopedCredential, @@ -166,6 +167,7 @@ export function ConnectSlackBotModal({ // window.location.origin) so Slack's servers can reach it. const requestUrl = buildSlackCustomBotRequestUrl(credentialId) + const nameError = isReconnect ? null : getSlackAppNameError(appName) const descriptionError = getAgentDescriptionError(appDescription) const slashCommandsError = searchOnly || isReconnect ? null : getSlashCommandsError(slashCommands) const manifestConfigurationError = descriptionError ?? slashCommandsError @@ -191,7 +193,7 @@ export function ConnectSlackBotModal({ ), ...(managedUserAuthorization ? { managedUserAuthorization } : {}), }) - return JSON.stringify(manifest, null, 2) + return JSON.stringify(manifest) }, [ isReconnect, manifestConfigurationError, @@ -269,12 +271,13 @@ export function ConnectSlackBotModal({ fallback, which collides for a second bot in the same workspace. */} 0 && !descriptionError && !slashCommandsError} + canAdvance={appName.trim().length > 0 && !nameError && !manifestConfigurationError} > void appDescription: string onAppDescriptionChange: (next: string) => void @@ -350,6 +354,7 @@ function StepConfigure({ searchOnly, reconnect, appName, + nameError, onAppNameChange, appDescription, onAppDescriptionChange, @@ -371,6 +376,7 @@ function StepConfigure({ value={appName} onChange={onAppNameChange} placeholder={DEFAULT_APP_NAME} + error={nameError} /> -

Copy the manifest for your selected permissions:
+
Open Slack with the manifest for your selected permissions already filled in:
- +
- Open the{' '} - - Slack Apps page - - . - - - Click Create New App → From a manifest and pick your - workspace. - - - Select JSON, paste the manifest, then click Next →{' '} - Create. - - - In App Manifest, verify the event Request URL if shown. - You can verify it before connecting the bot. + Select your workspace, review the configuration, then click Create.
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.test.tsx index 85f665eee57..e60c820d50a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.test.tsx @@ -5,7 +5,6 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ loading: true, - copy: vi.fn(), })) vi.mock('@/hooks/use-webhook-management', () => ({ useWebhookManagement: () => ({ @@ -31,10 +30,9 @@ import { SlackSetupWizard } from '@/app/workspace/[workspaceId]/w/[workflowId]/c let root: Root let container: HTMLDivElement beforeEach(() => { + vi.useFakeTimers() vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) - vi.stubGlobal('navigator', { clipboard: { writeText: mocks.copy } }) mocks.loading = true - mocks.copy.mockReset().mockResolvedValue(undefined) container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -43,6 +41,7 @@ afterEach(async () => { await act(async () => root.unmount()) container.remove() vi.unstubAllGlobals() + vi.useRealTimers() }) async function render() { await act(async () => root.render()) @@ -66,48 +65,6 @@ async function fill(placeholder: string, value: string) { }) } -it('waits for the webhook URL without requiring an early deployment, then copies the current manifest', async () => { - await render() - await click('Set up Slack app') - await click('Next') - expect(button('Copy manifest')).toBeDisabled() - expect(button('Next')).toBeDisabled() - expect(document.body).toHaveTextContent('Loading the webhook URL') - expect(document.body).not.toHaveTextContent('Deploy once') - mocks.loading = false - await render() - await click('Copy manifest') - const manifest = JSON.parse(mocks.copy.mock.calls[0][0]) - expect(manifest.display_information.name).toBe('Test workflow bot') - expect(manifest.settings.event_subscriptions.request_url).toBe( - 'https://sim.test/api/webhooks/trigger/block-1' - ) -}) - -it('collects the token before the signing secret and retains both when going back', async () => { - mocks.loading = false - await render() - await click('Set up Slack app') - await click('Next') - await click('Next') - expect(button('Next')).toBeDisabled() - expect(document.querySelector('input[placeholder="xoxb-..."]')).toHaveAccessibleName('Bot Token') - await fill('xoxb-...', 'xoxb-test-token') - await click('Next') - expect(button('Next')).toBeDisabled() - expect( - document.querySelector('input[placeholder="Paste your signing secret"]') - ).toHaveAccessibleName('Signing Secret') - await fill('Paste your signing secret', 'test-secret') - await click('Back') - await click('Next') - expect(button('Next')).not.toBeDisabled() - await click('Next') - expect(document.body).toHaveTextContent('save and deploy the workflow with these credentials') - expect(document.body).toHaveTextContent('verify the event Request URL') - expect(document.body).not.toHaveTextContent('automatically') -}) - it('uses the existing default name when the bot name is cleared', async () => { mocks.loading = false await render() @@ -115,6 +72,7 @@ it('uses the existing default name when the bot name is cleared', async () => { await fill('Sim Workflow Bot', '') expect(button('Next')).not.toBeDisabled() await click('Next') - await click('Copy manifest') - expect(JSON.parse(mocks.copy.mock.calls[0][0]).display_information.name).toBe('Sim Workflow Bot') + const link = document.querySelector('a[href*="manifest_json"]')! + const manifest = JSON.parse(new URL(link.href).searchParams.get('manifest_json')!) + expect(manifest.display_information.name).toBe('Sim Workflow Bot') }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.tsx index 74c11a310c9..f6d6773524e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.tsx @@ -5,6 +5,7 @@ import { Checkbox, Chip, ChipModalField, cn, Label, Tooltip, Wizard } from '@sim import { Check, ChevronRight, CircleInfo } from '@sim/emcn/icons' import { useShallow } from 'zustand/react/shallow' import { SlackAppManifest } from '@/components/integrations/slack-app-manifest' +import { buildSlackAppCreationUrl, getSlackAppNameError } from '@/lib/integrations/slack-manifest' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useWebhookManagement } from '@/hooks/use-webhook-management' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -97,6 +98,7 @@ function WizardModal({ blockId, open, onOpenChange, isPreview, disabled }: Wizar const selected = useCapabilitySelection(blockId) const displayAppName = appName ?? DEFAULT_APP_NAME + const nameError = getSlackAppNameError(displayAppName.trim() || DEFAULT_APP_NAME) const effectiveWebhookUrl = !isLoading && webhookUrl ? webhookUrl : null const canCopy = effectiveWebhookUrl !== null const controlsDisabled = isPreview || disabled @@ -106,7 +108,7 @@ function WizardModal({ blockId, open, onOpenChange, isPreview, disabled }: Wizar appName: displayAppName.trim() || DEFAULT_APP_NAME, webhookUrl: effectiveWebhookUrl, }) - return JSON.stringify(manifest, null, 2) + return JSON.stringify(manifest) }, [selected, displayAppName, effectiveWebhookUrl]) const handleOpenChange = useCallback( @@ -126,10 +128,11 @@ function WizardModal({ blockId, open, onOpenChange, isPreview, disabled }: Wizar size='lg' height={MODAL_HEIGHT_CLASS} > - + { if (!controlsDisabled) setAppName(v) }} @@ -192,6 +195,7 @@ function SubStep({ n, children }: SubStepProps) { interface StepConfigureProps { blockId: string appName: string + nameError: string | null onAppNameChange: (next: string) => void selected: ReadonlySet disabled: boolean @@ -200,6 +204,7 @@ interface StepConfigureProps { function StepConfigure({ blockId, appName, + nameError, onAppNameChange, selected, disabled, @@ -213,6 +218,7 @@ function StepConfigure({ onChange={onAppNameChange} disabled={disabled} placeholder={DEFAULT_APP_NAME} + error={nameError} />
{GROUP_ORDER.map((group) => { @@ -245,7 +251,14 @@ function StepCreate({ manifestJson, canCopy, isLoading }: StepCreateProps) {
- +
Open Slack with your manifest already filled in:
+
+ +
{!canCopy && (

{isLoading @@ -255,24 +268,7 @@ function StepCreate({ manifestJson, canCopy, isLoading }: StepCreateProps) { )} - Open the{' '} - - Slack Apps page - - . - - - Click Create New App → From a manifest and pick your - workspace. - - - Select JSON, paste your manifest, then click Next →{' '} - Create. + Select your workspace, review the configuration, then click Create.

diff --git a/apps/sim/components/integrations/slack-app-manifest.test.tsx b/apps/sim/components/integrations/slack-app-manifest.test.tsx index 9ffd4cf476f..f3f9daecf6f 100644 --- a/apps/sim/components/integrations/slack-app-manifest.test.tsx +++ b/apps/sim/components/integrations/slack-app-manifest.test.tsx @@ -3,6 +3,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SlackAppManifest } from '@/components/integrations/slack-app-manifest' +import { buildSlackAppCreationUrl } from '@/lib/integrations/slack-manifest' import { createSlackSearchManifest } from '@/lib/slack-search/manifest' const writeText = vi.fn() @@ -10,6 +11,7 @@ let root: Root let container: HTMLDivElement beforeEach(() => { + vi.useFakeTimers() vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) vi.stubGlobal('navigator', { clipboard: { writeText } }) writeText.mockReset().mockResolvedValue(undefined) @@ -21,33 +23,34 @@ afterEach(async () => { await act(async () => root.unmount()) container.remove() vi.unstubAllGlobals() + vi.useRealTimers() }) -async function render(manifest: string, disabled = false) { - await act(async () => root.render()) +async function render(manifest: string, disabled = false, createAppUrl?: string) { + await act(async () => + root.render( + + ) + ) } async function copy() { await act(async () => container.querySelector('button')!.click()) } describe('Slack manifest copying', () => { - it('copies the current generated manifest, including its name, scopes, and environment', async () => { - const original = JSON.stringify( - createSlackSearchManifest('First app', 'Search', 'https://first.sim.test') - ) - const updated = JSON.stringify( - createSlackSearchManifest('Second app', 'Search', 'https://second.sim.test', ['files:write']) - ) - await render(original) - await copy() - expect(writeText).toHaveBeenLastCalledWith(original) - expect(container.querySelector('[role="status"]')).toHaveTextContent('Manifest copied') - await render(updated) - expect(container.querySelector('[role="status"]')).toBeNull() - await copy() - expect(writeText).toHaveBeenLastCalledWith(updated) - expect(container.querySelector('button')).toHaveTextContent('Copy manifest') - expect(container.querySelector('details')).not.toHaveAttribute('open') + it('opens the current manifest directly in Slack without requiring clipboard access', async () => { + for (const description of ['Research & support #1', 'Updated app % / 日本語']) { + const manifest = JSON.stringify( + createSlackSearchManifest('Research app', description, 'https://sim.test') + ) + await render(manifest, false, buildSlackAppCreationUrl(manifest)) + const link = container.querySelector('a')! + const url = new URL(link.href) + expect(url.origin).toBe('https://api.slack.com') + expect(url.searchParams.get('new_app')).toBe('1') + expect(url.searchParams.get('manifest_json')).toBe(manifest) + expect(writeText).not.toHaveBeenCalled() + } }) it('reports a failed repeat copy without stale success and allows retry', async () => { @@ -61,12 +64,4 @@ describe('Slack manifest copying', () => { expect(container.querySelector('[role="alert"]')).toBeNull() expect(writeText).toHaveBeenCalledTimes(3) }) - - it('does not copy an unavailable or empty manifest', async () => { - await render('{}', true) - await copy() - await render('') - await copy() - expect(writeText).not.toHaveBeenCalled() - }) }) diff --git a/apps/sim/components/integrations/slack-app-manifest.tsx b/apps/sim/components/integrations/slack-app-manifest.tsx index 4c30b86ca56..824c17e052f 100644 --- a/apps/sim/components/integrations/slack-app-manifest.tsx +++ b/apps/sim/components/integrations/slack-app-manifest.tsx @@ -1,16 +1,33 @@ 'use client' import { useState } from 'react' -import { Chip, Code, useCopyToClipboard } from '@sim/emcn' +import { Chip, ChipLink, Code, useCopyToClipboard } from '@sim/emcn' import { Check, Clipboard } from '@sim/emcn/icons' interface SlackAppManifestProps { manifest: string + createAppUrl?: string disabled?: boolean } -/** Shared copy action and optional preview for Slack app setup flows. */ -export function SlackAppManifest({ manifest, disabled }: SlackAppManifestProps) { +/** Opens new app creation or provides the manifest for updating an existing app. */ +export function SlackAppManifest({ manifest, createAppUrl, disabled }: SlackAppManifestProps) { + if (createAppUrl) { + return disabled || !manifest ? ( + + Create Slack app + + ) : ( + + Create Slack app + + ) + } + + return +} + +function SlackManifestUpdate({ manifest, disabled }: Omit) { const { copied, copy } = useCopyToClipboard() const [copiedManifest, setCopiedManifest] = useState(null) const [copyFailed, setCopyFailed] = useState(false) @@ -28,7 +45,7 @@ export function SlackAppManifest({ manifest, disabled }: SlackAppManifestProps) return (
-
+
{step === 'manifest' && ( - +

{configuredAppId ? 'In your Slack app, open App Manifest, replace the JSON, and save changes.' - : 'In Slack, choose Create New App → From a manifest, select your workspace, and paste the JSON. Review it and click Create.'} -

-

- In App Manifest, verify the event Request URL before continuing. You can verify it - before connecting the app. + : 'Create Slack app opens Slack with this manifest already filled in. Select your workspace, review the configuration, and click Create.'}

- - {configuredAppId ? 'Open app settings' : 'Open Slack Apps'} - + {configuredAppId && ( + + Open app settings + + )}
)} {step === 'credentials' && ( diff --git a/apps/sim/lib/integrations/slack-manifest.ts b/apps/sim/lib/integrations/slack-manifest.ts new file mode 100644 index 00000000000..4737e8e948a --- /dev/null +++ b/apps/sim/lib/integrations/slack-manifest.ts @@ -0,0 +1,19 @@ +const SLACK_APP_NAME_MAX_LENGTH = 35 + +/** Opens Slack's app creation flow with the generated manifest already filled in. */ +export function buildSlackAppCreationUrl(manifest: string): string { + return `https://api.slack.com/apps?new_app=1&manifest_json=${encodeURIComponent(manifest)}` +} + +/** Validates a name used for both the Slack app and its bot user. */ +export function getSlackAppNameError(name: string): string | null { + const trimmedName = name.trim() + if (!trimmedName) return 'Enter an app name.' + if (trimmedName.length > SLACK_APP_NAME_MAX_LENGTH) { + return `Use ${SLACK_APP_NAME_MAX_LENGTH} characters or fewer.` + } + if (!/^[a-zA-Z0-9 ._-]+$/.test(trimmedName)) { + return 'Use letters, numbers, spaces, periods, hyphens, or underscores.' + } + return null +} diff --git a/apps/sim/lib/knowledge/application/slack-search/setup.ts b/apps/sim/lib/knowledge/application/slack-search/setup.ts index fcc83c4cb08..57890df1601 100644 --- a/apps/sim/lib/knowledge/application/slack-search/setup.ts +++ b/apps/sim/lib/knowledge/application/slack-search/setup.ts @@ -14,6 +14,7 @@ import { } from '@/lib/credential-groups/organization-slack-app' import { configureSharedSlackMemberApp } from '@/lib/credential-groups/shared-slack-app' import type { DbOrTx } from '@/lib/db/types' +import { buildSlackAppCreationUrl } from '@/lib/integrations/slack-manifest' import { exchangeSlackBotAuthorization, revokeSlackBotAuthorization, @@ -98,7 +99,7 @@ export const prepareSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ sharedAppId: sharedApp?.id ?? null, manifest: JSON.stringify(manifest, null, 2), existingApp: member.app, - createAppUrl: `https://api.slack.com/apps?new_app=1&manifest_json=${encodeURIComponent(JSON.stringify(manifest))}`, + createAppUrl: buildSlackAppCreationUrl(JSON.stringify(manifest)), } }, }) From b57a91d49c54d200b3283d38fb5753ec2a9bd71a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 25 Sep 2026 11:52:31 -0700 Subject: [PATCH 4/4] fix(slack): reject oversized app creation links --- .../connect-slack-bot-modal.tsx | 35 ++++++++++++++----- apps/sim/lib/api/contracts/knowledge/slack.ts | 3 +- apps/sim/lib/integrations/slack-manifest.ts | 1 + 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx index 2c20e1f68a4..fd3d9ec48c7 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx @@ -18,7 +18,11 @@ import { SlackIcon } from '@/components/icons' import { SlackAppManifest } from '@/components/integrations/slack-app-manifest' import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope' import { getBaseUrl } from '@/lib/core/utils/urls' -import { buildSlackAppCreationUrl, getSlackAppNameError } from '@/lib/integrations/slack-manifest' +import { + buildSlackAppCreationUrl, + getSlackAppNameError, + SLACK_APP_CREATION_URL_MAX_LENGTH, +} from '@/lib/integrations/slack-manifest' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import { useCreateScopedCredential, @@ -205,6 +209,12 @@ export function ConnectSlackBotModal({ searchOnly, ]) + const createAppUrl = buildSlackAppCreationUrl(manifestJson) + const creationUrlError = + createAppUrl.length > SLACK_APP_CREATION_URL_MAX_LENGTH + ? 'This app configuration is too large to open in Slack. Shorten or remove slash commands.' + : null + const capabilityIds = [...selected] const setCapabilityIds = (next: string[]) => setSelected(new Set(next)) @@ -271,7 +281,12 @@ export function ConnectSlackBotModal({ fallback, which collides for a second bot in the same workspace. */} 0 && !nameError && !manifestConfigurationError} + canAdvance={ + appName.trim().length > 0 && + !nameError && + !manifestConfigurationError && + !creationUrlError + } > - + 0}> @@ -496,9 +515,10 @@ function SlashCommandsEditor({ commands, onChange, error }: SlashCommandsEditorP interface StepCreateProps { manifestJson: string + createAppUrl: string reconnect: boolean } -function StepCreate({ manifestJson, reconnect }: StepCreateProps) { +function StepCreate({ manifestJson, createAppUrl, reconnect }: StepCreateProps) { if (reconnect) { return ( @@ -528,10 +548,7 @@ function StepCreate({ manifestJson, reconnect }: StepCreateProps) {
Open Slack with the manifest for your selected permissions already filled in:
- +
diff --git a/apps/sim/lib/api/contracts/knowledge/slack.ts b/apps/sim/lib/api/contracts/knowledge/slack.ts index 6dd0bdb8885..ceaa3be8deb 100644 --- a/apps/sim/lib/api/contracts/knowledge/slack.ts +++ b/apps/sim/lib/api/contracts/knowledge/slack.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { organizationIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' +import { SLACK_APP_CREATION_URL_MAX_LENGTH } from '@/lib/integrations/slack-manifest' export const slackSearchOrganizationQuerySchema = z.object({ organizationId: organizationIdSchema }) export const slackSearchInstallationSchema = z.object({ @@ -71,7 +72,7 @@ export const prepareSlackSearchContract = defineRouteContract({ existingApp: z .object({ appId: z.string().min(1).max(200), teamId: z.string().min(1).max(200) }) .nullable(), - createAppUrl: z.string().url().max(30_000), + createAppUrl: z.string().url().max(SLACK_APP_CREATION_URL_MAX_LENGTH), }), }, }) diff --git a/apps/sim/lib/integrations/slack-manifest.ts b/apps/sim/lib/integrations/slack-manifest.ts index 4737e8e948a..25e0ad65c08 100644 --- a/apps/sim/lib/integrations/slack-manifest.ts +++ b/apps/sim/lib/integrations/slack-manifest.ts @@ -1,4 +1,5 @@ const SLACK_APP_NAME_MAX_LENGTH = 35 +export const SLACK_APP_CREATION_URL_MAX_LENGTH = 30_000 /** Opens Slack's app creation flow with the generated manifest already filled in. */ export function buildSlackAppCreationUrl(manifest: string): string {