@basictech/react 0.8.0-beta.2 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basictech/react",
3
- "version": "0.8.0-beta.2",
3
+ "version": "0.8.0-beta.3",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -268,6 +268,10 @@ export function BasicProvider({
268
268
  debug: debug,
269
269
  onAuthError: (error) => {
270
270
  log('RemoteDB auth error:', error)
271
+ if (error.errorType === 'forbidden') {
272
+ log('403 Forbidden - user lacks required scope, not signing out')
273
+ return
274
+ }
271
275
  handleSignOut()
272
276
  },
273
277
  })
@@ -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
  }
@@ -137,9 +137,37 @@ export const syncProtocol = function () {
137
137
  }
138
138
  };
139
139
 
140
+ // When the page becomes visible again (e.g. PWA/mobile browser resuming
141
+ // from background), the scheduled setTimeout for token refresh may have
142
+ // been frozen by the browser. Force-refresh the token and re-send it to
143
+ // the server so the WebSocket connection stays authenticated.
144
+ function handleVisibilityResume() {
145
+ if (document.visibilityState === 'visible' && ws.readyState === WebSocket.OPEN) {
146
+ log("Page became visible - refreshing token for WebSocket");
147
+ resolveGetToken()({ forceRefresh: true }).then(function(newToken) {
148
+ if (ws.readyState === WebSocket.OPEN) {
149
+ ws.send(JSON.stringify({ type: "tokenUpdate", authToken: newToken }));
150
+ scheduleTokenRefresh(newToken);
151
+ }
152
+ }).catch(function(err) {
153
+ log("Token refresh on visibility resume failed:", err);
154
+ });
155
+ }
156
+ }
157
+ if (typeof document !== 'undefined') {
158
+ document.addEventListener('visibilitychange', handleVisibilityResume);
159
+ }
160
+
161
+ function cleanupVisibilityListener() {
162
+ if (typeof document !== 'undefined') {
163
+ document.removeEventListener('visibilitychange', handleVisibilityResume);
164
+ }
165
+ }
166
+
140
167
  // If network down or other error, tell the framework to reconnect again in some time:
141
168
  ws.onerror = function (event) {
142
169
  clearRefreshTimer();
170
+ cleanupVisibilityListener();
143
171
  ws.close();
144
172
  log("ws.onerror", event);
145
173
  onError(event?.message, RECONNECT_DELAY);
@@ -148,6 +176,7 @@ export const syncProtocol = function () {
148
176
  // If socket is closed (network disconnected), inform framework and make it reconnect
149
177
  ws.onclose = function (event) {
150
178
  clearRefreshTimer();
179
+ cleanupVisibilityListener();
151
180
  onError("Socket closed: " + event.reason, RECONNECT_DELAY);
152
181
  };
153
182
 
@@ -211,6 +240,7 @@ export const syncProtocol = function () {
211
240
  },
212
241
  disconnect: function () {
213
242
  clearRefreshTimer();
243
+ cleanupVisibilityListener();
214
244
  ws.close();
215
245
  },
216
246
  });
@@ -110,7 +110,7 @@ export function cleanOAuthParamsFromUrl(): void {
110
110
  const url = new URL(window.location.href)
111
111
  url.searchParams.delete('code')
112
112
  url.searchParams.delete('state')
113
- window.history.pushState({}, document.title, url.pathname + url.search)
113
+ window.history.replaceState({}, document.title, url.pathname + url.search)
114
114
  log('Cleaned OAuth parameters from URL')
115
115
  }
116
116
  }