diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts index 638e3c3a747..2d6899c8c2a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts @@ -155,3 +155,87 @@ describe('parseSpecialTags with ', () => { ]) }) }) + +describe('service_account credential tag', () => { + it('parses a service_account tag into a credential segment', () => { + const body = JSON.stringify({ type: 'service_account', provider: 'slack' }) + const { segments } = parseSpecialTags(`Set this up: ${body}`, false) + + const credential = segments.find((segment) => segment.type === 'credential') + expect(credential).toBeDefined() + expect(credential).toMatchObject({ + type: 'credential', + data: { type: 'service_account', provider: 'slack' }, + }) + }) + + it('carries no value — the secret is typed into Sim’s own form, never the transcript', () => { + const body = JSON.stringify({ type: 'service_account', provider: 'google-sheets' }) + const { segments } = parseSpecialTags(`${body}`, false) + + const credential = segments.find((segment) => segment.type === 'credential') + expect((credential as { data: { value?: string } }).data.value).toBeUndefined() + }) + + it('suppresses the tag while it is still streaming', () => { + // A half-streamed tag must not flash raw JSON into the message body. + const { segments, hasPendingTag } = parseSpecialTags( + 'Set this up: {"type": "service_a', + true + ) + expect(hasPendingTag).toBe(true) + expect(segments.some((segment) => segment.type === 'credential')).toBe(false) + const text = segments + .filter((segment): segment is { type: 'text'; content: string } => segment.type === 'text') + .map((segment) => segment.content) + .join('') + expect(text).not.toContain('service_a') + }) +}) + +describe('service_account tag validation', () => { + it('rejects a provider-less tag, which would render an unresolvable control', () => { + const { segments } = parseSpecialTags( + `${JSON.stringify({ type: 'service_account' })}`, + false + ) + expect(segments.some((segment) => segment.type === 'credential')).toBe(false) + }) + + it('rejects a blank provider', () => { + const { segments } = parseSpecialTags( + `${JSON.stringify({ type: 'service_account', provider: ' ' })}`, + false + ) + expect(segments.some((segment) => segment.type === 'credential')).toBe(false) + }) + + it('accepts an optional credentialId for reconnect and carries it through', () => { + const body = JSON.stringify({ + type: 'service_account', + provider: 'notion', + credentialId: 'cred_abc123', + }) + const { segments } = parseSpecialTags(`${body}`, false) + const credential = segments.find((segment) => segment.type === 'credential') + expect(credential).toMatchObject({ + type: 'credential', + data: { type: 'service_account', provider: 'notion', credentialId: 'cred_abc123' }, + }) + }) + + it('rejects a non-string credentialId', () => { + const body = JSON.stringify({ type: 'service_account', provider: 'notion', credentialId: 42 }) + const { segments } = parseSpecialTags(`${body}`, false) + expect(segments.some((segment) => segment.type === 'credential')).toBe(false) + }) + + it.each(['', ' '])( + 'rejects a blank credentialId (%j) so reconnect cannot target a missing credential', + (credentialId) => { + const body = JSON.stringify({ type: 'service_account', provider: 'notion', credentialId }) + const { segments } = parseSpecialTags(`${body}`, false) + expect(segments.some((segment) => segment.type === 'credential')).toBe(false) + } + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index f44aab8407f..60227897d94 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -1,6 +1,6 @@ 'use client' -import { createElement, useMemo, useState } from 'react' +import { createElement, lazy, Suspense, useMemo, useState } from 'react' import { ArrowRight, Button, @@ -19,6 +19,10 @@ import { useSession } from '@/lib/auth/auth-client' import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' import { isSafeHttpUrl } from '@/lib/core/utils/urls' +import { + resolveOAuthServiceForSlug, + resolveServiceAccountIntegration, +} from '@/lib/integrations/oauth-service' import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' import { getServiceConfigByProviderId } from '@/lib/oauth/utils' import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' @@ -27,6 +31,10 @@ import type { ChatMessageContext, MothershipResource, } from '@/app/workspace/[workspaceId]/home/types' +// Deep import, not the barrel: the barrel also re-exports +// ConnectServiceAccountModal, and that edge would pull the modal into this +// chunk and defeat the lazy() split below. +import { useServiceAccountConnectTarget } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useWorkspaceCredential } from '@/hooks/queries/credentials' @@ -62,6 +70,17 @@ export interface UsageUpgradeTagData { message: string } +/** + * Kept out of the chat's initial chunk — it pulls in three provider-specific + * setup forms and is only mounted once a message actually offers a service + * account. + */ +const ConnectServiceAccountModal = lazy(() => + import( + '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal' + ).then((m) => ({ default: m.ConnectServiceAccountModal })) +) + export const CREDENTIAL_TAG_TYPES = [ 'env_key', 'oauth_key', @@ -69,6 +88,7 @@ export const CREDENTIAL_TAG_TYPES = [ 'credential_id', 'link', 'secret_input', + 'service_account', ] as const export type CredentialTagType = (typeof CREDENTIAL_TAG_TYPES)[number] @@ -88,6 +108,11 @@ export interface CredentialTagData { name?: string /** Where a secret_input value is persisted. Defaults to "workspace". */ scope?: SecretInputScope + /** + * Existing credential to reconnect in place (service_account only). Present = + * rotate the secret on this credential; absent = create a new one. + */ + credentialId?: string } export interface MothershipErrorTagData { @@ -225,6 +250,20 @@ function isCredentialTagData(value: unknown): value is CredentialTagData { } return typeof value.name === 'string' && value.name.trim().length > 0 } + // A service_account tag is a control, not a value: it names the provider + // whose setup form to open, and the user types the secret into that form — + // so it never carries a `value`, but it is useless without a provider. An + // optional `credentialId` reconnects an existing service account in place; + // reject a blank one, since the renderer treats a truthy id as "reconnect" + // and would try to rotate a non-existent credential. + if (value.type === 'service_account') { + if (value.credentialId !== undefined) { + if (typeof value.credentialId !== 'string' || value.credentialId.trim().length === 0) { + return false + } + } + return typeof value.provider === 'string' && value.provider.trim().length > 0 + } // A sim_key chip is platform-filled: the model only marks where the workspace // API key belongs (it never holds the value) and Sim injects it from the tool // result, so the tag is valid with or without a `value`. Every other rendered @@ -870,6 +909,74 @@ function SecretInputDisplay({ data }: { data: CredentialTagData }) { ) } +/** + * Inline "set up a service account" control rendered for + * `{"type":"service_account","provider":"slack"}`. + * + * Opens `ConnectServiceAccountModal` over the chat rather than linking out to + * the integrations page — the user stays in the conversation that asked for + * the credential, and comes back to it with the credential in hand. + */ +function ServiceAccountConnectDisplay({ data }: { data: CredentialTagData }) { + const { workspaceId } = useParams<{ workspaceId: string }>() + const { canEdit } = useUserPermissionsContext() + const [open, setOpen] = useState(false) + + const match = useMemo( + () => (data.provider ? resolveServiceAccountIntegration(data.provider) : null), + [data.provider] + ) + const service = useMemo(() => (match ? resolveOAuthServiceForSlug(match.slug) : null), [match]) + const target = useServiceAccountConnectTarget({ + serviceAccountProviderId: match?.serviceAccountProviderId, + serviceName: match?.serviceName, + serviceIcon: service?.serviceIcon, + }) + + // A credentialId reconnects (rotates the secret on) that existing service + // account in place rather than creating a new one — the modal keeps its id. + const reconnectCredentialId = data.credentialId + const { data: reconnectCredential } = useWorkspaceCredential(reconnectCredentialId) + + // Creating a credential mutates the workspace — hide it from read-only + // members, and honour the provider's own preview gate (custom Slack bots + // ride the slack_v2 flag) so chat can't surface what the integrations page + // deliberately hides. + if (!target || target.hidden || !canEdit || !workspaceId) return null + + const label = reconnectCredentialId + ? `Reconnect ${reconnectCredential?.displayName ?? target.serviceName}` + : `${target.label} for ${target.serviceName}` + + return ( + <> + + {open && ( + + + + )} + + ) +} + function CredentialLinkDisplay({ data }: { data: CredentialTagData }) { const { canEdit } = useUserPermissionsContext() @@ -921,6 +1028,10 @@ function CredentialDisplay({ data }: { data: CredentialTagData }) { return } + if (data.type === 'service_account') { + return + } + if (data.type === 'sim_key') { // SecretReveal masks itself when there's no value, so a value-less tag (the // model's placeholder / persisted form) renders masked and a Sim-filled tag diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index 2d7abb20569..b11854646f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -6,25 +6,23 @@ import { ArrowLeft, ArrowRight, Plus } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' -import { getClientCredentialAccountDescriptor } from '@/lib/credentials/client-credential-accounts/descriptors' -import { getTokenServiceAccountDescriptor } from '@/lib/credentials/token-service-accounts/descriptors' import { blockTypeToIconMap, type Integration, resolveOAuthServiceForIntegration, } from '@/lib/integrations' import { getServiceConfigByProviderId } from '@/lib/oauth' -import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' import { IntegrationSkillsSection } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section' import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params' -import { ConnectServiceAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' +import { + ConnectServiceAccountModal, + useServiceAccountConnectTarget, +} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route' import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration' -import { getBlock } from '@/blocks' -import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import { getTileIconColorClass } from '@/blocks/icon-color' import { storeCuratedPrompt } from '@/blocks/integration-matcher' import { @@ -32,7 +30,6 @@ import { getTemplatesForBlock, type ScopedBlockTemplate, } from '@/blocks/registry' -import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' import { useOAuthReturnRouter } from '@/hooks/use-oauth-return' @@ -78,29 +75,13 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration ) }, [credentials, oauthService]) const [serviceAccountOpen, setServiceAccountOpen] = useState(false) - const isSlackBot = oauthService?.serviceAccountProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID - const blockOverlayVersion = useCustomBlockOverlayVersion() - // Custom Slack bots ride the slack_v2 preview flag: the setup surface stays - // hidden until that block is revealed for this viewer. - const slackBotPreviewHidden = useMemo(() => { - if (!isSlackBot) return false - const v2 = getBlock('slack_v2') - return !v2 || isHiddenUnder(overlayVisibility(), v2) - }, [isSlackBot, blockOverlayVersion]) - const hasServiceAccount = - Boolean(oauthService?.serviceAccountProviderId) && !slackBotPreviewHidden - // Vendor-accurate connect label: token-paste and client-credential - // providers use their own noun ("Add API key", "Add server-to-server app"); - // only true service-account providers (Google, Atlassian) say - // "Add service account". - const nounDescriptor = - getTokenServiceAccountDescriptor(oauthService?.serviceAccountProviderId) ?? - getClientCredentialAccountDescriptor(oauthService?.serviceAccountProviderId) - const serviceAccountConnectLabel = isSlackBot - ? 'Set up a custom bot' - : nounDescriptor - ? `Add ${nounDescriptor.connectNoun}` - : 'Add service account' + const serviceAccountTarget = useServiceAccountConnectTarget({ + serviceAccountProviderId: oauthService?.serviceAccountProviderId, + serviceName: oauthService?.serviceName, + serviceIcon: oauthService?.serviceIcon, + }) + const hasServiceAccount = Boolean(serviceAccountTarget) && !serviceAccountTarget?.hidden + const serviceAccountConnectLabel = serviceAccountTarget?.label ?? 'Add service account' const hasHandledConnectQueryRef = useRef(false) useEffect(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/index.ts index e09a3c81a74..f9c7989e1e9 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/index.ts @@ -2,3 +2,7 @@ export { ConnectServiceAccountModal, type ServiceAccountProviderId, } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal' +export { + type ServiceAccountConnectTarget, + useServiceAccountConnectTarget, +} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect' diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts new file mode 100644 index 00000000000..fdeda1993b1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts @@ -0,0 +1,76 @@ +'use client' + +import { type ComponentType, useMemo } from 'react' +import { + getServiceAccountConnectNoun, + getServiceAccountGatingBlockType, +} from '@/lib/credentials/service-account-provider-ids' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' +import type { ServiceAccountProviderId } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal' +import { getBlock } from '@/blocks' +import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' +import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context' + +/** + * Everything a caller needs to render a service-account connect control: + * whether to show it at all, what to call it, and the props the modal takes. + */ +export interface ServiceAccountConnectTarget { + serviceAccountProviderId: ServiceAccountProviderId + serviceName: string + serviceIcon: ComponentType<{ className?: string }> + /** + * Vendor-accurate control label — token-paste and client-credential + * providers use their own noun ("Add API key", "Add server-to-server app"); + * only true service-account providers say "Add service account". + */ + label: string + /** + * True when the provider's setup surface must stay hidden for this viewer. + * Custom Slack bots ride the `slack_v2` preview flag, so any surface that + * offers one — the integrations page or the chat — has to honour it or the + * flag is trivially bypassed. + */ + hidden: boolean +} + +interface UseServiceAccountConnectTargetArgs { + serviceAccountProviderId: ServiceAccountProviderId | undefined + serviceName: string | undefined + serviceIcon: ComponentType<{ className?: string }> | undefined +} + +/** + * Derives the connect-control label and preview gating for a service-account + * provider. Shared by the integrations detail page and the chat's inline + * connect button so the two can't drift on either the wording or the gate. + */ +export function useServiceAccountConnectTarget({ + serviceAccountProviderId, + serviceName, + serviceIcon, +}: UseServiceAccountConnectTargetArgs): ServiceAccountConnectTarget | null { + const blockOverlayVersion = useCustomBlockOverlayVersion() + + const isSlackBot = serviceAccountProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID + + const hidden = useMemo(() => { + const gatingBlockType = serviceAccountProviderId + ? getServiceAccountGatingBlockType(serviceAccountProviderId) + : null + if (!gatingBlockType) return false + const gatingBlock = getBlock(gatingBlockType) + return !gatingBlock || isHiddenUnder(overlayVisibility(), gatingBlock) + // blockOverlayVersion is read to re-evaluate when the overlay changes. + }, [serviceAccountProviderId, blockOverlayVersion]) + + return useMemo(() => { + if (!serviceAccountProviderId || !serviceName || !serviceIcon) return null + + const label = isSlackBot + ? 'Set up a custom bot' + : `Add ${getServiceAccountConnectNoun(serviceAccountProviderId)}` + + return { serviceAccountProviderId, serviceName, serviceIcon, label, hidden } + }, [serviceAccountProviderId, serviceName, serviceIcon, isSlackBot, hidden]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index 8611bf7ac72..6674f8f6d6f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -17,6 +17,7 @@ import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/conn import { ConnectServiceAccountModal, type ServiceAccountProviderId, + useServiceAccountConnectTarget, } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' @@ -130,6 +131,20 @@ export function CredentialSelector({ [credentialKind, isMergedKinds, serviceId] ) + // Canonical resolver for the service-account connect control: the vendor- + // accurate label and — critically — the per-viewer preview gate (a custom + // Slack bot rides `slack_v2`). Shared with the integrations page and chat so + // the gate can't be bypassed here. When `hidden`, the setup action is + // suppressed; existing service-account credentials stay selectable. + const serviceAccountTarget = useServiceAccountConnectTarget({ + serviceAccountProviderId: serviceAccountService?.serviceAccountProviderId as + | ServiceAccountProviderId + | undefined, + serviceName: serviceAccountService?.name, + serviceIcon: serviceAccountService?.icon, + }) + const serviceAccountConnectHidden = Boolean(serviceAccountTarget?.hidden) + const selectedCredential = useMemo( () => credentials.find((cred) => cred.id === selectedId), [credentials, selectedId] @@ -249,19 +264,23 @@ export function CredentialSelector({ iconElement: getProviderIcon((cred.provider ?? provider) as OAuthProvider), })) - options.push({ - label: - credentialKind === 'service-account' - ? (subBlock.credentialLabels?.serviceAccountConnect ?? - (credentials.length > 0 - ? `Add another ${getProviderName(provider)} key` - : `Add ${getProviderName(provider)} key`)) - : credentials.length > 0 - ? `Connect another ${getProviderName(provider)} account` - : `Connect ${getProviderName(provider)} account`, - value: '__connect_account__', - iconElement: , - }) + // Suppress the setup action when the service-account flow is preview-gated + // for this viewer (a custom Slack bot needs slack_v2) — existing accounts + // above stay selectable. + if (credentialKind !== 'service-account' || !serviceAccountConnectHidden) { + options.push({ + label: + credentialKind === 'service-account' + ? (subBlock.credentialLabels?.serviceAccountConnect ?? + serviceAccountTarget?.label ?? + `Add ${getProviderName(provider)} key`) + : credentials.length > 0 + ? `Connect another ${getProviderName(provider)} account` + : `Connect ${getProviderName(provider)} account`, + value: '__connect_account__', + iconElement: , + }) + } return options }, [ @@ -271,6 +290,8 @@ export function CredentialSelector({ credentials, credentialKind, subBlock.credentialLabels, + serviceAccountConnectHidden, + serviceAccountTarget, provider, getProviderIcon, getProviderName, @@ -302,11 +323,19 @@ export function CredentialSelector({ section: labels?.serviceAccountGroup ?? 'Service accounts', items: [ ...credentials.filter((c) => c.type === 'service_account').map(toOption), - { - label: labels?.serviceAccountConnect ?? `Add ${getProviderName(provider)} key`, - value: '__connect_service_account__', - iconElement: , - }, + // Drop the setup action when the flow is preview-gated for this viewer. + ...(serviceAccountConnectHidden + ? [] + : [ + { + label: + labels?.serviceAccountConnect ?? + serviceAccountTarget?.label ?? + `Add ${getProviderName(provider)} key`, + value: '__connect_service_account__', + iconElement: , + }, + ]), ], }, ] @@ -314,6 +343,8 @@ export function CredentialSelector({ isMergedKinds, subBlock.credentialLabels, credentials, + serviceAccountConnectHidden, + serviceAccountTarget, provider, getProviderIcon, getProviderName, diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.test.ts b/apps/sim/lib/copilot/tools/handlers/oauth.test.ts index 30870b9d636..1168528e56a 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.test.ts @@ -207,3 +207,65 @@ describe('executeOAuthGetAuthLink', () => { }) }) }) + +describe('executeOAuthGetAuthLink service account rejection', () => { + beforeEach(() => { + vi.clearAllMocks() + process.env.NEXT_PUBLIC_APP_URL = BASE_URL + mockEnsureWorkspaceAccess.mockResolvedValue(WORKSPACE_ACCESS) + }) + + /** + * Regression: a user asked for a "new custom bot", the agent correctly + * resolved that to `slack-custom-bot` and passed it here, and the fuzzy + * substring pass matched it to the Slack OAuth service — `slack-custom-bot` + * contains `slack`. The tool returned a personal-OAuth authorize URL and + * reported success, so the user connected their own account instead of a + * shared bot. Failing loudly is the point: a wrong link that looks right is + * worse than an error the agent can recover from. + */ + it('rejects a service account id with a coherent recovery message, not a workspace link', async () => { + const result = await executeOAuthGetAuthLink({ providerName: 'slack-custom-bot' }, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('service account') + expect(result.error).toContain('service_account credential tag') + const output = result.output as { setup_url?: string; oauth_url?: string; message: string } + // The rejection must not fall into the generic catch, which would attach a + // contradicting workspace oauth_url and a "connect manually" message — the + // agent would then surface a workspace link instead of the tag. + expect(output.setup_url).toBeUndefined() + expect(output.oauth_url).toBeUndefined() + expect(output.message).toContain('service_account credential tag') + expect(output.message).not.toContain('Connect manually') + }) + + it.each([ + 'notion-service-account', + 'salesforce-service-account', + 'google-service-account', + 'atlassian-service-account', + 'SLACK-CUSTOM-BOT', + // Readable forms must be normalized (spaces/underscores → hyphens) so they + // are caught too, not passed to the fuzzy OAuth resolver. + 'slack custom bot', + 'google service account', + 'notion_service_account', + ])('rejects %s', async (providerName) => { + const result = await executeOAuthGetAuthLink({ providerName }, context) + expect(result.success).toBe(false) + expect(result.error).toContain('service_account credential tag') + }) + + it('still resolves ordinary OAuth providers for integrations that also offer a service account', async () => { + // `slack` and `notion` must keep working — the guard keys off the id being + // a service-account id, not off the integration having a service-account flow. + for (const providerName of ['slack', 'google-email']) { + const result = await executeOAuthGetAuthLink({ providerName }, context) + expect(result.success).toBe(true) + expect((result.output as { oauth_url: string }).oauth_url).toContain( + '/api/auth/oauth2/authorize' + ) + } + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.ts b/apps/sim/lib/copilot/tools/handlers/oauth.ts index e3b55c36bae..e392833e6e8 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.ts @@ -3,6 +3,7 @@ import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/typ import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { getBaseUrl } from '@/lib/core/utils/urls' import { getCredentialActorContext } from '@/lib/credentials/access' +import { isServiceAccountProviderId } from '@/lib/credentials/service-account-provider-ids' import { getAllOAuthServices } from '@/lib/oauth/utils' import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -14,6 +15,25 @@ export async function executeOAuthGetAuthLink( const rawCredentialId = rawParams.credentialId || rawParams.credential_id const credentialId = rawCredentialId ? String(rawCredentialId) : undefined const baseUrl = getBaseUrl() + + // A service account is not an OAuth provider. Catch it here — before the fuzzy + // resolver's substring match can swallow e.g. `slack-custom-bot` into the + // Slack OAuth service — and return a coherent failure directly rather than + // throwing into the generic catch below, which would attach a contradicting + // workspace `oauth_url` and "connect manually" message. Normalize spaces and + // underscores so a readable form ("slack custom bot") is caught too. + const serviceAccountId = providerName + .toLowerCase() + .trim() + .replace(/[\s_]+/g, '-') + if (isServiceAccountProviderId(serviceAccountId)) { + const message = + `"${providerName}" is a service account, not an OAuth provider. ` + + `Emit a service_account credential tag with the service's OAuth provider ` + + `value instead (e.g. "slack") — it opens the service account setup form in chat.` + return { success: false, error: message, output: { message } } + } + try { if (!context.workspaceId || !context.userId) { throw new Error('workspaceId and userId are required to generate an OAuth link') diff --git a/apps/sim/lib/copilot/vfs/serializers.test.ts b/apps/sim/lib/copilot/vfs/serializers.test.ts index 728db16cd6d..daf3c3c8086 100644 --- a/apps/sim/lib/copilot/vfs/serializers.test.ts +++ b/apps/sim/lib/copilot/vfs/serializers.test.ts @@ -8,6 +8,7 @@ import type { ToolConfig } from '@/tools/types' import { serializeApiKeyIntegrations, serializeBlockSchema, + serializeCredentials, serializeFileMeta, serializeIntegrationSchema, serializeKBMeta, @@ -284,3 +285,77 @@ describe('serializeKBMeta', () => { expect(missing).not.toHaveProperty('tagDefinitions') }) }) + +function oauthTool(id: string, provider: string): ToolConfig { + return { + id, + name: id, + description: `Run ${id}`, + version: '1.0.0', + params: {}, + request: { url: 'https://example.com', method: 'POST', headers: () => ({}) }, + oauth: { required: true, provider }, + } +} + +describe('serializeIntegrationSchema — service-account auth', () => { + it('marks an OAuth service that also offers a service account, with its secret noun', () => { + // Notion connects via OAuth or via an internal integration token; the agent + // must be able to discover the second option from the same auth field. + const schema = JSON.parse(serializeIntegrationSchema(oauthTool('notion_read', 'notion'))) + expect(schema.auth).toMatchObject({ + type: 'oauth', + provider: 'notion', + serviceAccount: { connectNoun: 'integration secret' }, + }) + }) + + it('omits serviceAccount for an OAuth service that has no service-account flow', () => { + const schema = JSON.parse(serializeIntegrationSchema(oauthTool('gh_read', 'github'))) + expect(schema.auth.type).toBe('oauth') + expect(schema.auth.serviceAccount).toBeUndefined() + }) + + // The preview-gate behavior (slack custom bot ↔ slack_v2) is covered in + // service-account-gate.test.ts, which mocks getBlock — the block registry is + // globally stubbed here, so slack_v2's real `preview: true` isn't observable + // through serializeIntegrationSchema. +}) + +describe('serializeCredentials — type distinguishes reconnect flow', () => { + const now = new Date('2026-07-21T00:00:00.000Z') + + it('marks a service account so the agent reconnects it via the tag, not oauth', () => { + const json = JSON.parse( + serializeCredentials([ + { + id: 'c1', + providerId: 'notion-service-account', + scope: null, + credentialType: 'service_account', + createdAt: now, + }, + { + id: 'c2', + providerId: 'google-email', + scope: null, + credentialType: 'oauth', + createdAt: now, + }, + ]) + ) + expect(json[0]).toMatchObject({ + id: 'c1', + provider: 'notion-service-account', + type: 'service_account', + }) + expect(json[1]).toMatchObject({ id: 'c2', provider: 'google-email', type: 'oauth' }) + }) + + it('leaves env-var credentials typeless', () => { + const json = JSON.parse( + serializeCredentials([{ providerId: 'OPENAI_API_KEY', scope: 'workspace', createdAt: now }]) + ) + expect(json[0].type).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 2750383005e..d394ed07347 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -1,18 +1,39 @@ import type { ShareAuthType } from '@/lib/api/contracts/public-shares' import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' import { isHosted } from '@/lib/core/config/env-flags' +import { + getServiceAccountConnectNoun, + getServiceAccountGatingBlockType, +} from '@/lib/credentials/service-account-provider-ids' import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types' +import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils' import { isSubBlockHidden } from '@/lib/workflows/subblocks/visibility' +import { getBlock } from '@/blocks' import { isCustomBlockType } from '@/blocks/custom/build-config' import type { BlockConfig, SubBlockConfig } from '@/blocks/types' +import { isHiddenUnder } from '@/blocks/visibility/context' import { DYNAMIC_MODEL_PROVIDERS, PROVIDER_DEFINITIONS } from '@/providers/models' import type { ToolConfig, ToolHostingCondition } from '@/tools/types' +/** The service-account alternative to OAuth for a service, when it offers one. */ +export interface VfsServiceAccountAuth { + /** Vendor noun for the secret it collects — "private app token", "server-to-server app", … */ + connectNoun: string +} + export type VfsToolAuth = | { type: 'oauth' required: boolean provider: string + /** + * Present when this OAuth service also accepts a shared service-account + * credential (connect AS AN APPLICATION, not as the user). The agent emits + * a `service_account` credential tag with this entry's OAuth `provider` to + * open the in-chat setup form. Omitted when the service has no + * service-account flow, or its flow is gated by a preview block. + */ + serviceAccount?: VfsServiceAccountAuth } | { type: 'api_key' @@ -22,6 +43,33 @@ export type VfsToolAuth = condition?: ToolHostingCondition } +/** + * Whether an OAuth provider value also exposes a service-account flow, and the + * noun for the secret it collects. The single composition point behind both the + * per-tool `auth.serviceAccount` field and the `oauth-integrations.json` + * roll-up, so the two never disagree. Returns `undefined` when the service has + * no service-account flow, or its flow is gated by a preview block (a custom + * Slack bot needs slack_v2) — GA-only discovery, so the agent never proactively + * offers a preview flow, matching the per-viewer gate the renderer applies. + */ +export function describeServiceAccountForOAuthProvider( + oauthProvider: string +): VfsServiceAccountAuth | undefined { + const serviceAccountProviderId = getServiceAccountProviderForProviderId(oauthProvider) + if (!serviceAccountProviderId) return undefined + const gatingBlockType = getServiceAccountGatingBlockType(serviceAccountProviderId) + if (gatingBlockType) { + const gatingBlock = getBlock(gatingBlockType) + // Omit when the gating block is missing (fail-closed) or hidden by the + // canonical predicate. Passing `null` vis reduces `isHiddenUnder` to the + // static preview check — so once the block GAs and drops `preview`, it is + // no longer hidden and discovery includes it again, matching the renderer. + // Hand-rolling `?.preview ?? true` would keep it omitted forever after GA. + if (!gatingBlock || isHiddenUnder(null, gatingBlock)) return undefined + } + return { connectNoun: getServiceAccountConnectNoun(serviceAccountProviderId) } +} + export interface ComponentSerializationOptions { hosted?: boolean toolConfigs?: ReadonlyMap @@ -33,10 +81,12 @@ export interface ComponentSerializationOptions { */ export function serializeToolAuth(tool: ToolConfig, hosted = isHosted): VfsToolAuth | undefined { if (tool.oauth) { + const serviceAccount = describeServiceAccountForOAuthProvider(tool.oauth.provider) return { type: 'oauth', required: tool.oauth.required, provider: tool.oauth.provider, + ...(serviceAccount ? { serviceAccount } : {}), } } @@ -589,6 +639,8 @@ export function serializeCredentials( displayName?: string | null role?: string | null scope: string | null + /** 'service_account' for a shared app credential; omitted/undefined for a personal OAuth connection. */ + credentialType?: 'oauth' | 'service_account' createdAt: Date }> ): string { @@ -599,6 +651,10 @@ export function serializeCredentials( displayName: a.displayName || undefined, role: a.role || undefined, scope: a.scope || undefined, + // 'oauth' (personal connection) vs 'service_account' (shared app + // credential) — they reconnect differently, so the agent must branch on + // this. Env-var credentials carry no type. + type: a.credentialType, connectedAt: a.createdAt.toISOString(), })), null, diff --git a/apps/sim/lib/copilot/vfs/service-account-gate.test.ts b/apps/sim/lib/copilot/vfs/service-account-gate.test.ts new file mode 100644 index 00000000000..e525dae124b --- /dev/null +++ b/apps/sim/lib/copilot/vfs/service-account-gate.test.ts @@ -0,0 +1,45 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetBlock } = vi.hoisted(() => ({ mockGetBlock: vi.fn() })) +vi.mock('@/blocks', () => ({ getBlock: mockGetBlock })) + +import { describeServiceAccountForOAuthProvider } from '@/lib/copilot/vfs/serializers' + +describe('describeServiceAccountForOAuthProvider — preview gate', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('omits a service account whose gating block is still a preview block', () => { + mockGetBlock.mockReturnValue({ type: 'slack_v2', preview: true }) + expect(describeServiceAccountForOAuthProvider('slack')).toBeUndefined() + }) + + it('includes it once the gating block GAs and drops preview', () => { + // slack_v2's documented GA migration removes `preview`. Discovery must then + // surface the custom bot, matching what the UI shows. A hand-rolled + // `?.preview ?? true` would keep it omitted forever — the "sticks after GA" + // regression; reusing isHiddenUnder(null, block) fixes it. + mockGetBlock.mockReturnValue({ type: 'slack_v2' }) + expect(describeServiceAccountForOAuthProvider('slack')).toEqual({ connectNoun: 'custom bot' }) + }) + + it('fail-closes (omits) when the gating block is missing entirely', () => { + mockGetBlock.mockReturnValue(undefined) + expect(describeServiceAccountForOAuthProvider('slack')).toBeUndefined() + }) + + it('includes an ungated provider without consulting the block registry', () => { + expect(describeServiceAccountForOAuthProvider('notion')).toEqual({ + connectNoun: 'integration secret', + }) + expect(mockGetBlock).not.toHaveBeenCalled() + }) + + it('returns undefined for a provider with no service-account flow', () => { + expect(describeServiceAccountForOAuthProvider('github')).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 1a6096870c4..2e07211fc51 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -51,8 +51,13 @@ import { canonicalWorkspaceFilePath, encodeVfsPathSegments, } from '@/lib/copilot/vfs/path-utils' -import type { DeploymentData, KbTagDefinitionSummary } from '@/lib/copilot/vfs/serializers' +import type { + DeploymentData, + KbTagDefinitionSummary, + VfsServiceAccountAuth, +} from '@/lib/copilot/vfs/serializers' import { + describeServiceAccountForOAuthProvider, serializeApiKeyIntegrations, serializeApiKeys, serializeBlockSchema, @@ -246,7 +251,15 @@ function getStaticComponentFiles(): Map { let integrationCount = 0 - const oauthServices = new Map() + // `serviceAccount` marks services that also accept a shared service-account + // credential (connect AS AN APPLICATION, not as the user) — the same + // `auth.serviceAccount` shape the per-operation schemas carry, so the agent + // discovers all three auth modes (oauth / api_key / service account) from one + // uniform field instead of a separate file. + const oauthServices = new Map< + string, + { provider: string; operations: string[]; serviceAccount?: VfsServiceAccountAuth } + >() // Integration tools come from the shared exposed-tool set (latest version of // each operation owned by a visible block), the same set used to build the @@ -267,7 +280,11 @@ function getStaticComponentFiles(): Map { if (existing) { existing.operations.push(operation) } else { - oauthServices.set(service, { provider: tool.oauth.provider, operations: [operation] }) + oauthServices.set(service, { + provider: tool.oauth.provider, + operations: [operation], + serviceAccount: describeServiceAccountForOAuthProvider(tool.oauth.provider), + }) } } } @@ -2554,6 +2571,7 @@ export class WorkspaceVFS { displayName: c.displayName, role: c.role, scope: null, + credentialType: c.type, createdAt: c.updatedAt, })), ]) diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts index 5fd7b711adf..9f2332b7cb1 100644 --- a/apps/sim/lib/credentials/environment.ts +++ b/apps/sim/lib/credentials/environment.ts @@ -481,6 +481,8 @@ export interface AccessibleOAuthCredential { providerId: string displayName: string role: 'admin' | 'member' + /** Distinguishes a personal OAuth connection from a shared service account. */ + type: 'oauth' | 'service_account' updatedAt: Date } @@ -498,6 +500,7 @@ export async function getAccessibleOAuthCredentials( id: credential.id, providerId: credential.providerId, displayName: credential.displayName, + type: credential.type, updatedAt: credential.updatedAt, }) .from(credential) @@ -515,6 +518,7 @@ export async function getAccessibleOAuthCredentials( providerId: row.providerId, displayName: row.displayName, role: 'admin' as const, + type: row.type as AccessibleOAuthCredential['type'], updatedAt: row.updatedAt, })) } @@ -525,6 +529,7 @@ export async function getAccessibleOAuthCredentials( providerId: credential.providerId, displayName: credential.displayName, role: credentialMember.role, + type: credential.type, updatedAt: credential.updatedAt, }) .from(credential) @@ -550,6 +555,7 @@ export async function getAccessibleOAuthCredentials( providerId: row.providerId!, displayName: row.displayName, role: row.role, + type: row.type as AccessibleOAuthCredential['type'], updatedAt: row.updatedAt, })) } diff --git a/apps/sim/lib/credentials/service-account-provider-ids.test.ts b/apps/sim/lib/credentials/service-account-provider-ids.test.ts new file mode 100644 index 00000000000..2e89d228f68 --- /dev/null +++ b/apps/sim/lib/credentials/service-account-provider-ids.test.ts @@ -0,0 +1,65 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + getServiceAccountConnectNoun, + getServiceAccountGatingBlockType, + isServiceAccountProviderId, +} from '@/lib/credentials/service-account-provider-ids' + +describe('isServiceAccountProviderId', () => { + it('recognizes every family of service-account id', () => { + expect(isServiceAccountProviderId('google-service-account')).toBe(true) + expect(isServiceAccountProviderId('atlassian-service-account')).toBe(true) + expect(isServiceAccountProviderId('slack-custom-bot')).toBe(true) + expect(isServiceAccountProviderId('notion-service-account')).toBe(true) + expect(isServiceAccountProviderId('salesforce-service-account')).toBe(true) + }) + + it('is case- and whitespace-insensitive', () => { + expect(isServiceAccountProviderId(' SLACK-CUSTOM-BOT ')).toBe(true) + }) + + it('rejects OAuth provider values and unknowns', () => { + // The distinction the oauth_get_auth_link guard depends on: `slack` is an + // OAuth provider value, not a service-account id, even though Slack offers a + // custom bot. + expect(isServiceAccountProviderId('slack')).toBe(false) + expect(isServiceAccountProviderId('google-email')).toBe(false) + expect(isServiceAccountProviderId('github')).toBe(false) + expect(isServiceAccountProviderId('')).toBe(false) + }) +}) + +describe('getServiceAccountGatingBlockType', () => { + it('maps the custom Slack bot to slack_v2 and leaves everything else ungated', () => { + expect(getServiceAccountGatingBlockType('slack-custom-bot')).toBe('slack_v2') + expect(getServiceAccountGatingBlockType('notion-service-account')).toBeNull() + expect(getServiceAccountGatingBlockType('google-service-account')).toBeNull() + expect(getServiceAccountGatingBlockType('salesforce-service-account')).toBeNull() + }) +}) + +describe('getServiceAccountConnectNoun', () => { + it('names the token-paste secret each provider actually collects', () => { + expect(getServiceAccountConnectNoun('notion-service-account')).toBe('integration secret') + expect(getServiceAccountConnectNoun('hubspot-service-account')).toBe('private app token') + expect(getServiceAccountConnectNoun('linear-service-account')).toBe('API key') + }) + + it('names the client-credential secret', () => { + expect(getServiceAccountConnectNoun('zoom-service-account')).toBe('server-to-server app') + }) + + it('calls a custom Slack bot a custom bot', () => { + expect(getServiceAccountConnectNoun('slack-custom-bot')).toBe('custom bot') + }) + + it('falls back to the generic noun for bespoke providers with no descriptor', () => { + // Google (paste a JSON key) and Atlassian (token + domain) have no + // token/client descriptor, so they read as a plain "service account". + expect(getServiceAccountConnectNoun('google-service-account')).toBe('service account') + expect(getServiceAccountConnectNoun('atlassian-service-account')).toBe('service account') + }) +}) diff --git a/apps/sim/lib/credentials/service-account-provider-ids.ts b/apps/sim/lib/credentials/service-account-provider-ids.ts new file mode 100644 index 00000000000..9e838b0a313 --- /dev/null +++ b/apps/sim/lib/credentials/service-account-provider-ids.ts @@ -0,0 +1,77 @@ +import { + getClientCredentialAccountDescriptor, + isClientCredentialAccountProviderId, +} from '@/lib/credentials/client-credential-accounts/descriptors' +import { + getTokenServiceAccountDescriptor, + isTokenServiceAccountProviderId, +} from '@/lib/credentials/token-service-accounts/descriptors' +import { + ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, + GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + SLACK_CUSTOM_BOT_PROVIDER_ID, +} from '@/lib/oauth/types' +import type { ServiceAccountProviderId } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' + +/** + * Narrows a runtime provider-id string to the {@link ServiceAccountProviderId} + * union. Anything outside the union is unsupported by + * `ConnectServiceAccountModal`. + * + * Lives here rather than beside the integration catalog so callers that only + * need the predicate — not a slug — avoid pulling in `integrations.json` and + * the `OAUTH_PROVIDERS` walk. + */ +export function asServiceAccountProviderId( + value: string | undefined +): ServiceAccountProviderId | undefined { + if ( + value === GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID || + value === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID || + value === SLACK_CUSTOM_BOT_PROVIDER_ID || + isTokenServiceAccountProviderId(value) || + isClientCredentialAccountProviderId(value) + ) { + return value + } + return undefined +} + +/** + * Whether a string is itself a service-account provider id + * (`slack-custom-bot`, `notion-service-account`, …) rather than an OAuth + * provider value. + * + * Note this asks what the id *is*, not whether the named integration happens + * to offer a service-account flow: `slack` is an OAuth provider value and + * returns false even though Slack also supports a custom bot. + */ +export function isServiceAccountProviderId(value: string): boolean { + return asServiceAccountProviderId(value.toLowerCase().trim()) !== undefined +} + +/** + * The block type whose preview gate governs a service-account provider's setup + * surface, or `null` when the provider is ungated. A custom Slack bot is only + * usable through `slack_v2`, so its setup form must stay hidden wherever that + * block is preview-hidden — both the in-chat connect button and the tool that + * offers it read this so they can't disagree on availability. + */ +export function getServiceAccountGatingBlockType(providerId: string): string | null { + return providerId === SLACK_CUSTOM_BOT_PROVIDER_ID ? 'slack_v2' : null +} + +/** + * Vendor-accurate noun for the credential a service-account provider collects + * ("private app token", "server-to-server app", …), for connect-control labels + * and agent-facing discovery. Token-paste and client-credential providers name + * their own; bespoke providers (Google JSON key, Atlassian token) fall back to + * the generic "service account". Single source shared by the connect hook and + * the VFS catalog so the wording can't drift. + */ +export function getServiceAccountConnectNoun(providerId: string): string { + if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) return 'custom bot' + const descriptor = + getTokenServiceAccountDescriptor(providerId) ?? getClientCredentialAccountDescriptor(providerId) + return descriptor?.connectNoun ?? 'service account' +} diff --git a/apps/sim/lib/integrations/oauth-service.test.ts b/apps/sim/lib/integrations/oauth-service.test.ts index 4cdf616ae4a..dbd70af7c58 100644 --- a/apps/sim/lib/integrations/oauth-service.test.ts +++ b/apps/sim/lib/integrations/oauth-service.test.ts @@ -3,7 +3,10 @@ */ import { describe, expect, it } from 'vitest' import integrationsJson from '@/lib/integrations/integrations.json' -import { resolveOAuthServiceForSlug } from '@/lib/integrations/oauth-service' +import { + resolveOAuthServiceForSlug, + resolveServiceAccountIntegration, +} from '@/lib/integrations/oauth-service' import type { Integration } from '@/lib/integrations/types' const INTEGRATIONS = integrationsJson.integrations as readonly Integration[] @@ -129,3 +132,52 @@ describe('resolveOAuthServiceForSlug', () => { expect(unexpected).toEqual([]) }) }) + +describe('resolveServiceAccountIntegration', () => { + it.concurrent('keeps a named service instead of collapsing to the family default', () => { + // Every Google integration issues the same google-service-account + // credential, so a fuzzy matcher can silently answer Drive for all of + // them. The user asked about Sheets; the link must land on Sheets. + expect(resolveServiceAccountIntegration('google-sheets')?.slug).toBe('google-sheets') + expect(resolveServiceAccountIntegration('gmail')?.slug).toBe('gmail') + expect(resolveServiceAccountIntegration('confluence')?.slug).toBe('confluence') + }) + + it.concurrent('resolves a family name to its canonical slug, not an arbitrary member', () => { + // Without an explicit canonical entry these fall through to fuzzy + // matching, which answers whichever member sorts first (BigQuery). + expect(resolveServiceAccountIntegration('google')?.slug).toBe('google-drive') + expect(resolveServiceAccountIntegration('google-service-account')?.slug).toBe('google-drive') + expect(resolveServiceAccountIntegration('atlassian')?.slug).toBe('jira') + expect(resolveServiceAccountIntegration('atlassian-service-account')?.slug).toBe('jira') + }) + + it.concurrent('accepts provider values, display names, and stray casing', () => { + expect(resolveServiceAccountIntegration('google-email')?.slug).toBe('gmail') + expect(resolveServiceAccountIntegration('slack-custom-bot')?.slug).toBe('slack') + expect(resolveServiceAccountIntegration('calcom')?.slug).toBe('cal-com') + expect(resolveServiceAccountIntegration('Cal.com')?.slug).toBe('cal-com') + expect(resolveServiceAccountIntegration(' NOTION ')?.slug).toBe('notion') + }) + + it.concurrent( + 'accepts the space/underscore-normalized id forms the oauth guard steers toward', + () => { + // oauth_get_auth_link rejects `slack custom bot` / `notion_service_account` + // and tells the agent to emit a service_account tag; the renderer must then + // resolve those same readable forms or the connect control renders nothing. + expect(resolveServiceAccountIntegration('slack custom bot')?.slug).toBe('slack') + expect(resolveServiceAccountIntegration('notion_service_account')?.slug).toBe('notion') + expect(resolveServiceAccountIntegration('google service account')?.slug).toBe('google-drive') + } + ) + + it.concurrent('returns null rather than inventing a link for unsupported input', () => { + // The handler turns null into "use oauth_get_auth_link instead"; a wrong + // match here would send the user to a modal that cannot take their key. + expect(resolveServiceAccountIntegration('github')).toBeNull() + expect(resolveServiceAccountIntegration('dropbox')).toBeNull() + expect(resolveServiceAccountIntegration('')).toBeNull() + expect(resolveServiceAccountIntegration(' ')).toBeNull() + }) +}) diff --git a/apps/sim/lib/integrations/oauth-service.ts b/apps/sim/lib/integrations/oauth-service.ts index 4cba5ce809e..6ef45d07179 100644 --- a/apps/sim/lib/integrations/oauth-service.ts +++ b/apps/sim/lib/integrations/oauth-service.ts @@ -1,10 +1,8 @@ import type { ComponentType } from 'react' -import { isClientCredentialAccountProviderId } from '@/lib/credentials/client-credential-accounts/descriptors' -import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors' +import { asServiceAccountProviderId } from '@/lib/credentials/service-account-provider-ids' import integrationsJson from '@/lib/integrations/integrations.json' import type { Integration } from '@/lib/integrations/types' import { getServiceConfigByServiceId } from '@/lib/oauth' -import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import type { ServiceAccountProviderId } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' const INTEGRATIONS_DATA: readonly Integration[] = @@ -29,26 +27,6 @@ export interface OAuthServiceMatch { serviceAccountProviderId?: ServiceAccountProviderId } -/** - * Narrows the runtime `OAuthServiceConfig.serviceAccountProviderId` string to - * the {@link ServiceAccountProviderId} union. Anything outside the union is - * unsupported by `ConnectServiceAccountModal` and is silently ignored. - */ -function asServiceAccountProviderId( - value: string | undefined -): ServiceAccountProviderId | undefined { - if ( - value === 'google-service-account' || - value === 'atlassian-service-account' || - value === SLACK_CUSTOM_BOT_PROVIDER_ID || - isTokenServiceAccountProviderId(value) || - isClientCredentialAccountProviderId(value) - ) { - return value - } - return undefined -} - /** * Looks up the OAuth service entry registered under the integration's * `oauthServiceId` — the service id its block declares on the `oauth-input` @@ -81,3 +59,104 @@ export function resolveOAuthServiceForSlug(slug: string): OAuthServiceMatch | nu if (!integration) return null return resolveOAuthServiceForIntegration(integration) } + +/** + * An integration that exposes a service-account connect flow, resolved to the + * catalog slug whose detail page mounts `ConnectServiceAccountModal`. + */ +export interface ServiceAccountIntegrationMatch { + slug: string + serviceAccountProviderId: ServiceAccountProviderId + serviceName: string + providerId: string +} + +/** + * Slug to prefer for a query that names a family rather than one of its + * integrations — either the shared service-account provider id or the bare + * base provider. Every Google integration issues the same + * `google-service-account` credential and every Atlassian one the same + * `atlassian-service-account`, so an unqualified request has to land + * somewhere; these are the most general surface of each family. + * + * Without the bare-provider entries, fuzzy matching resolves `google` to + * whichever Google integration sorts first in the catalog (BigQuery), which is + * both arbitrary and a poor landing page. A caller that names a specific + * integration still gets that integration. + */ +const CANONICAL_SERVICE_ACCOUNT_SLUGS: Readonly> = { + 'google-service-account': 'google-drive', + google: 'google-drive', + 'atlassian-service-account': 'jira', + atlassian: 'jira', +} as const + +/** + * Every integration that offers a service-account flow, in catalog order. + * Built once — `resolveOAuthServiceForIntegration` walks `OAUTH_PROVIDERS` per + * entry, which is wasted work to repeat on each lookup. + */ +const SERVICE_ACCOUNT_INTEGRATIONS: readonly ServiceAccountIntegrationMatch[] = + INTEGRATIONS_DATA.flatMap((integration) => { + const match = resolveOAuthServiceForIntegration(integration) + if (!match?.serviceAccountProviderId) return [] + return [ + { + slug: integration.slug, + serviceAccountProviderId: match.serviceAccountProviderId, + serviceName: integration.name, + providerId: match.providerId, + }, + ] + }) + +/** + * Resolves a loosely-specified integration name to the service-account setup + * surface for it. Accepts a catalog slug (`google-sheets`), an OAuth provider + * value (`google-email`), a service-account provider id + * (`google-service-account`), or a display name (`Google Sheets`). + * + * Exact matches are tried before fuzzy ones so a caller naming a specific + * integration always lands on it: `gmail` must not fall through to Drive just + * because both issue the same Google service account. A bare service-account + * provider id names no single integration, so it resolves through + * {@link CANONICAL_SERVICE_ACCOUNT_SLUGS}. + * + * The id-based lookups also accept the space/underscore-normalized form + * (`slack custom bot`, `notion_service_account`) that `oauth_get_auth_link`'s + * guard rejects and steers toward a `service_account` tag — otherwise the chat + * renderer would fail to resolve exactly the forms the guard produced. + * + * Returns `null` when nothing matches or the named integration has no + * service-account flow — callers should fall back to OAuth rather than + * inventing a link. + */ +export function resolveServiceAccountIntegration( + providerName: string +): ServiceAccountIntegrationMatch | null { + const query = providerName.toLowerCase().trim() + if (!query) return null + // Hyphenated form for id lookups (slug / providerId / SA-id are hyphenated); + // the raw query is kept for display-name matches, which aren't hyphenated. + const idQuery = query.replace(/[\s_]+/g, '-') + + const canonicalSlug = CANONICAL_SERVICE_ACCOUNT_SLUGS[idQuery] + if (canonicalSlug) { + const canonical = SERVICE_ACCOUNT_INTEGRATIONS.find((entry) => entry.slug === canonicalSlug) + if (canonical) return canonical + } + + return ( + SERVICE_ACCOUNT_INTEGRATIONS.find((entry) => entry.slug === idQuery) ?? + SERVICE_ACCOUNT_INTEGRATIONS.find((entry) => entry.providerId.toLowerCase() === idQuery) ?? + SERVICE_ACCOUNT_INTEGRATIONS.find( + (entry) => entry.serviceAccountProviderId.toLowerCase() === idQuery + ) ?? + SERVICE_ACCOUNT_INTEGRATIONS.find((entry) => entry.serviceName.toLowerCase() === query) ?? + SERVICE_ACCOUNT_INTEGRATIONS.find( + (entry) => + entry.serviceName.toLowerCase().includes(query) || entry.slug.replace(/-/g, ' ') === query + ) ?? + null + ) +}