@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.
- package/README.md +5 -57
- package/dist/index.d.ts +1000 -438
- package/dist/index.js +324 -258
- package/dist/index.js.map +1 -1
- package/package.json +17 -16
- package/src/_exports/sdk-react.ts +4 -1
- package/src/components/SDKProvider.tsx +6 -1
- package/src/components/SanityApp.test.tsx +29 -47
- package/src/components/SanityApp.tsx +12 -11
- package/src/components/auth/AuthBoundary.test.tsx +177 -7
- package/src/components/auth/AuthBoundary.tsx +32 -2
- package/src/components/auth/ConfigurationError.ts +22 -0
- package/src/components/auth/LoginError.tsx +9 -3
- package/src/hooks/auth/useVerifyOrgProjects.test.tsx +136 -0
- package/src/hooks/auth/useVerifyOrgProjects.tsx +48 -0
- package/src/hooks/client/useClient.ts +3 -3
- package/src/hooks/comlink/useManageFavorite.test.ts +276 -27
- package/src/hooks/comlink/useManageFavorite.ts +102 -51
- package/src/hooks/comlink/useWindowConnection.ts +3 -2
- package/src/hooks/document/useApplyDocumentActions.ts +105 -31
- package/src/hooks/document/useDocument.test.ts +41 -4
- package/src/hooks/document/useDocument.ts +198 -114
- package/src/hooks/document/useDocumentEvent.test.ts +5 -5
- package/src/hooks/document/useDocumentEvent.ts +67 -23
- package/src/hooks/document/useDocumentPermissions.ts +47 -8
- package/src/hooks/document/useDocumentSyncStatus.test.ts +12 -5
- package/src/hooks/document/useDocumentSyncStatus.ts +41 -14
- package/src/hooks/document/useEditDocument.test.ts +24 -6
- package/src/hooks/document/useEditDocument.ts +238 -133
- package/src/hooks/documents/useDocuments.test.tsx +1 -1
- package/src/hooks/documents/useDocuments.ts +153 -44
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +1 -1
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +120 -47
- package/src/hooks/projection/useProjection.ts +134 -46
- package/src/hooks/query/useQuery.test.tsx +4 -4
- package/src/hooks/query/useQuery.ts +115 -43
- package/src/hooks/releases/useActiveReleases.test.tsx +84 -0
- package/src/hooks/releases/useActiveReleases.ts +39 -0
- package/src/hooks/releases/usePerspective.test.tsx +120 -0
- 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.
|
|
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": "^
|
|
46
|
-
"@sanity/message-protocol": "^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.
|
|
54
|
+
"@sanity/sdk": "0.0.1"
|
|
54
55
|
},
|
|
55
56
|
"devDependencies": {
|
|
56
57
|
"@sanity/browserslist-config": "^1.0.5",
|
|
57
|
-
"@sanity/comlink": "^3.0.
|
|
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.
|
|
63
|
-
"@types/react-dom": "^19.1.
|
|
64
|
-
"@vitejs/plugin-react": "^4.
|
|
65
|
-
"@vitest/coverage-v8": "3.1.
|
|
66
|
-
"babel-plugin-react-compiler": "19.
|
|
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.
|
|
74
|
-
"vite": "^6.3.
|
|
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
|
|
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 --
|
|
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
|
|
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 {
|
|
7
|
-
|
|
8
|
-
//
|
|
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:
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
31
|
-
*
|
|
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={<
|
|
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={
|
|
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/
|
|
14
|
-
|
|
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
|
-
* @
|
|
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({
|
|
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
|
-
|
|
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">
|
|
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
|
|