@sanity/sdk-react 3.3.0-rc.0 → 3.3.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 -2
- package/dist/_exports/dashboard.d.ts +57 -1
- package/dist/_exports/dashboard.d.ts.map +1 -1
- package/dist/_exports/dashboard.js +69 -38
- package/dist/_exports/dashboard.js.map +1 -1
- package/dist/index.d.ts +5 -139
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +27 -145
- package/dist/index.js.map +1 -1
- package/dist/{useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js → useStudioWorkspacesByProjectIdDataset-B5A5kBH9.js} +85 -5
- package/dist/useStudioWorkspacesByProjectIdDataset-B5A5kBH9.js.map +1 -0
- package/package.json +8 -8
- package/src/_exports/dashboard-internal.ts +6 -1
- package/src/_exports/dashboard.test-d.ts +30 -4
- package/src/_exports/dashboard.ts +3 -0
- package/src/_exports/sdk-react.ts +0 -3
- package/src/components/auth/AuthBoundary.test.tsx +2 -81
- package/src/components/auth/AuthBoundary.tsx +3 -17
- package/src/components/auth/LoginCallback.test.tsx +7 -46
- package/src/components/auth/LoginCallback.tsx +4 -22
- package/src/hooks/dashboard/useApplicationBasePath.test.tsx +77 -0
- package/src/hooks/dashboard/useApplicationBasePath.ts +23 -0
- package/src/hooks/dashboard/useApplications.test.tsx +12 -3
- package/src/hooks/dashboard/useApplications.ts +5 -24
- package/src/hooks/dashboard/useAuthToken.test.tsx +48 -0
- package/src/hooks/dashboard/useAuthToken.ts +20 -0
- package/src/hooks/dashboard/useCurrentUser.test.tsx +57 -0
- package/src/hooks/dashboard/useCurrentUser.ts +22 -0
- package/src/hooks/dashboard/useOrganizationId.test.tsx +52 -1
- package/src/hooks/dashboard/useOrganizationId.tsx +15 -2
- package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.test.tsx +142 -1
- package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.ts +58 -1
- package/src/hooks/dashboard/useWindowTitle.test.ts +23 -0
- package/src/hooks/dashboard/useWindowTitle.ts +14 -0
- package/src/hooks/installations/useInstallations.test-d.ts +10 -0
- package/dist/useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js.map +0 -1
- package/src/hooks/auth/useHandleOAuthCallback.test.tsx +0 -16
- package/src/hooks/auth/useHandleOAuthCallback.tsx +0 -49
- package/src/hooks/auth/useOAuthAuthorize.test.tsx +0 -16
- package/src/hooks/auth/useOAuthAuthorize.tsx +0 -28
- package/src/hooks/auth/useOAuthTokens.test.tsx +0 -240
- package/src/hooks/auth/useOAuthTokens.tsx +0 -95
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
|
|
1
2
|
import {renderHook, waitFor} from '@testing-library/react'
|
|
2
3
|
import {afterEach, describe, expect, it, vi} from 'vitest'
|
|
3
4
|
|
|
@@ -8,6 +9,8 @@ vi.mock('../comlink/useWindowConnection', () => ({
|
|
|
8
9
|
useWindowConnection: vi.fn(),
|
|
9
10
|
}))
|
|
10
11
|
|
|
12
|
+
const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
|
|
13
|
+
|
|
11
14
|
function createContextResponse(resource: Record<string, unknown>) {
|
|
12
15
|
return {context: {resource}}
|
|
13
16
|
}
|
|
@@ -242,3 +245,23 @@ describe('useWindowTitle', () => {
|
|
|
242
245
|
consoleSpy.mockRestore()
|
|
243
246
|
})
|
|
244
247
|
})
|
|
248
|
+
|
|
249
|
+
describe('useWindowTitle (message bus)', () => {
|
|
250
|
+
afterEach(() => {
|
|
251
|
+
resetMessageBus()
|
|
252
|
+
delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
|
|
253
|
+
vi.unstubAllGlobals()
|
|
254
|
+
document.title = ''
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('no-ops without suspending under the message bus', () => {
|
|
258
|
+
vi.stubGlobal('__SANITY_APP_ID__', 'app')
|
|
259
|
+
installMessageBus({appId: 'dashboard'})
|
|
260
|
+
document.title = 'Host Owned Title'
|
|
261
|
+
|
|
262
|
+
renderHook(() => useWindowTitle('Movies'))
|
|
263
|
+
|
|
264
|
+
expect(document.title).toBe('Host Owned Title')
|
|
265
|
+
expect(useWindowConnection).not.toHaveBeenCalled()
|
|
266
|
+
})
|
|
267
|
+
})
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
/* eslint-disable react-compiler/react-compiler -- the transport branch in `useWindowTitle` is a deliberate rules-of-hooks exception; the compiler refuses files that disable it */
|
|
1
2
|
import {SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
|
|
3
|
+
import {isDashboardEnvironment} from '@sanity/sdk/_internal'
|
|
2
4
|
import {useEffect, useState} from 'react'
|
|
3
5
|
|
|
4
6
|
import {useWindowConnection} from '../comlink/useWindowConnection'
|
|
@@ -38,6 +40,9 @@ function resolveAppTitle(resource: ContextResource): string | undefined {
|
|
|
38
40
|
*
|
|
39
41
|
* The Sanity dashboard appends `| Sanity` to produce the final browser tab title.
|
|
40
42
|
*
|
|
43
|
+
* Works in both Dashboard runtimes: it manages the document title over the Comlink connection, and
|
|
44
|
+
* no-ops under the message bus, where the Dashboard host owns the title.
|
|
45
|
+
*
|
|
41
46
|
* @param viewTitle - An optional view-specific title to prepend to the app title.
|
|
42
47
|
*
|
|
43
48
|
* @example
|
|
@@ -66,6 +71,15 @@ function resolveAppTitle(resource: ContextResource): string | undefined {
|
|
|
66
71
|
* @public
|
|
67
72
|
*/
|
|
68
73
|
export function useWindowTitle(viewTitle?: string): void {
|
|
74
|
+
// The document title is set by the Dashboard host under the message bus, so this hook only
|
|
75
|
+
// manages it in the Comlink runtime. The branch is stable: the transport is fixed for the page
|
|
76
|
+
// lifetime, so the Comlink hooks below always run or never run.
|
|
77
|
+
if (isDashboardEnvironment()) return
|
|
78
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks -- transport is fixed for the page lifetime
|
|
79
|
+
useComlinkWindowTitle(viewTitle)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function useComlinkWindowTitle(viewTitle?: string): void {
|
|
69
83
|
const [appTitle, setAppTitle] = useState<string | null>(null)
|
|
70
84
|
|
|
71
85
|
const {fetch} = useWindowConnection({
|
|
@@ -20,6 +20,16 @@ test('useInstallations — include shapes each item', () => {
|
|
|
20
20
|
expectTypeOf<Item['access']>().toEqualTypeOf<InstallationAccess[]>()
|
|
21
21
|
})
|
|
22
22
|
|
|
23
|
+
test('useInstallations — includes the module federation manifest', () => {
|
|
24
|
+
const result = useInstallations({
|
|
25
|
+
organizationId: 'org_1',
|
|
26
|
+
include: ['config.mfManifest'],
|
|
27
|
+
})
|
|
28
|
+
expectTypeOf(result.data.data).toEqualTypeOf<Installation<'config.mfManifest'>[]>()
|
|
29
|
+
type Item = (typeof result.data.data)[number]
|
|
30
|
+
expectTypeOf<Item['application']['config']['mfManifest']>().toEqualTypeOf<unknown>()
|
|
31
|
+
})
|
|
32
|
+
|
|
23
33
|
test('useInstallations — requires an organizationId', () => {
|
|
24
34
|
// @ts-expect-error — organizationId is required
|
|
25
35
|
void useInstallations({})
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js","names":["SanityInstance","createContext","SanityInstanceContext","SanityInstance","useContext","SanityInstanceContext","useSanityInstance","instance","Error","SanityConfig","SanityInstance","StateSource","useSyncExternalStore","useSanityInstance","StateSourceFactory","instance","params","TParams","TState","CreateStateSourceHookOptions","getState","shouldSuspend","suspender","Promise","getConfig","createStateSourceHook","options","suspense","undefined","useHook","t0","$","_c","t1","state","subscribe","getCurrent","AuthState","getAuthState","createStateSourceHook","useAuthState","MessageData","NodeInput","SanityInstance","StateSource","FrameMessage","getNodeState","NodeState","WindowMessage","useCallback","useEffect","useRef","filter","firstValueFrom","useSanityInstance","createStateSourceHook","WindowMessageHandler","event","TFrameMessage","UseWindowConnectionOptions","name","connectTo","onMessage","Record","TMessage","WindowConnection","sendMessage","type","TType","data","Extract","fetch","options","signal","AbortSignal","suppressWarnings","responseTimeout","Promise","TResponse","useNodeState","getState","instance","nodeInput","shouldSuspend","getCurrent","undefined","suspender","observable","pipe","Boolean","useWindowConnection","t0","$","_c","t1","node","t2","Symbol","for","messageUnsubscribers","t3","Object","entries","forEach","t4","handler","messageUnsubscribe","on","current","push","_temp","t5","type_0","post","t6","type_1","data_0","fetchOptions","t7","unsubscribe","React","MODULE_SLOT_KEY","Symbol","for","ModuleContext","Context","ModuleSlot","WeakMap","createContext","getDashboardModuleContext","globals","globalThis","slot","key","context","get","undefined","set","ClientError","AuthStateType","setAuthToken","getDashboardMessageBus","MessageBus","React","PropsWithChildren","useContext","useEffect","useRef","useState","defer","of","catchError","getDashboardModuleContext","useAuthState","useSanityInstance","DashboardTokenRefresh","t0","$","_c","children","messageBus","instance","authState","processed401ErrorRef","t1","t2","subscription","subscribe","pipe","_temp","token","unsubscribe","t3","error","type","has401Error","ERROR","statusCode","current","emit","undefined","catch","_temp2","t4","console","warn","DashboardTokenRefreshProvider","FC","Symbol","for","moduleId","getDashboardOrganizationId","useMemo","useSyncExternalStore","useSanityInstance","useOrganizationId","$","_c","instance","t0","subscribe","getCurrent","SDK_CHANNEL_NAME","SDK_NODE_NAME","useEffect","useState","useWindowConnection","DashboardResource","id","name","title","basePath","projectId","dataset","type","userApplicationId","url","WorkspacesByProjectIdDataset","key","StudioWorkspacesResult","workspacesByProjectIdAndDataset","error","useStudioWorkspacesByProjectIdDataset","$","_c","t0","Symbol","for","setWorkspacesByProjectIdAndDataset","setError","t1","connectTo","fetch","t2","t3","fetchWorkspaces","signal","data","undefined","workspaceMap","noProjectIdAndDataset","context","availableResources","forEach","resource","push","const","length","t4","err","Error","controller","AbortController","abort"],"sources":["../src/context/SanityInstanceContext.ts","../src/hooks/context/useSanityInstance.ts","../src/hooks/helpers/createStateSourceHook.tsx","../src/hooks/auth/useAuthState.tsx","../src/hooks/comlink/useWindowConnection.ts","../src/dashboard/module.ts","../src/context/DashboardTokenRefresh.tsx","../src/hooks/dashboard/useOrganizationId.tsx","../src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.ts"],"sourcesContent":["import {type SanityInstance} from '@sanity/sdk'\nimport {createContext} from 'react'\n\nexport const SanityInstanceContext = createContext<SanityInstance | null>(null)\n","import {type SanityInstance} from '@sanity/sdk'\nimport {useContext} from 'react'\n\nimport {SanityInstanceContext} from '../../context/SanityInstanceContext'\n\n/**\n * Retrieves the current Sanity instance from context\n *\n * @public\n *\n * @category Platform\n * @returns The current Sanity instance\n *\n * @remarks\n * This hook accesses the nearest Sanity instance from the React context.\n * The hook must be used within a component wrapped by a `ResourceProvider` or `SanityApp`.\n *\n * @example Get the current instance\n * ```tsx\n * const instance = useSanityInstance()\n * console.log(instance.config.projectId)\n * ```\n *\n * @throws Error if no SanityInstance is found in context\n */\nexport const useSanityInstance = (): SanityInstance => {\n const instance = useContext(SanityInstanceContext)\n\n if (!instance) {\n throw new Error(\n `SanityInstance context not found. Please ensure that your component is wrapped in a ResourceProvider or a SanityApp component.`,\n )\n }\n\n return instance\n}\n","import {type SanityConfig, type SanityInstance, type StateSource} from '@sanity/sdk'\nimport {useSyncExternalStore} from 'react'\n\nimport {useSanityInstance} from '../context/useSanityInstance'\n\ntype StateSourceFactory<TParams extends unknown[], TState> = (\n instance: SanityInstance,\n ...params: TParams\n) => StateSource<TState>\n\ninterface CreateStateSourceHookOptions<TParams extends unknown[], TState> {\n getState: StateSourceFactory<TParams, TState>\n shouldSuspend?: (instance: SanityInstance, ...params: TParams) => boolean\n suspender?: (instance: SanityInstance, ...params: TParams) => Promise<unknown>\n getConfig?: (...params: TParams) => SanityConfig | undefined\n}\n\nexport function createStateSourceHook<TParams extends unknown[], TState>(\n options: StateSourceFactory<TParams, TState> | CreateStateSourceHookOptions<TParams, TState>,\n): (...params: TParams) => TState {\n const getState = typeof options === 'function' ? options : options.getState\n const suspense = 'shouldSuspend' in options && 'suspender' in options ? options : undefined\n\n function useHook(...params: TParams) {\n const instance = useSanityInstance()\n\n if (suspense?.suspender && suspense?.shouldSuspend?.(instance, ...params)) {\n throw suspense.suspender(instance, ...params)\n }\n\n const state = getState(instance, ...params)\n return useSyncExternalStore(state.subscribe, state.getCurrent)\n }\n\n return useHook\n}\n","import {type AuthState, getAuthState} from '@sanity/sdk'\n\nimport {createStateSourceHook} from '../helpers/createStateSourceHook'\n\n/**\n * @internal\n * A React hook that subscribes to authentication state changes.\n *\n * This hook provides access to the current authentication state type from the Sanity auth store.\n * It automatically re-renders when the authentication state changes.\n *\n * @remarks\n * The hook uses `useSyncExternalStore` to safely subscribe to auth state changes\n * and ensure consistency between server and client rendering.\n *\n * @returns The current authentication state type\n *\n * @example\n * ```tsx\n * function AuthStatus() {\n * const authState = useAuthState()\n * return <div>Current auth state: {authState}</div>\n * }\n * ```\n */\nexport const useAuthState: () => AuthState = createStateSourceHook(getAuthState)\n","import {type MessageData, type NodeInput} from '@sanity/comlink'\nimport {type SanityInstance, type StateSource} from '@sanity/sdk'\nimport {\n type FrameMessage,\n getNodeState,\n type NodeState,\n type WindowMessage,\n} from '@sanity/sdk/comlink'\nimport {useCallback, useEffect, useRef} from 'react'\nimport {filter, firstValueFrom} from 'rxjs'\n\nimport {useSanityInstance} from '../context/useSanityInstance'\nimport {createStateSourceHook} from '../helpers/createStateSourceHook'\n\n/**\n * @internal\n */\nexport type WindowMessageHandler<TFrameMessage extends FrameMessage> = (\n event: TFrameMessage['data'],\n) => TFrameMessage['response']\n\n/**\n * @internal\n */\nexport interface UseWindowConnectionOptions<TMessage extends FrameMessage> {\n name: string\n connectTo: string\n onMessage?: Record<TMessage['type'], WindowMessageHandler<TMessage>>\n}\n\n/**\n * @internal\n */\nexport interface WindowConnection<TMessage extends WindowMessage> {\n sendMessage: <TType extends TMessage['type']>(\n type: TType,\n data?: Extract<TMessage, {type: TType}>['data'],\n ) => void\n fetch: <TResponse>(\n type: string,\n data?: MessageData,\n options?: {\n signal?: AbortSignal\n suppressWarnings?: boolean\n responseTimeout?: number\n },\n ) => Promise<TResponse>\n}\n\nconst useNodeState = createStateSourceHook({\n getState: getNodeState as (\n instance: SanityInstance,\n nodeInput: NodeInput,\n ) => StateSource<NodeState>,\n shouldSuspend: (instance: SanityInstance, nodeInput: NodeInput) =>\n getNodeState(instance, nodeInput).getCurrent() === undefined,\n suspender: (instance: SanityInstance, nodeInput: NodeInput) => {\n return firstValueFrom(getNodeState(instance, nodeInput).observable.pipe(filter(Boolean)))\n },\n})\n\n/**\n * @internal\n * Hook to wrap a Comlink node in a React hook.\n * Our store functionality takes care of the lifecycle of the node,\n * as well as sharing a single node between invocations if they share the same name.\n *\n * Generally not to be used directly, but to be used as a dependency of\n * Comlink-powered hooks like `useStudioWorkspacesByProjectIdDataset`.\n */\nexport function useWindowConnection<\n TWindowMessage extends WindowMessage,\n TFrameMessage extends FrameMessage,\n>({\n name,\n connectTo,\n onMessage,\n}: UseWindowConnectionOptions<TFrameMessage>): WindowConnection<TWindowMessage> {\n const {node} = useNodeState({name, connectTo})\n const messageUnsubscribers = useRef<(() => void)[]>([])\n const instance = useSanityInstance()\n\n useEffect(() => {\n if (onMessage) {\n Object.entries(onMessage).forEach(([type, handler]) => {\n const messageUnsubscribe = node.on(type, handler as WindowMessageHandler<TFrameMessage>)\n if (messageUnsubscribe) {\n messageUnsubscribers.current.push(messageUnsubscribe)\n }\n })\n }\n\n return () => {\n messageUnsubscribers.current.forEach((unsubscribe) => unsubscribe())\n messageUnsubscribers.current = []\n }\n }, [instance, name, onMessage, node])\n\n const sendMessage = useCallback(\n (type: TWindowMessage['type'], data?: Extract<TWindowMessage, {type: typeof type}>['data']) => {\n node.post(type, data)\n },\n [node],\n )\n\n const fetch = useCallback(\n <TResponse>(\n type: string,\n data?: MessageData,\n fetchOptions?: {\n responseTimeout?: number\n signal?: AbortSignal\n suppressWarnings?: boolean\n },\n ): Promise<TResponse> => {\n return node.fetch(type, data, fetchOptions ?? {}) as Promise<TResponse>\n },\n [node],\n )\n return {\n sendMessage,\n fetch,\n }\n}\n","import * as React from 'react'\n\nconst MODULE_SLOT_KEY = Symbol.for('sanity.os.module')\n\ntype ModuleContext = React.Context<string | undefined>\ntype ModuleSlot = WeakMap<typeof React.createContext, ModuleContext>\n\n/**\n * Returns the React context that carries the current federation module id.\n *\n * @remarks\n * This is the slot the CLI-generated wrapper populates with\n * `renderOptions.moduleId`. The context lives in a per-React-copy slot on\n * `globalThis` so the CLI wrapper and the SDK share the same context even\n * across module copies. Each React copy gets its own context, since a context\n * created by one copy is inert in another.\n *\n * The slot is keyed on `React.createContext` rather than the `React` namespace\n * object: `import * as React` and `import React from 'react'` can yield\n * different wrapper objects for the same React copy under bundler interop,\n * whereas the `createContext` function is the same reference under both.\n *\n * The provider side lives in the CLI, which must not import the SDK:\n * `packages/@sanity/workbench-cli/src/actions/build/render-remote.ts` in\n * `sanity-io/cli` reconstructs this accessor (same symbol, same key) and\n * wraps `App` in the context's `Provider`. Nothing in this repo exports it.\n * @internal\n */\nexport function getDashboardModuleContext(): ModuleContext {\n const globals = globalThis as {[MODULE_SLOT_KEY]?: ModuleSlot}\n const slot = (globals[MODULE_SLOT_KEY] ??= new WeakMap())\n const key = React.createContext\n let context = slot.get(key)\n if (!context) {\n context = React.createContext<string | undefined>(undefined)\n slot.set(key, context)\n }\n return context\n}\n","import {type ClientError} from '@sanity/client'\nimport {AuthStateType, setAuthToken} from '@sanity/sdk'\nimport {getDashboardMessageBus} from '@sanity/sdk/_internal'\nimport {type MessageBus} from '@sanity/sdk/dashboard'\nimport React, {type PropsWithChildren, useContext, useEffect, useRef, useState} from 'react'\nimport {defer, of} from 'rxjs'\nimport {catchError} from 'rxjs/operators'\n\nimport {getDashboardModuleContext} from '../dashboard/module'\nimport {useAuthState} from '../hooks/auth/useAuthState'\nimport {useSanityInstance} from '../hooks/context/useSanityInstance'\n\n/**\n * Keeps the SDK auth token in sync with the dashboard \"OS\".\n *\n * When running inside the dashboard the OS owns the session, so we subscribe\n * to its `auth.token` stream and mirror each value into\n * the auth store — a token logs us in, `null` logs us out, and later OS\n * sign-in/out propagates automatically. When a request is rejected with a 401\n * (the token expired), we ask the OS to reissue rather than tearing the session\n * down; the new token arrives back through the same subscription.\n */\nfunction DashboardTokenRefresh({\n children,\n messageBus,\n}: PropsWithChildren<{messageBus: MessageBus}>) {\n const instance = useSanityInstance()\n const authState = useAuthState()\n const processed401ErrorRef = useRef<unknown | null>(null)\n\n useEffect(() => {\n const subscription = defer(() => messageBus.subscribe('auth.token'))\n .pipe(catchError(() => of(null)))\n .subscribe((token) => setAuthToken(instance, token))\n return () => subscription.unsubscribe()\n }, [instance, messageBus])\n\n useEffect(() => {\n const has401Error =\n authState.type === AuthStateType.ERROR && (authState.error as ClientError)?.statusCode === 401\n\n if (has401Error && processed401ErrorRef.current !== authState.error) {\n processed401ErrorRef.current = authState.error\n // Event topics have no replay, so a missing responder or timeout is otherwise dropped silently.\n messageBus.emit('auth.token.refresh', undefined).catch((error) => {\n // eslint-disable-next-line no-console\n console.warn('[sanity/sdk] Dashboard token refresh failed:', error)\n })\n } else if (!has401Error) {\n processed401ErrorRef.current = null\n }\n }, [authState, messageBus])\n\n return children\n}\n\n/**\n * Authenticates the SDK with the Sanity Dashboard's session when the app runs\n * inside the dashboard.\n *\n * The dashboard owns the session there: this provider subscribes to the token\n * the dashboard issues, writes each new value into the SDK's auth store (where\n * SDK hooks read it from), and asks the dashboard for a fresh token when a\n * request fails with a 401. Outside the dashboard it renders children\n * unchanged and the app's normal auth flow applies.\n *\n * @remarks\n * `AuthBoundary` mounts this automatically, so most apps never need it\n * directly. Mount it yourself only when your app runs inside the dashboard\n * without `AuthBoundary` — that is, the app renders its own loading and error\n * UI instead of the SDK's login flow — but still uses SDK hooks such as\n * `useQuery`, which need the dashboard's token in the auth store to\n * authenticate their requests.\n *\n * Mount it once, inside the provider that creates the Sanity instance whose\n * store should receive the token.\n *\n * @example\n * ```tsx\n * import {ResourceProvider} from '@sanity/sdk-react'\n * import {TokenRefreshProvider} from '@sanity/sdk-react/dashboard'\n *\n * function EmbeddedApp() {\n * return (\n * <ResourceProvider fallback={<Loading />}>\n * <TokenRefreshProvider>\n * <App />\n * </TokenRefreshProvider>\n * </ResourceProvider>\n * )\n * }\n * ```\n *\n * @public\n */\nexport const DashboardTokenRefreshProvider: React.FC<PropsWithChildren> = ({children}) => {\n const instance = useSanityInstance()\n const moduleId = useContext(getDashboardModuleContext())\n // The connection is first-caller-wins per instance, and hooks below read it during their\n // render, before any effect here could run. Connecting in the first render pins the module\n // identity before they do; nothing has subscribed to the store yet, so the write is safe.\n // The module id is read once: the CLI wrapper provides it statically above this tree.\n // No retry: the host installs the bus at module evaluation, before any remote renders, and\n // a standalone app has no host to wait for.\n const [messageBus] = useState(() => getDashboardMessageBus(instance, moduleId))\n if (messageBus) {\n return <DashboardTokenRefresh messageBus={messageBus}>{children}</DashboardTokenRefresh>\n }\n\n return children\n}\n","import {getDashboardOrganizationId} from '@sanity/sdk'\nimport {useMemo, useSyncExternalStore} from 'react'\n\nimport {useSanityInstance} from '../context/useSanityInstance'\n\n/**\n * @public\n *\n * A React hook that retrieves the dashboard organization ID that is currently selected in the Sanity Dashboard.\n *\n * @example\n * ```tsx\n * function DashboardComponent() {\n * const orgId = useOrganizationId()\n *\n * if (!orgId) return null\n *\n * return <div>Organization ID: {String(orgId)}</div>\n * }\n * ```\n *\n * @category Dashboard\n * @returns The dashboard organization ID (string | undefined)\n */\nexport function useOrganizationId(): string | undefined {\n const instance = useSanityInstance()\n const {subscribe, getCurrent} = useMemo(() => getDashboardOrganizationId(instance), [instance])\n\n return useSyncExternalStore(subscribe, getCurrent)\n}\n","import {SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'\nimport {useEffect, useState} from 'react'\n\nimport {useWindowConnection} from '../comlink/useWindowConnection'\n\nexport interface DashboardResource {\n id: string\n name: string\n title: string\n basePath: string\n projectId: string\n dataset: string\n type: string\n userApplicationId: string\n url: string\n}\n\ninterface WorkspacesByProjectIdDataset {\n [key: `${string}:${string}`]: DashboardResource[] // key format: `${projectId}:${dataset}`\n}\n\ninterface StudioWorkspacesResult {\n workspacesByProjectIdAndDataset: WorkspacesByProjectIdDataset\n error: string | null\n}\n\n/**\n * Hook that fetches studio workspaces and organizes them by projectId:dataset\n * @internal\n *\n * @example\n * ```tsx\n * import {useStudioWorkspacesByProjectIdDataset} from '@sanity/sdk-react'\n * import {Card, Code, Button} from '@sanity/ui'\n * import {Suspense} from 'react'\n *\n * function WorkspacesCard() {\n * const {workspacesByProjectIdAndDataset, error} = useStudioWorkspacesByProjectIdDataset()\n * if (error) {\n * return <div>Error: {error}</div>\n * }\n * return (\n * <Card padding={4} radius={2} shadow={1}>\n * <Code language=\"json\">\n * {JSON.stringify(workspacesByProjectIdAndDataset, null, 2)}\n * </Code>\n * </Card>\n * )\n * }\n *\n * // Wrap the component with Suspense since the hook may suspend\n * function DashboardWorkspaces() {\n * return (\n * <Suspense fallback={<Button text=\"Loading...\" disabled />}>\n * <WorkspacesCard />\n * </Suspense>\n * )\n * }\n * ```\n */\nexport function useStudioWorkspacesByProjectIdDataset(): StudioWorkspacesResult {\n const [workspacesByProjectIdAndDataset, setWorkspacesByProjectIdAndDataset] =\n useState<WorkspacesByProjectIdDataset>({})\n const [error, setError] = useState<string | null>(null)\n\n const {fetch} = useWindowConnection({\n name: SDK_NODE_NAME,\n connectTo: SDK_CHANNEL_NAME,\n })\n\n // Once computed, this should probably be in a store and poll for changes\n // However, our stores are currently being refactored\n useEffect(() => {\n if (!fetch) return\n\n async function fetchWorkspaces(signal: AbortSignal) {\n try {\n const data = await fetch<{\n context: {availableResources: Array<DashboardResource>}\n }>('dashboard/v1/context', undefined, {signal})\n\n const workspaceMap: WorkspacesByProjectIdDataset = {}\n const noProjectIdAndDataset: DashboardResource[] = []\n\n data.context.availableResources.forEach((resource) => {\n if (resource.type !== 'studio') return\n if (!resource.projectId || !resource.dataset) {\n noProjectIdAndDataset.push(resource)\n return\n }\n const key = `${resource.projectId}:${resource.dataset}` as const\n if (!workspaceMap[key]) {\n workspaceMap[key] = []\n }\n workspaceMap[key].push(resource)\n })\n\n if (noProjectIdAndDataset.length > 0) {\n workspaceMap['NO_PROJECT_ID:NO_DATASET'] = noProjectIdAndDataset\n }\n\n setWorkspacesByProjectIdAndDataset(workspaceMap)\n setError(null)\n } catch (err: unknown) {\n if (err instanceof Error) {\n if (err.name === 'AbortError') {\n return\n }\n setError('Failed to fetch workspaces')\n }\n }\n }\n\n const controller = new AbortController()\n fetchWorkspaces(controller.signal)\n\n return () => {\n controller.abort()\n }\n }, [fetch])\n\n return {\n workspacesByProjectIdAndDataset,\n error,\n }\n}\n"],"mappings":"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"}
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import {handleOAuthCallback} 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', () => ({handleOAuthCallback: vi.fn()}))
|
|
9
|
-
|
|
10
|
-
describe('useHandleOAuthCallback', () => {
|
|
11
|
-
it('calls `createCallbackHook` with `handleOAuthCallback`', async () => {
|
|
12
|
-
const {useHandleOAuthCallback} = await import('./useHandleOAuthCallback')
|
|
13
|
-
expect(createCallbackHook).toHaveBeenCalledWith(handleOAuthCallback)
|
|
14
|
-
expect(useHandleOAuthCallback).toBe(handleOAuthCallback)
|
|
15
|
-
})
|
|
16
|
-
})
|
|
@@ -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
|
-
}
|