Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 */}

Expand Down
14 changes: 14 additions & 0 deletions apps/sim/app/api/credentials/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
7 changes: 7 additions & 0 deletions apps/sim/blocks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions apps/sim/lib/webhooks/polling/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getOAuthToken,
refreshAccessTokenIfNeeded,
resolveOAuthAccountId,
resolveServiceAccountToken,
} from '@/app/api/auth/oauth/utils'
import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants'

Expand Down Expand Up @@ -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}`)
Expand Down
1 change: 1 addition & 0 deletions apps/sim/triggers/hubspot/poller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down
Loading