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
5 changes: 5 additions & 0 deletions .changeset/friendly-theme-scope-error.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions packages/cli-kit/src/public/node/themes/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,26 @@ 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
const errorResponse = {
status: 200,
errors: [
{
message: 'Access denied for themes field. Required access: `read_themes` access scope.',
extensions: {code: 'ACCESS_DENIED', requiredAccess: '`read_themes` access scope.'},
path: ['themes'],
} as any,
],
}
vi.mocked(adminRequestDoc).mockRejectedValue(new ClientError(errorResponse, {query: ''}))

// When/Then
await expect(fetchThemes(session)).rejects.toThrow(
'The authenticated account or access token is missing `read_themes` access scope.',
)
})
})

describe('fetchChecksums', () => {
Expand Down
36 changes: 36 additions & 0 deletions packages/cli-kit/src/public/node/themes/api.ts
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are quite a few conditionals in this new logic that aren't covered by the test. We don't need to add all (e.g. don't add a test asserting that the message doesn't appear for other exceptions) but some extra coverage would be good.

Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {AdminSession} from '../session.js'
import {AbortError} from '../error.js'
import {outputDebug} from '../output.js'
import {recordTiming, recordEvent, recordError} from '../analytics.js'
import {ClientError} from 'graphql-request'

export type ThemeParams = Partial<Pick<Theme, 'name' | 'role' | 'processing' | 'src'>>
export type AssetParams = Pick<ThemeAsset, 'key'> & Partial<Pick<ThemeAsset, 'value' | 'attachment'>>
Expand Down Expand Up @@ -90,7 +91,11 @@ export async function fetchThemes(session: AdminSession): Promise<Theme[]> {
variables: {after},
responseOptions: {handleErrors: false},
preferredBehaviour: THEME_API_NETWORK_BEHAVIOUR,
}).catch((error: unknown) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we apply this same logic to findDevelopmentThemeByName? Are there any other theme fetches that we could drop this into?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(BOT COMMENT)

🎨 Code Style: Every other adminRequestDoc call in this file uses a bare await; the .catch() chained directly onto the awaited promise is the only instance of that pattern. A try/catch block reads more naturally for the intent (intercept an error, optionally translate it, otherwise rethrow) and doesn't rely on the reader verifying that abortIfMissingThemeAccessScope either throws or returns void.

Suggestion: Consider restructuring as:

let response
try {
  response = await adminRequestDoc({
    query: GetThemes,
    session,
    variables: {after},
    responseOptions: {handleErrors: false},
    preferredBehaviour: THEME_API_NETWORK_BEHAVIOUR,
  })
} catch (error) {
  abortIfMissingThemeAccessScope(error)
  throw error
}

abortIfMissingThemeAccessScope(error)
throw error
})

if (!response.themes) {
unexpectedGraphQLError('Failed to fetch themes')
}
Expand Down Expand Up @@ -606,6 +611,37 @@ function unexpectedGraphQLError(message: string): never {
throw recordError(new AbortError(message))
}

function abortIfMissingThemeAccessScope(error: unknown): void {
if (!(error instanceof ClientError)) return

const requiredAccess = getRequiredAccessForAccessDeniedError(error)
if (!requiredAccess) return

const tryMessage = [
'If you authenticated with a custom app Admin API access token, open the custom app in your Shopify admin,',
'add the required theme access scopes, reinstall the app, and use the new access token.',
Comment on lines +621 to +622
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the announcement about custom apps we might want to change the language here.

'For theme pull, theme list, and theme info, add `read_themes`.',
'For theme push and theme dev, add both `read_themes` and `write_themes`.',
'If you authenticated with your Shopify account, make sure your staff or collaborator account can access Online Store themes, then run `shopify auth logout` and try again.',
'See https://shopify.dev/api/usage/access-scopes.',
].join(' ')

throw recordError(
new AbortError(`The authenticated account or access token is missing ${requiredAccess}.`, tryMessage),
)
}

function getRequiredAccessForAccessDeniedError(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')
const requiredAccess = accessDeniedError?.extensions?.requiredAccess
if (typeof requiredAccess !== 'string') return undefined

return requiredAccess.replace(/\.$/, '')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I worry that there's some fragility/assumptions here where we're relying on the shape of the translation from the API but I don't have a good alternative suggestion.

}

function themeGid(id: number): string {
return `gid://shopify/OnlineStoreTheme/${id}`
}
Expand Down
Loading