@sanity/sdk 0.0.0-alpha.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/dist/index.d.ts +339 -0
- package/dist/index.js +492 -0
- package/dist/index.js.map +1 -0
- package/package.json +77 -0
- package/src/_exports/index.ts +39 -0
- package/src/auth/authStore.test.ts +296 -0
- package/src/auth/authStore.ts +125 -0
- package/src/auth/getAuthStore.test.ts +14 -0
- package/src/auth/getInternalAuthStore.ts +20 -0
- package/src/auth/internalAuthStore.test.ts +334 -0
- package/src/auth/internalAuthStore.ts +519 -0
- package/src/client/getClient.test.ts +41 -0
- package/src/client/getClient.ts +13 -0
- package/src/client/getSubscribableClient.test.ts +71 -0
- package/src/client/getSubscribableClient.ts +17 -0
- package/src/client/store/actions/getClientEvents.test.ts +95 -0
- package/src/client/store/actions/getClientEvents.ts +33 -0
- package/src/client/store/actions/getOrCreateClient.test.ts +56 -0
- package/src/client/store/actions/getOrCreateClient.ts +40 -0
- package/src/client/store/actions/receiveToken.test.ts +18 -0
- package/src/client/store/actions/receiveToken.ts +31 -0
- package/src/client/store/clientStore.test.ts +152 -0
- package/src/client/store/clientStore.ts +98 -0
- package/src/documentList/documentListStore.test.ts +575 -0
- package/src/documentList/documentListStore.ts +269 -0
- package/src/documents/.keep +0 -0
- package/src/instance/identity.test.ts +46 -0
- package/src/instance/identity.ts +28 -0
- package/src/instance/sanityInstance.test.ts +66 -0
- package/src/instance/sanityInstance.ts +64 -0
- package/src/instance/types.d.ts +29 -0
- package/src/schema/schemaStore.test.ts +30 -0
- package/src/schema/schemaStore.ts +32 -0
- package/src/store/createStore.test.ts +108 -0
- package/src/store/createStore.ts +106 -0
- package/src/tsdoc.json +39 -0
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import {type ClientConfig, createClient, type SanityClient} from '@sanity/client'
|
|
2
|
+
import type {CurrentUser} from '@sanity/types'
|
|
3
|
+
import {distinctUntilChanged, EMPTY, filter, fromEvent, map, Observable} from 'rxjs'
|
|
4
|
+
import {devtools} from 'zustand/middleware'
|
|
5
|
+
import {createStore, type StoreApi} from 'zustand/vanilla'
|
|
6
|
+
|
|
7
|
+
import type {SanityInstance} from '../instance/types'
|
|
8
|
+
|
|
9
|
+
const AUTH_CODE_PARAM = 'sid'
|
|
10
|
+
const DEFAULT_BASE = 'http://localhost'
|
|
11
|
+
const DEFAULT_API_VERSION = '2021-06-07'
|
|
12
|
+
const REQUEST_TAG_PREFIX = 'sdk.auth'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Represents the various states the authentication store can be in.
|
|
16
|
+
*
|
|
17
|
+
* @public
|
|
18
|
+
*/
|
|
19
|
+
export type AuthState =
|
|
20
|
+
| {type: 'logged-in'; token: string; currentUser: CurrentUser | null}
|
|
21
|
+
| {type: 'logging-in'; isExchangingToken: boolean}
|
|
22
|
+
| {type: 'error'; error: unknown}
|
|
23
|
+
| {type: 'logged-out'; isDestroyingSession: boolean}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Configuration for an authentication provider
|
|
27
|
+
* @public
|
|
28
|
+
*/
|
|
29
|
+
export interface AuthProvider {
|
|
30
|
+
/**
|
|
31
|
+
* Unique identifier for the auth provider (e.g., 'google', 'github')
|
|
32
|
+
*/
|
|
33
|
+
name: string
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Display name for the auth provider in the UI
|
|
37
|
+
*/
|
|
38
|
+
title: string
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Complete authentication URL including callback and token parameters
|
|
42
|
+
*/
|
|
43
|
+
url: string
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Optional URL for direct sign-up flow
|
|
47
|
+
*/
|
|
48
|
+
signUpUrl?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Configuration options for creating an auth store.
|
|
53
|
+
*
|
|
54
|
+
* @public
|
|
55
|
+
*/
|
|
56
|
+
export interface AuthConfig {
|
|
57
|
+
/**
|
|
58
|
+
* The initial location href to use when handling auth callbacks.
|
|
59
|
+
* Defaults to the current window location if available.
|
|
60
|
+
*/
|
|
61
|
+
initialLocationHref?: string
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Factory function to create a SanityClient instance.
|
|
65
|
+
* Defaults to the standard Sanity client factory if not provided.
|
|
66
|
+
*/
|
|
67
|
+
clientFactory?: (config: ClientConfig) => SanityClient
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Custom authentication providers to use instead of or in addition to the default ones.
|
|
71
|
+
* Can be an array of providers or a function that takes the default providers and returns
|
|
72
|
+
* a modified array or a Promise resolving to one.
|
|
73
|
+
*/
|
|
74
|
+
providers?: AuthProvider[] | ((prev: AuthProvider[]) => AuthProvider[] | Promise<AuthProvider[]>)
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The API hostname for requests. Usually leave this undefined, but it can be set
|
|
78
|
+
* if using a custom domain or CNAME for the API endpoint.
|
|
79
|
+
*/
|
|
80
|
+
apiHost?: string
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Storage implementation to persist authentication state.
|
|
84
|
+
* Defaults to `localStorage` if available.
|
|
85
|
+
*/
|
|
86
|
+
storageArea?: Storage
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A callback URL for your application.
|
|
90
|
+
* If none is provided, the auth API will redirect back to the current location (`location.href`).
|
|
91
|
+
* When handling callbacks, this URL's pathname is checked to ensure it matches the callback.
|
|
92
|
+
*/
|
|
93
|
+
callbackUrl?: string
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A static authentication token to use instead of handling the OAuth flow.
|
|
97
|
+
* When provided, the auth store will remain in a logged-in state with this token,
|
|
98
|
+
* ignoring any storage or callback handling.
|
|
99
|
+
*/
|
|
100
|
+
token?: string
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The authentication scope.
|
|
104
|
+
* If set to 'project', requests are scoped to the project-level.
|
|
105
|
+
* If set to 'org', requests are scoped to the organization-level.
|
|
106
|
+
* Defaults to 'project'.
|
|
107
|
+
*/
|
|
108
|
+
authScope?: 'project' | 'org'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Represents an authentication store that can handle login/logout flows, fetch providers,
|
|
113
|
+
* handle auth callbacks, subscribe to state changes, and more.
|
|
114
|
+
*
|
|
115
|
+
* @internal
|
|
116
|
+
*/
|
|
117
|
+
export interface InternalAuthState {
|
|
118
|
+
authState: AuthState
|
|
119
|
+
setAuthState: (authState: AuthState) => void
|
|
120
|
+
providers: AuthProvider[] | undefined
|
|
121
|
+
setProviders: (providers: AuthProvider[] | undefined) => void
|
|
122
|
+
/**
|
|
123
|
+
* Handles an OAuth callback by reading the `sid` parameter from the given `locationHref`.
|
|
124
|
+
* If a token is successfully fetched, the state transitions to `logged-in`.
|
|
125
|
+
*
|
|
126
|
+
* @param locationHref - The location to parse for callback parameters. Defaults to current location.
|
|
127
|
+
* @returns A promise resolving to the updated URL (with callback params removed) or `false` if no callback was handled.
|
|
128
|
+
*/
|
|
129
|
+
handleCallback(locationHref?: string): Promise<string | false>
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Fetches authentication providers (OAuth endpoints) for logging in.
|
|
133
|
+
* Can optionally be customized with `providers` in {@link AuthConfig}.
|
|
134
|
+
* Results are cached after the first call. Subsequent calls return synchronously from cache.
|
|
135
|
+
*
|
|
136
|
+
* @returns Authentication providers as {@link AuthProvider} objects with pre-configured login URLs.
|
|
137
|
+
*/
|
|
138
|
+
getLoginUrls(): AuthProvider[] | Promise<AuthProvider[]>
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Logs out the current user. If a static token was provided, logout is a no-op.
|
|
142
|
+
* Otherwise, sends a request to invalidate the current token and updates state to `logged-out`.
|
|
143
|
+
*/
|
|
144
|
+
logout(): Promise<void>
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Disposes of any internal resources and subscriptions used by the store.
|
|
148
|
+
* After calling dispose, the store should no longer be used.
|
|
149
|
+
*/
|
|
150
|
+
dispose(): void
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Internal auth store type.
|
|
155
|
+
* @internal
|
|
156
|
+
*/
|
|
157
|
+
export type InternalAuthStore = StoreApi<InternalAuthState>
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Returns the default location to use.
|
|
161
|
+
* Tries accessing `location.href`, falls back to a default base if not available or on error.
|
|
162
|
+
*/
|
|
163
|
+
function getDefaultLocation() {
|
|
164
|
+
try {
|
|
165
|
+
if (typeof location === 'undefined') return DEFAULT_BASE
|
|
166
|
+
if (typeof location.href === 'string') return location.href
|
|
167
|
+
return DEFAULT_BASE
|
|
168
|
+
} catch {
|
|
169
|
+
return DEFAULT_BASE
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Returns a default storage instance (localStorage) if available.
|
|
175
|
+
* If not available or an error occurs, returns undefined.
|
|
176
|
+
*/
|
|
177
|
+
function getDefaultStorage() {
|
|
178
|
+
try {
|
|
179
|
+
if (typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function') {
|
|
180
|
+
return localStorage
|
|
181
|
+
}
|
|
182
|
+
return undefined
|
|
183
|
+
} catch {
|
|
184
|
+
return undefined
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Creates an observable stream of storage events. If not in a browser environment,
|
|
190
|
+
* returns an EMPTY observable.
|
|
191
|
+
*/
|
|
192
|
+
function getStorageEvents(): Observable<StorageEvent> {
|
|
193
|
+
const isBrowser = typeof window !== 'undefined' && typeof window.addEventListener === 'function'
|
|
194
|
+
|
|
195
|
+
if (!isBrowser) {
|
|
196
|
+
return EMPTY
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return fromEvent<StorageEvent>(window, 'storage')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Creates a new authentication store for managing OAuth flows, tokens, and related state.
|
|
204
|
+
*
|
|
205
|
+
* @param instance - The Sanity instance configuration.
|
|
206
|
+
* @param config - The auth configuration options.
|
|
207
|
+
* @returns An {@link InternalAuthStore} instance.
|
|
208
|
+
*
|
|
209
|
+
* @internal
|
|
210
|
+
*/
|
|
211
|
+
export function createInternalAuthStore(
|
|
212
|
+
instance: SanityInstance,
|
|
213
|
+
config: AuthConfig = {},
|
|
214
|
+
): InternalAuthStore {
|
|
215
|
+
const {
|
|
216
|
+
clientFactory = createClient,
|
|
217
|
+
initialLocationHref = getDefaultLocation(),
|
|
218
|
+
storageArea = getDefaultStorage(),
|
|
219
|
+
authScope = 'project',
|
|
220
|
+
apiHost,
|
|
221
|
+
callbackUrl,
|
|
222
|
+
providers: customProviders,
|
|
223
|
+
token: providedToken,
|
|
224
|
+
} = config
|
|
225
|
+
|
|
226
|
+
const {projectId, dataset} = instance.identity
|
|
227
|
+
const storageKey = `__sanity_auth_token_${projectId}_${dataset}`
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Attempts to retrieve a token from the configured storage.
|
|
231
|
+
* If invalid or not present, returns null.
|
|
232
|
+
*/
|
|
233
|
+
function getTokenFromStorage() {
|
|
234
|
+
if (!storageArea) return null
|
|
235
|
+
const item = storageArea.getItem(storageKey)
|
|
236
|
+
if (item === null) return null
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const parsed: unknown = JSON.parse(item)
|
|
240
|
+
if (
|
|
241
|
+
typeof parsed !== 'object' ||
|
|
242
|
+
parsed === null ||
|
|
243
|
+
!('token' in parsed) ||
|
|
244
|
+
typeof parsed.token !== 'string'
|
|
245
|
+
) {
|
|
246
|
+
throw new Error('Invalid stored auth data structure')
|
|
247
|
+
}
|
|
248
|
+
return parsed.token
|
|
249
|
+
} catch {
|
|
250
|
+
storageArea.removeItem(storageKey)
|
|
251
|
+
return null
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Extracts the auth code (`sid`) from a location, if it matches the callback URL conditions.
|
|
257
|
+
* Returns null if no valid code is found.
|
|
258
|
+
*/
|
|
259
|
+
function getAuthCode(locationHref: string) {
|
|
260
|
+
const loc = new URL(locationHref, DEFAULT_BASE)
|
|
261
|
+
const callbackLocation = callbackUrl ? new URL(callbackUrl, DEFAULT_BASE) : undefined
|
|
262
|
+
const callbackLocationMatches = callbackLocation
|
|
263
|
+
? loc.pathname.toLowerCase().startsWith(callbackLocation.pathname.toLowerCase())
|
|
264
|
+
: true
|
|
265
|
+
|
|
266
|
+
const authCode = new URLSearchParams(loc.hash.slice(1)).get(AUTH_CODE_PARAM)
|
|
267
|
+
return authCode && callbackLocationMatches ? authCode : null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Determines the initial auth state based on provided token, callback params, or stored token.
|
|
272
|
+
*/
|
|
273
|
+
function getInitialState(): AuthState {
|
|
274
|
+
if (providedToken) return {type: 'logged-in', token: providedToken, currentUser: null}
|
|
275
|
+
if (getAuthCode(initialLocationHref)) return {type: 'logging-in', isExchangingToken: false}
|
|
276
|
+
const token = getTokenFromStorage()
|
|
277
|
+
if (token) return {type: 'logged-in', token, currentUser: null}
|
|
278
|
+
return {type: 'logged-out', isDestroyingSession: false}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function handleCallback(locationHref = getDefaultLocation()) {
|
|
282
|
+
// If a token is provided, no need to handle callback
|
|
283
|
+
if (providedToken) return false
|
|
284
|
+
|
|
285
|
+
// Don't handle the callback if already in flight.
|
|
286
|
+
const {authState} = store.getState()
|
|
287
|
+
if (authState.type === 'logging-in' && authState.isExchangingToken) return false
|
|
288
|
+
|
|
289
|
+
// If there is no matching `authCode` then we can't handle the callback
|
|
290
|
+
const authCode = getAuthCode(locationHref)
|
|
291
|
+
if (!authCode) return false
|
|
292
|
+
|
|
293
|
+
// Otherwise, start the exchange
|
|
294
|
+
store.setState(
|
|
295
|
+
{authState: {type: 'logging-in', isExchangingToken: true}},
|
|
296
|
+
undefined,
|
|
297
|
+
'exchangeSessionForToken',
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
const client = clientFactory({
|
|
302
|
+
projectId,
|
|
303
|
+
dataset,
|
|
304
|
+
apiVersion: DEFAULT_API_VERSION,
|
|
305
|
+
requestTagPrefix: REQUEST_TAG_PREFIX,
|
|
306
|
+
useProjectHostname: authScope === 'project',
|
|
307
|
+
...(apiHost && {apiHost}),
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
const {token} = await client.request<{token: string; label: string}>({
|
|
311
|
+
method: 'GET',
|
|
312
|
+
uri: '/auth/fetch',
|
|
313
|
+
query: {sid: authCode},
|
|
314
|
+
tag: 'fetch-token',
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
storageArea?.setItem(storageKey, JSON.stringify({token}))
|
|
318
|
+
store.setState({authState: {type: 'logged-in', token, currentUser: null}})
|
|
319
|
+
|
|
320
|
+
const loc = new URL(locationHref)
|
|
321
|
+
loc.hash = ''
|
|
322
|
+
return loc.toString()
|
|
323
|
+
} catch (error) {
|
|
324
|
+
store.setState({authState: {type: 'error', error}}, undefined, 'exchangeSessionForTokenError')
|
|
325
|
+
return false
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Fetches the providers from `/auth/providers`, adds params to each url, and
|
|
331
|
+
* caches the result for synchronous usage.
|
|
332
|
+
*/
|
|
333
|
+
async function fetchLoginUrls() {
|
|
334
|
+
const client = clientFactory({
|
|
335
|
+
projectId,
|
|
336
|
+
dataset,
|
|
337
|
+
apiVersion: DEFAULT_API_VERSION,
|
|
338
|
+
requestTagPrefix: REQUEST_TAG_PREFIX,
|
|
339
|
+
useProjectHostname: authScope === 'project',
|
|
340
|
+
...(apiHost && {apiHost}),
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
const {providers: defaultProviders} = await client.request<{providers: AuthProvider[]}>({
|
|
344
|
+
uri: '/auth/providers',
|
|
345
|
+
tag: 'fetch-providers',
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
let providers: AuthProvider[]
|
|
349
|
+
|
|
350
|
+
if (typeof customProviders === 'function') {
|
|
351
|
+
providers = await customProviders(defaultProviders)
|
|
352
|
+
} else if (!customProviders?.length) {
|
|
353
|
+
providers = defaultProviders
|
|
354
|
+
} else {
|
|
355
|
+
const customProviderUrls = new Set(customProviders.map((p) => p.url))
|
|
356
|
+
providers = defaultProviders
|
|
357
|
+
.filter((official) => !customProviderUrls.has(official.url))
|
|
358
|
+
.concat(customProviders)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const configuredProviders = providers.map((provider) => {
|
|
362
|
+
const url = new URL(provider.url)
|
|
363
|
+
const origin = new URL(
|
|
364
|
+
callbackUrl
|
|
365
|
+
? new URL(callbackUrl, new URL(getDefaultLocation()).origin).toString()
|
|
366
|
+
: getDefaultLocation(),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
// `getDefaultLocation()` may be populated with an `sid` from a previous
|
|
370
|
+
// failed login attempt and should be omitted from the next login URL
|
|
371
|
+
const hashParams = new URLSearchParams(origin.hash.slice(1))
|
|
372
|
+
hashParams.delete('sid')
|
|
373
|
+
origin.hash = hashParams.toString()
|
|
374
|
+
|
|
375
|
+
// similarly, the origin may be populated with an `error` query param if
|
|
376
|
+
// the auth provider redirects back to the application. this should also
|
|
377
|
+
// be omitted from the origin sent
|
|
378
|
+
origin.searchParams.delete('error')
|
|
379
|
+
|
|
380
|
+
url.searchParams.set('origin', origin.toString())
|
|
381
|
+
url.searchParams.set('withSid', 'true')
|
|
382
|
+
if (authScope === 'project') {
|
|
383
|
+
url.searchParams.set('projectId', projectId)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return {...provider, url: url.toString()}
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
store.setState({providers: configuredProviders}, undefined, 'fetchedLoginUrls')
|
|
390
|
+
|
|
391
|
+
return configuredProviders
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const store = createStore<InternalAuthState>()(
|
|
395
|
+
devtools(
|
|
396
|
+
(set, get) => ({
|
|
397
|
+
authState: getInitialState(),
|
|
398
|
+
setAuthState: (authState: AuthState) => {
|
|
399
|
+
set({authState}, undefined, 'setAuthState')
|
|
400
|
+
},
|
|
401
|
+
providers: undefined,
|
|
402
|
+
setProviders: (providers: AuthProvider[] | undefined) => {
|
|
403
|
+
set({providers}, undefined, 'setProviders')
|
|
404
|
+
},
|
|
405
|
+
logout: async () => {
|
|
406
|
+
// If a token is statically provided, logout does nothing
|
|
407
|
+
if (providedToken) return
|
|
408
|
+
|
|
409
|
+
const {authState} = store.getState()
|
|
410
|
+
|
|
411
|
+
// If we already have an inflight request, no-op
|
|
412
|
+
if (authState.type === 'logged-out' && authState.isDestroyingSession) return
|
|
413
|
+
const token = authState.type === 'logged-in' && authState.token
|
|
414
|
+
|
|
415
|
+
try {
|
|
416
|
+
if (token) {
|
|
417
|
+
set(
|
|
418
|
+
{authState: {type: 'logged-out', isDestroyingSession: true}},
|
|
419
|
+
undefined,
|
|
420
|
+
'loggingOut',
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
const client = clientFactory({
|
|
424
|
+
token,
|
|
425
|
+
projectId,
|
|
426
|
+
dataset,
|
|
427
|
+
requestTagPrefix: REQUEST_TAG_PREFIX,
|
|
428
|
+
apiVersion: DEFAULT_API_VERSION,
|
|
429
|
+
useProjectHostname: authScope === 'project',
|
|
430
|
+
...(apiHost && {apiHost}),
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
await client.request<void>({uri: '/auth/logout', method: 'POST'})
|
|
434
|
+
}
|
|
435
|
+
} finally {
|
|
436
|
+
set(
|
|
437
|
+
{authState: {type: 'logged-out', isDestroyingSession: false}},
|
|
438
|
+
undefined,
|
|
439
|
+
'logoutSuccess',
|
|
440
|
+
)
|
|
441
|
+
storageArea?.removeItem(storageKey)
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
getCurrent: () => get().authState,
|
|
445
|
+
handleCallback: (locationHref = getDefaultLocation()) => handleCallback(locationHref),
|
|
446
|
+
getLoginUrls: () => get().providers ?? fetchLoginUrls(),
|
|
447
|
+
dispose: () => {
|
|
448
|
+
storageSubscription.unsubscribe()
|
|
449
|
+
},
|
|
450
|
+
}),
|
|
451
|
+
{
|
|
452
|
+
name: 'SanityInternalAuthStore',
|
|
453
|
+
enabled: true, // Should be process.env.NODE_ENV === 'development',
|
|
454
|
+
},
|
|
455
|
+
),
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
const storageSubscription = getStorageEvents()
|
|
459
|
+
.pipe(
|
|
460
|
+
filter(
|
|
461
|
+
(e): e is StorageEvent & {newValue: string} =>
|
|
462
|
+
e.storageArea === storageArea && e.key === storageKey,
|
|
463
|
+
),
|
|
464
|
+
map(() => getTokenFromStorage()),
|
|
465
|
+
distinctUntilChanged(),
|
|
466
|
+
)
|
|
467
|
+
.subscribe((token) =>
|
|
468
|
+
store
|
|
469
|
+
.getState()
|
|
470
|
+
.setAuthState(
|
|
471
|
+
token
|
|
472
|
+
? {type: 'logged-in', token, currentUser: null}
|
|
473
|
+
: {type: 'logged-out', isDestroyingSession: false},
|
|
474
|
+
),
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
const fetchCurrentUser = (authState: Extract<AuthState, {type: 'logged-in'}>) => {
|
|
478
|
+
const client = clientFactory({
|
|
479
|
+
token: authState.token,
|
|
480
|
+
projectId,
|
|
481
|
+
dataset,
|
|
482
|
+
requestTagPrefix: REQUEST_TAG_PREFIX,
|
|
483
|
+
apiVersion: DEFAULT_API_VERSION,
|
|
484
|
+
useProjectHostname: authScope === 'project',
|
|
485
|
+
...(apiHost && {apiHost}),
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
client
|
|
489
|
+
.request<CurrentUser>({uri: '/users/me', method: 'GET'})
|
|
490
|
+
.then((currentUser) =>
|
|
491
|
+
store.getState().setAuthState({
|
|
492
|
+
...authState,
|
|
493
|
+
currentUser,
|
|
494
|
+
} as Extract<AuthState, {type: 'logged-in'}>),
|
|
495
|
+
)
|
|
496
|
+
.catch((error) => {
|
|
497
|
+
return store.getState().setAuthState({
|
|
498
|
+
...authState,
|
|
499
|
+
type: 'error',
|
|
500
|
+
error,
|
|
501
|
+
} as Extract<AuthState, {type: 'error'}>)
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (
|
|
506
|
+
store.getState().authState.type === 'logged-in' &&
|
|
507
|
+
!(store.getState().authState as Extract<AuthState, {type: 'logged-in'}>).currentUser?.id
|
|
508
|
+
) {
|
|
509
|
+
fetchCurrentUser(store.getState().authState as Extract<AuthState, {type: 'logged-in'}>)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
store.subscribe((state) => {
|
|
513
|
+
if (state.authState.type === 'logged-in' && !state.authState.currentUser) {
|
|
514
|
+
fetchCurrentUser(state.authState)
|
|
515
|
+
}
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
return store
|
|
519
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type {SanityClient} from '@sanity/client'
|
|
2
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
3
|
+
|
|
4
|
+
import type {SanityInstance} from '../instance/types'
|
|
5
|
+
import {getClient} from './getClient'
|
|
6
|
+
import {type ClientStore, getClientStore} from './store/clientStore'
|
|
7
|
+
|
|
8
|
+
// Mock the getClientStore module
|
|
9
|
+
vi.mock('./store/clientStore', () => ({
|
|
10
|
+
getClientStore: vi.fn(),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
describe('getClient', () => {
|
|
14
|
+
const mockClient = {} as SanityClient
|
|
15
|
+
const getOrCreateClient = vi.fn()
|
|
16
|
+
const mockInstance = {} as SanityInstance
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
// Reset all mocks before each test
|
|
20
|
+
vi.clearAllMocks()
|
|
21
|
+
|
|
22
|
+
// Setup mock implementation
|
|
23
|
+
getOrCreateClient.mockReturnValue(mockClient)
|
|
24
|
+
|
|
25
|
+
const mockGetClientStore = vi.fn().mockReturnValue({
|
|
26
|
+
getOrCreateClient,
|
|
27
|
+
} as unknown as ClientStore)
|
|
28
|
+
|
|
29
|
+
// Set the mock implementation for getClientStore
|
|
30
|
+
vi.mocked(getClientStore).mockImplementation(mockGetClientStore)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('should get client from store with provided options', () => {
|
|
34
|
+
const options = {apiVersion: '2024-01-01'}
|
|
35
|
+
const result = getClient(options, mockInstance)
|
|
36
|
+
|
|
37
|
+
expect(getClientStore).toHaveBeenCalledWith(mockInstance)
|
|
38
|
+
expect(getOrCreateClient).toHaveBeenCalledWith(options)
|
|
39
|
+
expect(result).toBe(mockClient)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type {SanityClient} from '@sanity/client'
|
|
2
|
+
|
|
3
|
+
import type {SanityInstance} from '../instance/types'
|
|
4
|
+
import {type ClientOptions, getClientStore} from './store/clientStore'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Retrieve a memoized client based on the apiVersion.
|
|
8
|
+
* @public
|
|
9
|
+
*/
|
|
10
|
+
export const getClient = (options: ClientOptions, instance: SanityInstance): SanityClient => {
|
|
11
|
+
const clientStore = getClientStore(instance)
|
|
12
|
+
return clientStore.getOrCreateClient(options)
|
|
13
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {type SanityClient} from '@sanity/client'
|
|
2
|
+
import {describe, expect, it, vi} from 'vitest'
|
|
3
|
+
|
|
4
|
+
import {config} from '../../test/fixtures'
|
|
5
|
+
import {createSanityInstance} from '../instance/sanityInstance'
|
|
6
|
+
import {getSubscribableClient} from './getSubscribableClient'
|
|
7
|
+
import {getClientStore} from './store/clientStore'
|
|
8
|
+
|
|
9
|
+
describe('getSubscribableClient', () => {
|
|
10
|
+
const API_VERSION = '2024-12-05'
|
|
11
|
+
const instance = createSanityInstance(config)
|
|
12
|
+
const store = getClientStore(instance)
|
|
13
|
+
|
|
14
|
+
it('should create subscribable client and emit initial client', () => {
|
|
15
|
+
const options = {apiVersion: API_VERSION}
|
|
16
|
+
|
|
17
|
+
const storeSpy = vi.spyOn(getClientStore(instance), 'getClientEvents')
|
|
18
|
+
|
|
19
|
+
const client$ = getSubscribableClient(options, instance)
|
|
20
|
+
|
|
21
|
+
client$.subscribe({
|
|
22
|
+
next: (emittedClient) => {
|
|
23
|
+
expect(storeSpy).toHaveBeenCalledWith(options)
|
|
24
|
+
expect(emittedClient.config().apiVersion).toBe(API_VERSION)
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('should emit updated client when store changes', () => {
|
|
30
|
+
const options = {apiVersion: API_VERSION}
|
|
31
|
+
const client$ = getSubscribableClient(options, instance)
|
|
32
|
+
|
|
33
|
+
// Track emissions
|
|
34
|
+
const emittedClients: SanityClient[] = []
|
|
35
|
+
|
|
36
|
+
client$.subscribe({
|
|
37
|
+
next: (client) => {
|
|
38
|
+
emittedClients.push(client)
|
|
39
|
+
|
|
40
|
+
if (emittedClients.length === 2) {
|
|
41
|
+
expect(emittedClients[0].config().token).toBe(undefined)
|
|
42
|
+
expect(emittedClients[1].config().token).toBe('new-token')
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
complete: () => {},
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// Simulate auth change using the curried action
|
|
49
|
+
store.receiveToken('new-token')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('should use the same client for same API version', async () => {
|
|
53
|
+
const options = {apiVersion: API_VERSION}
|
|
54
|
+
const client1$ = getSubscribableClient(options, instance)
|
|
55
|
+
const client2$ = getSubscribableClient(options, instance)
|
|
56
|
+
|
|
57
|
+
let firstClient: SanityClient | undefined
|
|
58
|
+
|
|
59
|
+
client1$.subscribe({
|
|
60
|
+
next: (client) => {
|
|
61
|
+
firstClient = client
|
|
62
|
+
|
|
63
|
+
client2$.subscribe({
|
|
64
|
+
next: (secondClient) => {
|
|
65
|
+
expect(secondClient).toBe(firstClient)
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type {SanityClient} from '@sanity/client'
|
|
2
|
+
import type {Subscribable} from 'rxjs'
|
|
3
|
+
|
|
4
|
+
import type {SanityInstance} from '../instance/types'
|
|
5
|
+
import {type ClientOptions, getClientStore} from './store/clientStore'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Creates a subscribable client based on the apiVersion.
|
|
9
|
+
* The client will update when the underlying store changes (e.g., on user authentication changes).
|
|
10
|
+
* @public
|
|
11
|
+
*/
|
|
12
|
+
export const getSubscribableClient = (
|
|
13
|
+
options: ClientOptions,
|
|
14
|
+
instance: SanityInstance,
|
|
15
|
+
): Subscribable<SanityClient> => {
|
|
16
|
+
return getClientStore(instance).getClientEvents(options)
|
|
17
|
+
}
|