diff --git a/.changeset/friendly-theme-scope-error.md b/.changeset/friendly-theme-scope-error.md new file mode 100644 index 00000000000..3aac4c782ee --- /dev/null +++ b/.changeset/friendly-theme-scope-error.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-kit': patch +--- + +Improve the error shown when theme commands use an Admin API token that is missing required theme access scopes. diff --git a/packages/cli-kit/src/public/node/themes/api.test.ts b/packages/cli-kit/src/public/node/themes/api.test.ts index bbdd779226a..79f5954b6d4 100644 --- a/packages/cli-kit/src/public/node/themes/api.test.ts +++ b/packages/cli-kit/src/public/node/themes/api.test.ts @@ -89,6 +89,14 @@ describe('fetchTheme', () => { preferredBehaviour: expectedApiOptions, }) }) + + test('throws a friendly error when access is denied by a missing theme access scope', async () => { + vi.mocked(adminRequestDoc).mockRejectedValue(themeAccessDeniedError('`read_themes` access scope.')) + + await expect(fetchTheme(123, session)).rejects.toThrow( + 'The authenticated account or access token is missing `read_themes` access scope.', + ) + }) }) describe('findDevelopmentThemeByName', () => { @@ -151,6 +159,14 @@ describe('findDevelopmentThemeByName', () => { await expect(findDevelopmentThemeByName('PR-123', session)).rejects.toThrow() }) + + test('throws a friendly error when access is denied by a missing theme access scope', async () => { + vi.mocked(adminRequestDoc).mockRejectedValue(themeAccessDeniedError('`read_themes` access scope.')) + + await expect(findDevelopmentThemeByName('PR-123', session)).rejects.toThrow( + 'The authenticated account or access token is missing `read_themes` access scope.', + ) + }) }) describe('fetchThemes', () => { @@ -188,6 +204,33 @@ describe('fetchThemes', () => { expect(themes[0]!.processing).toBeFalsy() expect(themes[1]!.processing).toBeTruthy() }) + + test('throws a friendly error when the token is missing the required themes access scope', async () => { + // Given + vi.mocked(adminRequestDoc).mockRejectedValue(themeAccessDeniedError('`read_themes` access scope.')) + + // When/Then + await expect(fetchThemes(session)).rejects.toMatchObject({ + message: 'The authenticated account or access token is missing `read_themes` access scope.', + nextSteps: expect.arrayContaining([ + expect.arrayContaining([ + 'If you authenticated with an Admin API access token, update the app or integration that issued the token to include the required theme access scopes, then reauthorize it or generate a new token.', + ]), + expect.arrayContaining([{command: 'theme pull'}, {command: 'read_themes'}]), + expect.arrayContaining([{command: 'shopify auth logout'}]), + ]), + }) + }) + + test('throws a friendly error when access denied errors omit required access details', async () => { + // Given + vi.mocked(adminRequestDoc).mockRejectedValue(themeAccessDeniedError()) + + // When/Then + await expect(fetchThemes(session)).rejects.toThrow( + 'The authenticated account or access token is missing the required theme access scope.', + ) + }) }) describe('fetchChecksums', () => { @@ -764,3 +807,24 @@ describe('parseThemeFileContent', () => { }) }) }) + +function themeAccessDeniedError(requiredAccess?: string): ClientError { + const extensions = requiredAccess ? {code: 'ACCESS_DENIED', requiredAccess} : {code: 'ACCESS_DENIED'} + const message = requiredAccess + ? `Access denied for themes field. Required access: ${requiredAccess}` + : 'Access denied for themes field.' + + return new ClientError( + { + status: 200, + errors: [ + { + message, + extensions, + path: ['themes'], + } as any, + ], + }, + {query: ''}, + ) +} diff --git a/packages/cli-kit/src/public/node/themes/api.ts b/packages/cli-kit/src/public/node/themes/api.ts index a6e1f3bbcfe..5b91b7130bc 100644 --- a/packages/cli-kit/src/public/node/themes/api.ts +++ b/packages/cli-kit/src/public/node/themes/api.ts @@ -25,11 +25,13 @@ import {GetTheme} from '../../../cli/api/graphql/admin/generated/get_theme.js' import {FindDevelopmentThemeByName} from '../../../cli/api/graphql/admin/generated/find_development_theme_by_name.js' import {OnlineStorePasswordProtection} from '../../../cli/api/graphql/admin/generated/online_store_password_protection.js' import {RequestModeInput} from '../http.js' -import {adminRequestDoc} from '../api/admin.js' +import {adminRequestDoc, type AdminRequestOptions} from '../api/admin.js' import {AdminSession} from '../session.js' import {AbortError} from '../error.js' import {outputDebug} from '../output.js' import {recordTiming, recordEvent, recordError} from '../analytics.js' +import {ClientError, type Variables} from 'graphql-request' +import type {InlineToken, TokenItem} from '../ui.js' export type ThemeParams = Partial> export type AssetParams = Pick & Partial> @@ -40,6 +42,7 @@ const THEME_API_NETWORK_BEHAVIOUR: RequestModeInput = { maxRetryTimeMs: 90 * 1000, recordCommandRetries: true, } +const DEFAULT_THEME_ACCESS_REQUIREMENT = 'the required theme access scope' export async function fetchTheme(id: number, session: AdminSession): Promise { const gid = composeThemeGid(id) @@ -65,6 +68,7 @@ export async function fetchTheme(id: number, session: AdminSession): Promise { while (true) { // eslint-disable-next-line no-await-in-loop - const response = await adminRequestDoc({ + const response = await requestThemeAdminDoc({ query: GetThemes, session, variables: {after}, responseOptions: {handleErrors: false}, preferredBehaviour: THEME_API_NETWORK_BEHAVIOUR, }) + if (!response.themes) { unexpectedGraphQLError('Failed to fetch themes') } @@ -117,7 +122,7 @@ export async function fetchThemes(session: AdminSession): Promise { export async function findDevelopmentThemeByName(name: string, session: AdminSession): Promise { recordEvent('theme-api:find-development-theme-by-name') - const {themes} = await adminRequestDoc({ + const {themes} = await requestThemeAdminDoc({ query: FindDevelopmentThemeByName, session, variables: {name}, @@ -188,7 +193,7 @@ export async function fetchThemeAssets(id: number, filenames: Key[], session: Ad while (true) { // eslint-disable-next-line no-await-in-loop - const response = await adminRequestDoc({ + const response = await requestThemeAdminDoc({ query: GetThemeFileBodies, session, variables: {id: themeGid(id), filenames, after}, @@ -374,7 +379,7 @@ export async function fetchChecksums(id: number, session: AdminSession): Promise while (true) { // eslint-disable-next-line no-await-in-loop - const response = await adminRequestDoc({ + const response = await requestThemeAdminDoc({ query: GetThemeFileChecksums, session, variables: {id: themeGid(id), after}, @@ -606,6 +611,82 @@ function unexpectedGraphQLError(message: string): never { throw recordError(new AbortError(message)) } +async function requestThemeAdminDoc( + options: AdminRequestOptions, +): Promise { + try { + const response = await adminRequestDoc(options) + return response + } catch (error) { + abortIfMissingThemeAccessScope(error) + throw error + } +} + +function abortIfMissingThemeAccessScope(error: unknown): void { + if (!(error instanceof ClientError)) return + + const requiredAccess = getThemeAccessRequirementForAccessDeniedError(error) + if (!requiredAccess) return + + const nextSteps: TokenItem[] = [ + [ + 'If you authenticated with an Admin API access token, update the app or integration that issued the token to include the required theme access scopes, then reauthorize it or generate a new token.', + ], + [ + 'For', + {command: 'theme pull'}, + {char: ','}, + {command: 'theme list'}, + {char: ','}, + 'and', + {command: 'theme info'}, + {char: ','}, + 'add the', + {command: 'read_themes'}, + 'scope', + {char: '.'}, + ], + [ + 'For', + {command: 'theme push'}, + 'and', + {command: 'theme dev'}, + {char: ','}, + 'add both the', + {command: 'read_themes'}, + 'and', + {command: 'write_themes'}, + 'scopes', + {char: '.'}, + ], + [ + 'If you authenticated with your Shopify account, make sure your staff or collaborator account can access Online Store themes, then run', + {command: 'shopify auth logout'}, + 'and try again', + {char: '.'}, + ], + ['See', {link: {label: 'Shopify access scopes', url: 'https://shopify.dev/api/usage/access-scopes'}}, {char: '.'}], + ] + + throw recordError( + new AbortError(`The authenticated account or access token is missing ${requiredAccess}.`, undefined, nextSteps), + ) +} + +function getThemeAccessRequirementForAccessDeniedError(error: ClientError): string | undefined { + const graphQLErrors = error.response.errors + if (!Array.isArray(graphQLErrors)) return undefined + + const accessDeniedError = graphQLErrors.find((graphQLError) => graphQLError.extensions?.code === 'ACCESS_DENIED') + if (!accessDeniedError) return undefined + + const requiredAccess = accessDeniedError.extensions?.requiredAccess + if (typeof requiredAccess !== 'string') return DEFAULT_THEME_ACCESS_REQUIREMENT + + return requiredAccess.trim().replace(/\.$/, '') || DEFAULT_THEME_ACCESS_REQUIREMENT +} + function themeGid(id: number): string { return `gid://shopify/OnlineStoreTheme/${id}` }