Skip to content

Commit f646d53

Browse files
authored
feat: add new token fetching via comlink (#475)
* feat: add new token fetching via comlink
1 parent 7978b17 commit f646d53

File tree

9 files changed

+468
-28
lines changed

9 files changed

+468
-28
lines changed

packages/core/src/_exports/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ export {
1313
getAuthState,
1414
getCurrentUserState,
1515
getDashboardOrganizationId,
16+
getIsInDashboardState,
1617
getLoginUrlState,
1718
getTokenState,
1819
type LoggedInAuthState,
1920
type LoggedOutAuthState,
2021
type LoggingInAuthState,
22+
setAuthToken,
2123
} from '../auth/authStore'
2224
export {observeOrganizationVerificationState} from '../auth/getOrganizationVerificationState'
2325
export {handleAuthCallback} from '../auth/handleAuthCallback'
@@ -32,8 +34,14 @@ export {
3234
releaseChannel,
3335
} from '../comlink/controller/comlinkControllerStore'
3436
export type {ComlinkNodeState} from '../comlink/node/comlinkNodeStore'
37+
export {getOrCreateNode, releaseNode} from '../comlink/node/comlinkNodeStore'
3538
export {getNodeState, type NodeState} from '../comlink/node/getNodeState'
36-
export {type FrameMessage, type WindowMessage} from '../comlink/types'
39+
export {
40+
type FrameMessage,
41+
type NewTokenResponseMessage,
42+
type RequestNewTokenMessage,
43+
type WindowMessage,
44+
} from '../comlink/types'
3745
export {type AuthConfig, type AuthProvider} from '../config/authConfig'
3846
export {
3947
createDatasetHandle,

packages/core/src/auth/authStore.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,3 +280,48 @@ export const getDashboardOrganizationId = bindActionGlobally(
280280
authStore,
281281
createStateSourceAction(({state: {dashboardContext}}) => dashboardContext?.orgId),
282282
)
283+
284+
/**
285+
* Returns a state source indicating if the SDK is running within a dashboard context.
286+
* @public
287+
*/
288+
export const getIsInDashboardState = bindActionGlobally(
289+
authStore,
290+
createStateSourceAction(
291+
({state: {dashboardContext}}) =>
292+
// Check if dashboardContext exists and is not empty
293+
!!dashboardContext && Object.keys(dashboardContext).length > 0,
294+
),
295+
)
296+
297+
/**
298+
* Action to explicitly set the authentication token.
299+
* Used internally by the Comlink token refresh.
300+
* @internal
301+
*/
302+
export const setAuthToken = bindActionGlobally(authStore, ({state}, token: string | null) => {
303+
const currentAuthState = state.get().authState
304+
if (token) {
305+
// Update state only if the new token is different or currently logged out
306+
if (currentAuthState.type !== AuthStateType.LOGGED_IN || currentAuthState.token !== token) {
307+
// This state update structure should trigger listeners in clientStore
308+
state.set('setToken', {
309+
authState: {
310+
type: AuthStateType.LOGGED_IN,
311+
token: token,
312+
// Keep existing user or set to null? Setting to null forces refetch.
313+
// Keep existing user to avoid unnecessary refetches if user data is still valid.
314+
currentUser:
315+
currentAuthState.type === AuthStateType.LOGGED_IN ? currentAuthState.currentUser : null,
316+
},
317+
})
318+
}
319+
} else {
320+
// Handle setting token to null (logging out)
321+
if (currentAuthState.type !== AuthStateType.LOGGED_OUT) {
322+
state.set('setToken', {
323+
authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: false},
324+
})
325+
}
326+
}
327+
})

packages/core/src/comlink/types.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,21 @@ export type FrameMessage = Message
1111
* @public
1212
*/
1313
export type WindowMessage = Message
14+
15+
/**
16+
* Message from SDK (iframe) to Parent (dashboard) to request a new token
17+
* @internal
18+
*/
19+
export type RequestNewTokenMessage = {
20+
type: 'dashboard/v1/auth/tokens/create'
21+
payload?: undefined
22+
}
23+
24+
/**
25+
* Message from Parent (dashboard) to SDK (iframe) with the new token
26+
* @internal
27+
*/
28+
export type NewTokenResponseMessage = {
29+
type: 'dashboard/v1/auth/tokens/create'
30+
payload: {token: string | null; error?: string}
31+
}

packages/react/src/_exports/sdk-react.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
export {AuthBoundary, type AuthBoundaryProps} from '../components/auth/AuthBoundary'
55
export {SanityApp, type SanityAppProps} from '../components/SanityApp'
66
export {SDKProvider, type SDKProviderProps} from '../components/SDKProvider'
7+
export {ComlinkTokenRefreshProvider} from '../context/ComlinkTokenRefresh'
78
export {ResourceProvider, type ResourceProviderProps} from '../context/ResourceProvider'
89
export {useAuthState} from '../hooks/auth/useAuthState'
910
export {useAuthToken} from '../hooks/auth/useAuthToken'

packages/react/src/components/auth/AuthBoundary.test.tsx

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {AuthStateType} from '@sanity/sdk'
1+
import {AuthStateType, type SanityConfig} from '@sanity/sdk'
22
import {render, screen, waitFor} from '@testing-library/react'
33
import React from 'react'
44
import {type FallbackProps} from 'react-error-boundary'
@@ -8,6 +8,7 @@ import {ResourceProvider} from '../../context/ResourceProvider'
88
import {useAuthState} from '../../hooks/auth/useAuthState'
99
import {useLoginUrl} from '../../hooks/auth/useLoginUrl'
1010
import {useVerifyOrgProjects} from '../../hooks/auth/useVerifyOrgProjects'
11+
import {useSanityInstance} from '../../hooks/context/useSanityInstance'
1112
import {AuthBoundary} from './AuthBoundary'
1213

1314
// Mock hooks
@@ -22,6 +23,9 @@ vi.mock('../../hooks/auth/useHandleAuthCallback', () => ({
2223
vi.mock('../../hooks/auth/useLogOut', () => ({
2324
useLogOut: vi.fn(() => async () => {}),
2425
}))
26+
vi.mock('../../hooks/context/useSanityInstance', () => ({
27+
useSanityInstance: vi.fn(),
28+
}))
2529

2630
// Mock AuthError throwing scenario
2731
vi.mock('./AuthError', async (importOriginal) => {
@@ -105,8 +109,27 @@ describe('AuthBoundary', () => {
105109
const mockUseAuthState = vi.mocked(useAuthState)
106110
const mockUseLoginUrl = vi.mocked(useLoginUrl)
107111
const mockUseVerifyOrgProjects = vi.mocked(useVerifyOrgProjects)
112+
const mockUseSanityInstance = vi.mocked(useSanityInstance)
108113
const testProjectIds = ['proj-test'] // Example project ID for tests
109114

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+
110133
beforeEach(() => {
111134
vi.clearAllMocks()
112135
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -116,6 +139,8 @@ describe('AuthBoundary', () => {
116139
mockUseLoginUrl.mockReturnValue('http://example.com/login')
117140
// Default mock for useVerifyOrgProjects - returns null (no error)
118141
mockUseVerifyOrgProjects.mockImplementation(() => null)
142+
// Mock useSanityInstance to return our mock instance
143+
mockUseSanityInstance.mockReturnValue(mockSanityInstance)
119144
})
120145

121146
afterEach(() => {
@@ -145,9 +170,7 @@ describe('AuthBoundary', () => {
145170
isExchangingToken: false,
146171
})
147172
const {container} = render(
148-
<ResourceProvider fallback={null}>
149-
<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
150-
</ResourceProvider>,
173+
<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>,
151174
)
152175

153176
// The callback screen renders null check that it renders nothing
@@ -161,11 +184,7 @@ describe('AuthBoundary', () => {
161184
currentUser: null,
162185
token: 'exampleToken',
163186
})
164-
render(
165-
<ResourceProvider fallback={null}>
166-
<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
167-
</ResourceProvider>,
168-
)
187+
render(<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>)
169188

170189
expect(screen.getByText('Protected Content')).toBeInTheDocument()
171190
})
@@ -175,11 +194,7 @@ describe('AuthBoundary', () => {
175194
type: AuthStateType.ERROR,
176195
error: new Error('test error'),
177196
})
178-
render(
179-
<ResourceProvider fallback={null}>
180-
<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
181-
</ResourceProvider>,
182-
)
197+
render(<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>)
183198

184199
// The AuthBoundary should throw an AuthError internally
185200
// and then display the LoginError component as the fallback.
@@ -192,11 +207,7 @@ describe('AuthBoundary', () => {
192207
})
193208

194209
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>)
200211
expect(screen.getByText('Protected Content')).toBeInTheDocument()
201212
})
202213

packages/react/src/components/auth/AuthBoundary.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {AuthStateType} from '@sanity/sdk'
22
import {useEffect, useMemo} from 'react'
33
import {ErrorBoundary, type FallbackProps} from 'react-error-boundary'
44

5+
import {ComlinkTokenRefreshProvider} from '../../context/ComlinkTokenRefresh'
56
import {useAuthState} from '../../hooks/auth/useAuthState'
67
import {useLoginUrl} from '../../hooks/auth/useLoginUrl'
78
import {useVerifyOrgProjects} from '../../hooks/auth/useVerifyOrgProjects'
@@ -111,9 +112,11 @@ export function AuthBoundary({
111112
}, [LoginErrorComponent])
112113

113114
return (
114-
<ErrorBoundary FallbackComponent={FallbackComponent}>
115-
<AuthSwitch {...props} />
116-
</ErrorBoundary>
115+
<ComlinkTokenRefreshProvider>
116+
<ErrorBoundary FallbackComponent={FallbackComponent}>
117+
<AuthSwitch {...props} />
118+
</ErrorBoundary>
119+
</ComlinkTokenRefreshProvider>
117120
)
118121
}
119122

packages/react/src/components/auth/LoginError.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,15 @@ export type LoginErrorProps = FallbackProps
1919
* @alpha
2020
*/
2121
export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.ReactNode {
22-
if (!(error instanceof AuthError || error instanceof ConfigurationError)) throw error
22+
if (
23+
!(
24+
error instanceof AuthError ||
25+
error instanceof ConfigurationError ||
26+
error instanceof ClientError
27+
)
28+
)
29+
throw error
30+
2331
const logout = useLogOut()
2432
const authState = useAuthState()
2533

@@ -33,11 +41,11 @@ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.
3341
}, [logout, resetErrorBoundary])
3442

3543
useEffect(() => {
36-
if (authState.type === AuthStateType.ERROR && authState.error instanceof ClientError) {
37-
if (authState.error.statusCode === 401) {
44+
if (error instanceof ClientError) {
45+
if (error.statusCode === 401) {
3846
handleRetry()
39-
} else if (authState.error.statusCode === 404) {
40-
const errorMessage = authState.error.response.body.message || ''
47+
} else if (error.statusCode === 404) {
48+
const errorMessage = error.response.body.message || ''
4149
if (errorMessage.startsWith('Session with sid') && errorMessage.endsWith('not found')) {
4250
setAuthErrorMessage('The session ID is invalid or expired.')
4351
} else {

0 commit comments

Comments
 (0)