Skip to content
Merged
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 @@ -12,9 +12,10 @@ import {
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { isApiClientError } from '@/lib/api/client/errors'
import type {
TokenServiceAccountDescriptor,
TokenServiceAccountField,
import {
getTokenServiceAccountErrorMessage,
type TokenServiceAccountDescriptor,
type TokenServiceAccountField,
} from '@/lib/credentials/token-service-accounts/descriptors'
import {
useCreateWorkspaceCredential,
Expand All @@ -23,33 +24,6 @@ import {

const logger = createLogger('TokenServiceAccountModal')

const FALLBACK_ERROR_MESSAGE = "We couldn't add this credential. Try again in a moment."

/**
* Maps server `error.code` values from token service-account verification to
* user-facing messages, personalized with the provider's own token noun.
*/
function messageForTokenAccountError(
err: unknown,
descriptor: TokenServiceAccountDescriptor
): string {
if (isApiClientError(err) && err.code) {
switch (err.code) {
case 'invalid_credentials':
return `We couldn't authenticate with that ${descriptor.tokenNoun}. Double-check it in ${descriptor.serviceLabel} and try again.`
case 'site_not_found':
return "We couldn't find an account at that domain. Check the spelling and try again."
case 'provider_unavailable':
return `We couldn't reach ${descriptor.serviceLabel} to verify these credentials. Try again in a moment.`
case 'duplicate_display_name':
return 'A credential with that name already exists in this workspace.'
default:
return FALLBACK_ERROR_MESSAGE
}
}
return FALLBACK_ERROR_MESSAGE
}

function normalizeDomainInput(raw: string): string {
return raw
.trim()
Expand Down Expand Up @@ -154,7 +128,8 @@ export function TokenServiceAccountModal({
}
onOpenChange(false)
} catch (err: unknown) {
setError(messageForTokenAccountError(err, descriptor))
const code = isApiClientError(err) ? err.code : undefined
setError(getTokenServiceAccountErrorMessage(descriptor, code))
logger.error(`Failed to add ${descriptor.serviceLabel} service account credential`, err)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
getTokenServiceAccountDescriptor,
getTokenServiceAccountErrorMessage,
HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID,
SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID,
type TokenServiceAccountDescriptor,
} from '@/lib/credentials/token-service-accounts/descriptors'

function descriptorFor(providerId: string): TokenServiceAccountDescriptor {
const descriptor = getTokenServiceAccountDescriptor(providerId)
if (!descriptor) throw new Error(`missing descriptor for ${providerId}`)
return descriptor
}

describe('getTokenServiceAccountErrorMessage', () => {
const shopify = descriptorFor(SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID)
const hubspot = descriptorFor(HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID)

it('uses the provider-specific invalidCredentialsHelp override when present', () => {
expect(shopify.invalidCredentialsHelp).toBeDefined()
expect(getTokenServiceAccountErrorMessage(shopify, 'invalid_credentials')).toBe(
shopify.invalidCredentialsHelp
)
})

it('falls back to the generic token-noun message when no override is set', () => {
expect(hubspot.invalidCredentialsHelp).toBeUndefined()
expect(getTokenServiceAccountErrorMessage(hubspot, 'invalid_credentials')).toBe(
`We couldn't authenticate with that ${hubspot.tokenNoun}. Double-check it in ${hubspot.serviceLabel} and try again.`
)
})

it('maps site_not_found to the domain hint', () => {
expect(getTokenServiceAccountErrorMessage(shopify, 'site_not_found')).toBe(
"We couldn't find an account at that domain. Check the spelling and try again."
)
})

it('maps provider_unavailable to a service-labeled retry message', () => {
expect(getTokenServiceAccountErrorMessage(hubspot, 'provider_unavailable')).toBe(
`We couldn't reach ${hubspot.serviceLabel} to verify these credentials. Try again in a moment.`
)
})

it('maps duplicate_display_name to the name-collision message', () => {
expect(getTokenServiceAccountErrorMessage(shopify, 'duplicate_display_name')).toBe(
'A credential with that name already exists in this workspace.'
)
})

it('falls back to a generic message for an unknown or absent code', () => {
const fallback = "We couldn't add this credential. Try again in a moment."
expect(getTokenServiceAccountErrorMessage(shopify, 'something_else')).toBe(fallback)
expect(getTokenServiceAccountErrorMessage(shopify, undefined)).toBe(fallback)
})
})
42 changes: 40 additions & 2 deletions apps/sim/lib/credentials/token-service-accounts/descriptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
* API key, …) instead of running an OAuth flow — mirroring the Atlassian
* service-account pattern: the token is verified once server-side, encrypted,
* and returned as the access token at execution time with no exchange or
* refresh. This module holds only UI/contract metadata (field lists, labels,
* docs links); server-side verification lives in
* refresh. This module holds the client-safe UI/contract metadata (field
* lists, labels, docs links) plus pure derivations over it (required-field
* lookups, connect-modal error copy); server-side verification lives in
* `@/lib/credentials/token-service-accounts/server`.
*/

Expand Down Expand Up @@ -46,6 +47,13 @@ export interface TokenServiceAccountDescriptor {
docsUrl: string
/** Optional one-line caveat rendered under the token field. */
helpText?: string
/**
* Optional provider-specific message that replaces the generic
* `invalid_credentials` rejection copy. Use it to name the exact
* credential-paste mistake most users make (e.g. copying the API secret key
* instead of the Admin API access token) rather than a vague "double-check".
*/
invalidCredentialsHelp?: string
/**
* HTTP auth scheme the pasted token requires at execution time. Defaults to
* `bearer` (`Authorization: Bearer <token>`); `x-api-token` providers (e.g.
Expand Down Expand Up @@ -263,6 +271,8 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record<
docsUrl: 'https://docs.sim.ai/integrations/shopify-service-account',
helpText:
'Legacy admin-created custom apps reveal the shpat_ token once; new Dev Dashboard apps issue tokens via OAuth, not a UI reveal. The token is store-bound and does not expire.',
invalidCredentialsHelp:
'Shopify rejected this token. Make sure you copied the Admin API access token (starts with shpat_) — not the API key or API secret key — for an app installed on this exact store domain, and that it has not since been revoked or regenerated.',
},
[WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID]: {
providerId: WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID,
Expand Down Expand Up @@ -398,3 +408,31 @@ export function getTokenServiceAccountDescriptor(
? TOKEN_SERVICE_ACCOUNT_DESCRIPTORS[providerId]
: undefined
}

/**
* Maps a credential-verification `error.code` to a user-facing message for a
* given provider. Provider-specific copy is inherited from the descriptor
* (token noun, service label, and the optional `invalidCredentialsHelp`
* override) rather than hard-coded in the shared connect modal. An
* unknown/absent code falls back to a generic retry message.
*/
export function getTokenServiceAccountErrorMessage(
descriptor: TokenServiceAccountDescriptor,
code: string | undefined
): string {
switch (code) {
case 'invalid_credentials':
return (
descriptor.invalidCredentialsHelp ??
`We couldn't authenticate with that ${descriptor.tokenNoun}. Double-check it in ${descriptor.serviceLabel} and try again.`
)
case 'site_not_found':
return "We couldn't find an account at that domain. Check the spelling and try again."
case 'provider_unavailable':
return `We couldn't reach ${descriptor.serviceLabel} to verify these credentials. Try again in a moment.`
case 'duplicate_display_name':
return 'A credential with that name already exists in this workspace.'
default:
return "We couldn't add this credential. Try again in a moment."
}
}
Loading