diff --git a/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx b/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx index 2489fc1c750..ae5a8ac2d05 100644 --- a/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx @@ -116,7 +116,7 @@ The access token is bearer credentials for your entire portal, limited only by i ## Using the Service Account in Workflows -Add a HubSpot block to your workflow. In the credential dropdown, your HubSpot service account appears alongside any OAuth credentials. Select it and configure the block as you normally would. +Add a HubSpot block or the HubSpot CRM Trigger to your workflow. In the credential dropdown, your HubSpot service account appears alongside any OAuth credentials. Select it and configure the block as you normally would. For the polling trigger, a service account is often the better choice — the token never expires and keeps working even if the person who connected it leaves the portal. {/* TODO(screenshot): HubSpot block in a workflow with the HubSpot service account selected as the credential */} diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index e1708f0b48b..ea0f554d40f 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -27,6 +27,7 @@ import { ServiceAccountSecretError, verifyAndBuildServiceAccountSecret, } from '@/lib/credentials/service-account-secret' +import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { getServiceConfigByProviderId } from '@/lib/oauth' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' @@ -437,6 +438,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + // Token service-account creates always carry a fresh token that must be + // stored — falling through to the existing-credential path would return + // the old credential as success and silently drop the submitted token. + if (resolvedProviderId && isTokenServiceAccountProviderId(resolvedProviderId)) { + return NextResponse.json( + { + code: 'duplicate_display_name', + error: `A credential named "${resolvedDisplayName}" already exists in this workspace. Give this one a different name.`, + }, + { status: 409 } + ) + } + const access = await getCredentialActorContext(existingCredential.id, session.user.id, { workspaceAccess, }) 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 c8c0ddd9829..51ddb37ef90 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 @@ -106,10 +106,10 @@ export function CredentialSelector({ if (credentialKind === 'custom-bot') { return rawCredentials.filter((cred) => cred.type === 'service_account') } - return isTriggerMode + return isTriggerMode && !subBlock.allowServiceAccounts ? rawCredentials.filter((cred) => cred.type !== 'service_account') : rawCredentials - }, [rawCredentials, isTriggerMode, credentialKind]) + }, [rawCredentials, isTriggerMode, credentialKind, subBlock.allowServiceAccounts]) const selectedCredential = useMemo( () => credentials.find((cred) => cred.id === selectedId), diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index cec549b83ab..9024e44cb88 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -372,6 +372,13 @@ export interface SubBlockConfig { * connect row opens the custom-bot setup modal instead of the OAuth flow. */ credentialKind?: 'custom-bot' + /** + * Opts a trigger-mode `oauth-input` selector into listing service-account + * credentials, which are otherwise excluded in trigger mode. Set only when the + * trigger's server-side polling path can resolve the provider's service-account + * token (see `resolveOAuthCredential` in `@/lib/webhooks/polling/utils`). + */ + allowServiceAccounts?: boolean // Selector properties — declarative mapping to a SelectorKey selectorKey?: SelectorKey selectorAllowSearch?: boolean diff --git a/apps/sim/lib/webhooks/polling/utils.ts b/apps/sim/lib/webhooks/polling/utils.ts index 91f65015b69..7916a3965e9 100644 --- a/apps/sim/lib/webhooks/polling/utils.ts +++ b/apps/sim/lib/webhooks/polling/utils.ts @@ -7,6 +7,7 @@ import { getOAuthToken, refreshAccessTokenIfNeeded, resolveOAuthAccountId, + resolveServiceAccountToken, } from '@/app/api/auth/oauth/utils' import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants' @@ -203,6 +204,13 @@ export async function resolveOAuthCredential( `Failed to resolve OAuth account for credential ${credentialId}, webhook ${webhookData.id}` ) } + if (resolved.credentialType === 'service_account' && resolved.credentialId) { + const { accessToken: serviceAccountToken } = await resolveServiceAccountToken( + resolved.credentialId, + resolved.providerId + ) + return serviceAccountToken + } const rows = await db.select().from(account).where(eq(account.id, resolved.accountId)).limit(1) if (!rows.length) { throw new Error(`Credential ${credentialId} not found for webhook ${webhookData.id}`) diff --git a/apps/sim/triggers/hubspot/poller.ts b/apps/sim/triggers/hubspot/poller.ts index 75a58dbe569..a05e17068c0 100644 --- a/apps/sim/triggers/hubspot/poller.ts +++ b/apps/sim/triggers/hubspot/poller.ts @@ -61,6 +61,7 @@ export const hubspotPollingTrigger: TriggerConfig = { description: 'Connect a HubSpot account so Sim can poll your CRM on your behalf.', serviceId: 'hubspot', requiredScopes: getScopesForService('hubspot'), + allowServiceAccounts: true, required: true, mode: 'trigger', },