Skip to content

Commit 0bde1e4

Browse files
fix(slack): simplify app creation and setup order (#8280)
* fix(slack): make manifest copying direct and order setup steps * fix(slack): preserve manual setup and accessible credential fields * fix(slack): prefill app creation and validate bot names * fix(slack): reject oversized app creation links
1 parent 7c115a1 commit 0bde1e4

9 files changed

Lines changed: 401 additions & 238 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx‎

Lines changed: 58 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
type ChipDropdownOption,
99
ChipInput,
1010
ChipModalField,
11-
SecretInput,
1211
Wizard,
1312
} from '@sim/emcn'
1413
import { Loader, Plus, Trash } from '@sim/emcn/icons'
@@ -19,6 +18,11 @@ import { SlackIcon } from '@/components/icons'
1918
import { SlackAppManifest } from '@/components/integrations/slack-app-manifest'
2019
import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope'
2120
import { getBaseUrl } from '@/lib/core/utils/urls'
21+
import {
22+
buildSlackAppCreationUrl,
23+
getSlackAppNameError,
24+
SLACK_APP_CREATION_URL_MAX_LENGTH,
25+
} from '@/lib/integrations/slack-manifest'
2226
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
2327
import {
2428
useCreateScopedCredential,
@@ -167,6 +171,7 @@ export function ConnectSlackBotModal({
167171
// window.location.origin) so Slack's servers can reach it.
168172
const requestUrl = buildSlackCustomBotRequestUrl(credentialId)
169173

174+
const nameError = isReconnect ? null : getSlackAppNameError(appName)
170175
const descriptionError = getAgentDescriptionError(appDescription)
171176
const slashCommandsError = searchOnly || isReconnect ? null : getSlashCommandsError(slashCommands)
172177
const manifestConfigurationError = descriptionError ?? slashCommandsError
@@ -192,7 +197,7 @@ export function ConnectSlackBotModal({
192197
),
193198
...(managedUserAuthorization ? { managedUserAuthorization } : {}),
194199
})
195-
return JSON.stringify(manifest, null, 2)
200+
return JSON.stringify(manifest)
196201
}, [
197202
isReconnect,
198203
manifestConfigurationError,
@@ -204,6 +209,12 @@ export function ConnectSlackBotModal({
204209
searchOnly,
205210
])
206211

212+
const createAppUrl = buildSlackAppCreationUrl(manifestJson)
213+
const creationUrlError =
214+
createAppUrl.length > SLACK_APP_CREATION_URL_MAX_LENGTH
215+
? 'This app configuration is too large to open in Slack. Shorten or remove slash commands.'
216+
: null
217+
207218
const capabilityIds = [...selected]
208219
const setCapabilityIds = (next: string[]) => setSelected(new Set(next))
209220

@@ -270,32 +281,42 @@ export function ConnectSlackBotModal({
270281
fallback, which collides for a second bot in the same workspace. */}
271282
<Wizard.Step
272283
title={searchOnly ? 'Name your Slack app' : 'Configure your bot'}
273-
canAdvance={appName.trim().length > 0 && !descriptionError && !slashCommandsError}
284+
canAdvance={
285+
appName.trim().length > 0 &&
286+
!nameError &&
287+
!manifestConfigurationError &&
288+
!creationUrlError
289+
}
274290
>
275291
<StepConfigure
276292
searchOnly={searchOnly}
277293
reconnect={isReconnect}
278294
appName={appName}
295+
nameError={appName ? nameError : null}
279296
onAppNameChange={setAppName}
280297
appDescription={appDescription}
281298
onAppDescriptionChange={setAppDescription}
282299
descriptionError={descriptionError}
283300
slashCommands={slashCommands}
284301
onSlashCommandsChange={setSlashCommands}
285-
slashCommandsError={slashCommandsError}
302+
slashCommandsError={slashCommandsError ?? creationUrlError}
286303
capabilityIds={capabilityIds}
287304
onCapabilityIdsChange={setCapabilityIds}
288305
/>
289306
</Wizard.Step>
290307
<Wizard.Step title={isReconnect ? 'Open your app in Slack' : 'Create the app in Slack'}>
291-
<StepCreate manifestJson={manifestJson} reconnect={isReconnect} />
308+
<StepCreate
309+
manifestJson={manifestJson}
310+
createAppUrl={createAppUrl}
311+
reconnect={isReconnect}
312+
/>
313+
</Wizard.Step>
314+
<Wizard.Step title='Install and paste your Bot Token' canAdvance={botToken.trim().length > 0}>
315+
<StepToken value={botToken} onChange={setBotToken} reconnect={isReconnect} />
292316
</Wizard.Step>
293317
<Wizard.Step title='Paste your Signing Secret' canAdvance={signingSecret.trim().length > 0}>
294318
<StepSecret value={signingSecret} onChange={setSigningSecret} />
295319
</Wizard.Step>
296-
<Wizard.Step title='Install and paste your Bot Token' canAdvance={botToken.trim().length > 0}>
297-
<StepToken value={botToken} onChange={setBotToken} />
298-
</Wizard.Step>
299320
<Wizard.Step title='All set'>
300321
<StepDone
301322
searchOnly={searchOnly}
@@ -337,6 +358,7 @@ interface StepConfigureProps {
337358
searchOnly: boolean
338359
reconnect: boolean
339360
appName: string
361+
nameError: string | null
340362
onAppNameChange: (next: string) => void
341363
appDescription: string
342364
onAppDescriptionChange: (next: string) => void
@@ -351,6 +373,7 @@ function StepConfigure({
351373
searchOnly,
352374
reconnect,
353375
appName,
376+
nameError,
354377
onAppNameChange,
355378
appDescription,
356379
onAppDescriptionChange,
@@ -372,6 +395,7 @@ function StepConfigure({
372395
value={appName}
373396
onChange={onAppNameChange}
374397
placeholder={DEFAULT_APP_NAME}
398+
error={nameError}
375399
/>
376400
<ChipModalField
377401
type='input'
@@ -491,9 +515,10 @@ function SlashCommandsEditor({ commands, onChange, error }: SlashCommandsEditorP
491515

492516
interface StepCreateProps {
493517
manifestJson: string
518+
createAppUrl: string
494519
reconnect: boolean
495520
}
496-
function StepCreate({ manifestJson, reconnect }: StepCreateProps) {
521+
function StepCreate({ manifestJson, createAppUrl, reconnect }: StepCreateProps) {
497522
if (reconnect) {
498523
return (
499524
<SubStepList>
@@ -521,29 +546,13 @@ function StepCreate({ manifestJson, reconnect }: StepCreateProps) {
521546
<div className='space-y-4'>
522547
<SubStepList>
523548
<SubStep n={1}>
524-
<div>Copy your manifest:</div>
549+
<div>Open Slack with the manifest for your selected permissions already filled in:</div>
525550
<div className='mt-2'>
526-
<SlackAppManifest manifest={manifestJson} />
551+
<SlackAppManifest manifest={manifestJson} createAppUrl={createAppUrl} />
527552
</div>
528553
</SubStep>
529554
<SubStep n={2}>
530-
Open the{' '}
531-
<a
532-
href='https://api.slack.com/apps'
533-
target='_blank'
534-
rel='noopener noreferrer'
535-
className='text-[var(--brand-secondary)] underline underline-offset-2'
536-
>
537-
Slack Apps page
538-
</a>
539-
.
540-
</SubStep>
541-
<SubStep n={3}>
542-
Click <strong>Create New App</strong> → <strong>From a manifest</strong> and pick your
543-
workspace.
544-
</SubStep>
545-
<SubStep n={4}>
546-
Paste your manifest, then click <strong>Next</strong> → <strong>Create</strong>.
555+
Select your workspace, review the configuration, then click <strong>Create</strong>.
547556
</SubStep>
548557
</SubStepList>
549558
</div>
@@ -576,13 +585,22 @@ function StepSecret({ value, onChange }: SecretStepProps) {
576585
)
577586
}
578587

579-
function StepToken({ value, onChange }: SecretStepProps) {
588+
function StepToken({ value, onChange, reconnect }: SecretStepProps & { reconnect: boolean }) {
580589
return (
581590
<div className='space-y-4'>
582591
<SubStepList>
583592
<SubStep n={1}>
584-
In Slack, open <strong>Install App</strong> → <strong>Install to Workspace</strong> and
585-
authorize.
593+
{reconnect ? (
594+
<>
595+
Open <strong>OAuth &amp; Permissions</strong> in your existing Slack app. Reinstall
596+
only if Slack requests it.
597+
</>
598+
) : (
599+
<>
600+
In Slack, open <strong>OAuth &amp; Permissions</strong> →{' '}
601+
<strong>Install to Workspace</strong> and approve access.
602+
</>
603+
)}
586604
</SubStep>
587605
<SubStep n={2}>
588606
Copy the <strong>Bot User OAuth Token</strong> (starts with <code>xoxb-</code>).
@@ -602,9 +620,15 @@ interface SecretFieldProps {
602620
}
603621
function SecretField({ label, value, onChange, placeholder }: SecretFieldProps) {
604622
return (
605-
<ChipModalField type='custom' title={label}>
606-
<SecretInput value={value} onChange={onChange} placeholder={placeholder} />
607-
</ChipModalField>
623+
<ChipModalField
624+
type='input'
625+
inputType='password'
626+
title={label}
627+
value={value}
628+
onChange={onChange}
629+
placeholder={placeholder}
630+
autoComplete='off'
631+
/>
608632
)
609633
}
610634

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/** @vitest-environment jsdom */
2+
import { act, useState } from 'react'
3+
import { createRoot, type Root } from 'react-dom/client'
4+
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
5+
6+
const mocks = vi.hoisted(() => ({
7+
loading: true,
8+
}))
9+
vi.mock('@/hooks/use-webhook-management', () => ({
10+
useWebhookManagement: () => ({
11+
webhookUrl: 'https://sim.test/api/webhooks/trigger/block-1',
12+
isLoading: mocks.loading,
13+
}),
14+
}))
15+
vi.mock('@/stores/workflows/registry/store', () => ({ useWorkflowRegistry: () => 'workflow-1' }))
16+
vi.mock('@/stores/workflows/subblock/store', () => ({
17+
useSubBlockStore: (selector: (state: { workflowValues: object }) => unknown) =>
18+
selector({ workflowValues: {} }),
19+
}))
20+
vi.mock(
21+
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value',
22+
() => ({
23+
useSubBlockValue: (_blockId: string, key: string) =>
24+
useState(key === 'botDisplayName' ? 'Test workflow bot' : ''),
25+
})
26+
)
27+
28+
import { SlackSetupWizard } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard'
29+
30+
let root: Root
31+
let container: HTMLDivElement
32+
beforeEach(() => {
33+
vi.useFakeTimers()
34+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
35+
mocks.loading = true
36+
container = document.createElement('div')
37+
document.body.appendChild(container)
38+
root = createRoot(container)
39+
})
40+
afterEach(async () => {
41+
await act(async () => root.unmount())
42+
container.remove()
43+
vi.unstubAllGlobals()
44+
vi.useRealTimers()
45+
})
46+
async function render() {
47+
await act(async () => root.render(<SlackSetupWizard blockId='block-1' />))
48+
}
49+
function button(name: string) {
50+
const element = [...document.querySelectorAll('button')].find(
51+
(button) => button.textContent?.trim() === name
52+
)
53+
expect(element).toBeDefined()
54+
return element!
55+
}
56+
async function click(name: string) {
57+
await act(async () => button(name).click())
58+
}
59+
async function fill(placeholder: string, value: string) {
60+
const input = document.querySelector<HTMLInputElement>(`input[placeholder="${placeholder}"]`)!
61+
await act(async () => input.focus())
62+
await act(async () => {
63+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value)
64+
input.dispatchEvent(new Event('input', { bubbles: true }))
65+
})
66+
}
67+
68+
it('uses the existing default name when the bot name is cleared', async () => {
69+
mocks.loading = false
70+
await render()
71+
await click('Set up Slack app')
72+
await fill('Sim Workflow Bot', '')
73+
expect(button('Next')).not.toBeDisabled()
74+
await click('Next')
75+
const link = document.querySelector<HTMLAnchorElement>('a[href*="manifest_json"]')!
76+
const manifest = JSON.parse(new URL(link.href).searchParams.get('manifest_json')!)
77+
expect(manifest.display_information.name).toBe('Sim Workflow Bot')
78+
})

0 commit comments

Comments
 (0)