@sanity/sdk-react 0.0.0-alpha.3 → 0.0.0-alpha.31
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 +6 -100
- package/dist/index.d.ts +2390 -2
- package/dist/index.js +1119 -2
- package/dist/index.js.map +1 -1
- package/package.json +35 -49
- package/src/_exports/index.ts +2 -10
- package/src/_exports/sdk-react.ts +73 -0
- package/src/components/SDKProvider.test.tsx +103 -0
- package/src/components/SDKProvider.tsx +52 -0
- package/src/components/SanityApp.test.tsx +244 -0
- package/src/components/SanityApp.tsx +106 -0
- package/src/components/auth/AuthBoundary.test.tsx +204 -29
- package/src/components/auth/AuthBoundary.tsx +96 -19
- package/src/components/auth/ConfigurationError.ts +22 -0
- package/src/components/auth/LoginCallback.test.tsx +22 -24
- package/src/components/auth/LoginCallback.tsx +6 -16
- package/src/components/auth/LoginError.test.tsx +11 -18
- package/src/components/auth/LoginError.tsx +43 -25
- package/src/components/utils.ts +22 -0
- package/src/context/ResourceProvider.test.tsx +157 -0
- package/src/context/ResourceProvider.tsx +111 -0
- package/src/context/SanityInstanceContext.ts +4 -0
- package/src/hooks/_synchronous-groq-js.mjs +4 -0
- package/src/hooks/auth/useAuthState.tsx +4 -5
- package/src/hooks/auth/useAuthToken.tsx +1 -1
- package/src/hooks/auth/useCurrentUser.tsx +28 -4
- package/src/hooks/auth/useDashboardOrganizationId.test.tsx +42 -0
- package/src/hooks/auth/useDashboardOrganizationId.tsx +30 -0
- package/src/hooks/auth/useHandleAuthCallback.test.tsx +16 -0
- package/src/hooks/auth/{useHandleCallback.tsx → useHandleAuthCallback.tsx} +7 -6
- package/src/hooks/auth/useLogOut.test.tsx +2 -2
- package/src/hooks/auth/useLogOut.tsx +1 -1
- package/src/hooks/auth/useLoginUrl.tsx +14 -0
- package/src/hooks/auth/useVerifyOrgProjects.test.tsx +136 -0
- package/src/hooks/auth/useVerifyOrgProjects.tsx +48 -0
- package/src/hooks/client/useClient.ts +13 -33
- package/src/hooks/comlink/useFrameConnection.test.tsx +167 -0
- package/src/hooks/comlink/useFrameConnection.ts +107 -0
- package/src/hooks/comlink/useManageFavorite.test.ts +368 -0
- package/src/hooks/comlink/useManageFavorite.ts +210 -0
- package/src/hooks/comlink/useRecordDocumentHistoryEvent.test.ts +85 -0
- package/src/hooks/comlink/useRecordDocumentHistoryEvent.ts +115 -0
- package/src/hooks/comlink/useWindowConnection.test.ts +135 -0
- package/src/hooks/comlink/useWindowConnection.ts +123 -0
- package/src/hooks/context/useSanityInstance.test.tsx +157 -15
- package/src/hooks/context/useSanityInstance.ts +68 -11
- package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +276 -0
- package/src/hooks/dashboard/useNavigateToStudioDocument.ts +139 -0
- package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.test.tsx +291 -0
- package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.ts +101 -0
- package/src/hooks/datasets/useDatasets.test.ts +80 -0
- package/src/hooks/datasets/useDatasets.ts +52 -0
- package/src/hooks/document/useApplyDocumentActions.test.ts +20 -0
- package/src/hooks/document/useApplyDocumentActions.ts +124 -0
- package/src/hooks/document/useDocument.test.ts +118 -0
- package/src/hooks/document/useDocument.ts +212 -0
- package/src/hooks/document/useDocumentEvent.test.ts +62 -0
- package/src/hooks/document/useDocumentEvent.ts +94 -0
- package/src/hooks/document/useDocumentPermissions.test.ts +204 -0
- package/src/hooks/document/useDocumentPermissions.ts +131 -0
- package/src/hooks/document/useDocumentSyncStatus.test.ts +23 -0
- package/src/hooks/document/useDocumentSyncStatus.ts +61 -0
- package/src/hooks/document/useEditDocument.test.ts +196 -0
- package/src/hooks/document/useEditDocument.ts +314 -0
- package/src/hooks/documents/useDocuments.test.tsx +179 -0
- package/src/hooks/documents/useDocuments.ts +300 -0
- package/src/hooks/helpers/createCallbackHook.test.tsx +2 -2
- package/src/hooks/helpers/createCallbackHook.tsx +1 -1
- package/src/hooks/helpers/createStateSourceHook.test.tsx +67 -1
- package/src/hooks/helpers/createStateSourceHook.tsx +27 -11
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +284 -0
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +353 -0
- package/src/hooks/preview/usePreview.test.tsx +85 -17
- package/src/hooks/preview/usePreview.tsx +81 -22
- package/src/hooks/projection/useProjection.test.tsx +283 -0
- package/src/hooks/projection/useProjection.ts +232 -0
- package/src/hooks/projects/useProject.test.ts +80 -0
- package/src/hooks/projects/useProject.ts +51 -0
- package/src/hooks/projects/useProjects.test.ts +77 -0
- package/src/hooks/projects/useProjects.ts +45 -0
- package/src/hooks/query/useQuery.test.tsx +188 -0
- package/src/hooks/query/useQuery.ts +193 -0
- 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 +49 -0
- package/src/hooks/users/useUsers.test.tsx +330 -0
- package/src/hooks/users/useUsers.ts +120 -0
- package/src/utils/getEnv.ts +21 -0
- package/src/version.ts +8 -0
- package/src/vite-env.d.ts +10 -0
- package/dist/_chunks-es/useLogOut.js +0 -44
- package/dist/_chunks-es/useLogOut.js.map +0 -1
- package/dist/assets/bundle-CcAyERuZ.css +0 -11
- package/dist/components.d.ts +0 -259
- package/dist/components.js +0 -301
- package/dist/components.js.map +0 -1
- package/dist/hooks.d.ts +0 -186
- package/dist/hooks.js +0 -81
- package/dist/hooks.js.map +0 -1
- package/src/_exports/components.ts +0 -13
- package/src/_exports/hooks.ts +0 -9
- package/src/components/DocumentGridLayout/DocumentGridLayout.stories.tsx +0 -113
- package/src/components/DocumentGridLayout/DocumentGridLayout.test.tsx +0 -42
- package/src/components/DocumentGridLayout/DocumentGridLayout.tsx +0 -21
- package/src/components/DocumentListLayout/DocumentListLayout.stories.tsx +0 -105
- package/src/components/DocumentListLayout/DocumentListLayout.test.tsx +0 -42
- package/src/components/DocumentListLayout/DocumentListLayout.tsx +0 -12
- package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.md +0 -49
- package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.stories.tsx +0 -39
- package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.test.tsx +0 -30
- package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.tsx +0 -171
- package/src/components/Login/LoginLinks.test.tsx +0 -100
- package/src/components/Login/LoginLinks.tsx +0 -73
- package/src/components/auth/Login.test.tsx +0 -41
- package/src/components/auth/Login.tsx +0 -45
- package/src/components/auth/LoginFooter.test.tsx +0 -29
- package/src/components/auth/LoginFooter.tsx +0 -65
- package/src/components/auth/LoginLayout.test.tsx +0 -33
- package/src/components/auth/LoginLayout.tsx +0 -81
- package/src/components/context/SanityProvider.test.tsx +0 -25
- package/src/components/context/SanityProvider.tsx +0 -42
- package/src/css/css.config.js +0 -220
- package/src/css/paramour.css +0 -2347
- package/src/css/styles.css +0 -11
- package/src/hooks/auth/useHandleCallback.test.tsx +0 -16
- package/src/hooks/auth/useLoginUrls.test.tsx +0 -68
- package/src/hooks/auth/useLoginUrls.tsx +0 -51
- package/src/hooks/client/useClient.test.tsx +0 -130
- package/src/hooks/documentCollection/useDocuments.test.ts +0 -130
- package/src/hooks/documentCollection/useDocuments.ts +0 -87
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import {type SanityConfig} from '@sanity/sdk'
|
|
2
|
+
import {type ReactElement, useEffect} from 'react'
|
|
3
|
+
|
|
4
|
+
import {SDKProvider} from './SDKProvider'
|
|
5
|
+
import {isInIframe, isLocalUrl} from './utils'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @public
|
|
9
|
+
* @category Types
|
|
10
|
+
*/
|
|
11
|
+
export interface SanityAppProps {
|
|
12
|
+
/* One or more SanityConfig objects providing a project ID and dataset name */
|
|
13
|
+
config: SanityConfig | SanityConfig[]
|
|
14
|
+
/** @deprecated use the `config` prop instead. */
|
|
15
|
+
sanityConfigs?: SanityConfig[]
|
|
16
|
+
children: React.ReactNode
|
|
17
|
+
/* Fallback content to show when child components are suspending. Same as the `fallback` prop for React Suspense. */
|
|
18
|
+
fallback: React.ReactNode
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const REDIRECT_URL = 'https://sanity.io/welcome'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @public
|
|
25
|
+
*
|
|
26
|
+
* The SanityApp component provides your Sanity application with access to your Sanity configuration,
|
|
27
|
+
* as well as application context and state which is used by the Sanity React hooks. Your application
|
|
28
|
+
* must be wrapped with the SanityApp component to function properly.
|
|
29
|
+
*
|
|
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.
|
|
36
|
+
*
|
|
37
|
+
* @category Components
|
|
38
|
+
* @param props - Your Sanity configuration and the React children to render
|
|
39
|
+
* @returns Your Sanity application, integrated with your Sanity configuration and application context
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```tsx
|
|
43
|
+
* import { SanityApp, type SanityConfig } from '@sanity/sdk-react'
|
|
44
|
+
*
|
|
45
|
+
* import MyAppRoot from './Root'
|
|
46
|
+
*
|
|
47
|
+
* // Single project configuration
|
|
48
|
+
* const mySanityConfig: SanityConfig = {
|
|
49
|
+
* projectId: 'my-project-id',
|
|
50
|
+
* dataset: 'production',
|
|
51
|
+
* }
|
|
52
|
+
*
|
|
53
|
+
* // Or multiple project configurations
|
|
54
|
+
* const multipleConfigs: SanityConfig[] = [
|
|
55
|
+
* // Configuration for your main project. This will be used as the default project for hooks.
|
|
56
|
+
* {
|
|
57
|
+
* projectId: 'marketing-website-project',
|
|
58
|
+
* dataset: 'production',
|
|
59
|
+
* },
|
|
60
|
+
* // Configuration for a separate blog project
|
|
61
|
+
* {
|
|
62
|
+
* projectId: 'blog-project',
|
|
63
|
+
* dataset: 'production',
|
|
64
|
+
* },
|
|
65
|
+
* // Configuration for a separate ecommerce project
|
|
66
|
+
* {
|
|
67
|
+
* projectId: 'ecommerce-project',
|
|
68
|
+
* dataset: 'production',
|
|
69
|
+
* }
|
|
70
|
+
* ]
|
|
71
|
+
*
|
|
72
|
+
* export default function MyApp() {
|
|
73
|
+
* return (
|
|
74
|
+
* <SanityApp config={mySanityConfig} fallback={<div>Loading…</div>}>
|
|
75
|
+
* <MyAppRoot />
|
|
76
|
+
* </SanityApp>
|
|
77
|
+
* )
|
|
78
|
+
* }
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export function SanityApp({
|
|
82
|
+
children,
|
|
83
|
+
fallback,
|
|
84
|
+
config = [],
|
|
85
|
+
...props
|
|
86
|
+
}: SanityAppProps): ReactElement {
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
let timeout: NodeJS.Timeout | undefined
|
|
89
|
+
|
|
90
|
+
if (!isInIframe() && !isLocalUrl(window)) {
|
|
91
|
+
// If the app is not running in an iframe and is not a local url, redirect to core.
|
|
92
|
+
timeout = setTimeout(() => {
|
|
93
|
+
// eslint-disable-next-line no-console
|
|
94
|
+
console.warn('Redirecting to core', REDIRECT_URL)
|
|
95
|
+
window.location.replace(REDIRECT_URL)
|
|
96
|
+
}, 1000)
|
|
97
|
+
}
|
|
98
|
+
return () => clearTimeout(timeout)
|
|
99
|
+
}, [])
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<SDKProvider {...props} fallback={fallback} config={config}>
|
|
103
|
+
{children}
|
|
104
|
+
</SDKProvider>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
import {AuthStateType
|
|
2
|
-
import {ThemeProvider} from '@sanity/ui'
|
|
3
|
-
import {buildTheme} from '@sanity/ui/theme'
|
|
1
|
+
import {AuthStateType} from '@sanity/sdk'
|
|
4
2
|
import {render, screen, waitFor} from '@testing-library/react'
|
|
5
3
|
import React from 'react'
|
|
4
|
+
import {type FallbackProps} from 'react-error-boundary'
|
|
6
5
|
import {beforeEach, describe, expect, it, type MockInstance, vi} from 'vitest'
|
|
7
6
|
|
|
7
|
+
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
8
8
|
import {useAuthState} from '../../hooks/auth/useAuthState'
|
|
9
|
-
import {
|
|
9
|
+
import {useLoginUrl} from '../../hooks/auth/useLoginUrl'
|
|
10
|
+
import {useVerifyOrgProjects} from '../../hooks/auth/useVerifyOrgProjects'
|
|
10
11
|
import {AuthBoundary} from './AuthBoundary'
|
|
11
12
|
|
|
12
13
|
// Mock hooks
|
|
13
14
|
vi.mock('../../hooks/auth/useAuthState', () => ({
|
|
14
15
|
useAuthState: vi.fn(() => 'logged-out'),
|
|
15
16
|
}))
|
|
16
|
-
vi.mock('../../hooks/auth/
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
vi.
|
|
20
|
-
useHandleCallback: vi.fn(() => async () => {}),
|
|
17
|
+
vi.mock('../../hooks/auth/useLoginUrl')
|
|
18
|
+
vi.mock('../../hooks/auth/useVerifyOrgProjects')
|
|
19
|
+
vi.mock('../../hooks/auth/useHandleAuthCallback', () => ({
|
|
20
|
+
useHandleAuthCallback: vi.fn(() => async () => {}),
|
|
21
21
|
}))
|
|
22
22
|
vi.mock('../../hooks/auth/useLogOut', () => ({
|
|
23
23
|
useLogOut: vi.fn(() => async () => {}),
|
|
@@ -38,48 +38,121 @@ vi.mock('./AuthError', async (importOriginal) => {
|
|
|
38
38
|
}
|
|
39
39
|
})
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
+
}))
|
|
50
102
|
|
|
51
103
|
describe('AuthBoundary', () => {
|
|
52
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
|
+
|
|
53
110
|
beforeEach(() => {
|
|
54
111
|
vi.clearAllMocks()
|
|
55
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)
|
|
56
119
|
})
|
|
57
120
|
|
|
58
121
|
afterEach(() => {
|
|
59
122
|
consoleErrorSpy?.mockRestore()
|
|
60
123
|
})
|
|
61
124
|
|
|
62
|
-
it('
|
|
125
|
+
it.skip('redirects to the sanity.io/login url when authState="logged-out"', async () => {
|
|
63
126
|
vi.mocked(useAuthState).mockReturnValue({
|
|
64
127
|
type: AuthStateType.LOGGED_OUT,
|
|
65
128
|
isDestroyingSession: false,
|
|
66
129
|
})
|
|
67
|
-
|
|
130
|
+
render(
|
|
131
|
+
<ResourceProvider fallback={null}>
|
|
132
|
+
<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
|
|
133
|
+
</ResourceProvider>,
|
|
134
|
+
)
|
|
68
135
|
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
136
|
+
// Wait for the redirect to happen
|
|
137
|
+
await waitFor(() => {
|
|
138
|
+
expect(window.location.href).toBe('https://sanity.io/login')
|
|
139
|
+
})
|
|
72
140
|
})
|
|
73
141
|
|
|
74
|
-
it('renders the LoginCallback component when authState="logging-in"', () => {
|
|
142
|
+
it('renders the empty LoginCallback component when authState="logging-in"', () => {
|
|
75
143
|
vi.mocked(useAuthState).mockReturnValue({
|
|
76
144
|
type: AuthStateType.LOGGING_IN,
|
|
77
145
|
isExchangingToken: false,
|
|
78
146
|
})
|
|
79
|
-
|
|
147
|
+
const {container} = render(
|
|
148
|
+
<ResourceProvider fallback={null}>
|
|
149
|
+
<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
|
|
150
|
+
</ResourceProvider>,
|
|
151
|
+
)
|
|
80
152
|
|
|
81
|
-
// The callback screen
|
|
82
|
-
expect(
|
|
153
|
+
// The callback screen renders null check that it renders nothing
|
|
154
|
+
expect(container.innerHTML).toBe('')
|
|
155
|
+
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument()
|
|
83
156
|
})
|
|
84
157
|
|
|
85
158
|
it('renders children when authState="logged-in"', () => {
|
|
@@ -88,7 +161,11 @@ describe('AuthBoundary', () => {
|
|
|
88
161
|
currentUser: null,
|
|
89
162
|
token: 'exampleToken',
|
|
90
163
|
})
|
|
91
|
-
|
|
164
|
+
render(
|
|
165
|
+
<ResourceProvider fallback={null}>
|
|
166
|
+
<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
|
|
167
|
+
</ResourceProvider>,
|
|
168
|
+
)
|
|
92
169
|
|
|
93
170
|
expect(screen.getByText('Protected Content')).toBeInTheDocument()
|
|
94
171
|
})
|
|
@@ -98,7 +175,11 @@ describe('AuthBoundary', () => {
|
|
|
98
175
|
type: AuthStateType.ERROR,
|
|
99
176
|
error: new Error('test error'),
|
|
100
177
|
})
|
|
101
|
-
|
|
178
|
+
render(
|
|
179
|
+
<ResourceProvider fallback={null}>
|
|
180
|
+
<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
|
|
181
|
+
</ResourceProvider>,
|
|
182
|
+
)
|
|
102
183
|
|
|
103
184
|
// The AuthBoundary should throw an AuthError internally
|
|
104
185
|
// and then display the LoginError component as the fallback.
|
|
@@ -109,4 +190,98 @@ describe('AuthBoundary', () => {
|
|
|
109
190
|
).toBeInTheDocument()
|
|
110
191
|
})
|
|
111
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.
|
|
112
287
|
})
|
|
@@ -1,36 +1,82 @@
|
|
|
1
1
|
import {AuthStateType} from '@sanity/sdk'
|
|
2
|
-
import {useMemo} from 'react'
|
|
2
|
+
import {useEffect, useMemo} from 'react'
|
|
3
3
|
import {ErrorBoundary, type FallbackProps} from 'react-error-boundary'
|
|
4
4
|
|
|
5
5
|
import {useAuthState} from '../../hooks/auth/useAuthState'
|
|
6
|
+
import {useLoginUrl} from '../../hooks/auth/useLoginUrl'
|
|
7
|
+
import {useVerifyOrgProjects} from '../../hooks/auth/useVerifyOrgProjects'
|
|
8
|
+
import {isInIframe} from '../utils'
|
|
6
9
|
import {AuthError} from './AuthError'
|
|
7
|
-
import {
|
|
10
|
+
import {ConfigurationError} from './ConfigurationError'
|
|
8
11
|
import {LoginCallback} from './LoginCallback'
|
|
9
12
|
import {LoginError, type LoginErrorProps} from './LoginError'
|
|
10
|
-
|
|
13
|
+
|
|
14
|
+
// Only import bridge if we're in an iframe. This assumes that the app is
|
|
15
|
+
// running within SanityOS if it is in an iframe and that the bridge hasn't already been loaded
|
|
16
|
+
if (isInIframe() && !document.querySelector('[data-sanity-core]')) {
|
|
17
|
+
const parsedUrl = new URL(window.location.href)
|
|
18
|
+
const mode = new URLSearchParams(parsedUrl.hash.slice(1)).get('mode')
|
|
19
|
+
const script = document.createElement('script')
|
|
20
|
+
script.src =
|
|
21
|
+
mode === 'core-ui--staging'
|
|
22
|
+
? 'https://core.sanity-cdn.work/bridge.js'
|
|
23
|
+
: 'https://core.sanity-cdn.com/bridge.js'
|
|
24
|
+
script.type = 'module'
|
|
25
|
+
script.async = true
|
|
26
|
+
document.head.appendChild(script)
|
|
27
|
+
}
|
|
11
28
|
|
|
12
29
|
/**
|
|
13
|
-
* @
|
|
30
|
+
* @public
|
|
14
31
|
*/
|
|
15
|
-
export interface AuthBoundaryProps
|
|
32
|
+
export interface AuthBoundaryProps {
|
|
16
33
|
/**
|
|
17
34
|
* Custom component to render the login screen.
|
|
18
|
-
* Receives all
|
|
35
|
+
* Receives all props. Defaults to {@link Login}.
|
|
19
36
|
*/
|
|
20
|
-
LoginComponent?: React.ComponentType<
|
|
37
|
+
LoginComponent?: React.ComponentType<{
|
|
38
|
+
header?: React.ReactNode
|
|
39
|
+
footer?: React.ReactNode
|
|
40
|
+
}>
|
|
21
41
|
|
|
22
42
|
/**
|
|
23
43
|
* Custom component to render during OAuth callback processing.
|
|
24
|
-
* Receives all
|
|
44
|
+
* Receives all props. Defaults to {@link LoginCallback}.
|
|
25
45
|
*/
|
|
26
|
-
CallbackComponent?: React.ComponentType<
|
|
46
|
+
CallbackComponent?: React.ComponentType<{
|
|
47
|
+
header?: React.ReactNode
|
|
48
|
+
footer?: React.ReactNode
|
|
49
|
+
}>
|
|
27
50
|
|
|
28
51
|
/**
|
|
29
52
|
* Custom component to render when authentication errors occur.
|
|
30
|
-
* Receives
|
|
53
|
+
* Receives error boundary props and layout props. Defaults to
|
|
31
54
|
* {@link LoginError}
|
|
32
55
|
*/
|
|
33
56
|
LoginErrorComponent?: React.ComponentType<LoginErrorProps>
|
|
57
|
+
|
|
58
|
+
/** Header content to display */
|
|
59
|
+
header?: React.ReactNode
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The project IDs to use for organization verification.
|
|
63
|
+
*/
|
|
64
|
+
projectIds?: string[]
|
|
65
|
+
|
|
66
|
+
/** Footer content to display */
|
|
67
|
+
footer?: React.ReactNode
|
|
68
|
+
|
|
69
|
+
/** Protected content to render when authenticated */
|
|
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
|
|
34
80
|
}
|
|
35
81
|
|
|
36
82
|
/**
|
|
@@ -52,18 +98,17 @@ export interface AuthBoundaryProps extends LoginLayoutProps {
|
|
|
52
98
|
* }
|
|
53
99
|
* ```
|
|
54
100
|
*
|
|
55
|
-
* @
|
|
101
|
+
* @internal
|
|
56
102
|
*/
|
|
57
103
|
export function AuthBoundary({
|
|
58
104
|
LoginErrorComponent = LoginError,
|
|
59
105
|
...props
|
|
60
106
|
}: AuthBoundaryProps): React.ReactNode {
|
|
61
|
-
const {header, footer} = props
|
|
62
107
|
const FallbackComponent = useMemo(() => {
|
|
63
108
|
return function LoginComponentWithLayoutProps(fallbackProps: FallbackProps) {
|
|
64
|
-
return <LoginErrorComponent {...fallbackProps}
|
|
109
|
+
return <LoginErrorComponent {...fallbackProps} />
|
|
65
110
|
}
|
|
66
|
-
}, [
|
|
111
|
+
}, [LoginErrorComponent])
|
|
67
112
|
|
|
68
113
|
return (
|
|
69
114
|
<ErrorBoundary FallbackComponent={FallbackComponent}>
|
|
@@ -72,18 +117,46 @@ export function AuthBoundary({
|
|
|
72
117
|
)
|
|
73
118
|
}
|
|
74
119
|
|
|
75
|
-
interface AuthSwitchProps
|
|
76
|
-
LoginComponent?: React.ComponentType<
|
|
77
|
-
|
|
120
|
+
interface AuthSwitchProps {
|
|
121
|
+
LoginComponent?: React.ComponentType<{
|
|
122
|
+
header?: React.ReactNode
|
|
123
|
+
footer?: React.ReactNode
|
|
124
|
+
}>
|
|
125
|
+
CallbackComponent?: React.ComponentType<{
|
|
126
|
+
header?: React.ReactNode
|
|
127
|
+
footer?: React.ReactNode
|
|
128
|
+
}>
|
|
129
|
+
header?: React.ReactNode
|
|
130
|
+
footer?: React.ReactNode
|
|
131
|
+
children?: React.ReactNode
|
|
132
|
+
verifyOrganization?: boolean
|
|
133
|
+
projectIds?: string[]
|
|
78
134
|
}
|
|
79
135
|
|
|
80
136
|
function AuthSwitch({
|
|
81
|
-
LoginComponent = Login,
|
|
82
137
|
CallbackComponent = LoginCallback,
|
|
83
138
|
children,
|
|
139
|
+
verifyOrganization = true,
|
|
140
|
+
projectIds,
|
|
84
141
|
...props
|
|
85
142
|
}: AuthSwitchProps) {
|
|
86
143
|
const authState = useAuthState()
|
|
144
|
+
const orgError = useVerifyOrgProjects(!verifyOrganization, projectIds)
|
|
145
|
+
|
|
146
|
+
const isLoggedOut = authState.type === AuthStateType.LOGGED_OUT && !authState.isDestroyingSession
|
|
147
|
+
const loginUrl = useLoginUrl()
|
|
148
|
+
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (isLoggedOut && !isInIframe()) {
|
|
151
|
+
// We don't want to redirect to login if we're in the Dashboard
|
|
152
|
+
window.location.href = loginUrl
|
|
153
|
+
}
|
|
154
|
+
}, [isLoggedOut, loginUrl])
|
|
155
|
+
|
|
156
|
+
// Only check the error if verification is enabled
|
|
157
|
+
if (verifyOrganization && orgError) {
|
|
158
|
+
throw new ConfigurationError({message: orgError})
|
|
159
|
+
}
|
|
87
160
|
|
|
88
161
|
switch (authState.type) {
|
|
89
162
|
case AuthStateType.ERROR: {
|
|
@@ -95,8 +168,12 @@ function AuthSwitch({
|
|
|
95
168
|
case AuthStateType.LOGGED_IN: {
|
|
96
169
|
return children
|
|
97
170
|
}
|
|
171
|
+
case AuthStateType.LOGGED_OUT: {
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
98
174
|
default: {
|
|
99
|
-
|
|
175
|
+
// @ts-expect-error - This state should never happen
|
|
176
|
+
throw new Error(`Invalid auth state: ${authState.type}`)
|
|
100
177
|
}
|
|
101
178
|
}
|
|
102
179
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,26 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {ThemeProvider} from '@sanity/ui'
|
|
3
|
-
import {buildTheme} from '@sanity/ui/theme'
|
|
4
|
-
import {render, screen, waitFor} from '@testing-library/react'
|
|
5
|
-
import React from 'react'
|
|
1
|
+
import {render, waitFor} from '@testing-library/react'
|
|
6
2
|
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
7
3
|
|
|
8
|
-
import {
|
|
4
|
+
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
9
5
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const renderWithWrappers = (ui: React.ReactElement) => {
|
|
14
|
-
return render(
|
|
15
|
-
<ThemeProvider theme={theme}>
|
|
16
|
-
<SanityProvider sanityInstance={sanityInstance}>{ui}</SanityProvider>
|
|
17
|
-
</ThemeProvider>,
|
|
18
|
-
)
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// Mock `useHandleCallback`
|
|
22
|
-
vi.mock('../../hooks/auth/useHandleCallback', () => ({
|
|
23
|
-
useHandleCallback: vi.fn(() => async (url: string) => {
|
|
6
|
+
// Mock `useHandleAuthCallback`
|
|
7
|
+
vi.mock('../../hooks/auth/useHandleAuthCallback', () => ({
|
|
8
|
+
useHandleAuthCallback: vi.fn(() => async (url: string) => {
|
|
24
9
|
const parsedUrl = new URL(url)
|
|
25
10
|
const sid = new URLSearchParams(parsedUrl.hash.slice(1)).get('sid')
|
|
26
11
|
if (sid === 'valid') {
|
|
@@ -52,8 +37,13 @@ describe('LoginCallback', () => {
|
|
|
52
37
|
|
|
53
38
|
it('renders a loading message', async () => {
|
|
54
39
|
const {LoginCallback} = await import('./LoginCallback') // Reload after resetModules
|
|
55
|
-
|
|
56
|
-
|
|
40
|
+
const {container} = render(
|
|
41
|
+
<ResourceProvider fallback={null}>
|
|
42
|
+
<LoginCallback />
|
|
43
|
+
</ResourceProvider>,
|
|
44
|
+
)
|
|
45
|
+
// The callback screen renders null check that it renders nothing
|
|
46
|
+
expect(container.innerHTML).toBe('')
|
|
57
47
|
})
|
|
58
48
|
|
|
59
49
|
it('handles a successful callback and calls history.replaceState', async () => {
|
|
@@ -61,7 +51,11 @@ describe('LoginCallback', () => {
|
|
|
61
51
|
vi.stubGlobal('location', {href: 'http://localhost#sid=valid'})
|
|
62
52
|
const {LoginCallback} = await import('./LoginCallback') // Reload after resetModules
|
|
63
53
|
|
|
64
|
-
|
|
54
|
+
render(
|
|
55
|
+
<ResourceProvider fallback={null}>
|
|
56
|
+
<LoginCallback />
|
|
57
|
+
</ResourceProvider>,
|
|
58
|
+
)
|
|
65
59
|
|
|
66
60
|
await waitFor(() => {
|
|
67
61
|
expect(history.replaceState).toHaveBeenCalledWith(
|
|
@@ -77,7 +71,11 @@ describe('LoginCallback', () => {
|
|
|
77
71
|
vi.stubGlobal('location', {href: 'http://localhost#sid=invalid'})
|
|
78
72
|
const {LoginCallback} = await import('./LoginCallback') // Reload after resetModules
|
|
79
73
|
|
|
80
|
-
|
|
74
|
+
render(
|
|
75
|
+
<ResourceProvider fallback={null}>
|
|
76
|
+
<LoginCallback />
|
|
77
|
+
</ResourceProvider>,
|
|
78
|
+
)
|
|
81
79
|
|
|
82
80
|
await waitFor(() => {
|
|
83
81
|
expect(history.replaceState).not.toHaveBeenCalled()
|