@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.
Files changed (131) hide show
  1. package/README.md +6 -100
  2. package/dist/index.d.ts +2390 -2
  3. package/dist/index.js +1119 -2
  4. package/dist/index.js.map +1 -1
  5. package/package.json +35 -49
  6. package/src/_exports/index.ts +2 -10
  7. package/src/_exports/sdk-react.ts +73 -0
  8. package/src/components/SDKProvider.test.tsx +103 -0
  9. package/src/components/SDKProvider.tsx +52 -0
  10. package/src/components/SanityApp.test.tsx +244 -0
  11. package/src/components/SanityApp.tsx +106 -0
  12. package/src/components/auth/AuthBoundary.test.tsx +204 -29
  13. package/src/components/auth/AuthBoundary.tsx +96 -19
  14. package/src/components/auth/ConfigurationError.ts +22 -0
  15. package/src/components/auth/LoginCallback.test.tsx +22 -24
  16. package/src/components/auth/LoginCallback.tsx +6 -16
  17. package/src/components/auth/LoginError.test.tsx +11 -18
  18. package/src/components/auth/LoginError.tsx +43 -25
  19. package/src/components/utils.ts +22 -0
  20. package/src/context/ResourceProvider.test.tsx +157 -0
  21. package/src/context/ResourceProvider.tsx +111 -0
  22. package/src/context/SanityInstanceContext.ts +4 -0
  23. package/src/hooks/_synchronous-groq-js.mjs +4 -0
  24. package/src/hooks/auth/useAuthState.tsx +4 -5
  25. package/src/hooks/auth/useAuthToken.tsx +1 -1
  26. package/src/hooks/auth/useCurrentUser.tsx +28 -4
  27. package/src/hooks/auth/useDashboardOrganizationId.test.tsx +42 -0
  28. package/src/hooks/auth/useDashboardOrganizationId.tsx +30 -0
  29. package/src/hooks/auth/useHandleAuthCallback.test.tsx +16 -0
  30. package/src/hooks/auth/{useHandleCallback.tsx → useHandleAuthCallback.tsx} +7 -6
  31. package/src/hooks/auth/useLogOut.test.tsx +2 -2
  32. package/src/hooks/auth/useLogOut.tsx +1 -1
  33. package/src/hooks/auth/useLoginUrl.tsx +14 -0
  34. package/src/hooks/auth/useVerifyOrgProjects.test.tsx +136 -0
  35. package/src/hooks/auth/useVerifyOrgProjects.tsx +48 -0
  36. package/src/hooks/client/useClient.ts +13 -33
  37. package/src/hooks/comlink/useFrameConnection.test.tsx +167 -0
  38. package/src/hooks/comlink/useFrameConnection.ts +107 -0
  39. package/src/hooks/comlink/useManageFavorite.test.ts +368 -0
  40. package/src/hooks/comlink/useManageFavorite.ts +210 -0
  41. package/src/hooks/comlink/useRecordDocumentHistoryEvent.test.ts +85 -0
  42. package/src/hooks/comlink/useRecordDocumentHistoryEvent.ts +115 -0
  43. package/src/hooks/comlink/useWindowConnection.test.ts +135 -0
  44. package/src/hooks/comlink/useWindowConnection.ts +123 -0
  45. package/src/hooks/context/useSanityInstance.test.tsx +157 -15
  46. package/src/hooks/context/useSanityInstance.ts +68 -11
  47. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +276 -0
  48. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +139 -0
  49. package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.test.tsx +291 -0
  50. package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.ts +101 -0
  51. package/src/hooks/datasets/useDatasets.test.ts +80 -0
  52. package/src/hooks/datasets/useDatasets.ts +52 -0
  53. package/src/hooks/document/useApplyDocumentActions.test.ts +20 -0
  54. package/src/hooks/document/useApplyDocumentActions.ts +124 -0
  55. package/src/hooks/document/useDocument.test.ts +118 -0
  56. package/src/hooks/document/useDocument.ts +212 -0
  57. package/src/hooks/document/useDocumentEvent.test.ts +62 -0
  58. package/src/hooks/document/useDocumentEvent.ts +94 -0
  59. package/src/hooks/document/useDocumentPermissions.test.ts +204 -0
  60. package/src/hooks/document/useDocumentPermissions.ts +131 -0
  61. package/src/hooks/document/useDocumentSyncStatus.test.ts +23 -0
  62. package/src/hooks/document/useDocumentSyncStatus.ts +61 -0
  63. package/src/hooks/document/useEditDocument.test.ts +196 -0
  64. package/src/hooks/document/useEditDocument.ts +314 -0
  65. package/src/hooks/documents/useDocuments.test.tsx +179 -0
  66. package/src/hooks/documents/useDocuments.ts +300 -0
  67. package/src/hooks/helpers/createCallbackHook.test.tsx +2 -2
  68. package/src/hooks/helpers/createCallbackHook.tsx +1 -1
  69. package/src/hooks/helpers/createStateSourceHook.test.tsx +67 -1
  70. package/src/hooks/helpers/createStateSourceHook.tsx +27 -11
  71. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +284 -0
  72. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +353 -0
  73. package/src/hooks/preview/usePreview.test.tsx +85 -17
  74. package/src/hooks/preview/usePreview.tsx +81 -22
  75. package/src/hooks/projection/useProjection.test.tsx +283 -0
  76. package/src/hooks/projection/useProjection.ts +232 -0
  77. package/src/hooks/projects/useProject.test.ts +80 -0
  78. package/src/hooks/projects/useProject.ts +51 -0
  79. package/src/hooks/projects/useProjects.test.ts +77 -0
  80. package/src/hooks/projects/useProjects.ts +45 -0
  81. package/src/hooks/query/useQuery.test.tsx +188 -0
  82. package/src/hooks/query/useQuery.ts +193 -0
  83. package/src/hooks/releases/useActiveReleases.test.tsx +84 -0
  84. package/src/hooks/releases/useActiveReleases.ts +39 -0
  85. package/src/hooks/releases/usePerspective.test.tsx +120 -0
  86. package/src/hooks/releases/usePerspective.ts +49 -0
  87. package/src/hooks/users/useUsers.test.tsx +330 -0
  88. package/src/hooks/users/useUsers.ts +120 -0
  89. package/src/utils/getEnv.ts +21 -0
  90. package/src/version.ts +8 -0
  91. package/src/vite-env.d.ts +10 -0
  92. package/dist/_chunks-es/useLogOut.js +0 -44
  93. package/dist/_chunks-es/useLogOut.js.map +0 -1
  94. package/dist/assets/bundle-CcAyERuZ.css +0 -11
  95. package/dist/components.d.ts +0 -259
  96. package/dist/components.js +0 -301
  97. package/dist/components.js.map +0 -1
  98. package/dist/hooks.d.ts +0 -186
  99. package/dist/hooks.js +0 -81
  100. package/dist/hooks.js.map +0 -1
  101. package/src/_exports/components.ts +0 -13
  102. package/src/_exports/hooks.ts +0 -9
  103. package/src/components/DocumentGridLayout/DocumentGridLayout.stories.tsx +0 -113
  104. package/src/components/DocumentGridLayout/DocumentGridLayout.test.tsx +0 -42
  105. package/src/components/DocumentGridLayout/DocumentGridLayout.tsx +0 -21
  106. package/src/components/DocumentListLayout/DocumentListLayout.stories.tsx +0 -105
  107. package/src/components/DocumentListLayout/DocumentListLayout.test.tsx +0 -42
  108. package/src/components/DocumentListLayout/DocumentListLayout.tsx +0 -12
  109. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.md +0 -49
  110. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.stories.tsx +0 -39
  111. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.test.tsx +0 -30
  112. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.tsx +0 -171
  113. package/src/components/Login/LoginLinks.test.tsx +0 -100
  114. package/src/components/Login/LoginLinks.tsx +0 -73
  115. package/src/components/auth/Login.test.tsx +0 -41
  116. package/src/components/auth/Login.tsx +0 -45
  117. package/src/components/auth/LoginFooter.test.tsx +0 -29
  118. package/src/components/auth/LoginFooter.tsx +0 -65
  119. package/src/components/auth/LoginLayout.test.tsx +0 -33
  120. package/src/components/auth/LoginLayout.tsx +0 -81
  121. package/src/components/context/SanityProvider.test.tsx +0 -25
  122. package/src/components/context/SanityProvider.tsx +0 -42
  123. package/src/css/css.config.js +0 -220
  124. package/src/css/paramour.css +0 -2347
  125. package/src/css/styles.css +0 -11
  126. package/src/hooks/auth/useHandleCallback.test.tsx +0 -16
  127. package/src/hooks/auth/useLoginUrls.test.tsx +0 -68
  128. package/src/hooks/auth/useLoginUrls.tsx +0 -51
  129. package/src/hooks/client/useClient.test.tsx +0 -130
  130. package/src/hooks/documentCollection/useDocuments.test.ts +0 -130
  131. 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, createSanityInstance} from '@sanity/sdk'
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 {SanityProvider} from '../context/SanityProvider'
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/useLoginUrls', () => ({
17
- useLoginUrls: vi.fn(() => [{title: 'Provider A', url: 'https://provider-a.com/auth'}]),
18
- }))
19
- vi.mock('../../hooks/auth/useHandleCallback', () => ({
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
- const theme = buildTheme({})
42
- const sanityInstance = createSanityInstance({projectId: 'test-project-id', dataset: 'production'})
43
- const renderWithWrappers = (ui: React.ReactElement) => {
44
- return render(
45
- <ThemeProvider theme={theme}>
46
- <SanityProvider sanityInstance={sanityInstance}>{ui}</SanityProvider>
47
- </ThemeProvider>,
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('renders the Login component when authState="logged-out"', () => {
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
- renderWithWrappers(<AuthBoundary>Protected Content</AuthBoundary>)
130
+ render(
131
+ <ResourceProvider fallback={null}>
132
+ <AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
133
+ </ResourceProvider>,
134
+ )
68
135
 
69
- // The login screen should show "Choose login provider" by default
70
- expect(screen.getByText('Choose login provider')).toBeInTheDocument()
71
- expect(screen.queryByText('Protected Content')).not.toBeInTheDocument()
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
- renderWithWrappers(<AuthBoundary>Protected Content</AuthBoundary>)
147
+ const {container} = render(
148
+ <ResourceProvider fallback={null}>
149
+ <AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
150
+ </ResourceProvider>,
151
+ )
80
152
 
81
- // The callback screen shows "Logging you in…"
82
- expect(screen.getByText('Logging you in…')).toBeInTheDocument()
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
- renderWithWrappers(<AuthBoundary>Protected Content</AuthBoundary>)
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
- renderWithWrappers(<AuthBoundary>Protected Content</AuthBoundary>)
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 {Login} from './Login'
10
+ import {ConfigurationError} from './ConfigurationError'
8
11
  import {LoginCallback} from './LoginCallback'
9
12
  import {LoginError, type LoginErrorProps} from './LoginError'
10
- import type {LoginLayoutProps} from './LoginLayout'
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
- * @alpha
30
+ * @public
14
31
  */
15
- export interface AuthBoundaryProps extends LoginLayoutProps {
32
+ export interface AuthBoundaryProps {
16
33
  /**
17
34
  * Custom component to render the login screen.
18
- * Receives all login layout props. Defaults to {@link Login}.
35
+ * Receives all props. Defaults to {@link Login}.
19
36
  */
20
- LoginComponent?: React.ComponentType<LoginLayoutProps>
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 login layout props. Defaults to {@link LoginCallback}.
44
+ * Receives all props. Defaults to {@link LoginCallback}.
25
45
  */
26
- CallbackComponent?: React.ComponentType<LoginLayoutProps>
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 login layout props and error boundary props. Defaults to
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
- * @alpha
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} header={header} footer={footer} />
109
+ return <LoginErrorComponent {...fallbackProps} />
65
110
  }
66
- }, [header, footer, LoginErrorComponent])
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 extends LoginLayoutProps {
76
- LoginComponent?: React.ComponentType<LoginLayoutProps>
77
- CallbackComponent?: React.ComponentType<LoginLayoutProps>
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
- return <LoginComponent {...props} />
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 {createSanityInstance} from '@sanity/sdk'
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 {SanityProvider} from '../context/SanityProvider'
4
+ import {ResourceProvider} from '../../context/ResourceProvider'
9
5
 
10
- const theme = buildTheme({})
11
- const sanityInstance = createSanityInstance({projectId: 'test-project-id', dataset: 'production'})
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
- renderWithWrappers(<LoginCallback />)
56
- expect(screen.getByText('Logging you in…')).toBeInTheDocument()
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
- renderWithWrappers(<LoginCallback />)
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
- renderWithWrappers(<LoginCallback />)
74
+ render(
75
+ <ResourceProvider fallback={null}>
76
+ <LoginCallback />
77
+ </ResourceProvider>,
78
+ )
81
79
 
82
80
  await waitFor(() => {
83
81
  expect(history.replaceState).not.toHaveBeenCalled()