From b61c36a069e03a52a832b617f01b716cf8de50ff Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 23 Jul 2026 11:00:50 -0700 Subject: [PATCH] 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). --- .../token-service-account-modal.tsx | 37 ++---------- .../descriptors.test.ts | 60 +++++++++++++++++++ .../token-service-accounts/descriptors.ts | 42 ++++++++++++- 3 files changed, 106 insertions(+), 33 deletions(-) create mode 100644 apps/sim/lib/credentials/token-service-accounts/descriptors.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx index ae2bdc00368..428644b62f8 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx @@ -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, @@ -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() @@ -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) } } diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.test.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.test.ts new file mode 100644 index 00000000000..be6cc7ad757 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.test.ts @@ -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) + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index e60338c3b71..9b606e0fd4f 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -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`. */ @@ -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 `); `x-api-token` providers (e.g. @@ -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, @@ -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." + } +}