@sanity/sdk-react 1.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.d.ts +60 -9
- package/dist/index.js +145 -77
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/_exports/sdk-react.ts +2 -0
- package/src/components/auth/AuthBoundary.test.tsx +30 -19
- package/src/components/auth/AuthBoundary.tsx +6 -3
- package/src/components/auth/LoginError.tsx +13 -5
- package/src/context/ComlinkTokenRefresh.test.tsx +221 -0
- package/src/context/ComlinkTokenRefresh.tsx +125 -0
- package/src/hooks/dashboard/useDashboardNavigate.test.ts +44 -0
- package/src/hooks/dashboard/useDashboardNavigate.ts +60 -0
- package/src/hooks/dashboard/useManageFavorite.ts +1 -3
- package/src/hooks/dashboard/useNavigateToStudioDocument.ts +1 -3
- package/src/hooks/dashboard/useRecordDocumentHistoryEvent.ts +1 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanity/sdk-react",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Sanity SDK React toolkit for Content OS",
|
|
6
6
|
"keywords": [
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"browserslist": "extends @sanity/browserslist-config",
|
|
43
43
|
"prettier": "@sanity/prettier-config",
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@sanity/client": "^7.
|
|
45
|
+
"@sanity/client": "^7.2.1",
|
|
46
46
|
"@sanity/message-protocol": "^0.12.0",
|
|
47
47
|
"@sanity/types": "^3.83.0",
|
|
48
48
|
"@types/lodash-es": "^4.17.12",
|
|
@@ -51,11 +51,11 @@
|
|
|
51
51
|
"react-compiler-runtime": "19.1.0-rc.1",
|
|
52
52
|
"react-error-boundary": "^5.0.0",
|
|
53
53
|
"rxjs": "^7.8.2",
|
|
54
|
-
"@sanity/sdk": "
|
|
54
|
+
"@sanity/sdk": "2.0.1"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@sanity/browserslist-config": "^1.0.5",
|
|
58
|
-
"@sanity/comlink": "^3.0.
|
|
58
|
+
"@sanity/comlink": "^3.0.4",
|
|
59
59
|
"@sanity/pkg-utils": "^7.2.2",
|
|
60
60
|
"@sanity/prettier-config": "^1.0.3",
|
|
61
61
|
"@testing-library/jest-dom": "^6.6.3",
|
|
@@ -74,11 +74,11 @@
|
|
|
74
74
|
"typescript": "^5.8.3",
|
|
75
75
|
"vite": "^6.3.4",
|
|
76
76
|
"vitest": "^3.1.2",
|
|
77
|
-
"@repo/config-test": "0.0.1",
|
|
78
|
-
"@repo/package.bundle": "3.82.0",
|
|
79
77
|
"@repo/package.config": "0.0.1",
|
|
80
78
|
"@repo/tsconfig": "0.0.1",
|
|
81
|
-
"@repo/config-eslint": "0.0.0"
|
|
79
|
+
"@repo/config-eslint": "0.0.0",
|
|
80
|
+
"@repo/config-test": "0.0.1",
|
|
81
|
+
"@repo/package.bundle": "3.82.0"
|
|
82
82
|
},
|
|
83
83
|
"peerDependencies": {
|
|
84
84
|
"react": "^18.0.0 || ^19.0.0",
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
export {AuthBoundary, type AuthBoundaryProps} from '../components/auth/AuthBoundary'
|
|
5
5
|
export {SanityApp, type SanityAppProps} from '../components/SanityApp'
|
|
6
6
|
export {SDKProvider, type SDKProviderProps} from '../components/SDKProvider'
|
|
7
|
+
export {ComlinkTokenRefreshProvider} from '../context/ComlinkTokenRefresh'
|
|
7
8
|
export {ResourceProvider, type ResourceProviderProps} from '../context/ResourceProvider'
|
|
8
9
|
export {useAuthState} from '../hooks/auth/useAuthState'
|
|
9
10
|
export {useAuthToken} from '../hooks/auth/useAuthToken'
|
|
@@ -27,6 +28,7 @@ export {
|
|
|
27
28
|
type WindowMessageHandler,
|
|
28
29
|
} from '../hooks/comlink/useWindowConnection'
|
|
29
30
|
export {useSanityInstance} from '../hooks/context/useSanityInstance'
|
|
31
|
+
export {useDashboardNavigate} from '../hooks/dashboard/useDashboardNavigate'
|
|
30
32
|
export {useManageFavorite} from '../hooks/dashboard/useManageFavorite'
|
|
31
33
|
export {
|
|
32
34
|
type NavigateToStudioResult,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {AuthStateType} from '@sanity/sdk'
|
|
1
|
+
import {AuthStateType, type SanityConfig} from '@sanity/sdk'
|
|
2
2
|
import {render, screen, waitFor} from '@testing-library/react'
|
|
3
3
|
import React from 'react'
|
|
4
4
|
import {type FallbackProps} from 'react-error-boundary'
|
|
@@ -8,6 +8,7 @@ import {ResourceProvider} from '../../context/ResourceProvider'
|
|
|
8
8
|
import {useAuthState} from '../../hooks/auth/useAuthState'
|
|
9
9
|
import {useLoginUrl} from '../../hooks/auth/useLoginUrl'
|
|
10
10
|
import {useVerifyOrgProjects} from '../../hooks/auth/useVerifyOrgProjects'
|
|
11
|
+
import {useSanityInstance} from '../../hooks/context/useSanityInstance'
|
|
11
12
|
import {AuthBoundary} from './AuthBoundary'
|
|
12
13
|
|
|
13
14
|
// Mock hooks
|
|
@@ -22,6 +23,9 @@ vi.mock('../../hooks/auth/useHandleAuthCallback', () => ({
|
|
|
22
23
|
vi.mock('../../hooks/auth/useLogOut', () => ({
|
|
23
24
|
useLogOut: vi.fn(() => async () => {}),
|
|
24
25
|
}))
|
|
26
|
+
vi.mock('../../hooks/context/useSanityInstance', () => ({
|
|
27
|
+
useSanityInstance: vi.fn(),
|
|
28
|
+
}))
|
|
25
29
|
|
|
26
30
|
// Mock AuthError throwing scenario
|
|
27
31
|
vi.mock('./AuthError', async (importOriginal) => {
|
|
@@ -105,8 +109,27 @@ describe('AuthBoundary', () => {
|
|
|
105
109
|
const mockUseAuthState = vi.mocked(useAuthState)
|
|
106
110
|
const mockUseLoginUrl = vi.mocked(useLoginUrl)
|
|
107
111
|
const mockUseVerifyOrgProjects = vi.mocked(useVerifyOrgProjects)
|
|
112
|
+
const mockUseSanityInstance = vi.mocked(useSanityInstance)
|
|
108
113
|
const testProjectIds = ['proj-test'] // Example project ID for tests
|
|
109
114
|
|
|
115
|
+
// Mock Sanity instance
|
|
116
|
+
const mockSanityInstance = {
|
|
117
|
+
instanceId: 'test-instance-id',
|
|
118
|
+
config: {
|
|
119
|
+
projectId: 'test-project',
|
|
120
|
+
dataset: 'test-dataset',
|
|
121
|
+
},
|
|
122
|
+
isDisposed: () => false,
|
|
123
|
+
dispose: () => {},
|
|
124
|
+
onDispose: () => () => {},
|
|
125
|
+
getParent: () => undefined,
|
|
126
|
+
createChild: (config: SanityConfig) => ({
|
|
127
|
+
...mockSanityInstance,
|
|
128
|
+
config: {...mockSanityInstance.config, ...config},
|
|
129
|
+
}),
|
|
130
|
+
match: () => undefined,
|
|
131
|
+
}
|
|
132
|
+
|
|
110
133
|
beforeEach(() => {
|
|
111
134
|
vi.clearAllMocks()
|
|
112
135
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
@@ -116,6 +139,8 @@ describe('AuthBoundary', () => {
|
|
|
116
139
|
mockUseLoginUrl.mockReturnValue('http://example.com/login')
|
|
117
140
|
// Default mock for useVerifyOrgProjects - returns null (no error)
|
|
118
141
|
mockUseVerifyOrgProjects.mockImplementation(() => null)
|
|
142
|
+
// Mock useSanityInstance to return our mock instance
|
|
143
|
+
mockUseSanityInstance.mockReturnValue(mockSanityInstance)
|
|
119
144
|
})
|
|
120
145
|
|
|
121
146
|
afterEach(() => {
|
|
@@ -145,9 +170,7 @@ describe('AuthBoundary', () => {
|
|
|
145
170
|
isExchangingToken: false,
|
|
146
171
|
})
|
|
147
172
|
const {container} = render(
|
|
148
|
-
<
|
|
149
|
-
<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
|
|
150
|
-
</ResourceProvider>,
|
|
173
|
+
<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>,
|
|
151
174
|
)
|
|
152
175
|
|
|
153
176
|
// The callback screen renders null check that it renders nothing
|
|
@@ -161,11 +184,7 @@ describe('AuthBoundary', () => {
|
|
|
161
184
|
currentUser: null,
|
|
162
185
|
token: 'exampleToken',
|
|
163
186
|
})
|
|
164
|
-
render(
|
|
165
|
-
<ResourceProvider fallback={null}>
|
|
166
|
-
<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
|
|
167
|
-
</ResourceProvider>,
|
|
168
|
-
)
|
|
187
|
+
render(<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>)
|
|
169
188
|
|
|
170
189
|
expect(screen.getByText('Protected Content')).toBeInTheDocument()
|
|
171
190
|
})
|
|
@@ -175,11 +194,7 @@ describe('AuthBoundary', () => {
|
|
|
175
194
|
type: AuthStateType.ERROR,
|
|
176
195
|
error: new Error('test error'),
|
|
177
196
|
})
|
|
178
|
-
render(
|
|
179
|
-
<ResourceProvider fallback={null}>
|
|
180
|
-
<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
|
|
181
|
-
</ResourceProvider>,
|
|
182
|
-
)
|
|
197
|
+
render(<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>)
|
|
183
198
|
|
|
184
199
|
// The AuthBoundary should throw an AuthError internally
|
|
185
200
|
// and then display the LoginError component as the fallback.
|
|
@@ -192,11 +207,7 @@ describe('AuthBoundary', () => {
|
|
|
192
207
|
})
|
|
193
208
|
|
|
194
209
|
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
|
-
)
|
|
210
|
+
render(<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>)
|
|
200
211
|
expect(screen.getByText('Protected Content')).toBeInTheDocument()
|
|
201
212
|
})
|
|
202
213
|
|
|
@@ -2,6 +2,7 @@ import {AuthStateType} from '@sanity/sdk'
|
|
|
2
2
|
import {useEffect, useMemo} from 'react'
|
|
3
3
|
import {ErrorBoundary, type FallbackProps} from 'react-error-boundary'
|
|
4
4
|
|
|
5
|
+
import {ComlinkTokenRefreshProvider} from '../../context/ComlinkTokenRefresh'
|
|
5
6
|
import {useAuthState} from '../../hooks/auth/useAuthState'
|
|
6
7
|
import {useLoginUrl} from '../../hooks/auth/useLoginUrl'
|
|
7
8
|
import {useVerifyOrgProjects} from '../../hooks/auth/useVerifyOrgProjects'
|
|
@@ -111,9 +112,11 @@ export function AuthBoundary({
|
|
|
111
112
|
}, [LoginErrorComponent])
|
|
112
113
|
|
|
113
114
|
return (
|
|
114
|
-
<
|
|
115
|
-
<
|
|
116
|
-
|
|
115
|
+
<ComlinkTokenRefreshProvider>
|
|
116
|
+
<ErrorBoundary FallbackComponent={FallbackComponent}>
|
|
117
|
+
<AuthSwitch {...props} />
|
|
118
|
+
</ErrorBoundary>
|
|
119
|
+
</ComlinkTokenRefreshProvider>
|
|
117
120
|
)
|
|
118
121
|
}
|
|
119
122
|
|
|
@@ -19,7 +19,15 @@ export type LoginErrorProps = FallbackProps
|
|
|
19
19
|
* @alpha
|
|
20
20
|
*/
|
|
21
21
|
export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.ReactNode {
|
|
22
|
-
if (
|
|
22
|
+
if (
|
|
23
|
+
!(
|
|
24
|
+
error instanceof AuthError ||
|
|
25
|
+
error instanceof ConfigurationError ||
|
|
26
|
+
error instanceof ClientError
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
throw error
|
|
30
|
+
|
|
23
31
|
const logout = useLogOut()
|
|
24
32
|
const authState = useAuthState()
|
|
25
33
|
|
|
@@ -33,11 +41,11 @@ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.
|
|
|
33
41
|
}, [logout, resetErrorBoundary])
|
|
34
42
|
|
|
35
43
|
useEffect(() => {
|
|
36
|
-
if (
|
|
37
|
-
if (
|
|
44
|
+
if (error instanceof ClientError) {
|
|
45
|
+
if (error.statusCode === 401) {
|
|
38
46
|
handleRetry()
|
|
39
|
-
} else if (
|
|
40
|
-
const errorMessage =
|
|
47
|
+
} else if (error.statusCode === 404) {
|
|
48
|
+
const errorMessage = error.response.body.message || ''
|
|
41
49
|
if (errorMessage.startsWith('Session with sid') && errorMessage.endsWith('not found')) {
|
|
42
50
|
setAuthErrorMessage('The session ID is invalid or expired.')
|
|
43
51
|
} else {
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import {SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
|
|
2
|
+
import {AuthStateType, getIsInDashboardState, setAuthToken} from '@sanity/sdk'
|
|
3
|
+
import {act, render} from '@testing-library/react'
|
|
4
|
+
import {afterEach, beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
|
|
5
|
+
|
|
6
|
+
import {useAuthState} from '../hooks/auth/useAuthState'
|
|
7
|
+
import {useWindowConnection} from '../hooks/comlink/useWindowConnection'
|
|
8
|
+
import {useSanityInstance} from '../hooks/context/useSanityInstance'
|
|
9
|
+
import {ComlinkTokenRefreshProvider} from './ComlinkTokenRefresh'
|
|
10
|
+
|
|
11
|
+
// Mocks
|
|
12
|
+
vi.mock('@sanity/sdk', async () => {
|
|
13
|
+
const actual = await vi.importActual('@sanity/sdk')
|
|
14
|
+
return {
|
|
15
|
+
...actual,
|
|
16
|
+
getIsInDashboardState: vi.fn(() => ({getCurrent: vi.fn()})),
|
|
17
|
+
setAuthToken: vi.fn(),
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
vi.mock('../hooks/auth/useAuthState', () => ({
|
|
22
|
+
useAuthState: vi.fn(),
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
vi.mock('../hooks/comlink/useWindowConnection', () => ({
|
|
26
|
+
useWindowConnection: vi.fn(),
|
|
27
|
+
}))
|
|
28
|
+
|
|
29
|
+
vi.mock('../hooks/context/useSanityInstance', () => ({
|
|
30
|
+
useSanityInstance: vi.fn(),
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
// Use simpler mock typings
|
|
34
|
+
const mockGetIsInDashboardState = getIsInDashboardState as Mock
|
|
35
|
+
const mockSetAuthToken = setAuthToken as Mock
|
|
36
|
+
const mockUseAuthState = useAuthState as Mock
|
|
37
|
+
const mockUseWindowConnection = useWindowConnection as Mock
|
|
38
|
+
const mockUseSanityInstance = useSanityInstance as Mock
|
|
39
|
+
|
|
40
|
+
const mockFetch = vi.fn()
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
42
|
+
const mockSanityInstance: any = {projectId: 'test', dataset: 'test'}
|
|
43
|
+
|
|
44
|
+
describe('ComlinkTokenRefresh', () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
vi.useFakeTimers()
|
|
47
|
+
mockGetIsInDashboardState.mockReturnValue({getCurrent: vi.fn(() => false)})
|
|
48
|
+
mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_IN})
|
|
49
|
+
mockUseWindowConnection.mockReturnValue({fetch: mockFetch})
|
|
50
|
+
mockUseSanityInstance.mockReturnValue(mockSanityInstance)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
vi.clearAllMocks()
|
|
55
|
+
vi.useRealTimers()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('ComlinkTokenRefreshProvider', () => {
|
|
59
|
+
describe('when not in dashboard', () => {
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
mockGetIsInDashboardState.mockReturnValue({getCurrent: () => false})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should not request new token on 401 if not in dashboard', async () => {
|
|
65
|
+
mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_IN})
|
|
66
|
+
const {rerender} = render(
|
|
67
|
+
<ComlinkTokenRefreshProvider>
|
|
68
|
+
<div>Test</div>
|
|
69
|
+
</ComlinkTokenRefreshProvider>,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
mockUseAuthState.mockReturnValue({
|
|
73
|
+
type: AuthStateType.ERROR,
|
|
74
|
+
error: {statusCode: 401, message: 'Unauthorized'},
|
|
75
|
+
})
|
|
76
|
+
act(() => {
|
|
77
|
+
rerender(
|
|
78
|
+
<ComlinkTokenRefreshProvider>
|
|
79
|
+
<div>Test</div>
|
|
80
|
+
</ComlinkTokenRefreshProvider>,
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
await act(async () => {
|
|
85
|
+
await vi.advanceTimersByTimeAsync(100)
|
|
86
|
+
})
|
|
87
|
+
expect(mockFetch).not.toHaveBeenCalled()
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('when in dashboard', () => {
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
mockGetIsInDashboardState.mockReturnValue({getCurrent: () => true})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('should initialize useWindowConnection with correct parameters', () => {
|
|
97
|
+
render(
|
|
98
|
+
<ComlinkTokenRefreshProvider>
|
|
99
|
+
<div>Test</div>
|
|
100
|
+
</ComlinkTokenRefreshProvider>,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
expect(mockUseWindowConnection).toHaveBeenCalledWith(
|
|
104
|
+
expect.objectContaining({
|
|
105
|
+
name: SDK_NODE_NAME,
|
|
106
|
+
connectTo: SDK_CHANNEL_NAME,
|
|
107
|
+
}),
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('should handle received token', async () => {
|
|
112
|
+
mockUseAuthState.mockReturnValue({
|
|
113
|
+
type: AuthStateType.ERROR,
|
|
114
|
+
error: {statusCode: 401, message: 'Unauthorized'},
|
|
115
|
+
})
|
|
116
|
+
mockFetch.mockResolvedValueOnce({token: 'new-token'})
|
|
117
|
+
|
|
118
|
+
render(
|
|
119
|
+
<ComlinkTokenRefreshProvider>
|
|
120
|
+
<div>Test</div>
|
|
121
|
+
</ComlinkTokenRefreshProvider>,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
await act(async () => {
|
|
125
|
+
await vi.advanceTimersByTimeAsync(100)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
expect(mockSetAuthToken).toHaveBeenCalledWith(mockSanityInstance, 'new-token')
|
|
129
|
+
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('should not set auth token if received token is null', async () => {
|
|
133
|
+
mockUseAuthState.mockReturnValue({
|
|
134
|
+
type: AuthStateType.ERROR,
|
|
135
|
+
error: {statusCode: 401, message: 'Unauthorized'},
|
|
136
|
+
})
|
|
137
|
+
mockFetch.mockResolvedValueOnce({token: null})
|
|
138
|
+
|
|
139
|
+
render(
|
|
140
|
+
<ComlinkTokenRefreshProvider>
|
|
141
|
+
<div>Test</div>
|
|
142
|
+
</ComlinkTokenRefreshProvider>,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
await act(async () => {
|
|
146
|
+
await vi.advanceTimersByTimeAsync(100)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
expect(mockSetAuthToken).not.toHaveBeenCalled()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('should handle fetch errors gracefully', async () => {
|
|
153
|
+
mockUseAuthState.mockReturnValue({
|
|
154
|
+
type: AuthStateType.ERROR,
|
|
155
|
+
error: {statusCode: 401, message: 'Unauthorized'},
|
|
156
|
+
})
|
|
157
|
+
mockFetch.mockRejectedValueOnce(new Error('Fetch failed'))
|
|
158
|
+
|
|
159
|
+
render(
|
|
160
|
+
<ComlinkTokenRefreshProvider>
|
|
161
|
+
<div>Test</div>
|
|
162
|
+
</ComlinkTokenRefreshProvider>,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
await act(async () => {
|
|
166
|
+
await vi.advanceTimersByTimeAsync(100)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
describe('Automatic token refresh', () => {
|
|
173
|
+
it('should not request new token for non-401 errors', async () => {
|
|
174
|
+
mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_IN})
|
|
175
|
+
const {rerender} = render(
|
|
176
|
+
<ComlinkTokenRefreshProvider>
|
|
177
|
+
<div>Test</div>
|
|
178
|
+
</ComlinkTokenRefreshProvider>,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
mockUseAuthState.mockReturnValue({
|
|
182
|
+
type: AuthStateType.ERROR,
|
|
183
|
+
error: {statusCode: 500, message: 'Server Error'},
|
|
184
|
+
})
|
|
185
|
+
act(() => {
|
|
186
|
+
rerender(
|
|
187
|
+
<ComlinkTokenRefreshProvider>
|
|
188
|
+
<div>Test</div>
|
|
189
|
+
</ComlinkTokenRefreshProvider>,
|
|
190
|
+
)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
await act(async () => {
|
|
194
|
+
await vi.advanceTimersByTimeAsync(100)
|
|
195
|
+
})
|
|
196
|
+
expect(mockFetch).not.toHaveBeenCalled()
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('should request new token on LOGGED_OUT state', async () => {
|
|
200
|
+
mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_IN})
|
|
201
|
+
const {rerender} = render(
|
|
202
|
+
<ComlinkTokenRefreshProvider>
|
|
203
|
+
<div>Test</div>
|
|
204
|
+
</ComlinkTokenRefreshProvider>,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_OUT})
|
|
208
|
+
act(() => {
|
|
209
|
+
rerender(
|
|
210
|
+
<ComlinkTokenRefreshProvider>
|
|
211
|
+
<div>Test</div>
|
|
212
|
+
</ComlinkTokenRefreshProvider>,
|
|
213
|
+
)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
expect(mockFetch).toHaveBeenCalledWith('dashboard/v1/auth/tokens/create')
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
})
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import {type ClientError} from '@sanity/client'
|
|
2
|
+
import {SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
|
|
3
|
+
import {
|
|
4
|
+
AuthStateType,
|
|
5
|
+
type FrameMessage,
|
|
6
|
+
getIsInDashboardState,
|
|
7
|
+
type NewTokenResponseMessage,
|
|
8
|
+
type RequestNewTokenMessage,
|
|
9
|
+
setAuthToken,
|
|
10
|
+
type WindowMessage,
|
|
11
|
+
} from '@sanity/sdk'
|
|
12
|
+
import React, {type PropsWithChildren, useCallback, useEffect, useMemo, useRef} from 'react'
|
|
13
|
+
|
|
14
|
+
import {useAuthState} from '../hooks/auth/useAuthState'
|
|
15
|
+
import {useWindowConnection} from '../hooks/comlink/useWindowConnection'
|
|
16
|
+
import {useSanityInstance} from '../hooks/context/useSanityInstance'
|
|
17
|
+
|
|
18
|
+
// Define specific message types extending the base types for clarity
|
|
19
|
+
type SdkParentComlinkMessage = NewTokenResponseMessage | WindowMessage // Messages received by SDK
|
|
20
|
+
type SdkChildComlinkMessage = RequestNewTokenMessage | FrameMessage // Messages sent by SDK
|
|
21
|
+
|
|
22
|
+
const DEFAULT_RESPONSE_TIMEOUT = 10000 // 10 seconds
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Component that handles token refresh in dashboard mode
|
|
26
|
+
*/
|
|
27
|
+
function DashboardTokenRefresh({children}: PropsWithChildren) {
|
|
28
|
+
const instance = useSanityInstance()
|
|
29
|
+
const isTokenRefreshInProgress = useRef(false)
|
|
30
|
+
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
|
31
|
+
const processed401ErrorRef = useRef<unknown | null>(null)
|
|
32
|
+
const authState = useAuthState()
|
|
33
|
+
|
|
34
|
+
const clearRefreshTimeout = useCallback(() => {
|
|
35
|
+
if (timeoutRef.current) {
|
|
36
|
+
clearTimeout(timeoutRef.current)
|
|
37
|
+
timeoutRef.current = null
|
|
38
|
+
}
|
|
39
|
+
}, [])
|
|
40
|
+
|
|
41
|
+
const windowConnection = useWindowConnection<SdkParentComlinkMessage, SdkChildComlinkMessage>({
|
|
42
|
+
name: SDK_NODE_NAME,
|
|
43
|
+
connectTo: SDK_CHANNEL_NAME,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const requestNewToken = useCallback(async () => {
|
|
47
|
+
if (isTokenRefreshInProgress.current) {
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
isTokenRefreshInProgress.current = true
|
|
52
|
+
clearRefreshTimeout()
|
|
53
|
+
|
|
54
|
+
timeoutRef.current = setTimeout(() => {
|
|
55
|
+
if (isTokenRefreshInProgress.current) {
|
|
56
|
+
isTokenRefreshInProgress.current = false
|
|
57
|
+
}
|
|
58
|
+
timeoutRef.current = null
|
|
59
|
+
}, DEFAULT_RESPONSE_TIMEOUT)
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const res = await windowConnection.fetch<{token: string | null; error?: string}>(
|
|
63
|
+
'dashboard/v1/auth/tokens/create',
|
|
64
|
+
)
|
|
65
|
+
clearRefreshTimeout()
|
|
66
|
+
|
|
67
|
+
if (res.token) {
|
|
68
|
+
setAuthToken(instance, res.token)
|
|
69
|
+
}
|
|
70
|
+
isTokenRefreshInProgress.current = false
|
|
71
|
+
} catch {
|
|
72
|
+
isTokenRefreshInProgress.current = false
|
|
73
|
+
clearRefreshTimeout()
|
|
74
|
+
}
|
|
75
|
+
}, [windowConnection, clearRefreshTimeout, instance])
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
return () => {
|
|
79
|
+
clearRefreshTimeout()
|
|
80
|
+
}
|
|
81
|
+
}, [clearRefreshTimeout])
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
const has401Error =
|
|
85
|
+
authState.type === AuthStateType.ERROR &&
|
|
86
|
+
authState.error &&
|
|
87
|
+
(authState.error as ClientError)?.statusCode === 401 &&
|
|
88
|
+
!isTokenRefreshInProgress.current &&
|
|
89
|
+
processed401ErrorRef.current !== authState.error
|
|
90
|
+
|
|
91
|
+
const isLoggedOut =
|
|
92
|
+
authState.type === AuthStateType.LOGGED_OUT && !isTokenRefreshInProgress.current
|
|
93
|
+
|
|
94
|
+
if (has401Error || isLoggedOut) {
|
|
95
|
+
processed401ErrorRef.current =
|
|
96
|
+
authState.type === AuthStateType.ERROR ? authState.error : undefined
|
|
97
|
+
requestNewToken()
|
|
98
|
+
} else if (
|
|
99
|
+
authState.type !== AuthStateType.ERROR ||
|
|
100
|
+
processed401ErrorRef.current !==
|
|
101
|
+
(authState.type === AuthStateType.ERROR ? authState.error : undefined)
|
|
102
|
+
) {
|
|
103
|
+
processed401ErrorRef.current = null
|
|
104
|
+
}
|
|
105
|
+
}, [authState, requestNewToken])
|
|
106
|
+
|
|
107
|
+
return children
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* This provider is used to provide the Comlink token refresh feature.
|
|
112
|
+
* It is used to automatically request a new token on 401 error if enabled.
|
|
113
|
+
* @public
|
|
114
|
+
*/
|
|
115
|
+
export const ComlinkTokenRefreshProvider: React.FC<PropsWithChildren> = ({children}) => {
|
|
116
|
+
const instance = useSanityInstance()
|
|
117
|
+
const isInDashboard = useMemo(() => getIsInDashboardState(instance).getCurrent(), [instance])
|
|
118
|
+
|
|
119
|
+
if (isInDashboard) {
|
|
120
|
+
return <DashboardTokenRefresh>{children}</DashboardTokenRefresh>
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// If we're not in the dashboard, we don't need to do anything
|
|
124
|
+
return children
|
|
125
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {type PathChangeMessage} from '@sanity/message-protocol'
|
|
2
|
+
import {renderHook} from '@testing-library/react'
|
|
3
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
4
|
+
|
|
5
|
+
import {useDashboardNavigate} from './useDashboardNavigate'
|
|
6
|
+
|
|
7
|
+
const mockOnMessage = vi.fn()
|
|
8
|
+
let mockMessageHandler: ((data: PathChangeMessage['data']) => void) | undefined
|
|
9
|
+
|
|
10
|
+
vi.mock('../comlink/useWindowConnection', () => {
|
|
11
|
+
return {
|
|
12
|
+
useWindowConnection: ({
|
|
13
|
+
onMessage,
|
|
14
|
+
}: {
|
|
15
|
+
onMessage: Record<string, (data: PathChangeMessage['data']) => void>
|
|
16
|
+
}) => {
|
|
17
|
+
mockMessageHandler = onMessage['dashboard/v1/history/change-path']
|
|
18
|
+
return {
|
|
19
|
+
onMessage: mockOnMessage,
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('useDashboardNavigate', () => {
|
|
26
|
+
const mockNavigateFn = vi.fn()
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
vi.resetAllMocks()
|
|
30
|
+
mockMessageHandler = undefined
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('calls navigate function with correct data when message is received', () => {
|
|
34
|
+
renderHook(() => useDashboardNavigate(mockNavigateFn))
|
|
35
|
+
|
|
36
|
+
const mockNavigationData = {
|
|
37
|
+
path: '/test-path',
|
|
38
|
+
type: 'push' as const,
|
|
39
|
+
}
|
|
40
|
+
mockMessageHandler?.(mockNavigationData)
|
|
41
|
+
|
|
42
|
+
expect(mockNavigateFn).toHaveBeenCalledWith(mockNavigationData)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {type PathChangeMessage, SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
|
|
2
|
+
|
|
3
|
+
import {useWindowConnection} from '../comlink/useWindowConnection'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @public
|
|
7
|
+
*
|
|
8
|
+
* A helper hook designed to be injected into routing components for apps within the Dashboard.
|
|
9
|
+
* While the Dashboard can usually handle navigation, there are special cases when you
|
|
10
|
+
* are already within a target app, and need to navigate to another route inside of that app.
|
|
11
|
+
*
|
|
12
|
+
* For example, your user might "favorite" a document inside of your application.
|
|
13
|
+
* If they click on the Dashboard favorites item in the sidebar, and are already within your application,
|
|
14
|
+
* there needs to be some way for the dashboard to signal to your application to reroute to where that document was favorited.
|
|
15
|
+
*
|
|
16
|
+
* This hook is intended to receive those messages, and takes a function to route to the correct path.
|
|
17
|
+
*
|
|
18
|
+
* @param navigateFn - Function to handle navigation; should accept:
|
|
19
|
+
* - `path`: a string, which will be a relative path (for example, 'my-route')
|
|
20
|
+
* - `type`: 'push', 'replace', or 'pop', which will be the type of navigation to perform
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```tsx
|
|
24
|
+
* import {useDashboardNavigate} from '@sanity/sdk-react'
|
|
25
|
+
* import {BrowserRouter, useNavigate} from 'react-router'
|
|
26
|
+
* import {Suspense} from 'react'
|
|
27
|
+
*
|
|
28
|
+
* function DashboardNavigationHandler() {
|
|
29
|
+
* const navigate = useNavigate()
|
|
30
|
+
* useDashboardNavigate(({path, type}) => {
|
|
31
|
+
* navigate(path, {replace: type === 'replace'})
|
|
32
|
+
* })
|
|
33
|
+
* return null
|
|
34
|
+
* }
|
|
35
|
+
*
|
|
36
|
+
* // Wrap the component with Suspense since the hook may suspend
|
|
37
|
+
* function MyApp() {
|
|
38
|
+
* return (
|
|
39
|
+
* <BrowserRouter>
|
|
40
|
+
* <Suspense>
|
|
41
|
+
* <DashboardNavigationHandler />
|
|
42
|
+
* </Suspense>
|
|
43
|
+
* </BrowserRouter>
|
|
44
|
+
* )
|
|
45
|
+
* }
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function useDashboardNavigate(
|
|
49
|
+
navigateFn: (options: PathChangeMessage['data']) => void,
|
|
50
|
+
): void {
|
|
51
|
+
useWindowConnection<PathChangeMessage, never>({
|
|
52
|
+
name: SDK_NODE_NAME,
|
|
53
|
+
connectTo: SDK_CHANNEL_NAME,
|
|
54
|
+
onMessage: {
|
|
55
|
+
'dashboard/v1/history/change-path': (data: PathChangeMessage['data']) => {
|
|
56
|
+
navigateFn(data)
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
}
|