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
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.
64 changes: 64 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 @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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: ''},
)
}
91 changes: 86 additions & 5 deletions packages/cli-kit/src/public/node/themes/api.ts
Comment thread
charlespwd marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pick<Theme, 'name' | 'role' | 'processing' | 'src'>>
export type AssetParams = Pick<ThemeAsset, 'key'> & Partial<Pick<ThemeAsset, 'value' | 'attachment'>>
Expand All @@ -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<Theme | undefined> {
const gid = composeThemeGid(id)
Expand All @@ -65,6 +68,7 @@ export async function fetchTheme(id: number, session: AdminSession): Promise<The

// eslint-disable-next-line no-catch-all/no-catch-all
} catch (error) {
abortIfMissingThemeAccessScope(error)
/**
* Consumers of this and other theme APIs in this file expect either a theme
* or `undefined`.
Expand All @@ -84,13 +88,14 @@ export async function fetchThemes(session: AdminSession): Promise<Theme[]> {

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')
}
Expand All @@ -117,7 +122,7 @@ export async function fetchThemes(session: AdminSession): Promise<Theme[]> {
export async function findDevelopmentThemeByName(name: string, session: AdminSession): Promise<Theme | undefined> {
recordEvent('theme-api:find-development-theme-by-name')

const {themes} = await adminRequestDoc({
const {themes} = await requestThemeAdminDoc({
query: FindDevelopmentThemeByName,
session,
variables: {name},
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -606,6 +611,82 @@ function unexpectedGraphQLError(message: string): never {
throw recordError(new AbortError(message))
}

async function requestThemeAdminDoc<TResult, TVariables extends Variables>(
options: AdminRequestOptions<TResult, TVariables>,
): Promise<TResult> {
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<InlineToken>[] = [
[
'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}`
}
Expand Down
Loading