@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/changelog.md +6 -0
- package/dist/index.js +89 -41
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +89 -41
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/AuthContext.tsx +4 -0
- package/src/core/auth/AuthManager.ts +64 -40
- package/src/sync/syncProtocol.js +30 -0
- package/src/utils/network.ts +1 -1
package/package.json
CHANGED
package/src/AuthContext.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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.
|
|
661
|
-
|
|
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
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/sync/syncProtocol.js
CHANGED
|
@@ -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
|
});
|
package/src/utils/network.ts
CHANGED
|
@@ -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.
|
|
113
|
+
window.history.replaceState({}, document.title, url.pathname + url.search)
|
|
114
114
|
log('Cleaned OAuth parameters from URL')
|
|
115
115
|
}
|
|
116
116
|
}
|