@sanity/sdk-react 0.0.0-rc.6 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +5 -57
  2. package/dist/index.d.ts +1000 -438
  3. package/dist/index.js +324 -258
  4. package/dist/index.js.map +1 -1
  5. package/package.json +17 -16
  6. package/src/_exports/sdk-react.ts +4 -1
  7. package/src/components/SDKProvider.tsx +6 -1
  8. package/src/components/SanityApp.test.tsx +29 -47
  9. package/src/components/SanityApp.tsx +12 -11
  10. package/src/components/auth/AuthBoundary.test.tsx +177 -7
  11. package/src/components/auth/AuthBoundary.tsx +32 -2
  12. package/src/components/auth/ConfigurationError.ts +22 -0
  13. package/src/components/auth/LoginError.tsx +9 -3
  14. package/src/hooks/auth/useVerifyOrgProjects.test.tsx +136 -0
  15. package/src/hooks/auth/useVerifyOrgProjects.tsx +48 -0
  16. package/src/hooks/client/useClient.ts +3 -3
  17. package/src/hooks/comlink/useManageFavorite.test.ts +276 -27
  18. package/src/hooks/comlink/useManageFavorite.ts +102 -51
  19. package/src/hooks/comlink/useWindowConnection.ts +3 -2
  20. package/src/hooks/document/useApplyDocumentActions.ts +105 -31
  21. package/src/hooks/document/useDocument.test.ts +41 -4
  22. package/src/hooks/document/useDocument.ts +198 -114
  23. package/src/hooks/document/useDocumentEvent.test.ts +5 -5
  24. package/src/hooks/document/useDocumentEvent.ts +67 -23
  25. package/src/hooks/document/useDocumentPermissions.ts +47 -8
  26. package/src/hooks/document/useDocumentSyncStatus.test.ts +12 -5
  27. package/src/hooks/document/useDocumentSyncStatus.ts +41 -14
  28. package/src/hooks/document/useEditDocument.test.ts +24 -6
  29. package/src/hooks/document/useEditDocument.ts +238 -133
  30. package/src/hooks/documents/useDocuments.test.tsx +1 -1
  31. package/src/hooks/documents/useDocuments.ts +153 -44
  32. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +1 -1
  33. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +120 -47
  34. package/src/hooks/projection/useProjection.ts +134 -46
  35. package/src/hooks/query/useQuery.test.tsx +4 -4
  36. package/src/hooks/query/useQuery.ts +115 -43
  37. package/src/hooks/releases/useActiveReleases.test.tsx +84 -0
  38. package/src/hooks/releases/useActiveReleases.ts +39 -0
  39. package/src/hooks/releases/usePerspective.test.tsx +120 -0
  40. package/src/hooks/releases/usePerspective.ts +50 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/sdk-react",
3
- "version": "0.0.0-rc.6",
3
+ "version": "0.0.1",
4
4
  "private": false,
5
5
  "description": "Sanity SDK React toolkit for Content OS",
