Skip to content

Commit 7610ec2

Browse files
authored
feat: present a link to add CORS Manage setting when a CORS error occurs (#625)
* feat: present a link to add CORS Manage setting when a CORS error occurs
1 parent f220843 commit 7610ec2

File tree

9 files changed

+253
-6
lines changed

9 files changed

+253
-6
lines changed

apps/kitchensink-react/src/AppRoutes.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ import {ProtectedRoute} from './ProtectedRoute'
1717
import {DashboardContextRoute} from './routes/DashboardContextRoute'
1818
import {DashboardWorkspacesRoute} from './routes/DashboardWorkspacesRoute'
1919
import ExperimentalResourceClientRoute from './routes/ExperimentalResourceClientRoute'
20-
import {ProjectsRoute} from './routes/ProjectsRoute'
2120
import {PerspectivesRoute} from './routes/PerspectivesRoute'
21+
import {ProjectsRoute} from './routes/ProjectsRoute'
2222
import {ReleasesRoute} from './routes/releases/ReleasesRoute'
2323
import {UserDetailRoute} from './routes/UserDetailRoute'
2424
import {UsersRoute} from './routes/UsersRoute'

packages/core/src/_exports/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ export {resolveProjection} from '../projection/resolveProjection'
123123
export {type ProjectionValuePending, type ValidProjection} from '../projection/types'
124124
export {getProjectsState, resolveProjects} from '../projects/projects'
125125
export {
126+
clearQueryError,
127+
getQueryErrorState,
126128
getQueryKey,
127129
getQueryState,
128130
parseQueryKey,
@@ -157,6 +159,7 @@ export {
157159
export {type FetcherStore, type FetcherStoreState} from '../utils/createFetcherStore'
158160
export {createGroqSearchFilter} from '../utils/createGroqSearchFilter'
159161
export {defineIntent, type Intent, type IntentFilter} from '../utils/defineIntent'
162+
export {getCorsErrorProjectId} from '../utils/getCorsErrorProjectId'
160163
export {CORE_SDK_VERSION} from '../version'
161164
export {
162165
getIndexForKey,

packages/core/src/query/queryStore.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import {type ResponseQueryOptions} from '@sanity/client'
1+
import {CorsOriginError, type ResponseQueryOptions} from '@sanity/client'
22
import {type SanityQueryResult} from 'groq'
33
import {
44
catchError,
55
combineLatest,
6+
defer,
67
distinctUntilChanged,
78
EMPTY,
89
filter,
@@ -207,7 +208,18 @@ const listenToLiveClientAndSetLastLiveEventIds = ({
207208
apiVersion: QUERY_STORE_API_VERSION,
208209
}).observable.pipe(
209210
switchMap((client) =>
210-
client.live.events({includeDrafts: !!client.config().token, tag: 'query-store'}),
211+
defer(() =>
212+
client.live.events({includeDrafts: !!client.config().token, tag: 'query-store'}),
213+
).pipe(
214+
catchError((error) => {
215+
if (error instanceof CorsOriginError) {
216+
// Swallow only CORS errors in store without bubbling up so that they are handled by the Cors Error component
217+
state.set('setError', {error})
218+
return EMPTY
219+
}
220+
throw error
221+
}),
222+
),
211223
),
212224
share(),
213225
filter((e) => e.type === 'message'),
@@ -305,6 +317,29 @@ const _getQueryState = bindActionByDataset(
305317
}),
306318
)
307319

320+
/**
321+
* Returns a state source for the top-level query store error (if any).
322+
*
323+
* Unlike {@link getQueryState}, this selector does not throw; it simply returns the error value.
324+
* Subscribe to this to be notified when a global query error occurs (e.g., CORS failures).
325+
*
326+
* @beta
327+
*/
328+
export function getQueryErrorState(instance: SanityInstance): StateSource<unknown | undefined> {
329+
return _getQueryErrorState(instance)
330+
}
331+
332+
const _getQueryErrorState = bindActionByDataset(
333+
queryStore,
334+
createStateSourceAction({
335+
selector: ({state}: SelectorContext<QueryStoreState>) => state.error,
336+
onSubscribe: () => {
337+
// No-op subscription as we don't track per-query subscribers here
338+
return () => {}
339+
},
340+
}),
341+
)
342+
308343
/**
309344
* Resolves the result of a query without registering a lasting subscriber.
310345
*
@@ -378,3 +413,16 @@ const _resolveQuery = bindActionByDataset(
378413
return firstValueFrom(race([resolved$, aborted$]))
379414
},
380415
)
416+
417+
/**
418+
* Clears the top-level query store error.
419+
* @beta
420+
*/
421+
export function clearQueryError(instance: SanityInstance): void
422+
export function clearQueryError(...args: Parameters<typeof _clearQueryError>): void {
423+
return _clearQueryError(...args)
424+
}
425+
426+
const _clearQueryError = bindActionByDataset(queryStore, ({state}) => {
427+
state.set('setError', {error: undefined})
428+
})
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import {CorsOriginError} from '@sanity/client'
3+
import {afterEach, describe, expect, test} from 'vitest'
4+
5+
import {getCorsErrorProjectId} from './getCorsErrorProjectId'
6+
7+
describe('getCorsErrorProjectId', () => {
8+
const originalLocation = (globalThis as any).location
9+
10+
afterEach(() => {
11+
if (originalLocation === undefined) {
12+
delete (globalThis as any).location
13+
} else {
14+
;(globalThis as any).location = originalLocation
15+
}
16+
})
17+
18+
test('returns null for non-CorsOriginError', () => {
19+
const err = new Error(
20+
'Change your configuration here: https://sanity.io/manage/project/abc123/api',
21+
)
22+
23+
expect(getCorsErrorProjectId(err)).toBeNull()
24+
})
25+
26+
test('extracts projectId from CorsOriginError in Node environment', () => {
27+
delete (globalThis as any).location
28+
const err = new CorsOriginError({projectId: 'abc123'})
29+
30+
expect(getCorsErrorProjectId(err as unknown as Error)).toBe('abc123')
31+
})
32+
33+
test('extracts projectId from CorsOriginError with browser query params', () => {
34+
;(globalThis as any).location = {origin: 'https://example.com'}
35+
const err = new CorsOriginError({projectId: 'p123-xyz'})
36+
37+
expect(getCorsErrorProjectId(err as unknown as Error)).toBe('p123-xyz')
38+
})
39+
40+
test('returns null if CorsOriginError message does not contain manage URL', () => {
41+
const err = new CorsOriginError({projectId: 'abc123'})
42+
err.message = 'Some other message without the manage URL'
43+
44+
expect(getCorsErrorProjectId(err as unknown as Error)).toBeNull()
45+
})
46+
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {CorsOriginError} from '@sanity/client'
2+
3+
/**
4+
* @public
5+
* Extracts the project ID from a CorsOriginError message.
6+
* @param error - The error to extract the project ID from.
7+
* @returns The project ID or null if the error is not a CorsOriginError.
8+
*/
9+
export function getCorsErrorProjectId(error: Error): string | null {
10+
if (!(error instanceof CorsOriginError)) return null
11+
12+
const message = (error as unknown as {message?: string}).message || ''
13+
const projMatch = message.match(/manage\/project\/([^/?#]+)/)
14+
return projMatch ? projMatch[1] : null
15+
}

packages/react/src/components/auth/AuthBoundary.tsx

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
import {AuthStateType} from '@sanity/sdk'
1+
import {CorsOriginError} from '@sanity/client'
2+
import {AuthStateType, getCorsErrorProjectId} from '@sanity/sdk'
23
import {useEffect, useMemo} from 'react'
34
import {ErrorBoundary, type FallbackProps} from 'react-error-boundary'
45

56
import {ComlinkTokenRefreshProvider} from '../../context/ComlinkTokenRefresh'
67
import {useAuthState} from '../../hooks/auth/useAuthState'
78
import {useLoginUrl} from '../../hooks/auth/useLoginUrl'
89
import {useVerifyOrgProjects} from '../../hooks/auth/useVerifyOrgProjects'
10+
import {useCorsOriginError} from '../../hooks/errors/useCorsOriginError'
11+
import {CorsErrorComponent} from '../errors/CorsErrorComponent'
912
import {isInIframe} from '../utils'
1013
import {AuthError} from './AuthError'
1114
import {ConfigurationError} from './ConfigurationError'
@@ -105,16 +108,38 @@ export function AuthBoundary({
105108
LoginErrorComponent = LoginError,
106109
...props
107110
}: AuthBoundaryProps): React.ReactNode {
111+
const {error: corsError, projectId, clear: clearCorsError} = useCorsOriginError()
112+
108113
const FallbackComponent = useMemo(() => {
109114
return function LoginComponentWithLayoutProps(fallbackProps: FallbackProps) {
115+
if (fallbackProps.error instanceof CorsOriginError) {
116+
return (
117+
<CorsErrorComponent
118+
{...fallbackProps}
119+
projectId={getCorsErrorProjectId(fallbackProps.error)}
120+
resetErrorBoundary={() => {
121+
clearCorsError()
122+
fallbackProps.resetErrorBoundary()
123+
}}
124+
/>
125+
)
126+
}
110127
return <LoginErrorComponent {...fallbackProps} />
111128
}
112-
}, [LoginErrorComponent])
129+
}, [LoginErrorComponent, clearCorsError])
113130

114131
return (
115132
<ComlinkTokenRefreshProvider>
116133
<ErrorBoundary FallbackComponent={FallbackComponent}>
117-
<AuthSwitch {...props} />
134+
{corsError ? (
135+
<CorsErrorComponent
136+
error={corsError}
137+
resetErrorBoundary={() => clearCorsError()}
138+
projectId={projectId}
139+
/>
140+
) : (
141+
<AuthSwitch {...props} />
142+
)}
118143
</ErrorBoundary>
119144
</ComlinkTokenRefreshProvider>
120145
)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {describe, expect, it} from 'vitest'
2+
3+
import {render, screen} from '../../../test/test-utils'
4+
import {CorsErrorComponent} from './CorsErrorComponent'
5+
6+
describe('CorsErrorComponent', () => {
7+
it('shows origin and manage link when projectId is provided', () => {
8+
const origin = 'https://example.com'
9+
const originalLocation = window.location
10+
// Redefine window.location to control origin in this test
11+
Object.defineProperty(window, 'location', {
12+
value: {origin},
13+
configurable: true,
14+
})
15+
16+
render(
17+
<CorsErrorComponent
18+
projectId="proj123"
19+
error={new Error('nope')}
20+
resetErrorBoundary={() => {}}
21+
/>,
22+
)
23+
24+
expect(screen.getByText('Before you continue...')).toBeInTheDocument()
25+
expect(screen.getByText(origin)).toBeInTheDocument()
26+
27+
const link = screen.getByRole('link', {name: 'Manage CORS configuration'}) as HTMLAnchorElement
28+
expect(link).toBeInTheDocument()
29+
expect(link.target).toBe('_blank')
30+
expect(link.rel).toContain('noopener')
31+
expect(link.href).toContain('https://sanity.io/manage/project/proj123/api')
32+
expect(link.href).toContain('cors=add')
33+
expect(link.href).toContain(`origin=${encodeURIComponent(origin)}`)
34+
expect(link.href).toContain('credentials=include')
35+
36+
// restore
37+
Object.defineProperty(window, 'location', {value: originalLocation})
38+
})
39+
40+
it('shows error message when projectId is null', () => {
41+
const error = new Error('some error message')
42+
render(<CorsErrorComponent projectId={null} error={error} resetErrorBoundary={() => {}} />)
43+
44+
expect(screen.getByText('Before you continue...')).toBeInTheDocument()
45+
expect(screen.getByText('some error message')).toBeInTheDocument()
46+
expect(screen.queryByRole('link', {name: 'Manage CORS configuration'})).toBeNull()
47+
})
48+
})
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import {useMemo} from 'react'
2+
import {type FallbackProps} from 'react-error-boundary'
3+
4+
type CorsErrorComponentProps = FallbackProps & {
5+
projectId: string | null
6+
}
7+
8+
export function CorsErrorComponent({projectId, error}: CorsErrorComponentProps): React.ReactNode {
9+
const origin = window.location.origin
10+
const corsUrl = useMemo(() => {
11+
const url = new URL(`https://sanity.io/manage/project/${projectId}/api`)
12+
url.searchParams.set('cors', 'add')
13+
url.searchParams.set('origin', origin)
14+
url.searchParams.set('credentials', 'include')
15+
return url.toString()
16+
}, [origin, projectId])
17+
return (
18+
<div className="sc-login-error">
19+
<div className="sc-login-error__content">
20+
<h1>Before you continue...</h1>
21+
<p>
22+
To access your content, you need to <b>add the following URL as a CORS origin</b> to your
23+
Sanity project.
24+
</p>
25+
<p>
26+
<code>{origin}</code>
27+
</p>
28+
{projectId ? (
29+
<p>
30+
<a href={corsUrl ?? ''} target="_blank" rel="noopener noreferrer">
31+
Manage CORS configuration
32+
</a>
33+
</p>
34+
) : (
35+
<p>{error?.message}</p>
36+
)}
37+
</div>
38+
</div>
39+
)
40+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import {CorsOriginError} from '@sanity/client'
2+
import {clearQueryError, getCorsErrorProjectId, getQueryErrorState} from '@sanity/sdk'
3+
import {useCallback, useMemo, useSyncExternalStore} from 'react'
4+
5+
import {useSanityInstance} from '../context/useSanityInstance'
6+
7+
export function useCorsOriginError(): {
8+
error: Error | null
9+
projectId: string | null
10+
clear: () => void
11+
} {
12+
const instance = useSanityInstance()
13+
const {getCurrent, subscribe} = useMemo(() => getQueryErrorState(instance), [instance])
14+
const error = useSyncExternalStore(subscribe, getCurrent)
15+
const clear = useCallback(() => clearQueryError(instance), [instance])
16+
const value = useMemo(() => {
17+
if (!(error instanceof CorsOriginError)) return {error: null, projectId: null}
18+
19+
return {error: error as unknown as Error, projectId: getCorsErrorProjectId(error)}
20+
}, [error])
21+
return useMemo(() => ({...value, clear}), [value, clear])
22+
}

0 commit comments

Comments
 (0)