Skip to content

Commit 3669a0a

Browse files
improvement(slack): merge slack_v2 auth into one credential picker for accounts and custom bots
1 parent 3507934 commit 3669a0a

9 files changed

Lines changed: 197 additions & 219 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import { useCallback, useMemo, useState } from 'react'
4-
import { Button, Combobox } from '@sim/emcn'
4+
import { Button, Combobox, type ComboboxOptionGroup } from '@sim/emcn'
55
import { ExternalLink, KeyRound } from 'lucide-react'
66
import { useParams } from 'next/navigation'
77
import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state'
@@ -104,17 +104,22 @@ export function CredentialSelector({
104104
const credentialsLoading = isAllCredentials ? allCredentialsLoading : oauthCredentialsLoading
105105

106106
const credentialKind = subBlock.credentialKind
107+
const isMergedKinds = credentialKind === 'any'
107108

108109
const credentials = useMemo(() => {
109110
// A custom-bot or service-account picker lists only the reusable
110-
// service-account credentials, including in trigger mode.
111+
// service-account credentials, including in trigger mode. A merged ('any')
112+
// picker lists OAuth accounts and bots together.
111113
if (credentialKind === 'custom-bot' || credentialKind === 'service-account') {
112114
return rawCredentials.filter((cred) => cred.type === 'service_account')
113115
}
116+
if (isMergedKinds) {
117+
return rawCredentials
118+
}
114119
return isTriggerMode && !subBlock.allowServiceAccounts
115120
? rawCredentials.filter((cred) => cred.type !== 'service_account')
116121
: rawCredentials
117-
}, [rawCredentials, isTriggerMode, credentialKind, subBlock.allowServiceAccounts])
122+
}, [rawCredentials, isTriggerMode, credentialKind, isMergedKinds, subBlock.allowServiceAccounts])
118123

