@basictech/react 0.7.0 → 0.8.0-beta.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.
@@ -1,23 +1,30 @@
1
1
  import React, { createContext, useContext, useEffect, useState, useRef } from 'react'
2
- import { jwtDecode } from 'jwt-decode'
3
2
 
4
3
  import { BasicSync, initDexieExtensions } from './sync'
5
4
  import { RemoteDB, DBMode, BasicDB } from './core/db'
5
+ import { AuthManager } from './core/auth/AuthManager'
6
+ import type { Token, User, AuthResult, GetTokenOptions, PdsEndpoints } from './core/auth/AuthManager'
6
7
 
7
8
  import { log } from './config'
8
9
  import { version as currentVersion } from '../package.json'
9
10
  import { createVersionUpdater } from './updater/versionUpdater'
10
11
  import { getMigrations } from './updater/updateMigrations'
11
- import { BasicStorage, LocalStorageAdapter, STORAGE_KEYS, getCookie, setCookie, clearCookie } from './utils/storage'
12
- import { isDevelopment, checkForNewVersion, cleanOAuthParamsFromUrl, getSyncStatus } from './utils/network'
13
- import { getSchemaStatus, validateAndCheckSchema } from './utils/schema'
12
+ import { BasicStorage, LocalStorageAdapter, STORAGE_KEYS } from './utils/storage'
13
+ import { isDevelopment, checkForNewVersion, getSyncStatus } from './utils/network'
14
+ import { validateAndCheckSchema } from './utils/schema'
14
15
 
15
16
  export type { BasicStorage, LocalStorageAdapter } from './utils/storage'
16
17
  export type { DBMode, BasicDB, Collection } from './core/db'
18
+ export type { Token, User, AuthResult, GetTokenOptions, PdsEndpoints }
17
19
 
18
20
  export type AuthConfig = {
19
21
  scopes?: string | string[];
22
+ /** @deprecated Use pds_url instead */
20
23
  server_url?: string;
24
+ /** PDS URL for auth and data (default: https://pds.basic.id) */
25
+ pds_url?: string;
26
+ /** Admin server URL for connect reporting (default: https://api.basic.tech) */
27
+ admin_url?: string;
21
28
  ws_url?: string;
22
29
  }
23
30
 
