@sanity/sdk-react 3.1.0 → 3.3.0-rc.0
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/dist/_exports/dashboard-internal.d.ts +2 -0
- package/dist/_exports/dashboard-internal.js +2 -0
- package/dist/_exports/dashboard.d.ts +457 -7
- package/dist/_exports/dashboard.d.ts.map +1 -1
- package/dist/_exports/dashboard.js +623 -2
- package/dist/_exports/dashboard.js.map +1 -1
- package/dist/index.d.ts +227 -87
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +147 -30
- package/dist/index.js.map +1 -1
- package/dist/{useStudioWorkspacesByProjectIdDataset-DxUlukmF.js → useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js} +53 -56
- package/dist/useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js.map +1 -0
- package/package.json +34 -32
- package/src/_exports/dashboard-internal.ts +1 -0
- package/src/_exports/dashboard.test-d.ts +64 -0
- package/src/_exports/dashboard.ts +70 -0
- package/src/_exports/index.ts +1 -1
- package/src/_exports/sdk-react.ts +3 -0
- package/src/components/auth/AuthBoundary.test.tsx +115 -2
- package/src/components/auth/AuthBoundary.tsx +18 -5
- package/src/components/auth/LoginCallback.test.tsx +46 -7
- package/src/components/auth/LoginCallback.tsx +22 -4
- package/src/context/DashboardTokenRefresh.test.tsx +124 -22
- package/src/context/DashboardTokenRefresh.tsx +31 -15
- package/src/dashboard/createRemoteInstance.test.ts +168 -0
- package/src/dashboard/createRemoteInstance.ts +123 -0
- package/src/dashboard/module.ts +39 -0
- package/src/dashboard/remoteClientState.ts +27 -0
- package/src/dashboard/urlFor.test.ts +246 -0
- package/src/dashboard/urlFor.ts +462 -0
- package/src/hooks/applications/useApplication.test-d.ts +1 -1
- package/src/hooks/auth/useHandleOAuthCallback.test.tsx +16 -0
- package/src/hooks/auth/useHandleOAuthCallback.tsx +49 -0
- package/src/hooks/auth/useOAuthAuthorize.test.tsx +16 -0
- package/src/hooks/auth/useOAuthAuthorize.tsx +28 -0
- package/src/hooks/auth/useOAuthTokens.test.tsx +240 -0
- package/src/hooks/auth/useOAuthTokens.tsx +95 -0
- package/src/hooks/dashboard/useApplication.test.tsx +59 -0
- package/src/hooks/dashboard/useApplication.ts +22 -0
- package/src/hooks/dashboard/useApplicationConfig.test.tsx +106 -0
- package/src/hooks/dashboard/useApplicationConfig.ts +45 -0
- package/src/hooks/dashboard/useApplicationConfigs.test.tsx +89 -0
- package/src/hooks/dashboard/useApplicationConfigs.ts +26 -0
- package/src/hooks/dashboard/useApplicationForegroundId.test.tsx +54 -0
- package/src/hooks/dashboard/useApplicationForegroundId.ts +22 -0
- package/src/hooks/dashboard/useApplications.test.tsx +249 -0
- package/src/hooks/dashboard/useApplications.ts +128 -0
- package/src/hooks/dashboard/useEmit.test.tsx +124 -0
- package/src/hooks/dashboard/useEmit.ts +78 -0
- package/src/hooks/dashboard/useNavigate.ts +1 -3
- package/src/hooks/dashboard/useRemoteClient.test.tsx +88 -0
- package/src/hooks/dashboard/useRemoteClient.ts +10 -0
- package/src/hooks/dashboard/useTopic.test.tsx +200 -0
- package/src/hooks/dashboard/useTopic.ts +29 -0
- package/src/hooks/dashboard/useUpdateFavorite.test.tsx +8 -1
- package/src/hooks/document/useApplyDocumentActions.ts +3 -4
- package/src/hooks/document/useCreateDocument.ts +2 -3
- package/src/hooks/document/useDocument.ts +11 -6
- package/src/hooks/document/useEditDocument.ts +5 -5
- package/src/hooks/projection/useDocumentProjection.ts +6 -7
- package/src/hooks/query/useQuery.ts +3 -4
- package/dist/useStudioWorkspacesByProjectIdDataset-DxUlukmF.js.map +0 -1
- package/src/context/dashboardToken.ts +0 -63
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import {AuthStateType} from '@sanity/sdk'
|
|
2
|
+
import {installMessageBus} from '@sanity/sdk/_internal'
|
|
2
3
|
import {render, screen, waitFor} from '@testing-library/react'
|
|
3
4
|
import React from 'react'
|
|
4
5
|
import {type FallbackProps} from 'react-error-boundary'
|
|
5
6
|
import {beforeEach, describe, expect, it, type MockInstance, vi} from 'vitest'
|
|
6
7
|
|
|
8
|
+
import {DashboardTokenRefreshProvider} from '../../context/DashboardTokenRefresh'
|
|
7
9
|
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
8
10
|
import {useAuthState} from '../../hooks/auth/useAuthState'
|
|
9
11
|
import {useLoginUrl} from '../../hooks/auth/useLoginUrl'
|
|
12
|
+
import {useOAuthAuthorize} from '../../hooks/auth/useOAuthAuthorize'
|
|
10
13
|
import {useVerifyOrgProjects} from '../../hooks/auth/useVerifyOrgProjects'
|
|
11
14
|
import {AuthBoundary} from './AuthBoundary'
|
|
12
15
|
|
|
@@ -15,6 +18,9 @@ vi.mock('../../hooks/auth/useAuthState', () => ({
|
|
|
15
18
|
useAuthState: vi.fn(() => 'logged-out'),
|
|
16
19
|
}))
|
|
17
20
|
vi.mock('../../hooks/auth/useLoginUrl')
|
|
21
|
+
vi.mock('../../hooks/auth/useOAuthAuthorize', () => ({
|
|
22
|
+
useOAuthAuthorize: vi.fn(() => vi.fn().mockResolvedValue(undefined)),
|
|
23
|
+
}))
|
|
18
24
|
vi.mock('../../hooks/auth/useVerifyOrgProjects')
|
|
19
25
|
vi.mock('../../hooks/auth/useHandleAuthCallback', () => ({
|
|
20
26
|
useHandleAuthCallback: vi.fn(() => async () => {}),
|
|
@@ -32,8 +38,8 @@ vi.mock('./AuthError', async (importOriginal) => {
|
|
|
32
38
|
return {
|
|
33
39
|
...actual,
|
|
34
40
|
AuthError: class MockAuthError extends Error {
|
|
35
|
-
constructor(error:
|
|
36
|
-
super(error.message)
|
|
41
|
+
constructor(error: unknown) {
|
|
42
|
+
super(error instanceof Error ? error.message : undefined)
|
|
37
43
|
this.name = 'AuthError'
|
|
38
44
|
this.cause = error
|
|
39
45
|
}
|
|
@@ -142,6 +148,113 @@ describe('AuthBoundary', () => {
|
|
|
142
148
|
})
|
|
143
149
|
})
|
|
144
150
|
|
|
151
|
+
it('does not redirect when a host bus is installed but the provider has not connected yet', () => {
|
|
152
|
+
// Workbench remotes start LOGGED_OUT (the host mints the token over the bus) and are not
|
|
153
|
+
// in an iframe, so the only thing standing between them and a login redirect is the
|
|
154
|
+
// dashboard check. AuthSwitch's effect runs before its parent provider's, so the check
|
|
155
|
+
// must not depend on the provider having connected first.
|
|
156
|
+
vi.mocked(useAuthState).mockReturnValue({
|
|
157
|
+
type: AuthStateType.LOGGED_OUT,
|
|
158
|
+
isDestroyingSession: false,
|
|
159
|
+
})
|
|
160
|
+
const originalLocation = window.location
|
|
161
|
+
const location = {href: 'http://remote.test/'}
|
|
162
|
+
Object.defineProperty(window, 'location', {value: location, writable: true})
|
|
163
|
+
vi.stubGlobal('__SANITY_APP_ID__', 'remote')
|
|
164
|
+
const globals = globalThis as {[key: symbol]: unknown}
|
|
165
|
+
const busKey = Symbol.for('sanity.os.bus')
|
|
166
|
+
installMessageBus({appId: 'dashboard'})
|
|
167
|
+
try {
|
|
168
|
+
render(
|
|
169
|
+
<ResourceProvider projectId="p" dataset="d" fallback={null}>
|
|
170
|
+
<DashboardTokenRefreshProvider>
|
|
171
|
+
<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
|
|
172
|
+
</DashboardTokenRefreshProvider>
|
|
173
|
+
</ResourceProvider>,
|
|
174
|
+
)
|
|
175
|
+
expect(location.href).toBe('http://remote.test/')
|
|
176
|
+
} finally {
|
|
177
|
+
delete globals[busKey]
|
|
178
|
+
vi.unstubAllGlobals()
|
|
179
|
+
Object.defineProperty(window, 'location', {value: originalLocation, writable: true})
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe('oauth mode', () => {
|
|
184
|
+
const oauth = {
|
|
185
|
+
clientId: 'client-abc',
|
|
186
|
+
redirectUri: 'https://app.example.com/callback',
|
|
187
|
+
organizationId: 'org123',
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
it('starts the OAuth authorization flow when authState="logged-out"', async () => {
|
|
191
|
+
const authorize = vi.fn().mockResolvedValue(undefined)
|
|
192
|
+
vi.mocked(useOAuthAuthorize).mockReturnValue(authorize)
|
|
193
|
+
vi.mocked(useAuthState).mockReturnValue({
|
|
194
|
+
type: AuthStateType.LOGGED_OUT,
|
|
195
|
+
isDestroyingSession: false,
|
|
196
|
+
})
|
|
197
|
+
render(
|
|
198
|
+
<ResourceProvider projectId="p" dataset="d" auth={{oauth}} fallback={null}>
|
|
199
|
+
<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
|
|
200
|
+
</ResourceProvider>,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
await waitFor(() => expect(authorize).toHaveBeenCalledTimes(1))
|
|
204
|
+
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument()
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('does not start the OAuth flow when logged out without oauth config', async () => {
|
|
208
|
+
const authorize = vi.fn().mockResolvedValue(undefined)
|
|
209
|
+
vi.mocked(useOAuthAuthorize).mockReturnValue(authorize)
|
|
210
|
+
vi.mocked(useAuthState).mockReturnValue({
|
|
211
|
+
type: AuthStateType.LOGGED_OUT,
|
|
212
|
+
isDestroyingSession: false,
|
|
213
|
+
})
|
|
214
|
+
render(
|
|
215
|
+
<ResourceProvider projectId="p" dataset="d" fallback={null}>
|
|
216
|
+
<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
|
|
217
|
+
</ResourceProvider>,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
await waitFor(() => expect(screen.queryByText('Protected Content')).not.toBeInTheDocument())
|
|
221
|
+
expect(authorize).not.toHaveBeenCalled()
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('renders the error fallback when starting the OAuth flow rejects', async () => {
|
|
225
|
+
// A falsy rejection reason must still surface as an error, not a blank screen.
|
|
226
|
+
vi.mocked(useOAuthAuthorize).mockReturnValue(vi.fn().mockRejectedValue(undefined))
|
|
227
|
+
vi.mocked(useAuthState).mockReturnValue({
|
|
228
|
+
type: AuthStateType.LOGGED_OUT,
|
|
229
|
+
isDestroyingSession: false,
|
|
230
|
+
})
|
|
231
|
+
render(
|
|
232
|
+
<ResourceProvider projectId="p" dataset="d" auth={{oauth}} fallback={null}>
|
|
233
|
+
<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
|
|
234
|
+
</ResourceProvider>,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
await waitFor(() => expect(screen.getByText('Authentication Error')).toBeInTheDocument())
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('renders the error fallback without restarting the flow when authState="error"', async () => {
|
|
241
|
+
const authorize = vi.fn().mockResolvedValue(undefined)
|
|
242
|
+
vi.mocked(useOAuthAuthorize).mockReturnValue(authorize)
|
|
243
|
+
vi.mocked(useAuthState).mockReturnValue({
|
|
244
|
+
type: AuthStateType.ERROR,
|
|
245
|
+
error: new Error('access_denied'),
|
|
246
|
+
})
|
|
247
|
+
render(
|
|
248
|
+
<ResourceProvider projectId="p" dataset="d" auth={{oauth}} fallback={null}>
|
|
249
|
+
<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
|
|
250
|
+
</ResourceProvider>,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
await waitFor(() => expect(screen.getByText('Authentication Error')).toBeInTheDocument())
|
|
254
|
+
expect(authorize).not.toHaveBeenCalled()
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
|
|
145
258
|
it('renders the empty LoginCallback component when authState="logging-in"', () => {
|
|
146
259
|
vi.mocked(useAuthState).mockReturnValue({
|
|
147
260
|
type: AuthStateType.LOGGING_IN,
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import {CorsOriginError} from '@sanity/client'
|
|
2
2
|
import {AuthStateType, getCorsErrorProjectId, isImportError} from '@sanity/sdk'
|
|
3
|
-
import {isStudioConfig} from '@sanity/sdk/_internal'
|
|
4
|
-
import {useEffect, useMemo} from 'react'
|
|
3
|
+
import {isDashboardEnvironment, isStudioConfig} from '@sanity/sdk/_internal'
|
|
4
|
+
import {useEffect, useMemo, useState} from 'react'
|
|
5
5
|
import {ErrorBoundary, type FallbackProps} from 'react-error-boundary'
|
|
6
6
|
|
|
7
7
|
import {ComlinkTokenRefreshProvider} from '../../context/ComlinkTokenRefresh'
|
|
8
|
-
import {isDashboardEnvironment} from '../../context/dashboardToken'
|
|
9
8
|
import {DashboardTokenRefreshProvider} from '../../context/DashboardTokenRefresh'
|
|
10
9
|
import {useAuthState} from '../../hooks/auth/useAuthState'
|
|
11
10
|
import {useLoginUrl} from '../../hooks/auth/useLoginUrl'
|
|
11
|
+
import {useOAuthAuthorize} from '../../hooks/auth/useOAuthAuthorize'
|
|
12
12
|
import {useVerifyOrgProjects} from '../../hooks/auth/useVerifyOrgProjects'
|
|
13
13
|
import {useSanityInstance} from '../../hooks/context/useSanityInstance'
|
|
14
14
|
import {ChunkLoadError} from '../errors/ChunkLoadError'
|
|
@@ -184,21 +184,34 @@ function AuthSwitch({
|
|
|
184
184
|
const orgError = useVerifyOrgProjects(disableVerifyOrg, projectIds)
|
|
185
185
|
|
|
186
186
|
const isLoggedOut = authState.type === AuthStateType.LOGGED_OUT && !authState.isDestroyingSession
|
|
187
|
+
const isOAuth = !!instance.config.auth?.oauth
|
|
187
188
|
const loginUrl = useLoginUrl()
|
|
189
|
+
const authorize = useOAuthAuthorize()
|
|
190
|
+
const [authorizeError, setAuthorizeError] = useState<{error: unknown} | null>(null)
|
|
188
191
|
|
|
189
192
|
useEffect(() => {
|
|
190
193
|
if (isLoggedOut && !isInIframe() && !isStudio && !isDashboardEnvironment()) {
|
|
191
194
|
// We don't want to redirect to login if we're in the Dashboard, in studio
|
|
192
195
|
// mode, or in the workbench (the OS owns the session and mints the token)
|
|
193
|
-
|
|
196
|
+
if (isOAuth) {
|
|
197
|
+
// PKCE params and navigation are owned by core. LOGGED_OUT renders
|
|
198
|
+
// null, so a rejection here must be surfaced or the user sees nothing.
|
|
199
|
+
authorize().catch((error) => setAuthorizeError({error}))
|
|
200
|
+
} else {
|
|
201
|
+
window.location.href = loginUrl
|
|
202
|
+
}
|
|
194
203
|
}
|
|
195
|
-
}, [isLoggedOut, loginUrl, isStudio])
|
|
204
|
+
}, [isLoggedOut, isOAuth, authorize, loginUrl, isStudio])
|
|
196
205
|
|
|
197
206
|
// Only check the error if verification is enabled
|
|
198
207
|
if (verifyOrganization && orgError) {
|
|
199
208
|
throw new ConfigurationError({message: orgError})
|
|
200
209
|
}
|
|
201
210
|
|
|
211
|
+
if (authorizeError) {
|
|
212
|
+
throw new AuthError(authorizeError.error)
|
|
213
|
+
}
|
|
214
|
+
|
|
202
215
|
switch (authState.type) {
|
|
203
216
|
case AuthStateType.ERROR: {
|
|
204
217
|
throw new AuthError(authState.error)
|
|
@@ -9,7 +9,14 @@ vi.mock('../../hooks/auth/useHandleAuthCallback', () => ({
|
|
|
9
9
|
const parsedUrl = new URL(url)
|
|
10
10
|
const sid = new URLSearchParams(parsedUrl.hash.slice(1)).get('sid')
|
|
11
11
|
if (sid === 'valid') {
|
|
12
|
-
|
|
12
|
+
// same document, hash stripped
|
|
13
|
+
return 'http://localhost/'
|
|
14
|
+
}
|
|
15
|
+
if (sid === 'deep-link') {
|
|
16
|
+
return 'http://localhost/documents/abc?x=1'
|
|
17
|
+
}
|
|
18
|
+
if (sid === 'cross-origin') {
|
|
19
|
+
return 'https://evil.example.com/'
|
|
13
20
|
}
|
|
14
21
|
return false
|
|
15
22
|
}),
|
|
@@ -48,7 +55,25 @@ describe('LoginCallback', () => {
|
|
|
48
55
|
|
|
49
56
|
it('handles a successful callback and calls history.replaceState', async () => {
|
|
50
57
|
// Simulate a valid `sid` in the location hash
|
|
51
|
-
vi.
|
|
58
|
+
const replace = vi.fn()
|
|
59
|
+
vi.stubGlobal('location', {href: 'http://localhost/#sid=valid', replace})
|
|
60
|
+
const {LoginCallback} = await import('./LoginCallback') // Reload after resetModules
|
|
61
|
+
|
|
62
|
+
render(
|
|
63
|
+
<ResourceProvider fallback={null}>
|
|
64
|
+
<LoginCallback />
|
|
65
|
+
</ResourceProvider>,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
await waitFor(() => {
|
|
69
|
+
expect(history.replaceState).toHaveBeenCalledWith(null, '', 'http://localhost/')
|
|
70
|
+
})
|
|
71
|
+
expect(replace).not.toHaveBeenCalled()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('navigates when the callback resolves to a different route', async () => {
|
|
75
|
+
const replace = vi.fn()
|
|
76
|
+
vi.stubGlobal('location', {href: 'http://localhost/#sid=deep-link', replace})
|
|
52
77
|
const {LoginCallback} = await import('./LoginCallback') // Reload after resetModules
|
|
53
78
|
|
|
54
79
|
render(
|
|
@@ -58,11 +83,25 @@ describe('LoginCallback', () => {
|
|
|
58
83
|
)
|
|
59
84
|
|
|
60
85
|
await waitFor(() => {
|
|
61
|
-
expect(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
86
|
+
expect(replace).toHaveBeenCalledWith('http://localhost/documents/abc?x=1')
|
|
87
|
+
})
|
|
88
|
+
expect(history.replaceState).not.toHaveBeenCalled()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('does not navigate when the callback resolves to a different origin', async () => {
|
|
92
|
+
const replace = vi.fn()
|
|
93
|
+
vi.stubGlobal('location', {href: 'http://localhost/#sid=cross-origin', replace})
|
|
94
|
+
const {LoginCallback} = await import('./LoginCallback') // Reload after resetModules
|
|
95
|
+
|
|
96
|
+
render(
|
|
97
|
+
<ResourceProvider fallback={null}>
|
|
98
|
+
<LoginCallback />
|
|
99
|
+
</ResourceProvider>,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
await waitFor(() => {
|
|
103
|
+
expect(replace).not.toHaveBeenCalled()
|
|
104
|
+
expect(history.replaceState).not.toHaveBeenCalled()
|
|
66
105
|
})
|
|
67
106
|
})
|
|
68
107
|
|
|
@@ -5,7 +5,15 @@ import {useHandleAuthCallback} from '../../hooks/auth/useHandleAuthCallback'
|
|
|
5
5
|
/**
|
|
6
6
|
* Component shown during auth callback processing that handles login completion.
|
|
7
7
|
* Automatically processes the auth callback when mounted and updates the URL
|
|
8
|
-
* to remove callback parameters without triggering a page reload.
|
|
8
|
+
* to remove callback parameters without triggering a page reload. When the
|
|
9
|
+
* callback resolves to a different route (the OAuth flow returns the user to
|
|
10
|
+
* where they started), a real navigation is performed instead so the app's
|
|
11
|
+
* router picks it up.
|
|
12
|
+
*
|
|
13
|
+
* A different route is detected by pathname only, so apps that route in the
|
|
14
|
+
* hash (`#/documents/abc`) will not be navigated to the deep link. Those apps
|
|
15
|
+
* should build a custom callback component with `useHandleOAuthCallback` and
|
|
16
|
+
* their router's `navigate`.
|
|
9
17
|
*
|
|
10
18
|
* @alpha
|
|
11
19
|
*/
|
|
@@ -15,10 +23,20 @@ export function LoginCallback(): React.ReactNode {
|
|
|
15
23
|
useEffect(() => {
|
|
16
24
|
const url = new URL(location.href)
|
|
17
25
|
handleAuthCallback(url.toString()).then((replacementLocation) => {
|
|
18
|
-
if (replacementLocation)
|
|
19
|
-
|
|
20
|
-
|
|
26
|
+
if (!replacementLocation) return
|
|
27
|
+
const next = new URL(replacementLocation, url)
|
|
28
|
+
// Core only returns same-origin locations; guard here too since this is
|
|
29
|
+
// the code that navigates.
|
|
30
|
+
if (next.origin !== url.origin) return
|
|
31
|
+
if (next.pathname === url.pathname) {
|
|
32
|
+
// Same document: `replaceState` strips the callback params without a
|
|
33
|
+
// reload. Routers do not observe this, which is fine when only the
|
|
34
|
+
// query/hash changed. Caveat: a same-path return with different app
|
|
35
|
+
// query params won't re-run router search-param hooks until the next
|
|
36
|
+
// navigation.
|
|
21
37
|
history.replaceState(null, '', replacementLocation)
|
|
38
|
+
} else {
|
|
39
|
+
location.replace(replacementLocation)
|
|
22
40
|
}
|
|
23
41
|
})
|
|
24
42
|
}, [handleAuthCallback])
|
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
import {AuthStateType, setAuthToken} from '@sanity/sdk'
|
|
2
|
+
import {getDashboardMessageBus} from '@sanity/sdk/_internal'
|
|
2
3
|
import {act, render} from '@testing-library/react'
|
|
3
4
|
import {of} from 'rxjs'
|
|
4
5
|
import {afterEach, beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
|
|
5
6
|
|
|
7
|
+
import {getDashboardModuleContext} from '../dashboard/module'
|
|
6
8
|
import {useAuthState} from '../hooks/auth/useAuthState'
|
|
7
|
-
import {
|
|
8
|
-
isDashboardEnvironment,
|
|
9
|
-
observeDashboardToken,
|
|
10
|
-
refreshDashboardToken,
|
|
11
|
-
} from './dashboardToken'
|
|
9
|
+
import {useSanityInstance} from '../hooks/context/useSanityInstance'
|
|
12
10
|
import {DashboardTokenRefreshProvider} from './DashboardTokenRefresh'
|
|
13
11
|
import {ResourceProvider} from './ResourceProvider'
|
|
14
12
|
|
|
13
|
+
const messageBus = vi.hoisted(() => ({
|
|
14
|
+
client: undefined as
|
|
15
|
+
| undefined
|
|
16
|
+
| {emit: ReturnType<typeof vi.fn>; subscribe: ReturnType<typeof vi.fn>},
|
|
17
|
+
emit: vi.fn(),
|
|
18
|
+
subscribe: vi.fn(),
|
|
19
|
+
// A spy so tests can observe the forwarded arguments.
|
|
20
|
+
getDashboardMessageBus: vi.fn(),
|
|
21
|
+
}))
|
|
22
|
+
|
|
15
23
|
vi.mock('@sanity/sdk', async () => {
|
|
16
24
|
const actual = await vi.importActual('@sanity/sdk')
|
|
17
25
|
return {
|
|
@@ -24,17 +32,13 @@ vi.mock('../hooks/auth/useAuthState', () => ({
|
|
|
24
32
|
useAuthState: vi.fn(),
|
|
25
33
|
}))
|
|
26
34
|
|
|
27
|
-
vi.mock('
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
refreshDashboardToken: vi.fn(),
|
|
35
|
+
vi.mock('@sanity/sdk/_internal', async (importOriginal) => ({
|
|
36
|
+
...(await importOriginal<typeof import('@sanity/sdk/_internal')>()),
|
|
37
|
+
getDashboardMessageBus: messageBus.getDashboardMessageBus,
|
|
31
38
|
}))
|
|
32
39
|
|
|
33
40
|
const mockSetAuthToken = setAuthToken as Mock
|
|
34
41
|
const mockUseAuthState = useAuthState as Mock
|
|
35
|
-
const mockIsDashboardEnvironment = isDashboardEnvironment as Mock
|
|
36
|
-
const mockObserveDashboardToken = observeDashboardToken as Mock
|
|
37
|
-
const mockRefreshDashboardToken = refreshDashboardToken as Mock
|
|
38
42
|
|
|
39
43
|
const renderProvider = () =>
|
|
40
44
|
render(
|
|
@@ -47,6 +51,9 @@ const renderProvider = () =>
|
|
|
47
51
|
|
|
48
52
|
describe('DashboardTokenRefreshProvider', () => {
|
|
49
53
|
beforeEach(() => {
|
|
54
|
+
messageBus.client = undefined
|
|
55
|
+
messageBus.getDashboardMessageBus.mockReset()
|
|
56
|
+
messageBus.getDashboardMessageBus.mockImplementation(() => messageBus.client)
|
|
50
57
|
mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_IN})
|
|
51
58
|
})
|
|
52
59
|
|
|
@@ -55,9 +62,7 @@ describe('DashboardTokenRefreshProvider', () => {
|
|
|
55
62
|
})
|
|
56
63
|
|
|
57
64
|
describe('when not in the dashboard', () => {
|
|
58
|
-
it('does not subscribe to
|
|
59
|
-
mockIsDashboardEnvironment.mockReturnValue(false)
|
|
60
|
-
|
|
65
|
+
it('does not subscribe to a dashboard token', () => {
|
|
61
66
|
act(() => {
|
|
62
67
|
renderProvider()
|
|
63
68
|
})
|
|
@@ -68,12 +73,13 @@ describe('DashboardTokenRefreshProvider', () => {
|
|
|
68
73
|
|
|
69
74
|
describe('when in the dashboard', () => {
|
|
70
75
|
beforeEach(() => {
|
|
71
|
-
|
|
76
|
+
messageBus.client = messageBus
|
|
77
|
+
messageBus.subscribe.mockReturnValue(of('dashboard-token'))
|
|
78
|
+
// `emit` returns a lazily-awaited reply the provider attaches a `.catch` to.
|
|
79
|
+
messageBus.emit.mockReturnValue(Promise.resolve(undefined))
|
|
72
80
|
})
|
|
73
81
|
|
|
74
|
-
it('mirrors the
|
|
75
|
-
mockObserveDashboardToken.mockReturnValue(of('dashboard-token'))
|
|
76
|
-
|
|
82
|
+
it('mirrors the dashboard token into the auth store', () => {
|
|
77
83
|
act(() => {
|
|
78
84
|
renderProvider()
|
|
79
85
|
})
|
|
@@ -81,9 +87,80 @@ describe('DashboardTokenRefreshProvider', () => {
|
|
|
81
87
|
expect(mockSetAuthToken).toHaveBeenCalledWith(expect.anything(), 'dashboard-token')
|
|
82
88
|
})
|
|
83
89
|
|
|
84
|
-
it('
|
|
85
|
-
|
|
90
|
+
it('renders children bare when no host bus is installed', () => {
|
|
91
|
+
messageBus.client = undefined
|
|
92
|
+
|
|
93
|
+
act(() => {
|
|
94
|
+
renderProvider()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
expect(messageBus.getDashboardMessageBus).toHaveBeenCalledTimes(1)
|
|
98
|
+
expect(mockSetAuthToken).not.toHaveBeenCalled()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('connects with the module id before any child reads the bus during render', () => {
|
|
102
|
+
// getDashboardMessageBus is first-caller-wins per instance. A hook reading the bus in
|
|
103
|
+
// its render runs before any parent effect, so the provider must connect during render
|
|
104
|
+
// or the connection is pinned to the app id.
|
|
105
|
+
const ModuleContext = getDashboardModuleContext()
|
|
106
|
+
function ReadsBus() {
|
|
107
|
+
getDashboardMessageBus(useSanityInstance())
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
act(() => {
|
|
112
|
+
render(
|
|
113
|
+
<ModuleContext.Provider value="favorites/views/list/panel">
|
|
114
|
+
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
115
|
+
<DashboardTokenRefreshProvider>
|
|
116
|
+
<ReadsBus />
|
|
117
|
+
</DashboardTokenRefreshProvider>
|
|
118
|
+
</ResourceProvider>
|
|
119
|
+
</ModuleContext.Provider>,
|
|
120
|
+
)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
expect(messageBus.getDashboardMessageBus.mock.calls[0]).toEqual([
|
|
124
|
+
expect.anything(),
|
|
125
|
+
'favorites/views/list/panel',
|
|
126
|
+
])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('forwards the module id from the dashboard module context', () => {
|
|
130
|
+
const ModuleContext = getDashboardModuleContext()
|
|
131
|
+
|
|
132
|
+
act(() => {
|
|
133
|
+
render(
|
|
134
|
+
<ModuleContext.Provider value="favorites/views/list/panel">
|
|
135
|
+
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
136
|
+
<DashboardTokenRefreshProvider>
|
|
137
|
+
<div>Test</div>
|
|
138
|
+
</DashboardTokenRefreshProvider>
|
|
139
|
+
</ResourceProvider>
|
|
140
|
+
</ModuleContext.Provider>,
|
|
141
|
+
)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
expect(messageBus.getDashboardMessageBus).toHaveBeenCalledWith(
|
|
145
|
+
expect.anything(),
|
|
146
|
+
'favorites/views/list/panel',
|
|
147
|
+
)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('treats subscription failures as a missing token', () => {
|
|
151
|
+
messageBus.subscribe.mockImplementationOnce(() => {
|
|
152
|
+
throw new Error('Incompatible message bus')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
expect(() => {
|
|
156
|
+
act(() => {
|
|
157
|
+
renderProvider()
|
|
158
|
+
})
|
|
159
|
+
}).not.toThrow()
|
|
160
|
+
expect(mockSetAuthToken).toHaveBeenCalledWith(expect.anything(), null)
|
|
161
|
+
})
|
|
86
162
|
|
|
163
|
+
it('asks the message bus to reissue the token on a 401', () => {
|
|
87
164
|
const {rerender} = renderProvider()
|
|
88
165
|
|
|
89
166
|
mockUseAuthState.mockReturnValue({
|
|
@@ -100,7 +177,32 @@ describe('DashboardTokenRefreshProvider', () => {
|
|
|
100
177
|
)
|
|
101
178
|
})
|
|
102
179
|
|
|
103
|
-
expect(
|
|
180
|
+
expect(messageBus.emit).toHaveBeenCalledWith('auth.token.refresh', undefined)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('logs a failed token reissue instead of dropping it silently', async () => {
|
|
184
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
185
|
+
messageBus.emit.mockReturnValueOnce(Promise.reject(new Error('NO_RESPONDER')))
|
|
186
|
+
const {rerender} = renderProvider()
|
|
187
|
+
|
|
188
|
+
mockUseAuthState.mockReturnValue({
|
|
189
|
+
type: AuthStateType.ERROR,
|
|
190
|
+
error: {statusCode: 401, message: 'Unauthorized'},
|
|
191
|
+
})
|
|
192
|
+
await act(async () => {
|
|
193
|
+
rerender(
|
|
194
|
+
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
195
|
+
<DashboardTokenRefreshProvider>
|
|
196
|
+
<div>Test</div>
|
|
197
|
+
</DashboardTokenRefreshProvider>
|
|
198
|
+
</ResourceProvider>,
|
|
199
|
+
)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
expect(warn).toHaveBeenCalledWith(
|
|
203
|
+
'[sanity/sdk] Dashboard token refresh failed:',
|
|
204
|
+
expect.any(Error),
|
|
205
|
+
)
|
|
104
206
|
})
|
|
105
207
|
})
|
|
106
208
|
})
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import {type ClientError} from '@sanity/client'
|
|
2
2
|
import {AuthStateType, setAuthToken} from '@sanity/sdk'
|
|
3
|
-
import
|
|
3
|
+
import {getDashboardMessageBus} from '@sanity/sdk/_internal'
|
|
4
|
+
import {type MessageBus} from '@sanity/sdk/dashboard'
|
|
5
|
+
import React, {type PropsWithChildren, useContext, useEffect, useRef, useState} from 'react'
|
|
6
|
+
import {defer, of} from 'rxjs'
|
|
7
|
+
import {catchError} from 'rxjs/operators'
|
|
4
8
|
|
|
9
|
+
import {getDashboardModuleContext} from '../dashboard/module'
|
|
5
10
|
import {useAuthState} from '../hooks/auth/useAuthState'
|
|
6
11
|
import {useSanityInstance} from '../hooks/context/useSanityInstance'
|
|
7
|
-
import {
|
|
8
|
-
isDashboardEnvironment,
|
|
9
|
-
observeDashboardToken,
|
|
10
|
-
refreshDashboardToken,
|
|
11
|
-
} from './dashboardToken'
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Keeps the SDK auth token in sync with the dashboard "OS".
|
|
@@ -20,17 +20,20 @@ import {
|
|
|
20
20
|
* (the token expired), we ask the OS to reissue rather than tearing the session
|
|
21
21
|
* down; the new token arrives back through the same subscription.
|
|
22
22
|
*/
|
|
23
|
-
function DashboardTokenRefresh({
|
|
23
|
+
function DashboardTokenRefresh({
|
|
24
|
+
children,
|
|
25
|
+
messageBus,
|
|
26
|
+
}: PropsWithChildren<{messageBus: MessageBus}>) {
|
|
24
27
|
const instance = useSanityInstance()
|
|
25
28
|
const authState = useAuthState()
|
|
26
29
|
const processed401ErrorRef = useRef<unknown | null>(null)
|
|
27
30
|
|
|
28
31
|
useEffect(() => {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
const subscription = defer(() => messageBus.subscribe('auth.token'))
|
|
33
|
+
.pipe(catchError(() => of(null)))
|
|
34
|
+
.subscribe((token) => setAuthToken(instance, token))
|
|
32
35
|
return () => subscription.unsubscribe()
|
|
33
|
-
}, [instance])
|
|
36
|
+
}, [instance, messageBus])
|
|
34
37
|
|
|
35
38
|
useEffect(() => {
|
|
36
39
|
const has401Error =
|
|
@@ -38,11 +41,15 @@ function DashboardTokenRefresh({children}: PropsWithChildren) {
|
|
|
38
41
|
|
|
39
42
|
if (has401Error && processed401ErrorRef.current !== authState.error) {
|
|
40
43
|
processed401ErrorRef.current = authState.error
|
|
41
|
-
|
|
44
|
+
// Event topics have no replay, so a missing responder or timeout is otherwise dropped silently.
|
|
45
|
+
messageBus.emit('auth.token.refresh', undefined).catch((error) => {
|
|
46
|
+
// eslint-disable-next-line no-console
|
|
47
|
+
console.warn('[sanity/sdk] Dashboard token refresh failed:', error)
|
|
48
|
+
})
|
|
42
49
|
} else if (!has401Error) {
|
|
43
50
|
processed401ErrorRef.current = null
|
|
44
51
|
}
|
|
45
|
-
}, [authState])
|
|
52
|
+
}, [authState, messageBus])
|
|
46
53
|
|
|
47
54
|
return children
|
|
48
55
|
}
|
|
@@ -87,8 +94,17 @@ function DashboardTokenRefresh({children}: PropsWithChildren) {
|
|
|
87
94
|
* @public
|
|
88
95
|
*/
|
|
89
96
|
export const DashboardTokenRefreshProvider: React.FC<PropsWithChildren> = ({children}) => {
|
|
90
|
-
|
|
91
|
-
|
|
97
|
+
const instance = useSanityInstance()
|
|
98
|
+
const moduleId = useContext(getDashboardModuleContext())
|
|
99
|
+
// The connection is first-caller-wins per instance, and hooks below read it during their
|
|
100
|
+
// render, before any effect here could run. Connecting in the first render pins the module
|
|
101
|
+
// identity before they do; nothing has subscribed to the store yet, so the write is safe.
|
|
102
|
+
// The module id is read once: the CLI wrapper provides it statically above this tree.
|
|
103
|
+
// No retry: the host installs the bus at module evaluation, before any remote renders, and
|
|
104
|
+
// a standalone app has no host to wait for.
|
|
105
|
+
const [messageBus] = useState(() => getDashboardMessageBus(instance, moduleId))
|
|
106
|
+
if (messageBus) {
|
|
107
|
+
return <DashboardTokenRefresh messageBus={messageBus}>{children}</DashboardTokenRefresh>
|
|
92
108
|
}
|
|
93
109
|
|
|
94
110
|
return children
|