@sanity/sdk-react 3.4.0-rc.0 → 3.4.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.
Files changed (37) hide show
  1. package/dist/_exports/dashboard.d.ts +112 -34
  2. package/dist/_exports/dashboard.d.ts.map +1 -1
  3. package/dist/_exports/dashboard.js +219 -114
  4. package/dist/_exports/dashboard.js.map +1 -1
  5. package/dist/index.d.ts +21 -141
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +66 -147
  8. package/dist/index.js.map +1 -1
  9. package/dist/{useStudioWorkspacesByProjectIdDataset-B5A5kBH9.js → useStudioWorkspacesByProjectIdDataset-Bjwi4cLk.js} +60 -3
  10. package/dist/useStudioWorkspacesByProjectIdDataset-Bjwi4cLk.js.map +1 -0
  11. package/package.json +9 -9
  12. package/src/_exports/dashboard.test-d.ts +3 -0
  13. package/src/_exports/dashboard.ts +11 -2
  14. package/src/_exports/sdk-react.ts +0 -3
  15. package/src/components/auth/AuthBoundary.test.tsx +2 -81
  16. package/src/components/auth/AuthBoundary.tsx +3 -17
  17. package/src/components/auth/LoginCallback.test.tsx +7 -46
  18. package/src/components/auth/LoginCallback.tsx +4 -22
  19. package/src/hooks/dashboard/useAgentResourceContext.test.tsx +67 -2
  20. package/src/hooks/dashboard/useAgentResourceContext.ts +35 -6
  21. package/src/hooks/dashboard/useApplicationContext.test.tsx +95 -0
  22. package/src/hooks/dashboard/useApplicationContext.ts +47 -0
  23. package/src/hooks/dashboard/useCapabilities.test.tsx +84 -0
  24. package/src/hooks/dashboard/useCapabilities.ts +27 -0
  25. package/src/hooks/dashboard/useNavigate.test.ts +392 -5
  26. package/src/hooks/dashboard/useNavigate.ts +169 -26
  27. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +116 -9
  28. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +110 -39
  29. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.test.ts +99 -1
  30. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.ts +73 -2
  31. package/dist/useStudioWorkspacesByProjectIdDataset-B5A5kBH9.js.map +0 -1
  32. package/src/hooks/auth/useHandleOAuthCallback.test.tsx +0 -16
  33. package/src/hooks/auth/useHandleOAuthCallback.tsx +0 -49
  34. package/src/hooks/auth/useOAuthAuthorize.test.tsx +0 -16
  35. package/src/hooks/auth/useOAuthAuthorize.tsx +0 -28
  36. package/src/hooks/auth/useOAuthTokens.test.tsx +0 -240
  37. package/src/hooks/auth/useOAuthTokens.tsx +0 -95
