@basictech/react 0.8.0-beta.1 → 0.8.0-beta.3

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.
@@ -0,0 +1,104 @@
1
+ import { createContext, useContext } from 'react'
2
+ import type { BasicDB } from './core/db'
3
+ import type { User, AuthResult, GetTokenOptions } from './core/auth/AuthManager'
4
+ import type { DBMode } from './core/db'
5
+
6
+ export enum DBStatus {
7
+ LOADING = 'LOADING',
8
+ OFFLINE = 'OFFLINE',
9
+ CONNECTING = 'CONNECTING',
10
+ ONLINE = 'ONLINE',
11
+ SYNCING = 'SYNCING',
12
+ ERROR = 'ERROR',
13
+ ERROR_WILL_RETRY = 'ERROR_WILL_RETRY',
14
+ ERROR_TOKEN_EXPIRED = 'ERROR_TOKEN_EXPIRED',
15
+ }
16
+
17
+ /** Snapshot of local schema vs server (for dev toolbar and debugging). */
18
+ export type BasicSchemaDevInfo = {
19
+ projectId: string | null
20
+ localVersion: number | undefined
21
+ status: string
22
+ valid: boolean
23
+ lastCheckedAt: number
24
+ error?: string
25
+ }
26
+
27
+ /**
28
+ * Context type for useBasic hook
29
+ */
30
+ export type BasicContextType = {
31
+ isReady: boolean
32
+ isSignedIn: boolean
33
+ user: User | null
34
+ did: string | null
35
+ scope: string | null
36
+ hasScope: (scope: string) => boolean
37
+ missingScopes: () => string[]
38
+
39
+ signIn: () => Promise<void>
40
+ signInWithHandle: (handle: string) => Promise<void>
41
+ signOut: () => Promise<void>
42
+ signInWithCode: (code: string, state?: string) => Promise<AuthResult>
43
+
44
+ getToken: (options?: GetTokenOptions) => Promise<string>
45
+ getSignInUrl: (redirectUri?: string) => Promise<string>
46
+
47
+ db: BasicDB
48
+ dbStatus: DBStatus
49
+ dbMode: DBMode
50
+
51
+ /** Local schema vs server status; null if no schema on the provider. */
52
+ devInfo: BasicSchemaDevInfo | null
53
+ /** Re-run remote schema check (dev toolbar). */
54
+ refreshSchemaStatus: () => Promise<void>
55
+
56
+ isAuthReady: boolean
57
+ signin: () => Promise<void>
58
+ signout: () => Promise<void>
59
+ signinWithCode: (code: string, state?: string) => Promise<AuthResult>
60
+ getSignInLink: (redirectUri?: string) => Promise<string>
61
+ }
62
+
63
+ const noDb: BasicDB = {
64
+ collection: () => {
65
+ throw new Error('no basicdb found - initialization failed. double check your schema.')
66
+ },
67
+ }
68
+
69
+ export const BasicContext = createContext<BasicContextType>({
70
+ isReady: false,
71
+ isSignedIn: false,
72
+ user: null,
73
+ did: null,
74
+ scope: null,
75
+ hasScope: () => false,
76
+ missingScopes: () => [],
77
+
78
+ signIn: () => Promise.resolve(),
79
+ signInWithHandle: () => Promise.resolve(),
80
+ signOut: () => Promise.resolve(),
81
+ signInWithCode: () => Promise.resolve({ success: false }),
82
+
83
+ getToken: (_options?: GetTokenOptions) => Promise.reject(new Error('no token')),
84
+ getSignInUrl: () => Promise.resolve(''),
85
+
86
+ db: noDb,
87
+ dbStatus: DBStatus.LOADING,
88
+ dbMode: 'sync',
89
+
90
+ devInfo: null,
91
+ refreshSchemaStatus: async () => {},
92
+
93
+ isAuthReady: false,
94
+ signin: () => Promise.resolve(),
95
+ signout: () => Promise.resolve(),
96
+ signinWithCode: () => Promise.resolve({ success: false }),
97
+ getSignInLink: () => Promise.resolve(''),
98
+ })
99
+
100
+ export function useBasic() {
101
+ return useContext(BasicContext)
102
+ }
103
+
104
+ export { noDb }
@@ -223,8 +223,11 @@ export class AuthManager {
223
223
  const refreshToken = await this.storage.get(STORAGE_KEYS.REFRESH_TOKEN)
224
224
  if (refreshToken) {
225
225
  log('Found refresh token in storage, attempting to refresh access token')
226
- this.exchangeToken(refreshToken, true).catch((error) => {
226
+ this.exchangeToken(refreshToken, true).catch(async (error) => {
227
227
  log('Error fetching refresh token:', error)
228
+ if (this.isNetworkError(error)) {
229
+ await this.restoreCachedUser()
230
+ }
228
231
  })
229
232
  } else {
230
233
  const cachedUserInfo = await this.storage.get(STORAGE_KEYS.USER_INFO)
@@ -300,7 +303,8 @@ export class AuthManager {
300
303
  log('Token refresh already in progress, waiting...')
301
304
  try {
302
305
  const newToken = await this.refreshPromise
303
- return newToken?.access_token || ''
306
+ if (!newToken?.access_token) throw new Error('Token refresh returned empty access token')
307
+ return newToken.access_token
304
308
  } catch (error) {
305
309
  log('In-flight refresh failed:', error)
306
310
  if (this.isNetworkError(error)) {
@@ -315,7 +319,8 @@ export class AuthManager {
315
319
  if (refreshToken) {
316
320
  try {
317
321
  const newToken = await this.exchangeToken(refreshToken, true)
318
- return newToken?.access_token || ''
322
+ if (!newToken?.access_token) throw new Error('Token refresh returned empty access token')
323
+ return newToken.access_token
319
324
  } catch (error) {
320
325
  log('Failed to refresh expired token:', error)
321
326
  if (this.isNetworkError(error)) {
@@ -329,7 +334,8 @@ export class AuthManager {
329
334
  }
330
335
  }
331
336
 
332
- return this.token.access_token || ''
337
+ if (!this.token.access_token) throw new Error('Token exists but access_token is empty')
338
+ return this.token.access_token
333
339
  }
334
340
 
335
341
  async getSignInUrl(redirectUri?: string, endpoints?: PdsEndpoints): Promise<string> {
@@ -342,7 +348,7 @@ export class AuthManager {
342
348
  const pdsEndpoints = endpoints || this.defaultPdsEndpoints()
343
349
  await this.storage.set(STORAGE_KEYS.PDS_ENDPOINTS, JSON.stringify(pdsEndpoints))
344
350
 
345
- const randomState = Math.random().toString(36).substring(6)
351
+ const randomState = base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)))
346
352
  await this.storage.set(STORAGE_KEYS.AUTH_STATE, randomState)
347
353
 
348
354
  const redirectUrl = redirectUri || window.location.href
@@ -486,7 +492,10 @@ export class AuthManager {
486
492
  }
487
493
 
488
494
  /**
489
- * Register online/offline handlers that retry pending refreshes.
495
+ * Register online/offline and visibility handlers that retry pending
496
+ * refreshes and proactively refresh tokens when the app resumes from
497
+ * background (critical for PWAs and mobile browsers where timers are
498
+ * frozen while backgrounded).
490
499
  * Returns a cleanup function for useEffect teardown.
491
500
  */
492
501
  setupNetworkListeners(): () => void {
@@ -510,12 +519,28 @@ export class AuthManager {
510
519
  this.isOnline = false
511
520
  }
512
521
 
522
+ const handleVisibilityChange = () => {
523
+ if (document.visibilityState === 'visible' && this.isSignedIn) {
524
+ log('App became visible - checking token freshness')
525
+ this.getToken().catch(err => {
526
+ log('Token refresh on visibility resume failed:', err)
527
+ })
528
+ }
529
+ }
530
+
513
531
  window.addEventListener('online', handleOnline)
514
532
  window.addEventListener('offline', handleOffline)
515
533
 
534
+ if (typeof document !== 'undefined') {
535
+ document.addEventListener('visibilitychange', handleVisibilityChange)
536
+ }
537
+
516
538
  return () => {
517
539
  window.removeEventListener('online', handleOnline)
518
540
  window.removeEventListener('offline', handleOffline)
541
+ if (typeof document !== 'undefined') {
542
+ document.removeEventListener('visibilitychange', handleVisibilityChange)
543
+ }
519
544
  }
520
545
  }
521
546
 
@@ -581,34 +606,7 @@ export class AuthManager {
581
606
  if (decoded.sub) this.did = decoded.sub
582
607
  if (decoded.scope) this.tokenScope = decoded.scope
583
608
 
584
- const expirationBuffer = 5
585
- const isExpired = decoded.exp && decoded.exp < (Date.now() / 1000) + expirationBuffer
586
-
587
- if (isExpired) {
588
- log('token is expired - refreshing ...')
589
- const refreshToken = this.token.refresh_token
590
- if (!refreshToken) {
591
- log('Error: No refresh token available for expired token')
592
- this.isAuthReady = true
593
- this.notify()
594
- return
595
- }
596
- try {
597
- const newToken = await this.exchangeToken(refreshToken, true)
598
- await this.fetchUser(newToken?.access_token || '')
599
- } catch (error) {
600
- log('Failed to refresh token in processNewToken:', error)
601
- if (this.isNetworkError(error)) {
602
- log('Network issue - continuing with expired token until online')
603
- await this.fetchUser(this.token.access_token)
604
- } else {
605
- this.isAuthReady = true
606
- this.notify()
607
- }
608
- }
609
- } else {
610
- await this.fetchUser(this.token.access_token)
611
- }
609
+ await this.fetchUser(this.token.access_token)
612
610
  } catch (error) {
613
611
  log('Error processing token:', error)
614
612
  this.isAuthReady = true
@@ -616,6 +614,19 @@ export class AuthManager {
616
614
  }
617
615
  }
618
616
 
617
+ private async restoreCachedUser(): Promise<void> {
618
+ const cached = await this.storage.get(STORAGE_KEYS.USER_INFO)
619
+ if (cached) {
620
+ try {
621
+ this.user = JSON.parse(cached)
622
+ this.isSignedIn = true
623
+ log('Restored cached user info for offline mode')
624
+ } catch { /* corrupted cache, ignore */ }
625
+ }
626
+ this.isAuthReady = true
627
+ this.notify()
628
+ }
629
+
619
630
  private async fetchUser(accessToken: string): Promise<void> {
620
631
  log('fetching user')
621
632
  try {
@@ -657,8 +668,12 @@ export class AuthManager {
657
668
  this.notify()
658
669
  } catch (error) {
659
670
  log('Failed to fetch user info:', error)
660
- this.isAuthReady = true
661
- this.notify()
671
+ if (this.isNetworkError(error)) {
672
+ await this.restoreCachedUser()
673
+ } else {
674
+ this.isAuthReady = true
675
+ this.notify()
676
+ }
662
677
  }
663
678
  }
664
679
 
@@ -772,9 +787,15 @@ export class AuthManager {
772
787
  throw new Error('Network issue - refresh will be retried when online')
773
788
  }
774
789
 
775
- await this.clearStoredAuth()
776
- this.resetAuthState()
777
- this.notify()
790
+ // Only clear stored auth on definitive OAuth rejection.
791
+ // Transient server errors (500, 503, etc.) should NOT wipe
792
+ // the refresh token — the user can retry later.
793
+ const definitiveErrors = ['invalid_grant', 'invalid_client', 'unauthorized_client']
794
+ if (typeof token.error === 'string' && definitiveErrors.includes(token.error)) {
795
+ await this.clearStoredAuth()
796
+ this.resetAuthState()
797
+ this.notify()
798
+ }
778
799
  throw new Error(`Token refresh failed: ${token.error}`)
779
800
  } else {
780
801
  this.token = token
@@ -800,7 +821,9 @@ export class AuthManager {
800
821
  } catch (error) {
801
822
  log('Token refresh error:', error)
802
823
 
803
- if (!this.isNetworkError(error)) {
824
+ const msg = error instanceof Error ? error.message : ''
825
+ const alreadyHandled = msg.startsWith('Token refresh failed:')
826
+ if (!alreadyHandled && !this.isNetworkError(error)) {
804
827
  await this.clearStoredAuth()
805
828
  this.resetAuthState()
806
829
  this.notify()
@@ -850,6 +873,7 @@ export class AuthManager {
850
873
  }
851
874
 
852
875
  private isNetworkError(error: unknown): boolean {
876
+ if (error instanceof TypeError) return true
853
877
  if (error instanceof Error) {
854
878
  return error.message.includes('offline') || error.message.includes('Network')
855
879
  }