119124
// Resolved service-account provider metadata for the token-paste connect
120125
// modal. Gated on `credentialKind` and using the non-throwing lookup so it
@@ -239,6 +244,7 @@ export function CredentialSelector({
239244
const oauthCredentials = allWorkspaceCredentials.filter((c) => c.type === 'oauth')
240245
return oauthCredentials.map((cred) => ({ label: cred.displayName, value: cred.id }))
241246
}
247+
if (isMergedKinds) return []
242248

243249
const options = credentials.map((cred) => ({
244250
label: cred.name,
@@ -266,6 +272,7 @@ export function CredentialSelector({
266272
return options
267273
}, [
268274
isAllCredentials,
275+
isMergedKinds,
269276
allWorkspaceCredentials,
270277
credentials,
271278
credentialKind,
@@ -274,6 +281,41 @@ export function CredentialSelector({
274281
getProviderName,
275282
])
276283

284+
const comboboxGroups = useMemo<ComboboxOptionGroup[] | undefined>(() => {
285+
if (!isMergedKinds) return undefined
286+
287+
const toOption = (cred: (typeof credentials)[number]) => ({
288+
label: cred.name,
289+
value: cred.id,
290+
iconElement: getProviderIcon((cred.provider ?? provider) as OAuthProvider),
291+
})
292+
293+
return [
294+
{
295+
section: `${getProviderName(provider)} accounts`,
296+
items: [
297+
...credentials.filter((c) => c.type !== 'service_account').map(toOption),
298+
{
299+
label: `Connect ${getProviderName(provider)} account`,
300+
value: '__connect_account__',
301+
iconElement: <ExternalLink className='size-3' />,
302+
},
303+
],
304+
},
305+
{
306+
section: 'Custom bots',
307+
items: [
308+
...credentials.filter((c) => c.type === 'service_account').map(toOption),
309+
{
310+
label: 'Set up a custom bot',
311+
value: '__connect_bot__',
312+
iconElement: <ExternalLink className='size-3' />,
313+
},
314+
],
315+
},
316+
]
317+
}, [isMergedKinds, credentials, provider, getProviderIcon, getProviderName])
318+
277319
const selectedCredentialProvider = selectedCredential?.provider ?? provider
278320
const workflowSearchHighlight = getWorkflowSearchLabelHighlight({
279321
activeSearchTarget,
@@ -320,9 +362,17 @@ export function CredentialSelector({
320362
const handleComboboxChange = useCallback(
321363
(value: string) => {
322364
if (value === '__connect_account__') {
365+
if (isMergedKinds) {
366+
setShowConnectModal(true)
367+
return
368+
}
323369
handleAddCredential()
324370
return
325371
}
372+
if (value === '__connect_bot__') {
373+
setShowSlackBotModal(true)
374+
return
375+
}
326376

327377
const matchedCred = (
328378
isAllCredentials ? allWorkspaceCredentials.filter((c) => c.type === 'oauth') : credentials
@@ -335,13 +385,21 @@ export function CredentialSelector({
335385
setIsEditing(true)
336386
setEditingValue(value)
337387
},
338-
[isAllCredentials, allWorkspaceCredentials, credentials, handleAddCredential, handleSelect]
388+
[
389+
isAllCredentials,
390+
isMergedKinds,
391+
allWorkspaceCredentials,
392+
credentials,
393+
handleAddCredential,
394+
handleSelect,
395+
]
339396
)
340397

341398
return (
342399
<div>
343400
<Combobox
344401
options={comboboxOptions}
402+
groups={comboboxGroups}
345403
value={displayValue}
346404
selectedValue={selectedId}
347405
onChange={handleComboboxChange}
@@ -350,8 +408,10 @@ export function CredentialSelector({
350408
hasDependencies && !depsSatisfied ? 'Fill in required fields above first' : label
351409
}
352410
disabled={effectiveDisabled}
353-
editable={true}
354-
filterOptions={true}
411+
editable={!isMergedKinds}
412+
filterOptions={!isMergedKinds}
413+
searchable={isMergedKinds}
414+
searchPlaceholder='Search credentials...'
355415
isLoading={credentialsLoading}
356416
overlayContent={overlayContent}
357417
className={overlayContent ? 'pl-7' : ''}
@@ -371,7 +431,7 @@ export function CredentialSelector({
371431
workflowId: activeWorkflowId || '',
372432
displayName: selectedCredential?.name ?? getProviderName(provider),
373433
providerId: effectiveProviderId,
374-
preCount: credentials.length,
434+
preCount: credentials.filter((c) => c.type !== 'service_account').length,
375435
workspaceId,
376436
requestedAt: Date.now(),
377437
})

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,13 @@ import { useWebhookManagement } from '@/hooks/use-webhook-management'
5656

5757
const SLACK_OVERRIDES: SelectorOverrides = {
5858
transformContext: (context, deps) => {
59+
// v1 gates on authMethod (raw bot token vs OAuth); v2 has one merged
60+
// credential field for actions and customBotCredential for triggers.
5961
const authMethod = deps.authMethod as string
6062
const oauthCredential =
6163
authMethod === 'bot_token'
62-
? String(deps.customBotCredential ?? deps.botToken ?? '')
63-
: String(deps.credential ?? deps.customBotCredential ?? deps.triggerCredentials ?? '')
64+
? String(deps.botToken ?? '')
65+
: String(deps.credential ?? deps.customBotCredential ?? '')
6466
return { ...context, oauthCredential }
6567
},
6668
}

apps/sim/blocks/blocks/slack.ts

Lines changed: 37 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2627,48 +2627,41 @@ export const SlackBlockMeta = {
26272627
],
26282628
} as const satisfies BlockMeta
26292629

2630-
/**
2631-
* Custom Bot picker used by slack_v2 in place of v1's raw bot-token field — a
2632-
* canonical basic/advanced pair (dropdown + manual credential-ID paste),
2633-
* mirroring the OAuth `credential`/`manualCredential` pair.
2634-
*/
2635-
const SLACK_CUSTOM_BOT_SUBBLOCKS: SubBlockConfig[] = [
2636-
{
2637-
id: 'customBotCredential',
2638-
title: 'Slack Bot',
2639-
type: 'oauth-input',
2640-
canonicalParamId: 'botCredential',
2641-
mode: 'basic',
2642-
serviceId: 'slack',
2643-
credentialKind: 'custom-bot',
2644-
requiredScopes: getScopesForService('slack'),
2645-
placeholder: 'Select a connected bot',
2646-
dependsOn: ['authMethod'],
2647-
condition: { field: 'authMethod', value: 'bot_token' },
2648-
required: true,
2649-
},
2650-
{
2651-
id: 'manualCustomBotCredential',
2652-
title: 'Bot Credential ID',
2653-
type: 'short-input',
2654-
canonicalParamId: 'botCredential',
2655-
mode: 'advanced',
2656-
placeholder: 'Enter bot credential ID',
2657-
dependsOn: ['authMethod'],
2658-
condition: { field: 'authMethod', value: 'bot_token' },
2659-
required: true,
2660-
},
2661-
]
2662-
26632630
const SLACK_WEBHOOK_TRIGGER_SUBBLOCK_IDS = new Set(
26642631
getTrigger('slack_webhook').subBlocks.map((sb) => sb.id)
26652632
)
26662633

2634+
/**
2635+
* Adapts a v1 subblock for slack_v2's merged credential picker: fields gated on
2636+
* the removed `authMethod` dropdown now depend on the single `credential` field.
2637+
*/
2638+
function adaptSubBlockForV2(sb: SubBlockConfig): SubBlockConfig {
2639+
const { dependsOn, condition, ...rest } = sb
2640+
if (sb.id === 'credential') {
2641+
return { ...rest, credentialKind: 'any', placeholder: 'Select Slack account or bot' }
2642+
}
2643+
if (sb.id === 'manualCredential') {
2644+
return { ...rest, placeholder: 'Enter credential ID' }
2645+
}
2646+
if (dependsOn && !Array.isArray(dependsOn) && dependsOn.all?.includes('authMethod')) {
2647+
return { ...sb, dependsOn: ['credential'] }
2648+
}
2649+
return sb
2650+
}
2651+
2652+
const {
2653+
authMethod: _authMethod,
2654+
botToken: _botToken,
2655+
botCredential: _botCredential,
2656+
...slackV2Inputs
2657+
} = SlackBlock.inputs
2658+
26672659
/**
26682660
* slack_v2 — the go-forward Slack action block. Identical operations, tools, and
2669-
* outputs to v1 (shared by reference), but the "Custom Bot" auth method selects
2670-
* a reusable bot credential set up once, instead of pasting a raw token. Also
2671-
* hosts the redesigned slack_oauth trigger (v1 keeps the legacy slack_webhook).
2661+
* outputs to v1 (shared by reference), but auth is a single credential picker
2662+
* listing Sim OAuth accounts and reusable custom bots together — the credential's
2663+
* kind is resolved server-side, so no auth-method choice is needed. Also hosts
2664+
* the redesigned slack_oauth trigger (v1 keeps the legacy slack_webhook).
26722665
*/
26732666
export const SlackV2Block: BlockConfig<SlackResponse> = {
26742667
...SlackBlock,
@@ -2683,13 +2676,18 @@ export const SlackV2Block: BlockConfig<SlackResponse> = {
26832676
...SlackBlock.subBlocks.flatMap((sb) => {
26842677
// Drop the legacy paste-secret trigger config (v1 hosts slack_webhook)
26852678
// and v1's raw bot-token auth field — the trigger set includes an
2686-
// id-colliding 'botToken', so the set check covers both.
2679+
// id-colliding 'botToken', so the set check covers both. The authMethod
2680+
// dropdown is gone: the merged credential picker covers both auth kinds.
26872681
if (SLACK_WEBHOOK_TRIGGER_SUBBLOCK_IDS.has(sb.id)) return []
2688-
if (sb.id === 'authMethod') return [sb, ...SLACK_CUSTOM_BOT_SUBBLOCKS]
2689-
return [sb]
2682+
if (sb.id === 'authMethod') return []
2683+
return [adaptSubBlockForV2(sb)]
26902684
}),
26912685
...getTrigger('slack_oauth').subBlocks,
26922686
],
2687+
inputs: {
2688+
...slackV2Inputs,
2689+
oauthCredential: { type: 'string', description: 'Slack credential (OAuth account or bot)' },
2690+
},
26932691
triggers: {
26942692
enabled: true,
26952693
available: ['slack_oauth'],

apps/sim/blocks/types.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -372,9 +372,10 @@ export interface SubBlockConfig {
372372
* connect row opens the custom-bot setup modal instead of the OAuth flow.
373373
* `'service-account'` is the generic equivalent for a no-OAuth provider: it lists
374374
* only service-account credentials and its connect row opens the descriptor-driven
375-
* token-paste modal (`ConnectServiceAccountModal`).
375+
* token-paste modal (`ConnectServiceAccountModal`). `'any'` lists OAuth accounts
376+
* and custom bots together in a grouped dropdown with a connect action for each kind.
376377
*/
377-
credentialKind?: 'custom-bot' | 'service-account'
378+
credentialKind?: 'custom-bot' | 'service-account' | 'any'
378379
/**
379380
* Opts a trigger-mode `oauth-input` selector into listing service-account
380381
* credentials, which are otherwise excluded in trigger mode. Set only when the

apps/sim/lib/webhooks/deploy.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,23 @@ const tableTrigger = trigger([
6262
{ id: 'manualTableId', mode: 'trigger-advanced', canonicalParamId: 'tableId', required: true },
6363
])
6464

65+
const slackTrigger = trigger([
66+
{ id: 'eventType', mode: 'trigger', required: true },
67+
{
68+
id: 'customBotCredential',
69+
mode: 'trigger',
70+
canonicalParamId: 'botCredential',
71+
serviceId: 'slack',
72+
required: true,
73+
},
74+
{
75+
id: 'manualBotCredential',
76+
mode: 'trigger-advanced',
77+
canonicalParamId: 'botCredential',
78+
required: true,
79+
},
80+
])
81+
6582
function makeBlock(
6683
type: string,
6784
subBlockValues: Record<string, unknown>,
@@ -155,6 +172,28 @@ describe('buildProviderConfig canonical collapse', () => {
155172
const { providerConfig } = buildProviderConfig(block, 'table_new_row', tableTrigger)
156173
expect(providerConfig.tableId).toBe('ACTIVE')
157174
})
175+
176+
it('collapses the slack bot credential pair under botCredential for the routing branch', () => {
177+
const block = makeBlock('slack_v2', {
178+
eventType: 'message',
179+
customBotCredential: 'cred_bot_1',
180+
})
181+
const result = buildProviderConfig(block, 'slack_oauth', slackTrigger)
182+
183+
expect(result.providerConfig.botCredential).toBe('cred_bot_1')
184+
expect(result.providerConfig.eventType).toBe('message')
185+
// The slack trigger has no generic triggerCredentials field — the routing
186+
// branch resolves botCredential itself.
187+
expect(result.credentialReference).toBeUndefined()
188+
expect(result.credentialServiceId).toBeUndefined()
189+
})
190+
191+
it('reports a missing required slack bot credential as a missing field', () => {
192+
const block = makeBlock('slack_v2', { eventType: 'message' })
193+
const result = buildProviderConfig(block, 'slack_oauth', slackTrigger)
194+
195+
expect(result.missingFields.length).toBeGreaterThan(0)
196+
})
158197
})
159198

160199
describe('resolveTriggerCredentialId', () => {

0 commit comments

Comments
 (0)