Skip to content

Commit b61c36a

Browse files
committed
improvement(credentials): actionable Shopify admin-token rejection message
Shopify custom-app token verification faithfully surfaces a real 401 from Shopify, but the generic 'double-check it in Shopify' copy didn't tell users what to check. The #1 real cause is pasting the wrong secret (API key / API secret key) instead of the shpat_ Admin API access token, or using a token bound to a different store. - Add an optional per-provider invalidCredentialsHelp override on the token service-account descriptor; set it for Shopify to name the exact fix. - Move the error-code to message mapping out of the shared connect modal into the descriptor module (getTokenServiceAccountErrorMessage) so provider copy is inherited from the definition rather than hard-coded in the modal. - Add unit tests for the mapper (override, fallback, all codes).
1 parent 0199508 commit b61c36a

3 files changed

Lines changed: 106 additions & 33 deletions

File tree

apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx

Lines changed: 6 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ import {
1212
} from '@sim/emcn'
1313
import { createLogger } from '@sim/logger'
1414
import { isApiClientError } from '@/lib/api/client/errors'
15-
import type {
16-
TokenServiceAccountDescriptor,
17-
TokenServiceAccountField,
15+
import {
16+
getTokenServiceAccountErrorMessage,
17+
type TokenServiceAccountDescriptor,
18+
type TokenServiceAccountField,
1819
} from '@/lib/credentials/token-service-accounts/descriptors'
1920
import {
2021
useCreateWorkspaceCredential,
@@ -23,33 +24,6 @@ import {
2324

2425
const logger = createLogger('TokenServiceAccountModal')
2526

26-
const FALLBACK_ERROR_MESSAGE = "We couldn't add this credential. Try again in a moment."
27-
28-
/**
29-
* Maps server `error.code` values from token service-account verification to
30-
* user-facing messages, personalized with the provider's own token noun.
31-
*/
32-
function messageForTokenAccountError(
33-
err: unknown,
34-
descriptor: TokenServiceAccountDescriptor
35-
): string {
36-
if (isApiClientError(err) && err.code) {
37-
switch (err.code) {
38-
case 'invalid_credentials':
39-
return `We couldn't authenticate with that ${descriptor.tokenNoun}. Double-check it in ${descriptor.serviceLabel} and try again.`
40-
case 'site_not_found':
41-
return "We couldn't find an account at that domain. Check the spelling and try again."
42-
case 'provider_unavailable':
43-
return `We couldn't reach ${descriptor.serviceLabel} to verify these credentials. Try again in a moment.`
44-
case 'duplicate_display_name':
45-
return 'A credential with that name already exists in this workspace.'
46-
default:
47-
return FALLBACK_ERROR_MESSAGE
48-
}
49-
}
50-
return FALLBACK_ERROR_MESSAGE
51-
}
52-
5327
function normalizeDomainInput(raw: string): string {
5428
return raw
5529
.trim()
@@ -154,7 +128,8 @@ export function TokenServiceAccountModal({
154128
}
155129
onOpenChange(false)
156130
} catch (err: unknown) {
157-
setError(messageForTokenAccountError(err, descriptor))
131+
const code = isApiClientError(err) ? err.code : undefined
132+
setError(getTokenServiceAccountErrorMessage(descriptor, code))
158133
logger.error(`Failed to add ${descriptor.serviceLabel} service account credential`, err)
159134
}
160135
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import {
6+
getTokenServiceAccountDescriptor,
7+
getTokenServiceAccountErrorMessage,
8+
HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID,
9+
SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID,
10+
type TokenServiceAccountDescriptor,
11+
} from '@/lib/credentials/token-service-accounts/descriptors'
12+
13+
function descriptorFor(providerId: string): TokenServiceAccountDescriptor {
14+
const descriptor = getTokenServiceAccountDescriptor(providerId)
15+
if (!descriptor) throw new Error(`missing descriptor for ${providerId}`)
16+
return descriptor
17+
}
18+
19+
describe('getTokenServiceAccountErrorMessage', () => {
20+
const shopify = descriptorFor(SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID)
21+
const hubspot = descriptorFor(HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID)
22+
23+
it('uses the provider-specific invalidCredentialsHelp override when present', () => {
24+
expect(shopify.invalidCredentialsHelp).toBeDefined()
25+
expect(getTokenServiceAccountErrorMessage(shopify, 'invalid_credentials')).toBe(
26+
shopify.invalidCredentialsHelp
27+
)
28+
})
29+
30+
it('falls back to the generic token-noun message when no override is set', () => {
31+
expect(hubspot.invalidCredentialsHelp).toBeUndefined()
32+
expect(getTokenServiceAccountErrorMessage(hubspot, 'invalid_credentials')).toBe(
33+
`We couldn't authenticate with that ${hubspot.tokenNoun}. Double-check it in ${hubspot.serviceLabel} and try again.`
34+
)
35+
})
36+
37+
it('maps site_not_found to the domain hint', () => {
38+
expect(getTokenServiceAccountErrorMessage(shopify, 'site_not_found')).toBe(
39+
"We couldn't find an account at that domain. Check the spelling and try again."
40+
)
41+
})
42+
43+
it('maps provider_unavailable to a service-labeled retry message', () => {
44+
expect(getTokenServiceAccountErrorMessage(hubspot, 'provider_unavailable')).toBe(
45+
`We couldn't reach ${hubspot.serviceLabel} to verify these credentials. Try again in a moment.`
46+
)
47+
})
48+
49+
it('maps duplicate_display_name to the name-collision message', () => {
50+
expect(getTokenServiceAccountErrorMessage(shopify, 'duplicate_display_name')).toBe(
51+
'A credential with that name already exists in this workspace.'
52+
)
53+
})
54+
55+
it('falls back to a generic message for an unknown or absent code', () => {
56+
const fallback = "We couldn't add this credential. Try again in a moment."
57+
expect(getTokenServiceAccountErrorMessage(shopify, 'something_else')).toBe(fallback)
58+
expect(getTokenServiceAccountErrorMessage(shopify, undefined)).toBe(fallback)
59+
})
60+
})

apps/sim/lib/credentials/token-service-accounts/descriptors.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
* API key, …) instead of running an OAuth flow — mirroring the Atlassian
77
* service-account pattern: the token is verified once server-side, encrypted,
88
* and returned as the access token at execution time with no exchange or
9-
* refresh. This module holds only UI/contract metadata (field lists, labels,
10-
* docs links); server-side verification lives in
9+
* refresh. This module holds the client-safe UI/contract metadata (field
10+
* lists, labels, docs links) plus pure derivations over it (required-field
11+
* lookups, connect-modal error copy); server-side verification lives in
1112
* `@/lib/credentials/token-service-accounts/server`.
1213
*/
1314

@@ -46,6 +47,13 @@ export interface TokenServiceAccountDescriptor {
4647
docsUrl: string
4748
/** Optional one-line caveat rendered under the token field. */
4849
helpText?: string
50+
/**
51+
* Optional provider-specific message that replaces the generic
52+
* `invalid_credentials` rejection copy. Use it to name the exact
53+
* credential-paste mistake most users make (e.g. copying the API secret key
54+
* instead of the Admin API access token) rather than a vague "double-check".
55+
*/
56+
invalidCredentialsHelp?: string
4957
/**
5058
* HTTP auth scheme the pasted token requires at execution time. Defaults to
5159
* `bearer` (`Authorization: Bearer <token>`); `x-api-token` providers (e.g.
@@ -263,6 +271,8 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record<
263271
docsUrl: 'https://docs.sim.ai/integrations/shopify-service-account',
264272
helpText:
265273
'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.',
274+
invalidCredentialsHelp:
275+
'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.',
266276
},
267277
[WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID]: {
268278
providerId: WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID,
@@ -398,3 +408,31 @@ export function getTokenServiceAccountDescriptor(
398408
? TOKEN_SERVICE_ACCOUNT_DESCRIPTORS[providerId]
399409
: undefined
400410
}
411+
412+
/**
413+
* Maps a credential-verification `error.code` to a user-facing message for a
414+
* given provider. Provider-specific copy is inherited from the descriptor
415+
* (token noun, service label, and the optional `invalidCredentialsHelp`
416+
* override) rather than hard-coded in the shared connect modal. An
417+
* unknown/absent code falls back to a generic retry message.
418+
*/
419+
export function getTokenServiceAccountErrorMessage(
420+
descriptor: TokenServiceAccountDescriptor,
421+
code: string | undefined
422+
): string {
423+
switch (code) {
424+
case 'invalid_credentials':
425+
return (
426+
descriptor.invalidCredentialsHelp ??
427+
`We couldn't authenticate with that ${descriptor.tokenNoun}. Double-check it in ${descriptor.serviceLabel} and try again.`
428+
)
429+
case 'site_not_found':
430+
return "We couldn't find an account at that domain. Check the spelling and try again."
431+
case 'provider_unavailable':
432+
return `We couldn't reach ${descriptor.serviceLabel} to verify these credentials. Try again in a moment.`
433+
case 'duplicate_display_name':
434+
return 'A credential with that name already exists in this workspace.'
435+
default:
436+
return "We couldn't add this credential. Try again in a moment."
437+
}
438+
}

0 commit comments

Comments
 (0)