6
6
  "keywords": [
@@ -42,42 +42,43 @@
42
42
  "browserslist": "extends @sanity/browserslist-config",
43
43
  "prettier": "@sanity/prettier-config",
44
44
  "dependencies": {
45
- "@sanity/client": "^6.29.1",
46
- "@sanity/message-protocol": "^0.8.0",
45
+ "@sanity/client": "^7.0.0",
46
+ "@sanity/message-protocol": "^0.12.0",
47
47
  "@sanity/types": "^3.83.0",
48
48
  "@types/lodash-es": "^4.17.12",
49
+ "groq": "3.86.2-experimental.0",
49
50
  "lodash-es": "^4.17.21",
50
51
  "react-compiler-runtime": "19.0.0-beta-ebf51a3-20250411",
51
52
  "react-error-boundary": "^5.0.0",
52
53
  "rxjs": "^7.8.2",
53
- "@sanity/sdk": "0.0.0-rc.6"
54
+ "@sanity/sdk": "0.0.1"
54
55
  },
55
56
  "devDependencies": {
56
57
  "@sanity/browserslist-config": "^1.0.5",
57
- "@sanity/comlink": "^3.0.1",
58
+ "@sanity/comlink": "^3.0.2",
58
59
  "@sanity/pkg-utils": "^7.2.2",
59
60
  "@sanity/prettier-config": "^1.0.3",
60
61
  "@testing-library/jest-dom": "^6.6.3",
61
62
  "@testing-library/react": "^16.3.0",
62
- "@types/react": "^19.1.0",
63
- "@types/react-dom": "^19.1.1",
64
- "@vitejs/plugin-react": "^4.3.4",
65
- "@vitest/coverage-v8": "3.1.1",
66
- "babel-plugin-react-compiler": "19.0.0-beta-ebf51a3-20250411",
63
+ "@types/react": "^19.1.2",
64
+ "@types/react-dom": "^19.1.3",
65
+ "@vitejs/plugin-react": "^4.4.1",
66
+ "@vitest/coverage-v8": "3.1.2",
67
+ "babel-plugin-react-compiler": "19.1.0-rc.1",
67
68
  "eslint": "^9.22.0",
68
69
  "jsdom": "^25.0.1",
69
70
  "prettier": "^3.5.3",
70
71
  "react": "^19.1.0",
71
72
  "react-dom": "^19.1.0",
72
73
  "rollup-plugin-visualizer": "^5.14.0",
73
- "typescript": "^5.7.3",
74
- "vite": "^6.3.2",
74
+ "typescript": "^5.8.3",
75
+ "vite": "^6.3.4",
75
76
  "vitest": "^3.1.2",
76
77
  "@repo/config-eslint": "0.0.0",
77
- "@repo/package.config": "0.0.1",
78
- "@repo/tsconfig": "0.0.1",
79
78
  "@repo/package.bundle": "3.82.0",
80
- "@repo/config-test": "0.0.1"
79
+ "@repo/package.config": "0.0.1",
80
+ "@repo/config-test": "0.0.1",
81
+ "@repo/tsconfig": "0.0.1"
81
82
  },
82
83
  "peerDependencies": {
83
84
  "react": "^18.0.0 || ^19.0.0",
@@ -94,7 +95,7 @@
94
95
  "build:bundle": "vite build --configLoader runner --config package.bundle.ts",
95
96
  "clean": "rimraf dist",
96
97
  "dev": "pkg watch",
97
- "docs": "typedoc --out docs --tsconfig ./tsconfig.dist.json",
98
+ "docs": "typedoc --json docs/typedoc.json --tsconfig ./tsconfig.dist.json",
98
99
  "format": "prettier --write --cache --ignore-unknown .",
99
100
  "lint": "eslint .",
100
101
  "test": "vitest run",
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @module exports
3
3
  */
4
- export {AuthBoundary} from '../components/auth/AuthBoundary'
4
+ export {AuthBoundary, type AuthBoundaryProps} from '../components/auth/AuthBoundary'
5
5
  export {SanityApp, type SanityAppProps} from '../components/SanityApp'
6
6
  export {SDKProvider, type SDKProviderProps} from '../components/SDKProvider'
7
7
  export {ResourceProvider, type ResourceProviderProps} from '../context/ResourceProvider'
@@ -12,6 +12,7 @@ export {useDashboardOrganizationId} from '../hooks/auth/useDashboardOrganization
12
12
  export {useHandleAuthCallback} from '../hooks/auth/useHandleAuthCallback'
13
13
  export {useLoginUrl} from '../hooks/auth/useLoginUrl'
14
14
  export {useLogOut} from '../hooks/auth/useLogOut'
15
+ export {useVerifyOrgProjects} from '../hooks/auth/useVerifyOrgProjects'
15
16
  export {useClient} from '../hooks/client/useClient'
16
17
  export {
17
18
  type FrameConnection,
@@ -63,6 +64,8 @@ export {
63
64
  export {useProject} from '../hooks/projects/useProject'
64
65
  export {type ProjectWithoutMembers, useProjects} from '../hooks/projects/useProjects'
65
66
  export {useQuery} from '../hooks/query/useQuery'
67
+ export {useActiveReleases} from '../hooks/releases/useActiveReleases'
68
+ export {usePerspective} from '../hooks/releases/usePerspective'
66
69
  export {type UsersResult, useUsers} from '../hooks/users/useUsers'
67
70
  export {REACT_SDK_VERSION} from '../version'
68
71
  export {type DatasetsResponse, type SanityProjectMember} from '@sanity/client'
@@ -29,11 +29,16 @@ export function SDKProvider({
29
29
  // reverse because we want the first config to be the default, but the
30
30
  // ResourceProvider nesting makes the last one the default
31
31
  const configs = (Array.isArray(config) ? config : [config]).slice().reverse()
32
+ const projectIds = configs.map((c) => c.projectId).filter((id): id is string => !!id)
32
33
 
33
34
  // Create a nested structure of ResourceProviders for each config
34
35
  const createNestedProviders = (index: number): ReactElement => {
35
36
  if (index >= configs.length) {
36
- return <AuthBoundary {...props}>{children}</AuthBoundary>
37
+ return (
38
+ <AuthBoundary {...props} projectIds={projectIds}>
39
+ {children}
40
+ </AuthBoundary>
41
+ )
37
42
  }
38
43
 
39
44
  return (
@@ -3,11 +3,20 @@ import {render, screen} from '@testing-library/react'
3
3
  import {describe, expect, it, vi} from 'vitest'
4
4
 
5
5
  import {SanityApp} from './SanityApp'
6
- import {SDKProvider} from './SDKProvider'
7
-
8
- // Mock SDKProvider to verify it's being used correctly
6
+ import {type SDKProviderProps} from './SDKProvider'
7
+
8
+ // Hoist the mock function definition
9
+ // Rely on vi.fn type inference
10
+ const mockSDKProviderComponent = vi.hoisted(() =>
11
+ vi.fn((_props: SDKProviderProps) => (
12
+ // Simplified mock, doesn't access config directly to avoid type issues
13
+ <div data-testid="sdk-provider">SDKProvider Mock</div>
14
+ )),
15
+ )
16
+
17
+ // Use the hoisted mock in the factory
9
18
  vi.mock('./SDKProvider', () => ({
10
- SDKProvider: vi.fn(() => <div data-testid="sdk-provider">SDKProvider</div>),
19
+ SDKProvider: mockSDKProviderComponent,
11
20
  }))
12
21
 
13
22
  // Mock useEffect to prevent redirect logic from running in tests
@@ -45,6 +54,8 @@ vi.mock('../hooks/auth/useAuthState', () => ({
45
54
  describe('SanityApp', () => {
46
55
  beforeEach(() => {
47
56
  vi.clearAllMocks()
57
+ // Access the mock instance correctly
58
+ mockSDKProviderComponent.mockClear()
48
59
  })
49
60
 
50
61
  it('renders SDKProvider with a single config', () => {
@@ -63,11 +74,13 @@ describe('SanityApp', () => {
63
74
  expect(screen.getByTestId('sdk-provider')).toBeInTheDocument()
64
75
 
65
76
  // Verify SDKProvider was called with the correct props
66
- const sdkProviderCalls = vi.mocked(SDKProvider).mock.calls
67
- expect(sdkProviderCalls.length).toBe(1)
68
-
69
- const [props] = sdkProviderCalls[0]
70
- const {config} = props
77
+ expect(mockSDKProviderComponent).toHaveBeenCalledTimes(1)
78
+ const sdkProviderCalls = mockSDKProviderComponent.mock.calls
79
+ const firstCallArgs1 = sdkProviderCalls[0]
80
+ expect(firstCallArgs1).toBeDefined()
81
+ expect(firstCallArgs1.length).toBeGreaterThan(0)
82
+ const props = firstCallArgs1[0] as unknown as SDKProviderProps
83
+ const config = props?.config
71
84
 
72
85
  // Config is now passed directly as an object for single configs
73
86
  expect(config).toEqual(singleConfig)
@@ -100,49 +113,18 @@ describe('SanityApp', () => {
100
113
  expect(screen.getByTestId('sdk-provider')).toBeInTheDocument()
101
114
 
102
115
  // Verify SDKProvider was called with the correct props
103
- const sdkProviderCalls = vi.mocked(SDKProvider).mock.calls
104
- expect(sdkProviderCalls.length).toBe(1)
105
-
106
- const [props] = sdkProviderCalls[0]
107
- const {config} = props
116
+ expect(mockSDKProviderComponent).toHaveBeenCalledTimes(1)
117
+ const sdkProviderCalls = mockSDKProviderComponent.mock.calls
118
+ const firstCallArgs2 = sdkProviderCalls[0]
119
+ expect(firstCallArgs2).toBeDefined()
120
+ expect(firstCallArgs2.length).toBeGreaterThan(0)
121
+ const props = firstCallArgs2[0] as unknown as SDKProviderProps
122
+ const config = props?.config
108
123
 
109
124
  // Config should be passed directly to SDKProvider
110
125
  expect(config).toEqual(multipleConfigs)
111
126
  })
112
127
 
113
- it('supports legacy sanityConfigs prop', () => {
114
- const legacyConfigs = [
115
- {
116
- projectId: 'legacy-project-1',
117
- dataset: 'production',
118
- },
119
- {
120
- projectId: 'legacy-project-2',
121
- dataset: 'staging',
122
- },
123
- ]
124
-
125
- render(
126
- // @ts-expect-error purposefully using the deprecated prop
127
- <SanityApp sanityConfigs={legacyConfigs} fallback={<div>Loading...</div>}>
128
- <div>Child Content</div>
129
- </SanityApp>,
130
- )
131
-
132
- // Check that the SDKProvider is rendered
133
- expect(screen.getByTestId('sdk-provider')).toBeInTheDocument()
134
-
135
- // Verify SDKProvider was called with the correct props
136
- const sdkProviderCalls = vi.mocked(SDKProvider).mock.calls
137
- expect(sdkProviderCalls.length).toBe(1)
138
-
139
- const [props] = sdkProviderCalls[0]
140
- const {config} = props
141
-
142
- // Config should be passed to SDKProvider in the same order
143
- expect(config).toEqual(legacyConfigs)
144
- })
145
-
146
128
  it('handles iframe environment correctly', async () => {
147
129
  // Mock window.self and window.top to simulate iframe environment
148
130
  const originalTop = window.top
@@ -27,8 +27,12 @@ const REDIRECT_URL = 'https://sanity.io/welcome'
27
27
  * as well as application context and state which is used by the Sanity React hooks. Your application
28
28
  * must be wrapped with the SanityApp component to function properly.
29
29
  *
30
- * SanityApp creates a hierarchy of ResourceProviders, each providing a SanityInstance that can be
31
- * accessed by hooks. The first configuration in the array becomes the default instance.
30
+ * The `config` prop on the SanityApp component accepts either a single {@link SanityConfig} object, or an array of them.
31
+ * This allows your app to work with one or more of your organization’s datasets.
32
+ *
33
+ * @remarks
34
+ * When passing multiple SanityConfig objects to the `config` prop, the first configuration in the array becomes the default
35
+ * configuration used by the App SDK Hooks.
32
36
  *
33
37
  * @category Components
34
38
  * @param props - Your Sanity configuration and the React children to render
@@ -36,18 +40,18 @@ const REDIRECT_URL = 'https://sanity.io/welcome'
36
40
  *
37
41
  * @example
38
42
  * ```tsx
39
- * import { SanityApp } from '@sanity/sdk-react'
43
+ * import { SanityApp, type SanityConfig } from '@sanity/sdk-react'
40
44
  *
41
45
  * import MyAppRoot from './Root'
42
46
  *
43
47
  * // Single project configuration
44
- * const mySanityConfig = {
48
+ * const mySanityConfig: SanityConfig = {
45
49
  * projectId: 'my-project-id',
46
50
  * dataset: 'production',
47
51
  * }
48
52
  *
49
53
  * // Or multiple project configurations
50
- * const multipleConfigs = [
54
+ * const multipleConfigs: SanityConfig[] = [
51
55
  * // Configuration for your main project. This will be used as the default project for hooks.
52
56
  * {
53
57
  * projectId: 'marketing-website-project',
@@ -67,7 +71,7 @@ const REDIRECT_URL = 'https://sanity.io/welcome'
67
71
  *
68
72
  * export default function MyApp() {
69
73
  * return (
70
- * <SanityApp config={mySanityConfig} fallback={<LoadingSpinner />}>
74
+ * <SanityApp config={mySanityConfig} fallback={<div>Loading…</div>}>
71
75
  * <MyAppRoot />
72
76
  * </SanityApp>
73
77
  * )
@@ -77,12 +81,9 @@ const REDIRECT_URL = 'https://sanity.io/welcome'
77
81
  export function SanityApp({
78
82
  children,
79
83
  fallback,
80
- config,
81
- sanityConfigs,
84
+ config = [],
82
85
  ...props
83
86
  }: SanityAppProps): ReactElement {
84
- const configs = config ?? sanityConfigs ?? []
85
-
86
87
  useEffect(() => {
87
88
  let timeout: NodeJS.Timeout | undefined
88
89
 
@@ -98,7 +99,7 @@ export function SanityApp({
98
99
  }, [])
99
100
 
100
101
  return (
101
- <SDKProvider {...props} fallback={fallback} config={configs}>
102
+ <SDKProvider {...props} fallback={fallback} config={config}>
102
103
  {children}
103
104
  </SDKProvider>
104
105
  )
@@ -1,18 +1,21 @@
1
1
  import {AuthStateType} from '@sanity/sdk'
2
2
  import {render, screen, waitFor} from '@testing-library/react'
3
+ import React from 'react'
4
+ import {type FallbackProps} from 'react-error-boundary'
3
5
  import {beforeEach, describe, expect, it, type MockInstance, vi} from 'vitest'
4
6
 
5
7
  import {ResourceProvider} from '../../context/ResourceProvider'
6
8
  import {useAuthState} from '../../hooks/auth/useAuthState'
9
+ import {useLoginUrl} from '../../hooks/auth/useLoginUrl'
10
+ import {useVerifyOrgProjects} from '../../hooks/auth/useVerifyOrgProjects'
7
11
  import {AuthBoundary} from './AuthBoundary'
8
12
 
9
13
  // Mock hooks
10
14
  vi.mock('../../hooks/auth/useAuthState', () => ({
11
15
  useAuthState: vi.fn(() => 'logged-out'),
12
16
  }))
13
- vi.mock('../../hooks/auth/useLoginUrls', () => ({
14
- useLoginUrls: vi.fn(() => [{title: 'Provider A', url: 'https://provider-a.com/auth'}]),
15
- }))
17
+ vi.mock('../../hooks/auth/useLoginUrl')
18
+ vi.mock('../../hooks/auth/useVerifyOrgProjects')
16
19
  vi.mock('../../hooks/auth/useHandleAuthCallback', () => ({
17
20
  useHandleAuthCallback: vi.fn(() => async () => {}),
18
21
  }))
@@ -35,11 +38,84 @@ vi.mock('./AuthError', async (importOriginal) => {
35
38
  }
36
39
  })
37
40
 
41
+ // Mock ErrorBoundary with a functional component and state simulation
42
+ vi.mock('react-error-boundary', async (importOriginal) => {
43
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
44
+ const original = await importOriginal<any>()
45
+
46
+ // Functional mock that catches render errors
47
+ class MockErrorBoundaryComponent extends React.Component<
48
+ {
49
+ children: React.ReactNode
50
+ FallbackComponent?: React.ComponentType<FallbackProps>
51
+ onError?: (error: Error, errorInfo: React.ErrorInfo) => void
52
+ // Add any other props your actual ErrorBoundary might use
53
+ },
54
+ {error: Error | null}
55
+ > {
56
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
57
+ constructor(props: any) {
58
+ super(props)
59
+ this.state = {error: null}
60
+ }
61
+
62
+ // Static methods don't use override
63
+ static getDerivedStateFromError(error: Error) {
64
+ // Update state so the next render will show the fallback UI.
65
+ return {error}
66
+ }
67
+
68
+ override componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
69
+ // You can also log the error to an error reporting service
70
+ this.props.onError?.(error, errorInfo)
71
+ }
72
+
73
+ override render() {
74
+ if (this.state.error && this.props.FallbackComponent) {
75
+ // You can render any custom fallback UI
76
+ return (
77
+ <this.props.FallbackComponent
78
+ error={this.state.error}
79
+ resetErrorBoundary={() => this.setState({error: null})}
80
+ />
81
+ )
82
+ }
83
+ if (this.state.error && !this.props.FallbackComponent) {
84
+ return <div>Caught Error (No Fallback Provided)</div>
85
+ }
86
+
87
+ return this.props.children
88
+ }
89
+ }
90
+
91
+ return {
92
+ ...original,
93
+ ErrorBoundary: MockErrorBoundaryComponent, // Use the class component mock
94
+ useErrorHandler: vi.fn(),
95
+ }
96
+ })
97
+
98
+ // Mock isInIframe
99
+ vi.mock('../utils', () => ({
100
+ isInIframe: vi.fn(() => false),
101
+ }))
102
+
38
103
  describe('AuthBoundary', () => {
39
104
  let consoleErrorSpy: MockInstance
105
+ const mockUseAuthState = vi.mocked(useAuthState)
106
+ const mockUseLoginUrl = vi.mocked(useLoginUrl)
107
+ const mockUseVerifyOrgProjects = vi.mocked(useVerifyOrgProjects)
108
+ const testProjectIds = ['proj-test'] // Example project ID for tests
109
+
40
110
  beforeEach(() => {
41
111
  vi.clearAllMocks()
42
112
  consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
113
+ // Default mocks
114
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
115
+ mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_IN} as any)
116
+ mockUseLoginUrl.mockReturnValue('http://example.com/login')
117
+ // Default mock for useVerifyOrgProjects - returns null (no error)
118
+ mockUseVerifyOrgProjects.mockImplementation(() => null)
43
119
  })
44
120
 
45
121
  afterEach(() => {
@@ -53,7 +129,7 @@ describe('AuthBoundary', () => {
53
129
  })
54
130
  render(
55
131
  <ResourceProvider fallback={null}>
56
- <AuthBoundary>Protected Content</AuthBoundary>
132
+ <AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
57
133
  </ResourceProvider>,
58
134
  )
59
135
 
@@ -70,7 +146,7 @@ describe('AuthBoundary', () => {
70
146
  })
71
147
  const {container} = render(
72
148
  <ResourceProvider fallback={null}>
73
- <AuthBoundary>Protected Content</AuthBoundary>
149
+ <AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
74
150
  </ResourceProvider>,
75
151
  )
76
152
 
@@ -87,7 +163,7 @@ describe('AuthBoundary', () => {
87
163
  })
88
164
  render(
89
165
  <ResourceProvider fallback={null}>
90
- <AuthBoundary>Protected Content</AuthBoundary>
166
+ <AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
91
167
  </ResourceProvider>,
92
168
  )
93
169
 
@@ -101,7 +177,7 @@ describe('AuthBoundary', () => {
101
177
  })
102
178
  render(
103
179
  <ResourceProvider fallback={null}>
104
- <AuthBoundary>Protected Content</AuthBoundary>
180
+ <AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
105
181
  </ResourceProvider>,
106
182
  )
107
183
 
@@ -114,4 +190,98 @@ describe('AuthBoundary', () => {
114
190
  ).toBeInTheDocument()
115
191
  })
116
192
  })
193
+
194
+ it('renders children when logged in and org verification passes', () => {
195
+ render(
196
+ <ResourceProvider fallback={null}>
197
+ <AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
198
+ </ResourceProvider>,
199
+ )
200
+ expect(screen.getByText('Protected Content')).toBeInTheDocument()
201
+ })
202
+
203
+ it('throws AuthError via AuthSwitch when org verification fails (verifyOrganization=true)', async () => {
204
+ const orgErrorMessage = 'Organization mismatch!'
205
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
206
+ mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_IN} as any)
207
+ // Mock specific return value for this test
208
+ mockUseVerifyOrgProjects.mockImplementation((disabled, pIds) => {
209
+ // Expect verification to be enabled (disabled=false) and projectIds to match
210
+ if (!disabled && pIds === testProjectIds) {
211
+ return orgErrorMessage
212
+ }
213
+ return null // Default case
214
+ })
215
+
216
+ // Need to catch the error thrown during render. ErrorBoundary mock handles this.
217
+ render(
218
+ <AuthBoundary verifyOrganization={true} projectIds={testProjectIds}>
219
+ <div>Protected Content</div>
220
+ </AuthBoundary>,
221
+ )
222
+
223
+ // The ErrorBoundary's FallbackComponent should be rendered
224
+ // Check if the text rendered by the mocked LoginError component is present
225
+ await waitFor(() => {
226
+ // AuthSwitch throws ConfigurationError, ErrorBoundary catches and renders LoginErrorComponent mock
227
+ // Check for title and description separately as rendered by LoginError
228
+ expect(screen.getByText('Configuration Error')).toBeInTheDocument() // Check title
229
+ expect(screen.getByText(orgErrorMessage)).toBeInTheDocument() // Check description (the error message)
230
+ })
231
+ })
232
+
233
+ it('does NOT throw AuthError when org verification fails but verifyOrganization=false', () => {
234
+ const orgErrorMessage = 'Organization mismatch!'
235
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
236
+ mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_IN} as any)
237
+ // Mock specific return value for this test
238
+ mockUseVerifyOrgProjects.mockImplementation((disabled, pIds) => {
239
+ // Expect verification to be disabled (disabled=true) and projectIds to match
240
+ if (disabled && pIds === testProjectIds) {
241
+ // Hook should return null when disabled, but we mock based on call
242
+ return orgErrorMessage
243
+ }
244
+ return null // Default case
245
+ })
246
+
247
+ render(
248
+ <AuthBoundary verifyOrganization={false} projectIds={testProjectIds}>
249
+ <div>Protected Content</div>
250
+ </AuthBoundary>,
251
+ )
252
+
253
+ // Should render children because verification is disabled
254
+ expect(screen.getByText('Protected Content')).toBeInTheDocument()
255
+ // Error fallback should not be rendered
256
+ expect(screen.queryByText(/Login Error/)).not.toBeInTheDocument()
257
+ })
258
+
259
+ it('throws AuthError via AuthSwitch when auth state is ERROR', async () => {
260
+ const authErrorMessage = 'Some authentication error'
261
+
262
+ mockUseAuthState.mockReturnValue({
263
+ type: AuthStateType.ERROR,
264
+ error: new Error(authErrorMessage),
265
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
266
+ } as any)
267
+ mockUseVerifyOrgProjects.mockReturnValue(null) // Org verification passes or is irrelevant
268
+ mockUseVerifyOrgProjects.mockImplementation(() => null)
269
+
270
+ render(
271
+ <AuthBoundary projectIds={testProjectIds}>
272
+ <div>Protected Content</div>
273
+ </AuthBoundary>,
274
+ )
275
+
276
+ await waitFor(() => {
277
+ // AuthSwitch throws AuthError, ErrorBoundary catches and renders LoginErrorComponent mock
278
+ // Check for the generic title and description rendered by LoginError for AuthError
279
+ expect(screen.getByText('Authentication Error')).toBeInTheDocument()
280
+ expect(
281
+ screen.getByText('Please try again or contact support if the problem persists.'),
282
+ ).toBeInTheDocument()
283
+ })
284
+ })
285
+
286
+ // Add more tests for logged out state, redirects, etc.
117
287
  })
@@ -4,8 +4,10 @@ import {ErrorBoundary, type FallbackProps} from 'react-error-boundary'
4
4
 
5
5
  import {useAuthState} from '../../hooks/auth/useAuthState'
6
6
  import {useLoginUrl} from '../../hooks/auth/useLoginUrl'
7
+ import {useVerifyOrgProjects} from '../../hooks/auth/useVerifyOrgProjects'
7
8
  import {isInIframe} from '../utils'
8
9
  import {AuthError} from './AuthError'
10
+ import {ConfigurationError} from './ConfigurationError'
9
11
  import {LoginCallback} from './LoginCallback'
10
12
  import {LoginError, type LoginErrorProps} from './LoginError'
11
13
 
@@ -25,7 +27,7 @@ if (isInIframe() && !document.querySelector('[data-sanity-core]')) {
25
27
  }
26
28
 
27
29
  /**
28
- * @public
30
+ * @internal
29
31
  */
30
32
  export interface AuthBoundaryProps {
31
33
  /**
@@ -56,11 +58,25 @@ export interface AuthBoundaryProps {
56
58
  /** Header content to display */
57
59
  header?: React.ReactNode
58
60
 
61
+ /**
62
+ * The project IDs to use for organization verification.
63
+ */
64
+ projectIds?: string[]
65
+
59
66
  /** Footer content to display */
60
67
  footer?: React.ReactNode
61
68
 
62
69
  /** Protected content to render when authenticated */
63
70
  children?: React.ReactNode
71
+
72
+ /**
73
+ * Whether to verify that the project belongs to the organization specified in the dashboard context.
74
+ * By default, organization verification is enabled when running in a dashboard context.
75
+ *
76
+ * WARNING: Disabling organization verification is NOT RECOMMENDED and may cause your application
77
+ * to break in the future. This should never be disabled in production environments.
78
+ */
79
+ verifyOrganization?: boolean
64
80
  }
65
81
 
66
82
  /**
@@ -113,10 +129,19 @@ interface AuthSwitchProps {
113
129
  header?: React.ReactNode
114
130
  footer?: React.ReactNode
115
131
  children?: React.ReactNode
132
+ verifyOrganization?: boolean
133
+ projectIds?: string[]
116
134
  }
117
135
 
118
- function AuthSwitch({CallbackComponent = LoginCallback, children, ...props}: AuthSwitchProps) {
136
+ function AuthSwitch({
137
+ CallbackComponent = LoginCallback,
138
+ children,
139
+ verifyOrganization = true,
140
+ projectIds,
141
+ ...props
142
+ }: AuthSwitchProps) {
119
143
  const authState = useAuthState()
144
+ const orgError = useVerifyOrgProjects(!verifyOrganization, projectIds)
120
145
 
121
146
  const isLoggedOut = authState.type === AuthStateType.LOGGED_OUT && !authState.isDestroyingSession
122
147
  const loginUrl = useLoginUrl()
@@ -128,6 +153,11 @@ function AuthSwitch({CallbackComponent = LoginCallback, children, ...props}: Aut
128
153
  }
129
154
  }, [isLoggedOut, loginUrl])
130
155
 
156
+ // Only check the error if verification is enabled
157
+ if (verifyOrganization && orgError) {
158
+ throw new ConfigurationError({message: orgError})
159
+ }
160
+
131
161
  switch (authState.type) {
132
162
  case AuthStateType.ERROR: {
133
163
  throw new AuthError(authState.error)
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Error class for configuration-related errors. Wraps errors thrown during the
3
+ * configuration flow.
4
+ *
5
+ * @alpha
6
+ */
7
+ export class ConfigurationError extends Error {
8
+ constructor(error: unknown) {
9
+ if (
10
+ typeof error === 'object' &&
11
+ !!error &&
12
+ 'message' in error &&
13
+ typeof error.message === 'string'
14
+ ) {
15
+ super(error.message)
16
+ } else {
17
+ super()
18
+ }
19
+
20
+ this.cause = error
21
+ }
22
+ }
@@ -6,6 +6,7 @@ import {type FallbackProps} from 'react-error-boundary'
6
6
  import {useAuthState} from '../../hooks/auth/useAuthState'
7
7
  import {useLogOut} from '../../hooks/auth/useLogOut'
8
8
  import {AuthError} from './AuthError'
9
+ import {ConfigurationError} from './ConfigurationError'
9
10
  /**
10
11
  * @alpha
11
12
  */
@@ -18,7 +19,7 @@ export type LoginErrorProps = FallbackProps
18
19
  * @alpha
19
20
  */
20
21
  export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.ReactNode {
21
- if (!(error instanceof AuthError)) throw error
22
+ if (!(error instanceof AuthError || error instanceof ConfigurationError)) throw error
22
23
  const logout = useLogOut()
23
24
  const authState = useAuthState()
24
25
 
@@ -44,12 +45,17 @@ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.
44
45
  }
45
46
  }
46
47
  }
47
- }, [authState, handleRetry])
48
+ if (authState.type !== AuthStateType.ERROR && error instanceof ConfigurationError) {
49
+ setAuthErrorMessage(error.message)
50
+ }
51
+ }, [authState, handleRetry, error])
48
52
 
49
53
  return (
50
54
  <div className="sc-login-error">
51
55
  <div className="sc-login-error__content">
52
- <h2 className="sc-login-error__title">Authentication Error</h2>
56
+ <h2 className="sc-login-error__title">
57
+ {error instanceof AuthError ? 'Authentication Error' : 'Configuration Error'}
58
+ </h2>
53
59
  <p className="sc-login-error__description">{authErrorMessage}</p>
54
60
  </div>
55
61