@@ -1,49 +0,0 @@
1
- import {handleOAuthCallback} from '@sanity/sdk'
2
-
3
- import {createCallbackHook} from '../helpers/createCallbackHook'
4
-
5
- /**
6
- * A React hook that returns a function for handling the OAuth redirect callback.
7
- *
8
- * @remarks
9
- * This is the OAuth counterpart to `useHandleAuthCallback`. The returned
10
- * function invokes core's `handleOAuthCallback`, which validates the `state`
11
- * parameter, surfaces `?error=` redirects, exchanges the authorization `code`
12
- * for tokens, persists them, and transitions the auth state to `LOGGED_IN` —
13
- * all in core. On success it resolves the same-origin location the user was on
14
- * when the flow started (so deep links survive login), otherwise the callback
15
- * URL cleaned of the OAuth params (`code`, `state`, `error`,
16
- * `error_description`). It resolves `false` when there was nothing to handle.
17
- * The resolved URL may be a different route, so navigate to it rather than
18
- * only calling `history.replaceState`.
19
- *
20
- * `AuthBoundary` runs this for you when the app lands on the OAuth redirect
21
- * URI. Reach for this hook only when building a custom callback component.
22
- *
23
- * Concurrent calls are single-flight in core, so React StrictMode's double
24
- * invocation will not trigger a second code exchange, and a repeated call with
25
- * a stale callback URL is ignored once a session is established.
26
- *
27
- * @example
28
- * ```tsx
29
- * function OAuthCallback() {
30
- * const handleCallback = useHandleOAuthCallback()
31
- * const navigate = useNavigate() // your router's navigation
32
- *
33
- * useEffect(() => {
34
- * handleCallback(window.location.href)
35
- * .then((nextUrl) => {
36
- * // Returns the user to where they started, with OAuth params removed
37
- * if (nextUrl) navigate(nextUrl, {replace: true})
38
- * })
39
- * .catch(console.error)
40
- * }, [handleCallback, navigate])
41
- *
42
- * return <div>Completing sign-in…</div>
43
- * }
44
- * ```
45
- *
46
- * @returns A callback handler that processes the OAuth redirect
47
- * @public
48
- */
49
- export const useHandleOAuthCallback = createCallbackHook(handleOAuthCallback)
@@ -1,16 +0,0 @@
1
- import {startOAuthAuthorization} from '@sanity/sdk'
2
- import {identity} from 'rxjs'
3
- import {describe, it} from 'vitest'
4
-
5
- import {createCallbackHook} from '../helpers/createCallbackHook'
6
-
7
- vi.mock('../helpers/createCallbackHook', () => ({createCallbackHook: vi.fn(identity)}))
8
- vi.mock('@sanity/sdk', () => ({startOAuthAuthorization: vi.fn()}))
9
-
10
- describe('useOAuthAuthorize', () => {
11
- it('calls `createCallbackHook` with `startOAuthAuthorization`', async () => {
12
- const {useOAuthAuthorize} = await import('./useOAuthAuthorize')
13
- expect(createCallbackHook).toHaveBeenCalledWith(startOAuthAuthorization)
14
- expect(useOAuthAuthorize).toBe(startOAuthAuthorization)
15
- })
16
- })
@@ -1,28 +0,0 @@
1
- import {startOAuthAuthorization} from '@sanity/sdk'
2
-
3
- import {createCallbackHook} from '../helpers/createCallbackHook'
4
-
5
- /**
6
- * A React hook that returns a function for starting the OAuth authorization-code + PKCE flow.
7
- *
8
- * @remarks
9
- * The returned function invokes core's `startOAuthAuthorization`, which generates
10
- * the PKCE `code_verifier`, `code_challenge` and `state`, persists the verifier and
11
- * state to `sessionStorage`, and navigates the browser to the authorize endpoint.
12
- * `clientId`, `redirectUri` and `organizationId` are read from the instance's
13
- * `auth.oauth` config. The returned promise rejects if the instance has no `auth.oauth` config.
14
- *
15
- * Pair with {@link useHandleOAuthCallback} on the redirect URI to complete the flow.
16
- *
17
- * @example
18
- * ```tsx
19
- * function LoginButton() {
20
- * const authorize = useOAuthAuthorize()
21
- * return <button onClick={() => authorize().catch(console.error)}>Sign in</button>
22
- * }
23
- * ```
24
- *
25
- * @returns A function that starts the OAuth flow by navigating to the authorization URL
26
- * @public
27
- */
28
- export const useOAuthAuthorize = createCallbackHook(startOAuthAuthorization)
@@ -1,240 +0,0 @@
1
- import {
2
- getOAuthTokensState,
3
- type OAuthTokens,
4
- refreshOAuthTokens,
5
- revokeOAuthTokens,
6
- type StateSource,
7
- } from '@sanity/sdk'
8
- import {act, renderHook} from '@testing-library/react'
9
- import {throwError} from 'rxjs'
10
- import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
11
-
12
- import {ResourceProvider} from '../../context/ResourceProvider'
13
- import {useOAuthTokens} from './useOAuthTokens'
14
-
15
- vi.mock('@sanity/sdk', async (importOriginal) => {
16
- const original = await importOriginal<typeof import('@sanity/sdk')>()
17
- return {
18
- ...original,
19
- getOAuthTokensState: vi.fn(),
20
- refreshOAuthTokens: vi.fn(),
21
- revokeOAuthTokens: vi.fn(),
22
- }
23
- })
24
-
25
- /**
26
- * A controllable stand-in for core's token state source. `set` mimics core
27
- * updating the store (refresh/revoke or a cross-tab `storage` event) and
28
- * notifies subscribers so the hook re-renders.
29
- */
30
- function createFakeTokenSource(initial: OAuthTokens | null) {
31
- let current = initial
32
- const listeners = new Set<() => void>()
33
- const source: StateSource<OAuthTokens | null> & {set: (next: OAuthTokens | null) => void} = {
34
- subscribe: (onStoreChanged?: () => void) => {
35
- if (onStoreChanged) listeners.add(onStoreChanged)
36
- return () => {
37
- if (onStoreChanged) listeners.delete(onStoreChanged)
38
- }
39
- },
40
- getCurrent: () => current,
41
- observable: throwError(() => new Error('unexpected usage of observable')),
42
- set: (next) => {
43
- current = next
44
- for (const listener of listeners) listener()
45
- },
46
- }
47
- return source
48
- }
49
-
50
- const makeTokens = (overrides: Partial<OAuthTokens> = {}): OAuthTokens => ({
51
- accessToken: 'access-token',
52
- tokenType: 'bearer',
53
- expiresIn: 3600,
54
- expiresAt: new Date(Date.now() + 3600_000),
55
- refreshToken: 'refresh-token',
56
- ...overrides,
57
- })
58
-
59
- const wrapper = ({children}: {children: React.ReactNode}) => (
60
- <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
61
- {children}
62
- </ResourceProvider>
63
- )
64
-
65
- describe('useOAuthTokens', () => {
66
- const mockGetState = vi.mocked(getOAuthTokensState)
67
- const mockRefresh = vi.mocked(refreshOAuthTokens)
68
- const mockRevoke = vi.mocked(revokeOAuthTokens)
69
-
70
- beforeEach(() => {
71
- vi.clearAllMocks()
72
- })
73
-
74
- afterEach(() => {
75
- vi.useRealTimers()
76
- })
77
-
78
- it('returns the stored tokens with isExpired=false when expiresAt is in the future', () => {
79
- const tokens = makeTokens()
80
- mockGetState.mockReturnValue(createFakeTokenSource(tokens))
81
-
82
- const {result} = renderHook(() => useOAuthTokens(), {wrapper})
83
-
84
- expect(result.current.tokens).toEqual(tokens)
85
- expect(result.current.isExpired()).toBe(false)
86
- })
87
-
88
- it('derives isExpired=true when expiresAt is in the past', () => {
89
- mockGetState.mockReturnValue(
90
- createFakeTokenSource(makeTokens({expiresAt: new Date(Date.now() - 1000)})),
91
- )
92
-
93
- const {result} = renderHook(() => useOAuthTokens(), {wrapper})
94
-
95
- expect(result.current.isExpired()).toBe(true)
96
- })
97
-
98
- it('treats expiresAt exactly equal to now as expired (<= boundary)', () => {
99
- vi.useFakeTimers()
100
- const now = new Date('2030-01-01T00:00:00.000Z')
101
- vi.setSystemTime(now)
102
- mockGetState.mockReturnValue(createFakeTokenSource(makeTokens({expiresAt: new Date(now)})))
103
-
104
- const {result} = renderHook(() => useOAuthTokens(), {wrapper})
105
-
106
- expect(result.current.isExpired()).toBe(true)
107
- })
108
-
109
- it('re-evaluates isExpired against the clock at call time, with no re-render or token change', () => {
110
- vi.useFakeTimers()
111
- vi.setSystemTime(new Date('2030-01-01T00:00:00.000Z'))
112
- const expiresAt = new Date(Date.now() + 10_000)
113
- mockGetState.mockReturnValue(createFakeTokenSource(makeTokens({expiresAt})))
114
-
115
- const {result} = renderHook(() => useOAuthTokens(), {wrapper})
116
- const {isExpired} = result.current
117
- expect(isExpired()).toBe(false)
118
-
119
- // Advance past expiry. The same function reference must now report expired,
120
- // proving the read happens at call time, not render time.
121
- vi.setSystemTime(new Date(expiresAt.getTime() + 1000))
122
- expect(isExpired()).toBe(true)
123
- })
124
-
125
- it('returns tokens=null and isExpired=false when there are no tokens', () => {
126
- mockGetState.mockReturnValue(createFakeTokenSource(null))
127
-
128
- const {result} = renderHook(() => useOAuthTokens(), {wrapper})
129
-
130
- expect(result.current.tokens).toBeNull()
131
- expect(result.current.isExpired()).toBe(false)
132
- })
133
-
134
- it('calls core refreshOAuthTokens and re-renders with the new tokens', async () => {
135
- const source = createFakeTokenSource(makeTokens())
136
- mockGetState.mockReturnValue(source)
137
- const refreshed = makeTokens({accessToken: 'refreshed-token'})
138
- mockRefresh.mockImplementation(() => {
139
- source.set(refreshed)
140
- return Promise.resolve(refreshed)
141
- })
142
-
143
- const {result} = renderHook(() => useOAuthTokens(), {wrapper})
144
-
145
- let returned: OAuthTokens | null = null
146
- await act(async () => {
147
- returned = await result.current.refresh()
148
- })
149
-
150
- expect(mockRefresh).toHaveBeenCalledTimes(1)
151
- expect(returned).toEqual(refreshed)
152
- expect(result.current.tokens).toEqual(refreshed)
153
- })
154
-
155
- it('resolves null from refresh when core has no refresh token, and tokens become null', async () => {
156
- const existing = makeTokens({refreshToken: undefined})
157
- const source = createFakeTokenSource(existing)
158
- mockGetState.mockReturnValue(source)
159
- // Core clears stored tokens and logs out on the no-refresh-token path.
160
- mockRefresh.mockImplementation(() => {
161
- source.set(null)
162
- return Promise.resolve(null)
163
- })
164
-
165
- const {result} = renderHook(() => useOAuthTokens(), {wrapper})
166
-
167
- let returned: OAuthTokens | null = existing
168
- await act(async () => {
169
- returned = await result.current.refresh()
170
- })
171
-
172
- expect(returned).toBeNull()
173
- expect(result.current.tokens).toBeNull()
174
- })
175
-
176
- it('propagates a rejected refresh and leaves tokens unchanged', async () => {
177
- const existing = makeTokens()
178
- mockGetState.mockReturnValue(createFakeTokenSource(existing))
179
- mockRefresh.mockRejectedValue(new Error('network'))
180
-
181
- const {result} = renderHook(() => useOAuthTokens(), {wrapper})
182
-
183
- await act(async () => {
184
- await expect(result.current.refresh()).rejects.toThrow('network')
185
- })
186
-
187
- expect(result.current.tokens).toEqual(existing)
188
- })
189
-
190
- it('propagates an unrecoverable refresh rejection after core has cleared tokens', async () => {
191
- const source = createFakeTokenSource(makeTokens())
192
- mockGetState.mockReturnValue(source)
193
- // Core clears stored tokens and logs out before rethrowing a 4xx.
194
- mockRefresh.mockImplementation(() => {
195
- source.set(null)
196
- return Promise.reject(new Error('invalid_grant'))
197
- })
198
-
199
- const {result} = renderHook(() => useOAuthTokens(), {wrapper})
200
-
201
- await act(async () => {
202
- await expect(result.current.refresh()).rejects.toThrow('invalid_grant')
203
- })
204
-
205
- expect(result.current.tokens).toBeNull()
206
- })
207
-
208
- it('calls core revokeOAuthTokens and re-renders with tokens=null', async () => {
209
- const source = createFakeTokenSource(makeTokens())
210
- mockGetState.mockReturnValue(source)
211
- mockRevoke.mockImplementation(() => {
212
- source.set(null)
213
- return Promise.resolve()
214
- })
215
-
216
- const {result} = renderHook(() => useOAuthTokens(), {wrapper})
217
-
218
- await act(async () => {
219
- await result.current.revoke()
220
- })
221
-
222
- expect(mockRevoke).toHaveBeenCalledTimes(1)
223
- expect(result.current.tokens).toBeNull()
224
- })
225
-
226
- it('re-renders when tokens change externally (e.g. another tab)', () => {
227
- const source = createFakeTokenSource(null)
228
- mockGetState.mockReturnValue(source)
229
-
230
- const {result} = renderHook(() => useOAuthTokens(), {wrapper})
231
- expect(result.current.tokens).toBeNull()
232
-
233
- const otherTabTokens = makeTokens({accessToken: 'other-tab-token'})
234
- act(() => {
235
- source.set(otherTabTokens)
236
- })
237
-
238
- expect(result.current.tokens).toEqual(otherTabTokens)
239
- })
240
- })
@@ -1,95 +0,0 @@
1
- import {
2
- getOAuthTokensState,
3
- type OAuthTokens,
4
- refreshOAuthTokens,
5
- revokeOAuthTokens,
6
- } from '@sanity/sdk'
7
-
8
- import {createCallbackHook} from '../helpers/createCallbackHook'
9
- import {createStateSourceHook} from '../helpers/createStateSourceHook'
10
-
11
- /**
12
- * The current OAuth token state, plus actions to refresh and revoke it.
13
- *
14
- * @public
15
- */
16
- export interface UseOAuthTokensResult {
17
- /** The stored OAuth tokens, or `null` when not logged in via OAuth */
18
- tokens: OAuthTokens | null
19
- /**
20
- * Returns whether the access token has expired, comparing `expiresAt` against
21
- * the current time at the moment it is called. Reading the clock does not
22
- * trigger a re-render, so call this in an event handler or effect rather than
23
- * during render. Returns `false` when there are no tokens.
24
- */
25
- isExpired: () => boolean
26
- /**
27
- * Refresh via the OAuth `refresh_token` grant. When there is no refresh token,
28
- * core clears the stored tokens, logs the user out, and this resolves `null`.
29
- * Rejects on transient failures (network, 5xx, 408, 429), leaving tokens
30
- * unchanged so the call can be retried. Also rejects when the server rejects
31
- * the refresh token itself (other 4xx); core clears the tokens and logs out
32
- * first, so check `tokens` before retrying.
33
- */
34
- refresh: () => Promise<OAuthTokens | null>
35
- /** Revoke the tokens at the OAuth server, clear them locally, and log out. */
36
- revoke: () => Promise<void>
37
- }
38
-
39
- const useOAuthTokensState = createStateSourceHook(getOAuthTokensState)
40
- const useRefreshOAuthTokens = createCallbackHook(refreshOAuthTokens)
41
- const useRevokeOAuthTokens = createCallbackHook(revokeOAuthTokens)
42
-
43
- function isOAuthTokenExpired(tokens: OAuthTokens | null): boolean {
44
- return tokens ? tokens.expiresAt.getTime() <= Date.now() : false
45
- }
46
-
47
- /**
48
- * A React hook that exposes the stored OAuth token state along with `refresh`
49
- * and `revoke` actions.
50
- *
51
- * @remarks
52
- * The token view is a synchronous read over core's token state source, so the
53
- * hook re-renders whenever tokens change — including changes made in another
54
- * tab, which core propagates via `storage` events.
55
- *
56
- * @returns The current {@link UseOAuthTokensResult}
57
- *
58
- * @example
59
- * ```tsx
60
- * function TokenStatus() {
61
- * const {tokens, isExpired, refresh, revoke} = useOAuthTokens()
62
- *
63
- * if (!tokens) return <div>Not signed in</div>
64
- *
65
- * const handleRefresh = async () => {
66
- * if (!isExpired()) return
67
- * try {
68
- * await refresh()
69
- * } catch {
70
- * // Transient failure (tokens unchanged, retry later) or the refresh
71
- * // token was rejected (tokens now null, user is logged out).
72
- * }
73
- * }
74
- *
75
- * return (
76
- * <div>
77
- * <p>Expires at {tokens.expiresAt.toLocaleTimeString()}</p>
78
- * <button onClick={handleRefresh}>Refresh if expired</button>
79
- * <button onClick={() => revoke()}>Revoke tokens</button>
80
- * </div>
81
- * )
82
- * }
83
- * ```
84
- *
85
- * @public
86
- */
87
- export function useOAuthTokens(): UseOAuthTokensResult {
88
- const tokens = useOAuthTokensState()
89
- return {
90
- tokens,
91
- isExpired: () => isOAuthTokenExpired(tokens),
92
- refresh: useRefreshOAuthTokens(),
93
- revoke: useRevokeOAuthTokens(),
94
- }
95
- }