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..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 @@ -8,7 +8,6 @@ import { type ChipDropdownOption, ChipInput, ChipModalField, - SecretInput, Wizard, } from '@sim/emcn' import { Loader, Plus, Trash } from '@sim/emcn/icons' @@ -19,6 +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, + SLACK_APP_CREATION_URL_MAX_LENGTH, +} from '@/lib/integrations/slack-manifest' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import { useCreateScopedCredential, @@ -167,6 +171,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 @@ -192,7 +197,7 @@ export function ConnectSlackBotModal({ ), ...(managedUserAuthorization ? { managedUserAuthorization } : {}), }) - return JSON.stringify(manifest, null, 2) + return JSON.stringify(manifest) }, [ isReconnect, manifestConfigurationError, @@ -204,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)) @@ -270,32 +281,42 @@ export function ConnectSlackBotModal({ fallback, which collides for a second bot in the same workspace. */} 0 && !descriptionError && !slashCommandsError} + canAdvance={ + appName.trim().length > 0 && + !nameError && + !manifestConfigurationError && + !creationUrlError + } > - + + + 0}> + 0}> - 0}> - - void appDescription: string onAppDescriptionChange: (next: string) => void @@ -351,6 +373,7 @@ function StepConfigure({ searchOnly, reconnect, appName, + nameError, onAppNameChange, appDescription, onAppDescriptionChange, @@ -372,6 +395,7 @@ function StepConfigure({ value={appName} onChange={onAppNameChange} placeholder={DEFAULT_APP_NAME} + error={nameError} /> @@ -521,29 +546,13 @@ function StepCreate({ manifestJson, reconnect }: StepCreateProps) {
-
Copy your manifest:
+
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. - - - Paste your manifest, then click Next → Create. + Select your workspace, review the configuration, then click Create.
@@ -576,13 +585,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-). @@ -602,9 +620,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 new file mode 100644 index 00000000000..e60c820d50a --- /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,78 @@ +/** @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, +})) +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.useFakeTimers() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + mocks.loading = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.unstubAllGlobals() + vi.useRealTimers() +}) +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('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') + 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 ccc23a6decf..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 @@ -1,9 +1,11 @@ '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, 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 { 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' @@ -52,20 +54,14 @@ export function SlackSetupWizard({ return ( <> - + Set up Slack app + - + { if (!controlsDisabled) setAppName(v) }} @@ -142,30 +140,28 @@ function WizardModal({ blockId, open, onOpenChange, isPreview, disabled }: Wizar disabled={controlsDisabled} /> - - + + - - + { - if (!controlsDisabled) setSigningSecret(v) + if (!controlsDisabled) setBotToken(v) }} disabled={controlsDisabled} /> - - + { - if (!controlsDisabled) setBotToken(v) + if (!controlsDisabled) setSigningSecret(v) }} disabled={controlsDisabled} /> - + @@ -199,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 @@ -207,28 +204,22 @@ interface StepConfigureProps { function StepConfigure({ blockId, appName, + nameError, onAppNameChange, selected, disabled, }: 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,74 +243,32 @@ 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. +

Open Slack with your manifest already filled in:
+
+ +
+ {!canCopy && ( +

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

- ) : null} + )}
- Open the{' '} - - Slack Apps page - - . - - - Click Create New App → From a manifest and pick your - workspace. - - - Paste your manifest, then click Next → Create. + Select your workspace, review the configuration, then click Create.
@@ -327,13 +276,12 @@ function StepCreate({ manifestJson, canCopy }: 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 (
@@ -346,7 +294,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 (
- 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-). @@ -378,7 +324,6 @@ function StepToken({ blockId, value, onChange, disabled }: StepTokenProps) { Paste it into the field below. void @@ -398,27 +342,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 ( -
- - -
+ ) } @@ -431,14 +366,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..f3f9daecf6f --- /dev/null +++ b/apps/sim/components/integrations/slack-app-manifest.test.tsx @@ -0,0 +1,67 @@ +/** @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 { buildSlackAppCreationUrl } from '@/lib/integrations/slack-manifest' +import { createSlackSearchManifest } from '@/lib/slack-search/manifest' + +const writeText = vi.fn() +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) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.unstubAllGlobals() + vi.useRealTimers() +}) + +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('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 () => { + 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') + await copy() + expect(container.querySelector('[role="alert"]')).toBeNull() + expect(writeText).toHaveBeenCalledTimes(3) + }) +}) diff --git a/apps/sim/components/integrations/slack-app-manifest.tsx b/apps/sim/components/integrations/slack-app-manifest.tsx index 248207eee8c..824c17e052f 100644 --- a/apps/sim/components/integrations/slack-app-manifest.tsx +++ b/apps/sim/components/integrations/slack-app-manifest.tsx @@ -1,20 +1,78 @@ 'use client' -import { Code, CopyCodeButton } from '@sim/emcn' +import { useState } from 'react' +import { Chip, ChipLink, Code, useCopyToClipboard } from '@sim/emcn' +import { Check, Clipboard } from '@sim/emcn/icons' interface SlackAppManifestProps { manifest: string + createAppUrl?: string + disabled?: boolean } -/** Shared manifest preview for Slack app setup flows. */ -export function SlackAppManifest({ manifest }: 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) + const showCopied = copied && copiedManifest === manifest && !copyFailed + + async function copyManifest() { + setCopyFailed(false) + const success = await copy(manifest) + if (!success) { + setCopyFailed(true) + return + } + setCopiedManifest(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..bc2f5ed4756 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,10 @@ 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 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 +72,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 +188,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.' + : 'Create Slack app opens Slack with this manifest already filled in. Select your workspace, review the configuration, and click Create.'} +

+ {configuredAppId && ( + + Open app settings + + )} +
)} {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,21 +315,20 @@ 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 || - (step === 'manifest' - ? Boolean(configuredAppId && !configurationCopied) - : step === 'token' - ? !botToken.trim() - : !installationId && - (!clientId.trim() || !clientSecret.trim() || !signingSecret.trim())), + Boolean(prepare.error) || + (step === 'token' && !botToken.trim()) || + (step === 'credentials' && + !installationId && + (!clientId.trim() || !clientSecret.trim() || !signingSecret.trim())), }} />
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 new file mode 100644 index 00000000000..25e0ad65c08 --- /dev/null +++ b/apps/sim/lib/integrations/slack-manifest.ts @@ -0,0 +1,20 @@ +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 { + 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)), } }, })