@@ -43,57 +50,23 @@ export type BasicProviderProps = {
43
50
 
44
51
  const DEFAULT_AUTH_CONFIG = {
45
52
  scopes: 'profile,email,app:admin',
46
- server_url: 'https://api.basic.tech',
53
+ pds_url: 'https://pds.basic.id',
54
+ admin_url: 'https://api.basic.tech',
47
55
  ws_url: 'wss://pds.basic.id/ws'
48
56
  } as const
49
57
 
50
58
 
51
- type BasicSyncType = {
52
- basic_schema: any;
53
- connect: (options: { access_token: string; ws_url?: string }) => void;
54
- debugeroo: () => void;
55
- collection: (name: string) => {
56
- ref: {
57
- toArray: () => Promise<any[]>;
58
- count: () => Promise<number>;
59
- };
60
- };
61
- [key: string]: any;
62
- };
63
-
64
-
65
- enum DBStatus {
59
+ export enum DBStatus {
66
60
  LOADING = "LOADING",
67
61
  OFFLINE = "OFFLINE",
68
62
  CONNECTING = "CONNECTING",
69
63
  ONLINE = "ONLINE",
70
64
  SYNCING = "SYNCING",
71
- ERROR = "ERROR"
72
- }
73
-
74
- type User = {
75
- name?: string,
76
- email?: string,
77
- id?: string,
78
- primaryEmailAddress?: {
79
- emailAddress: string
80
- },
81
- fullName?: string
82
- }
83
- type Token = {
84
- access_token: string,
85
- token_type: string,
86
- expires_in: number,
87
- refresh_token: string,
88
- }
89
-
90
- /**
91
- * Auth result type for signInWithCode
92
- */
93
- export type AuthResult = {
94
- success: boolean;
95
- error?: string;
96
- code?: string;
65
+ ERROR = "ERROR",
66
+ /** Sync reported an error but will retry (e.g. expired token). Used for status code 4 from dexie-syncable. */
67
+ ERROR_WILL_RETRY = "ERROR_WILL_RETRY",
68
+ /** Token expired; the SDK is refreshing and will reconnect automatically. */
69
+ ERROR_TOKEN_EXPIRED = "ERROR_TOKEN_EXPIRED"
97
70
  }
98
71
 
99
72
  /**
@@ -104,21 +77,30 @@ export type BasicContextType = {
104
77
  isReady: boolean;
105
78
  isSignedIn: boolean;
106
79
  user: User | null;
107
-
80
+ /** The user's DID (Decentralized Identifier), extracted from the access token `sub` claim */
81
+ did: string | null;
82
+ /** Space-separated scope string from the access token */
83
+ scope: string | null;
84
+ /** Check if a specific scope is granted (e.g., hasScope('profile')) */
85
+ hasScope: (scope: string) => boolean;
86
+ /** Returns scopes that were requested but not granted in the current token */
87
+ missingScopes: () => string[];
88
+
108
89
  // Auth actions (new camelCase naming)
109
90
  signIn: () => Promise<void>;
91
+ signInWithHandle: (handle: string) => Promise<void>;
110
92
  signOut: () => Promise<void>;
111
93
  signInWithCode: (code: string, state?: string) => Promise<AuthResult>;
112
-
94
+
113
95
  // Token management
114
- getToken: () => Promise<string>;
96
+ getToken: (options?: GetTokenOptions) => Promise<string>;
115
97
  getSignInUrl: (redirectUri?: string) => Promise<string>;
116
-
98
+
117
99
  // DB access
118
100
  db: BasicDB;
119
101
  dbStatus: DBStatus;
120
102
  dbMode: DBMode;
121
-
103
+
122
104
  // Legacy aliases (deprecated - will be removed in future version)
123
105
  /** @deprecated Use isReady instead */
124
106
  isAuthReady: boolean;
@@ -143,21 +125,26 @@ export const BasicContext = createContext<BasicContextType>({
143
125
  isReady: false,
144
126
  isSignedIn: false,
145
127
  user: null,
146
-
128
+ did: null,
129
+ scope: null,
130
+ hasScope: () => false,
131
+ missingScopes: () => [],
132
+
147
133
  // Auth actions
148
134
  signIn: () => Promise.resolve(),
135
+ signInWithHandle: () => Promise.resolve(),
149
136
  signOut: () => Promise.resolve(),
150
137
  signInWithCode: () => Promise.resolve({ success: false }),
151
-
138
+
152
139
  // Token management
153
- getToken: () => Promise.reject(new Error('no token')),
140
+ getToken: (_options?: GetTokenOptions) => Promise.reject(new Error('no token')),
154
141
  getSignInUrl: () => Promise.resolve(""),
155
-
142
+
156
143
  // DB access
157
144
  db: noDb,
158
145
  dbStatus: DBStatus.LOADING,
159
146
  dbMode: 'sync',
160
-
147
+
161
148
  // Legacy aliases
162
149
  isAuthReady: false,
163
150
  signin: () => Promise.resolve(),
@@ -172,6 +159,27 @@ type ErrorObject = {
172
159
  message: string;
173
160
  }
174
161
 
162
+ // Tracks the subset of AuthManager state that React effects depend on.
163
+ type AuthSnapshot = {
164
+ isSignedIn: boolean
165
+ hasToken: boolean
166
+ isAuthReady: boolean
167
+ user: User | null
168
+ did: string | null
169
+ tokenScope: string | null
170
+ }
171
+
172
+ function snapshotAuth(mgr: AuthManager): AuthSnapshot {
173
+ return {
174
+ isSignedIn: mgr.isSignedIn,
175
+ hasToken: !!mgr.token,
176
+ isAuthReady: mgr.isAuthReady,
177
+ user: mgr.user,
178
+ did: mgr.did,
179
+ tokenScope: mgr.tokenScope,
180
+ }
181
+ }
182
+
175
183
  export function BasicProvider({
176
184
  children,
177
185
  project_id: project_id_prop,
@@ -181,88 +189,103 @@ export function BasicProvider({
181
189
  auth,
182
190
  dbMode = 'sync'
183
191
  }: BasicProviderProps) {
184
- // Extract project_id from schema, fall back to prop for backward compatibility
185
192
  const project_id = schema?.project_id || project_id_prop
186
-
187
- const [isAuthReady, setIsAuthReady] = useState(false)
188
- const [isSignedIn, setIsSignedIn] = useState<boolean>(false)
189
- const [token, setToken] = useState<Token | null>(null)
190
- const [user, setUser] = useState<User>({})
191
- const [shouldConnect, setShouldConnect] = useState<boolean>(false)
192
- const [isReady, setIsReady] = useState<boolean>(false)
193
193
 
194
- const [dbStatus, setDbStatus] = useState<DBStatus>(DBStatus.OFFLINE)
195
- const [error, setError] = useState<ErrorObject | null>(null)
196
- const [isOnline, setIsOnline] = useState<boolean>(navigator.onLine)
197
- const [pendingRefresh, setPendingRefresh] = useState<boolean>(false)
198
-
199
- const syncRef = useRef<BasicSync | null>(null);
200
- const remoteDbRef = useRef<RemoteDB | null>(null);
201
- const storageAdapter = storage || new LocalStorageAdapter();
202
-
203
- // Merge auth config with defaults
194
+ // Merge auth config with defaults (server_url is deprecated in favor of pds_url)
195
+ if (auth?.server_url && !auth?.pds_url) {
196
+ log('Warning: auth.server_url is deprecated, use auth.pds_url instead')
197
+ }
204
198
  const authConfig = {
205
199
  scopes: auth?.scopes || DEFAULT_AUTH_CONFIG.scopes,
206
- server_url: auth?.server_url || DEFAULT_AUTH_CONFIG.server_url,
200
+ pds_url: auth?.pds_url || auth?.server_url || DEFAULT_AUTH_CONFIG.pds_url,
201
+ admin_url: auth?.admin_url || DEFAULT_AUTH_CONFIG.admin_url,
207
202
  ws_url: auth?.ws_url || DEFAULT_AUTH_CONFIG.ws_url
208
203
  }
209
-
210
- // Normalize scopes to space-separated string
211
- const scopesString = Array.isArray(authConfig.scopes)
212
- ? authConfig.scopes.join(' ')
213
- : authConfig.scopes;
214
204
 
215
- // Token refresh mutex to prevent concurrent refreshes
216
- const refreshPromiseRef = useRef<Promise<Token | null> | null>(null);
205
+ const scopesString = Array.isArray(authConfig.scopes)
206
+ ? authConfig.scopes.join(' ')
207
+ : authConfig.scopes
208
+
209
+ const storageRef = useRef<BasicStorage>(storage || new LocalStorageAdapter())
210
+ const storageAdapter = storageRef.current
211
+
212
+ // --- AuthManager (stable instance held in a ref) ---
213
+ const [authState, setAuthState] = useState<AuthSnapshot>({
214
+ isSignedIn: false,
215
+ hasToken: false,
216
+ isAuthReady: false,
217
+ user: null,
218
+ did: null,
219
+ tokenScope: null,
220
+ })
221
+
222
+ const authRef = useRef<AuthManager>(null!)
223
+ if (!authRef.current) {
224
+ authRef.current = new AuthManager(
225
+ {
226
+ projectId: project_id,
227
+ scopes: scopesString,
228
+ pdsUrl: authConfig.pds_url,
229
+ adminUrl: authConfig.admin_url,
230
+ debug,
231
+ },
232
+ storageAdapter,
233
+ () => setAuthState(snapshotAuth(authRef.current)),
234
+ )
235
+ }
217
236
 
218
- const isDevMode = () => isDevelopment(debug)
237
+ // --- DB state (stays in React) ---
238
+ const syncRef = useRef<BasicSync | null>(null)
239
+ const remoteDbRef = useRef<RemoteDB | null>(null)
240
+ const [shouldConnect, setShouldConnect] = useState(false)
241
+ const [dbStatus, setDbStatus] = useState<DBStatus>(DBStatus.OFFLINE)
242
+ const [isReady, setIsReady] = useState(false)
243
+ const [error, setError] = useState<ErrorObject | null>(null)
219
244
 
220
- const cleanOAuthParams = () => cleanOAuthParamsFromUrl()
245
+ const isDevMode = () => isDevelopment(debug)
221
246
 
247
+ // --- Mount: version updater + auth init + DB init ---
222
248
  useEffect(() => {
223
- const handleOnline = () => {
224
- log('Network came back online')
225
- setIsOnline(true)
226
- if (pendingRefresh) {
227
- log('Retrying pending token refresh')
228
- setPendingRefresh(false)
229
- if (token) {
230
- const refreshToken = token.refresh_token || localStorage.getItem('basic_refresh_token')
231
- if (refreshToken) {
232
- fetchToken(refreshToken, true).catch(error => {
233
- log('Retry refresh failed:', error)
234
- })
235
- }
249
+ // Version updater (SDK migration, not auth-related)
250
+ const runVersionUpdater = async () => {
251
+ try {
252
+ const versionUpdater = createVersionUpdater(storageAdapter, currentVersion, getMigrations())
253
+ const updateResult = await versionUpdater.checkAndUpdate()
254
+
255
+ if (updateResult.updated) {
256
+ log(`App updated from ${updateResult.fromVersion} to ${updateResult.toVersion}`)
257
+ } else {
258
+ log(`App version ${updateResult.toVersion} is current`)
236
259
  }
260
+ } catch (error) {
261
+ log('Version update failed:', error)
237
262
  }
238
263
  }
239
264
 
240
- const handleOffline = () => {
241
- log('Network went offline')
242
- setIsOnline(false)
243
- }
244
-
245
- window.addEventListener('online', handleOnline)
246
- window.addEventListener('offline', handleOffline)
265
+ runVersionUpdater()
266
+ authRef.current.initialize()
247
267
 
248
- return () => {
249
- window.removeEventListener('online', handleOnline)
250
- window.removeEventListener('offline', handleOffline)
251
- }
252
- }, [pendingRefresh, token])
268
+ return authRef.current.setupNetworkListeners()
269
+ }, [])
253
270
 
271
+ // --- DB init (separate mount effect) ---
254
272
  useEffect(() => {
255
273
  async function initSyncDb(options: { shouldConnect: boolean }) {
256
274
  if (!syncRef.current) {
257
275
  log('Initializing Basic Sync DB')
258
-
259
- // Initialize Dexie extensions before creating BasicSync
276
+
260
277
  await initDexieExtensions()
261
-
278
+
262
279
  syncRef.current = new BasicSync('basicdb', { schema: schema });
263
280
 
264
- syncRef.current.syncable.on('statusChanged', (status: number, url: string) => {
265
- setDbStatus(getSyncStatus(status) as DBStatus)
281
+ syncRef.current.syncable.on('statusChanged', (status: number) => {
282
+ const newStatus = getSyncStatus(status) as DBStatus
283
+ setDbStatus(newStatus)
284
+
285
+ if (newStatus === DBStatus.ERROR_WILL_RETRY) {
286
+ log('Sync entered ERROR_WILL_RETRY - proactively refreshing token')
287
+ authRef.current.getToken({ forceRefresh: true }).catch(() => {})
288
+ }
266
289
  })
267
290
 
268
291
  if (options.shouldConnect) {
@@ -289,15 +312,14 @@ export function BasicProvider({
289
312
 
290
313
  log('Initializing Basic Remote DB')
291
314
  remoteDbRef.current = new RemoteDB({
292
- serverUrl: authConfig.server_url,
315
+ serverUrl: authConfig.pds_url,
293
316
  projectId: project_id,
294
- getToken: getToken,
317
+ getToken: (opts) => authRef.current.getToken(opts),
295
318
  schema: schema,
296
319
  debug: debug,
297
320
  onAuthError: (error) => {
298
321
  log('RemoteDB auth error:', error)
299
- // Sign out user when authentication fails after retry
300
- signout()
322
+ handleSignOut()
301
323
  }
302
324
  })
303
325
  setDbStatus(DBStatus.ONLINE)
@@ -311,7 +333,7 @@ export function BasicProvider({
311
333
  if (!result.isValid) {
312
334
  let errorMessage = ''
313
335
  if (result.errors) {
314
- result.errors.forEach((error, index) => {
336
+ result.errors.forEach((error: any, index: number) => {
315
337
  errorMessage += `${index + 1}: ${error.message} - at ${error.instancePath}\n`
316
338
  })
317
339
  }
@@ -324,15 +346,17 @@ export function BasicProvider({
324
346
  return null
325
347
  }
326
348
 
327
- // Initialize the appropriate DB based on mode
328
349
  if (dbMode === 'remote') {
329
350
  initRemoteDb()
330
351
  } else {
331
- // Sync mode
332
352
  if (result.schemaStatus.valid) {
333
353
  await initSyncDb({ shouldConnect: true })
334
354
  } else {
335
- log('Schema is invalid!', result.schemaStatus)
355
+ if (result.schemaStatus.status === 'unpublished') {
356
+ log('Schema not published yet (version 0) - sync is disabled. Publish your schema to enable sync.')
357
+ } else {
358
+ log('Schema is invalid!', result.schemaStatus)
359
+ }
336
360
  await initSyncDb({ shouldConnect: false })
337
361
  }
338
362
  }
@@ -343,7 +367,6 @@ export function BasicProvider({
343
367
  if (schema) {
344
368
  checkSchema()
345
369
  } else {
346
- // No schema - still initialize remote DB if in remote mode
347
370
  if (dbMode === 'remote' && project_id) {
348
371
  initRemoteDb()
349
372
  } else {
@@ -352,270 +375,41 @@ export function BasicProvider({
352
375
  }
353
376
  }, []);
354
377
 
378
+ // --- Connect sync DB when auth is ready ---
355
379
  useEffect(() => {
356
- async function connectToDb() {
357
- if (token && syncRef.current && isSignedIn && shouldConnect) {
358
- const tok = await getToken()
359
- if (!tok) {
360
- log('no token found')
361
- return
362
- }
380
+ if (authState.hasToken && syncRef.current && authState.isSignedIn && shouldConnect) {
381
+ log('connecting to db...')
363
382
 
364
- log('connecting to db...')
365
-
366
- syncRef.current?.connect({
367
- access_token: tok,
368
- ws_url: authConfig.ws_url
383
+ syncRef.current?.connect({
384
+ getToken: (opts?: GetTokenOptions) => authRef.current.getToken(opts),
385
+ ws_url: authConfig.ws_url
386
+ })
387
+ .catch((e: any) => {
388
+ log('error connecting to db', e)
369
389
  })
370
- .catch((e) => {
371
- log('error connecting to db', e)
372
- })
373
- }
374
390
  }
375
- connectToDb()
376
-
377
- }, [isSignedIn, shouldConnect])
391
+ }, [authState.isSignedIn, authState.hasToken, shouldConnect])
378
392
 
379
- useEffect(() => {
380
- const initializeAuth = async () => {
381
- await storageAdapter.set(STORAGE_KEYS.DEBUG, debug ? 'true' : 'false')
382
-
383
- // Check if server URL has changed - if so, clear tokens
384
- const storedServerUrl = await storageAdapter.get(STORAGE_KEYS.SERVER_URL)
385
- if (storedServerUrl && storedServerUrl !== authConfig.server_url) {
386
- log('Server URL changed, clearing stored tokens')
387
- await storageAdapter.remove(STORAGE_KEYS.REFRESH_TOKEN)
388
- await storageAdapter.remove(STORAGE_KEYS.USER_INFO)
389
- await storageAdapter.remove(STORAGE_KEYS.AUTH_STATE)
390
- await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
391
- clearCookie('basic_token')
392
- clearCookie('basic_access_token')
393
- }
394
- await storageAdapter.set(STORAGE_KEYS.SERVER_URL, authConfig.server_url)
395
-
396
- try {
397
- const versionUpdater = createVersionUpdater(storageAdapter, currentVersion, getMigrations())
398
- const updateResult = await versionUpdater.checkAndUpdate()
399
-
400
- if (updateResult.updated) {
401
- log(`App updated from ${updateResult.fromVersion} to ${updateResult.toVersion}`)
402
- } else {
403
- log(`App version ${updateResult.toVersion} is current`)
404
- }
405
- } catch (error) {
406
- log('Version update failed:', error)
407
- }
408
-
409
- try {
410
- if (window.location.search.includes('code')) {
411
- let code = window.location?.search?.split('code=')[1]?.split('&')[0]
412
- if (!code) return
413
-
414
- const state = await storageAdapter.get(STORAGE_KEYS.AUTH_STATE)
415
- const urlState = window.location.search.split('state=')[1]?.split('&')[0]
416
- if (!state || state !== urlState) {
417
- log('error: auth state does not match')
418
- setIsAuthReady(true)
419
-
420
- await storageAdapter.remove(STORAGE_KEYS.AUTH_STATE)
421
- cleanOAuthParams()
422
- return
423
- }
424
-
425
- await storageAdapter.remove(STORAGE_KEYS.AUTH_STATE)
426
- cleanOAuthParams()
427
-
428
- fetchToken(code, false).catch((error) => {
429
- log('Error fetching token:', error)
430
- })
431
- } else {
432
- const refreshToken = await storageAdapter.get(STORAGE_KEYS.REFRESH_TOKEN)
433
- if (refreshToken) {
434
- log('Found refresh token in storage, attempting to refresh access token')
435
- fetchToken(refreshToken, true).catch((error) => {
436
- log('Error fetching refresh token:', error)
437
- })
438
- } else {
439
- let cookie_token = getCookie('basic_token')
440
- if (cookie_token !== '') {
441
- const tokenData = JSON.parse(cookie_token)
442
- setToken(tokenData)
443
- if (tokenData.refresh_token) {
444
- await storageAdapter.set(STORAGE_KEYS.REFRESH_TOKEN, tokenData.refresh_token)
445
- }
446
- } else {
447
- const cachedUserInfo = await storageAdapter.get(STORAGE_KEYS.USER_INFO)
448
- if (cachedUserInfo) {
449
- try {
450
- const userData = JSON.parse(cachedUserInfo)
451
- setUser(userData)
452
- setIsSignedIn(true)
453
- log('Loaded cached user info for offline mode')
454
- } catch (error) {
455
- log('Error parsing cached user info:', error)
456
- }
457
- }
458
- setIsAuthReady(true)
459
- }
460
- }
461
- }
462
-
463
- } catch (e) {
464
- log('error getting token', e)
465
- }
466
- }
467
-
468
- initializeAuth()
469
- }, [])
470
-
471
- useEffect(() => {
472
- async function fetchUser(acc_token: string) {
473
- console.info('fetching user')
393
+ // --- Sign out (auth cleanup + sync teardown) ---
394
+ const handleSignOut = async () => {
395
+ await authRef.current.signOut()
396
+ if (syncRef.current) {
474
397
  try {
475
- const response = await fetch(`${authConfig.server_url}/auth/userInfo`, {
476
- method: 'GET',
477
- headers: {
478
- 'Authorization': `Bearer ${acc_token}`
479
- }
480
- })
481
-
482
- if (!response.ok) {
483
- throw new Error(`Failed to fetch user info: ${response.status}`)
484
- }
485
-
486
- const user = await response.json()
487
-
488
- if (user.error) {
489
- log('error fetching user', user.error)
490
- throw new Error(`User info error: ${user.error}`)
491
- }
492
-
493
- if (token?.refresh_token) {
494
- await storageAdapter.set(STORAGE_KEYS.REFRESH_TOKEN, token.refresh_token)
495
- }
496
-
497
- await storageAdapter.set(STORAGE_KEYS.USER_INFO, JSON.stringify(user))
498
- log('Cached user info in storage')
499
-
500
- setCookie('basic_access_token', token?.access_token || '', { httpOnly: false });
501
- setCookie('basic_token', JSON.stringify(token));
502
-
503
- setUser(user)
504
- setIsSignedIn(true)
505
- setIsAuthReady(true)
398
+ await syncRef.current.close()
399
+ await syncRef.current.delete({ disableAutoOpen: false })
400
+ syncRef.current = null
401
+ window?.location?.reload()
506
402
  } catch (error) {
507
- log('Failed to fetch user info:', error)
508
- // Don't clear tokens here - may be temporary network issue
509
- setIsAuthReady(true)
510
- }
511
- }
512
-
513
- async function checkToken() {
514
- if (!token) {
515
- log('error: no user token found')
516
-
517
- setIsAuthReady(true)
518
- return
519
- }
520
-
521
- const decoded = jwtDecode(token?.access_token)
522
- // Add 5 second buffer to prevent edge cases
523
- const expirationBuffer = 5
524
- const isExpired = decoded.exp && decoded.exp < (Date.now() / 1000) + expirationBuffer
525
-
526
- if (isExpired) {
527
- log('token is expired - refreshing ...')
528
- const refreshToken = token?.refresh_token
529
- if (!refreshToken) {
530
- log('Error: No refresh token available for expired token')
531
- setIsAuthReady(true)
532
- return
533
- }
534
- try {
535
- const newToken = await fetchToken(refreshToken, true)
536
- fetchUser(newToken?.access_token || '')
537
- } catch (error) {
538
- log('Failed to refresh token in checkToken:', error)
539
-
540
- if ((error as Error).message.includes('offline') || (error as Error).message.includes('Network')) {
541
- log('Network issue - continuing with expired token until online')
542
- fetchUser(token?.access_token || '')
543
- } else {
544
- setIsAuthReady(true)
545
- }
546
- }
547
- } else {
548
- fetchUser(token?.access_token || '')
403
+ console.error('Error during database cleanup:', error)
549
404
  }
550
405
  }
551
-
552
- if (token) {
553
- checkToken()
554
- }
555
- }, [token])
556
-
557
- const getSignInLink = async (redirectUri?: string) => {
558
- try {
559
- log('getting sign in link...')
560
-
561
- if (!project_id) {
562
- throw new Error('Project ID is required to generate sign-in link')
563
- }
564
-
565
- const randomState = Math.random().toString(36).substring(6);
566
- await storageAdapter.set(STORAGE_KEYS.AUTH_STATE, randomState)
567
-
568
- const redirectUrl = redirectUri || window.location.href
569
-
570
- if (!redirectUrl || (!redirectUrl.startsWith('http://') && !redirectUrl.startsWith('https://'))) {
571
- throw new Error('Invalid redirect URI provided')
572
- }
573
-
574
- // Store redirect_uri for token exchange
575
- await storageAdapter.set(STORAGE_KEYS.REDIRECT_URI, redirectUrl)
576
- log('Stored redirect_uri for token exchange:', redirectUrl)
577
-
578
- let baseUrl = `${authConfig.server_url}/auth/authorize`
579
- baseUrl += `?client_id=${project_id}`
580
- baseUrl += `&redirect_uri=${encodeURIComponent(redirectUrl)}`
581
- baseUrl += `&response_type=code`
582
- baseUrl += `&scope=${encodeURIComponent(scopesString)}`
583
- baseUrl += `&state=${randomState}`
584
-
585
- log('Generated sign-in link successfully with scopes:', scopesString)
586
- return baseUrl;
587
-
588
- } catch (error) {
589
- log('Error generating sign-in link:', error)
590
- throw error
591
- }
592
406
  }
593
407
 
594
- const signin = async () => {
408
+ // --- Sign in wrappers (add dev-mode error display) ---
409
+ const handleSignIn = async () => {
595
410
  try {
596
- log('signing in...')
597
-
598
- if (!project_id) {
599
- log('Error: project_id is required for sign-in')
600
- throw new Error('Project ID is required for authentication')
601
- }
602
-
603
- const signInLink = await getSignInLink()
604
- log('Generated sign-in link:', signInLink)
605
-
606
- // Validate URL format (supports https://, http://, and custom URI schemes)
607
- try {
608
- new URL(signInLink)
609
- } catch {
610
- log('Error: Invalid sign-in link generated')
611
- throw new Error('Failed to generate valid sign-in URL')
612
- }
613
-
614
- window.location.href = signInLink
615
-
411
+ await authRef.current.signIn()
616
412
  } catch (error) {
617
- log('Error during sign-in:', error)
618
-
619
413
  if (isDevMode()) {
620
414
  setError({
621
415
  code: 'signin_error',
@@ -623,325 +417,26 @@ export function BasicProvider({
623
417
  message: (error as Error).message || 'An error occurred during sign-in. Please try again.'
624
418
  })
625
419
  }
626
-
627
420
  throw error
628
421
  }
629
422
  }
630
423
 
631
- const signinWithCode = async (code: string, state?: string): Promise<{ success: boolean, error?: string }> => {
424
+ const handleSignInWithHandle = async (handle: string) => {
632
425
  try {
633
- log('signinWithCode called with code:', code)
634
-
635
- if (!code || typeof code !== 'string') {
636
- return { success: false, error: 'Invalid authorization code' }
637
- }
638
-
639
- if (state) {
640
- const storedState = await storageAdapter.get(STORAGE_KEYS.AUTH_STATE)
641
- if (storedState && storedState !== state) {
642
- log('State parameter mismatch:', { provided: state, stored: storedState })
643
- return { success: false, error: 'State parameter mismatch' }
644
- }
645
- }
646
-
647
- await storageAdapter.remove(STORAGE_KEYS.AUTH_STATE)
648
- cleanOAuthParams()
649
-
650
- const token = await fetchToken(code, false)
651
-
652
- if (token) {
653
- log('signinWithCode successful')
654
- return { success: true }
655
- } else {
656
- return { success: false, error: 'Failed to exchange code for token' }
657
- }
426
+ await authRef.current.signInWithHandle(handle)
658
427
  } catch (error) {
659
- log('signinWithCode error:', error)
660
- return {
661
- success: false,
662
- error: (error as Error).message || 'Authentication failed'
663
- }
664
- }
665
- }
666
-
667
- const signout = async () => {
668
- log('signing out!')
669
- setUser({})
670
- setIsSignedIn(false)
671
- setToken(null)
672
-
673
- clearCookie('basic_token');
674
- clearCookie('basic_access_token');
675
- await storageAdapter.remove(STORAGE_KEYS.AUTH_STATE)
676
- await storageAdapter.remove(STORAGE_KEYS.REFRESH_TOKEN)
677
- await storageAdapter.remove(STORAGE_KEYS.USER_INFO)
678
- await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
679
- await storageAdapter.remove(STORAGE_KEYS.SERVER_URL)
680
- if (syncRef.current) {
681
- (async () => {
682
- try {
683
- await syncRef.current?.close()
684
- await syncRef.current?.delete({ disableAutoOpen: false })
685
- syncRef.current = null
686
- window?.location?.reload()
687
- } catch (error) {
688
- console.error('Error during database cleanup:', error)
689
- }
690
- })()
691
- }
692
- }
693
-
694
- const getToken = async (): Promise<string> => {
695
- log('getting token...')
696
-
697
- if (!token) {
698
- // Try to recover from storage refresh token
699
- const refreshToken = await storageAdapter.get(STORAGE_KEYS.REFRESH_TOKEN)
700
- if (refreshToken) {
701
- log('No token in memory, attempting to refresh from storage')
702
-
703
- // Check if refresh is already in progress
704
- if (refreshPromiseRef.current) {
705
- log('Token refresh already in progress, waiting...')
706
- try {
707
- const newToken = await refreshPromiseRef.current
708
- if (newToken?.access_token) {
709
- return newToken.access_token
710
- }
711
- } catch (error) {
712
- log('In-flight refresh failed:', error)
713
- throw error
714
- }
715
- }
716
-
717
- try {
718
- const newToken = await fetchToken(refreshToken, true)
719
- if (newToken?.access_token) {
720
- return newToken.access_token
721
- }
722
- } catch (error) {
723
- log('Failed to refresh token from storage:', error)
724
-
725
- if ((error as Error).message.includes('offline') || (error as Error).message.includes('Network')) {
726
- log('Network issue - continuing with potentially expired token')
727
- const lastToken = localStorage.getItem('basic_access_token')
728
- if (lastToken) {
729
- return lastToken
730
- }
731
- throw new Error('Network offline - authentication will be retried when online')
732
- }
733
-
734
- throw new Error('Authentication expired. Please sign in again.')
735
- }
736
- }
737
- log('no token found')
738
- throw new Error('no token found')
739
- }
740
-
741
- const decoded = jwtDecode(token?.access_token)
742
- // Add 5 second buffer to prevent edge cases where token expires during request
743
- const expirationBuffer = 5
744
- const isExpired = decoded.exp && decoded.exp < (Date.now() / 1000) + expirationBuffer
745
-
746
- if (isExpired) {
747
- log('token is expired - refreshing ...')
748
-
749
- // Check if refresh is already in progress
750
- if (refreshPromiseRef.current) {
751
- log('Token refresh already in progress, waiting...')
752
- try {
753
- const newToken = await refreshPromiseRef.current
754
- return newToken?.access_token || ''
755
- } catch (error) {
756
- log('In-flight refresh failed:', error)
757
-
758
- if ((error as Error).message.includes('offline') || (error as Error).message.includes('Network')) {
759
- log('Network issue - using expired token until network is restored')
760
- return token.access_token
761
- }
762
-
763
- throw error
764
- }
765
- }
766
-
767
- const refreshToken = token?.refresh_token || await storageAdapter.get(STORAGE_KEYS.REFRESH_TOKEN)
768
- if (refreshToken) {
769
- try {
770
- const newToken = await fetchToken(refreshToken, true)
771
- return newToken?.access_token || ''
772
- } catch (error) {
773
- log('Failed to refresh expired token:', error)
774
-
775
- if ((error as Error).message.includes('offline') || (error as Error).message.includes('Network')) {
776
- log('Network issue - using expired token until network is restored')
777
- return token.access_token
778
- }
779
-
780
- throw new Error('Authentication expired. Please sign in again.')
781
- }
782
- } else {
783
- throw new Error('no refresh token available')
784
- }
785
- }
786
-
787
- return token?.access_token || ''
788
- }
789
-
790
- const fetchToken = async (codeOrRefreshToken: string, isRefreshToken: boolean = false): Promise<Token | null> => {
791
- // Validate input
792
- if (!codeOrRefreshToken || codeOrRefreshToken.trim() === '') {
793
- const errorMsg = isRefreshToken ? 'Refresh token is empty or undefined' : 'Authorization code is empty or undefined'
794
- log('Error:', errorMsg)
795
- throw new Error(errorMsg)
796
- }
797
-
798
- // If this is a refresh token request and one is already in progress, return that promise
799
- if (isRefreshToken && refreshPromiseRef.current) {
800
- log('Reusing in-flight refresh token request')
801
- return refreshPromiseRef.current
802
- }
803
-
804
- // Create new promise for this refresh attempt
805
- const refreshPromise = (async (): Promise<Token | null> => {
806
- try {
807
- if (!isOnline) {
808
- log('Network is offline, marking refresh as pending')
809
- setPendingRefresh(true)
810
- throw new Error('Network offline - refresh will be retried when online')
811
- }
812
-
813
- let requestBody: any
814
-
815
- if (isRefreshToken) {
816
- // Refresh token request
817
- requestBody = {
818
- grant_type: 'refresh_token',
819
- refresh_token: codeOrRefreshToken
820
- }
821
- // Include client_id if available for validation
822
- if (project_id) {
823
- requestBody.client_id = project_id
824
- }
825
- } else {
826
- // Authorization code exchange
827
- requestBody = {
828
- grant_type: 'authorization_code',
829
- code: codeOrRefreshToken
830
- }
831
-
832
- // Retrieve stored redirect_uri (required by OAuth2 spec)
833
- const storedRedirectUri = await storageAdapter.get(STORAGE_KEYS.REDIRECT_URI)
834
- if (storedRedirectUri) {
835
- requestBody.redirect_uri = storedRedirectUri
836
- log('Including redirect_uri in token exchange:', storedRedirectUri)
837
- } else {
838
- log('Warning: No redirect_uri found in storage for token exchange')
839
- }
840
-
841
- // Include client_id for validation
842
- if (project_id) {
843
- requestBody.client_id = project_id
844
- }
845
- }
846
-
847
- log('Token exchange request body:', { ...requestBody, refresh_token: isRefreshToken ? '[REDACTED]' : undefined, code: !isRefreshToken ? '[REDACTED]' : undefined })
848
-
849
- const token = await fetch(`${authConfig.server_url}/auth/token`, {
850
- method: 'POST',
851
- headers: {
852
- 'Content-Type': 'application/json'
853
- },
854
- body: JSON.stringify(requestBody)
428
+ if (isDevMode()) {
429
+ setError({
430
+ code: 'signin_error',
431
+ title: 'Sign-in Failed',
432
+ message: (error as Error).message || 'An error occurred during sign-in. Please try again.'
855
433
  })
856
- .then(response => response.json())
857
- .catch(error => {
858
- log('Network error fetching token:', error)
859
- if (!isOnline) {
860
- setPendingRefresh(true)
861
- throw new Error('Network offline - refresh will be retried when online')
862
- }
863
- throw new Error('Network error during token refresh')
864
- })
865
-
866
- if (token.error) {
867
- log('error fetching token', token.error)
868
-
869
- if (token.error.includes('network') || token.error.includes('timeout')) {
870
- setPendingRefresh(true)
871
- throw new Error('Network issue - refresh will be retried when online')
872
- }
873
-
874
- await storageAdapter.remove(STORAGE_KEYS.REFRESH_TOKEN)
875
- await storageAdapter.remove(STORAGE_KEYS.USER_INFO)
876
- await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
877
- await storageAdapter.remove(STORAGE_KEYS.SERVER_URL)
878
- clearCookie('basic_token');
879
- clearCookie('basic_access_token');
880
-
881
- setUser({})
882
- setIsSignedIn(false)
883
- setToken(null)
884
- setIsAuthReady(true)
885
-
886
- throw new Error(`Token refresh failed: ${token.error}`)
887
- } else {
888
- setToken(token)
889
- setPendingRefresh(false)
890
-
891
- if (token.refresh_token) {
892
- await storageAdapter.set(STORAGE_KEYS.REFRESH_TOKEN, token.refresh_token)
893
- log('Updated refresh token in storage')
894
- }
895
-
896
- // Clean up redirect_uri after successful token exchange
897
- if (!isRefreshToken) {
898
- await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
899
- log('Cleaned up redirect_uri from storage after successful exchange')
900
- }
901
-
902
- setCookie('basic_access_token', token.access_token, { httpOnly: false });
903
- setCookie('basic_token', JSON.stringify(token));
904
- log('Updated access token and full token in cookies')
905
- }
906
- return token
907
- } catch (error) {
908
- log('Token refresh error:', error)
909
-
910
- if (!(error as Error).message.includes('offline') && !(error as Error).message.includes('Network')) {
911
- await storageAdapter.remove(STORAGE_KEYS.REFRESH_TOKEN)
912
- await storageAdapter.remove(STORAGE_KEYS.USER_INFO)
913
- await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
914
- await storageAdapter.remove(STORAGE_KEYS.SERVER_URL)
915
- clearCookie('basic_token');
916
- clearCookie('basic_access_token');
917
-
918
- setUser({})
919
- setIsSignedIn(false)
920
- setToken(null)
921
- setIsAuthReady(true)
922
- }
923
-
924
- throw error
925
434
  }
926
- })()
927
-
928
- // Store promise if this is a refresh token request
929
- if (isRefreshToken) {
930
- refreshPromiseRef.current = refreshPromise
931
-
932
- // Clear the promise reference when done (success or failure)
933
- refreshPromise.finally(() => {
934
- if (refreshPromiseRef.current === refreshPromise) {
935
- refreshPromiseRef.current = null
936
- log('Cleared refresh promise reference')
937
- }
938
- })
435
+ throw error
939
436
  }
940
-
941
- return refreshPromise
942
437
  }
943
438
 
944
- // Get the current DB instance based on mode
439
+ // --- DB accessor ---
945
440
  const getCurrentDb = (): BasicDB => {
946
441
  if (dbMode === 'remote') {
947
442
  return remoteDbRef.current || noDb
@@ -949,33 +444,38 @@ export function BasicProvider({
949
444
  return syncRef.current || noDb
950
445
  }
951
446
 
952
- // Create context value with new names and legacy aliases
447
+ // --- Context value ---
953
448
  const contextValue: BasicContextType = {
954
- // Auth state (new naming)
955
- isReady: isAuthReady,
956
- isSignedIn,
957
- user,
958
-
959
- // Auth actions (new camelCase naming)
960
- signIn: signin,
961
- signOut: signout,
962
- signInWithCode: signinWithCode,
963
-
449
+ // Auth state
450
+ isReady: authState.isAuthReady,
451
+ isSignedIn: authState.isSignedIn,
452
+ user: authState.user,
453
+ did: authState.did,
454
+ scope: authState.tokenScope,
455
+ hasScope: (scope: string) => authRef.current.hasScope(scope),
456
+ missingScopes: () => authRef.current.missingScopes(),
457
+
458
+ // Auth actions
459
+ signIn: handleSignIn,
460
+ signInWithHandle: handleSignInWithHandle,
461
+ signOut: handleSignOut,
462
+ signInWithCode: (code: string, state?: string) => authRef.current.signInWithCode(code, state),
463
+
964
464
  // Token management
965
- getToken,
966
- getSignInUrl: getSignInLink,
967
-
465
+ getToken: (opts?: GetTokenOptions) => authRef.current.getToken(opts),
466
+ getSignInUrl: (redirectUri?: string) => authRef.current.getSignInUrl(redirectUri),
467
+
968
468
  // DB access
969
469
  db: getCurrentDb(),
970
470
  dbStatus,
971
471
  dbMode,
972
-
472
+
973
473
  // Legacy aliases (deprecated)
974
- isAuthReady,
975
- signin,
976
- signout,
977
- signinWithCode,
978
- getSignInLink,
474
+ isAuthReady: authState.isAuthReady,
475
+ signin: handleSignIn,
476
+ signout: handleSignOut,
477
+ signinWithCode: (code: string, state?: string) => authRef.current.signInWithCode(code, state),
478
+ getSignInLink: (redirectUri?: string) => authRef.current.getSignInUrl(redirectUri),
979
479
  }
980
480
 
981
481
  return (