@basictech/react 0.7.0-beta.4 → 0.7.0-beta.5

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.
@@ -16,6 +16,7 @@ export type { BasicStorage, LocalStorageAdapter } from './utils/storage'
16
16
  export type AuthConfig = {
17
17
  scopes?: string | string[];
18
18
  server_url?: string;
19
+ ws_url?: string;
19
20
  }
20
21
 
21
22
  export type BasicProviderProps = {
@@ -27,15 +28,16 @@ export type BasicProviderProps = {
27
28
  auth?: AuthConfig;
28
29
  }
29
30
 
30
- const DEFAULT_AUTH_CONFIG: Required<AuthConfig> = {
31
- scopes: 'profile email app:admin',
32
- server_url: 'https://api.basic.tech'
33
- }
31
+ const DEFAULT_AUTH_CONFIG = {
32
+ scopes: 'profile,email,app:admin',
33
+ server_url: 'https://api.basic.tech',
34
+ ws_url: 'wss://pds.basic.id/ws'
35
+ } as const
34
36
 
35
37
 
36
38
  type BasicSyncType = {
37
39
  basic_schema: any;
38
- connect: (options: { access_token: string }) => void;
40
+ connect: (options: { access_token: string; ws_url?: string }) => void;
39
41
  debugeroo: () => void;
40
42
  collection: (name: string) => {
41
43
  ref: {
@@ -128,9 +130,10 @@ export function BasicProvider({
128
130
  const storageAdapter = storage || new LocalStorageAdapter();
129
131
 
130
132
  // Merge auth config with defaults
131
- const authConfig: Required<AuthConfig> = {
133
+ const authConfig = {
132
134
  scopes: auth?.scopes || DEFAULT_AUTH_CONFIG.scopes,
133
- server_url: auth?.server_url || DEFAULT_AUTH_CONFIG.server_url
135
+ server_url: auth?.server_url || DEFAULT_AUTH_CONFIG.server_url,
136
+ ws_url: auth?.ws_url || DEFAULT_AUTH_CONFIG.ws_url
134
137
  }
135
138
 
136
139
  // Normalize scopes to space-separated string
@@ -138,6 +141,9 @@ export function BasicProvider({
138
141
  ? authConfig.scopes.join(' ')
139
142
  : authConfig.scopes;
140
143
 
144
+ // Token refresh mutex to prevent concurrent refreshes
145
+ const refreshPromiseRef = useRef<Promise<Token | null> | null>(null);
146
+
141
147
  const isDevMode = () => isDevelopment(debug)
142
148
 
143
149
  const cleanOAuthParams = () => cleanOAuthParamsFromUrl()
@@ -245,7 +251,10 @@ export function BasicProvider({
245
251
 
246
252
  log('connecting to db...')
247
253
 
248
- syncRef.current?.connect({ access_token: tok })
254
+ syncRef.current?.connect({
255
+ access_token: tok,
256
+ ws_url: authConfig.ws_url
257
+ })
249
258
  .catch((e) => {
250
259
  log('error connecting to db', e)
251
260
  })
@@ -259,6 +268,19 @@ export function BasicProvider({
259
268
  const initializeAuth = async () => {
260
269
  await storageAdapter.set(STORAGE_KEYS.DEBUG, debug ? 'true' : 'false')
261
270
 
271
+ // Check if server URL has changed - if so, clear tokens
272
+ const storedServerUrl = await storageAdapter.get(STORAGE_KEYS.SERVER_URL)
273
+ if (storedServerUrl && storedServerUrl !== authConfig.server_url) {
274
+ log('Server URL changed, clearing stored tokens')
275
+ await storageAdapter.remove(STORAGE_KEYS.REFRESH_TOKEN)
276
+ await storageAdapter.remove(STORAGE_KEYS.USER_INFO)
277
+ await storageAdapter.remove(STORAGE_KEYS.AUTH_STATE)
278
+ await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
279
+ clearCookie('basic_token')
280
+ clearCookie('basic_access_token')
281
+ }
282
+ await storageAdapter.set(STORAGE_KEYS.SERVER_URL, authConfig.server_url)
283
+
262
284
  try {
263
285
  const versionUpdater = createVersionUpdater(storageAdapter, currentVersion, getMigrations())
264
286
  const updateResult = await versionUpdater.checkAndUpdate()
@@ -337,19 +359,25 @@ export function BasicProvider({
337
359
  useEffect(() => {
338
360
  async function fetchUser(acc_token: string) {
339
361
  console.info('fetching user')
340
- const user = await fetch(`${authConfig.server_url}/auth/userInfo`, {
341
- method: 'GET',
342
- headers: {
343
- 'Authorization': `Bearer ${acc_token}`
362
+ try {
363
+ const response = await fetch(`${authConfig.server_url}/auth/userInfo`, {
364
+ method: 'GET',
365
+ headers: {
366
+ 'Authorization': `Bearer ${acc_token}`
367
+ }
368
+ })
369
+
370
+ if (!response.ok) {
371
+ throw new Error(`Failed to fetch user info: ${response.status}`)
372
+ }
373
+
374
+ const user = await response.json()
375
+
376
+ if (user.error) {
377
+ log('error fetching user', user.error)
378
+ throw new Error(`User info error: ${user.error}`)
344
379
  }
345
- })
346
- .then(response => response.json())
347
- .catch(error => log('Error:', error))
348
380
 
349
- if (user.error) {
350
- log('error fetching user', user.error)
351
- return
352
- } else {
353
381
  if (token?.refresh_token) {
354
382
  await storageAdapter.set(STORAGE_KEYS.REFRESH_TOKEN, token.refresh_token)
355
383
  }
@@ -362,7 +390,10 @@ export function BasicProvider({
362
390
 
363
391
  setUser(user)
364
392
  setIsSignedIn(true)
365
-
393
+ setIsAuthReady(true)
394
+ } catch (error) {
395
+ log('Failed to fetch user info:', error)
396
+ // Don't clear tokens here - may be temporary network issue
366
397
  setIsAuthReady(true)
367
398
  }
368
399
  }
@@ -376,7 +407,9 @@ export function BasicProvider({
376
407
  }
377
408
 
378
409
  const decoded = jwtDecode(token?.access_token)
379
- const isExpired = decoded.exp && decoded.exp < Date.now() / 1000
410
+ // Add 5 second buffer to prevent edge cases
411
+ const expirationBuffer = 5
412
+ const isExpired = decoded.exp && decoded.exp < (Date.now() / 1000) + expirationBuffer
380
413
 
381
414
  if (isExpired) {
382
415
  log('token is expired - refreshing ...')
@@ -452,7 +485,10 @@ export function BasicProvider({
452
485
  const signInLink = await getSignInLink()
453
486
  log('Generated sign-in link:', signInLink)
454
487
 
455
- if (!signInLink || !signInLink.startsWith('https://')) {
488
+ // Validate URL format (supports https://, http://, and custom URI schemes)
489
+ try {
490
+ new URL(signInLink)
491
+ } catch {
456
492
  log('Error: Invalid sign-in link generated')
457
493
  throw new Error('Failed to generate valid sign-in URL')
458
494
  }
@@ -522,6 +558,7 @@ export function BasicProvider({
522
558
  await storageAdapter.remove(STORAGE_KEYS.REFRESH_TOKEN)
523
559
  await storageAdapter.remove(STORAGE_KEYS.USER_INFO)
524
560
  await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
561
+ await storageAdapter.remove(STORAGE_KEYS.SERVER_URL)
525
562
  if (syncRef.current) {
526
563
  (async () => {
527
564
  try {
@@ -544,6 +581,21 @@ export function BasicProvider({
544
581
  const refreshToken = await storageAdapter.get(STORAGE_KEYS.REFRESH_TOKEN)
545
582
  if (refreshToken) {
546
583
  log('No token in memory, attempting to refresh from storage')
584
+
585
+ // Check if refresh is already in progress
586
+ if (refreshPromiseRef.current) {
587
+ log('Token refresh already in progress, waiting...')
588
+ try {
589
+ const newToken = await refreshPromiseRef.current
590
+ if (newToken?.access_token) {
591
+ return newToken.access_token
592
+ }
593
+ } catch (error) {
594
+ log('In-flight refresh failed:', error)
595
+ throw error
596
+ }
597
+ }
598
+
547
599
  try {
548
600
  const newToken = await fetchToken(refreshToken, true)
549
601
  if (newToken?.access_token) {
@@ -569,10 +621,31 @@ export function BasicProvider({
569
621
  }
570
622
 
571
623
  const decoded = jwtDecode(token?.access_token)
572
- const isExpired = decoded.exp && decoded.exp < Date.now() / 1000
624
+ // Add 5 second buffer to prevent edge cases where token expires during request
625
+ const expirationBuffer = 5
626
+ const isExpired = decoded.exp && decoded.exp < (Date.now() / 1000) + expirationBuffer
573
627
 
574
628
  if (isExpired) {
575
629
  log('token is expired - refreshing ...')
630
+
631
+ // Check if refresh is already in progress
632
+ if (refreshPromiseRef.current) {
633
+ log('Token refresh already in progress, waiting...')
634
+ try {
635
+ const newToken = await refreshPromiseRef.current
636
+ return newToken?.access_token || ''
637
+ } catch (error) {
638
+ log('In-flight refresh failed:', error)
639
+
640
+ if ((error as Error).message.includes('offline') || (error as Error).message.includes('Network')) {
641
+ log('Network issue - using expired token until network is restored')
642
+ return token.access_token
643
+ }
644
+
645
+ throw error
646
+ }
647
+ }
648
+
576
649
  const refreshToken = token?.refresh_token || await storageAdapter.get(STORAGE_KEYS.REFRESH_TOKEN)
577
650
  if (refreshToken) {
578
651
  try {
@@ -596,124 +669,151 @@ export function BasicProvider({
596
669
  return token?.access_token || ''
597
670
  }
598
671
 
599
- const fetchToken = async (codeOrRefreshToken: string, isRefreshToken: boolean = false) => {
600
- try {
601
- if (!isOnline) {
602
- log('Network is offline, marking refresh as pending')
603
- setPendingRefresh(true)
604
- throw new Error('Network offline - refresh will be retried when online')
605
- }
606
-
607
- let requestBody: any
672
+ const fetchToken = async (codeOrRefreshToken: string, isRefreshToken: boolean = false): Promise<Token | null> => {
673
+ // If this is a refresh token request and one is already in progress, return that promise
674
+ if (isRefreshToken && refreshPromiseRef.current) {
675
+ log('Reusing in-flight refresh token request')
676
+ return refreshPromiseRef.current
677
+ }
608
678
 
609
- if (isRefreshToken) {
610
- // Refresh token request
611
- requestBody = {
612
- grant_type: 'refresh_token',
613
- refresh_token: codeOrRefreshToken
614
- }
615
- // Include client_id if available for validation
616
- if (project_id) {
617
- requestBody.client_id = project_id
618
- }
619
- } else {
620
- // Authorization code exchange
621
- requestBody = {
622
- grant_type: 'authorization_code',
623
- code: codeOrRefreshToken
679
+ // Create new promise for this refresh attempt
680
+ const refreshPromise = (async (): Promise<Token | null> => {
681
+ try {
682
+ if (!isOnline) {
683
+ log('Network is offline, marking refresh as pending')
684
+ setPendingRefresh(true)
685
+ throw new Error('Network offline - refresh will be retried when online')
624
686
  }
625
-
626
- // Retrieve stored redirect_uri (required by OAuth2 spec)
627
- const storedRedirectUri = await storageAdapter.get(STORAGE_KEYS.REDIRECT_URI)
628
- if (storedRedirectUri) {
629
- requestBody.redirect_uri = storedRedirectUri
630
- log('Including redirect_uri in token exchange:', storedRedirectUri)
687
+
688
+ let requestBody: any
689
+
690
+ if (isRefreshToken) {
691
+ // Refresh token request
692
+ requestBody = {
693
+ grant_type: 'refresh_token',
694
+ refresh_token: codeOrRefreshToken
695
+ }
696
+ // Include client_id if available for validation
697
+ if (project_id) {
698
+ requestBody.client_id = project_id
699
+ }
631
700
  } else {
632
- log('Warning: No redirect_uri found in storage for token exchange')
633
- }
634
-
635
- // Include client_id for validation
636
- if (project_id) {
637
- requestBody.client_id = project_id
701
+ // Authorization code exchange
702
+ requestBody = {
703
+ grant_type: 'authorization_code',
704
+ code: codeOrRefreshToken
705
+ }
706
+
707
+ // Retrieve stored redirect_uri (required by OAuth2 spec)
708
+ const storedRedirectUri = await storageAdapter.get(STORAGE_KEYS.REDIRECT_URI)
709
+ if (storedRedirectUri) {
710
+ requestBody.redirect_uri = storedRedirectUri
711
+ log('Including redirect_uri in token exchange:', storedRedirectUri)
712
+ } else {
713
+ log('Warning: No redirect_uri found in storage for token exchange')
714
+ }
715
+
716
+ // Include client_id for validation
717
+ if (project_id) {
718
+ requestBody.client_id = project_id
719
+ }
638
720
  }
639
- }
640
721
 
641
- log('Token exchange request body:', { ...requestBody, refresh_token: isRefreshToken ? '[REDACTED]' : undefined, code: !isRefreshToken ? '[REDACTED]' : undefined })
722
+ log('Token exchange request body:', { ...requestBody, refresh_token: isRefreshToken ? '[REDACTED]' : undefined, code: !isRefreshToken ? '[REDACTED]' : undefined })
642
723
 
643
- const token = await fetch(`${authConfig.server_url}/auth/token`, {
644
- method: 'POST',
645
- headers: {
646
- 'Content-Type': 'application/json'
647
- },
648
- body: JSON.stringify(requestBody)
649
- })
650
- .then(response => response.json())
651
- .catch(error => {
652
- log('Network error fetching token:', error)
653
- if (!isOnline) {
724
+ const token = await fetch(`${authConfig.server_url}/auth/token`, {
725
+ method: 'POST',
726
+ headers: {
727
+ 'Content-Type': 'application/json'
728
+ },
729
+ body: JSON.stringify(requestBody)
730
+ })
731
+ .then(response => response.json())
732
+ .catch(error => {
733
+ log('Network error fetching token:', error)
734
+ if (!isOnline) {
735
+ setPendingRefresh(true)
736
+ throw new Error('Network offline - refresh will be retried when online')
737
+ }
738
+ throw new Error('Network error during token refresh')
739
+ })
740
+
741
+ if (token.error) {
742
+ log('error fetching token', token.error)
743
+
744
+ if (token.error.includes('network') || token.error.includes('timeout')) {
654
745
  setPendingRefresh(true)
655
- throw new Error('Network offline - refresh will be retried when online')
746
+ throw new Error('Network issue - refresh will be retried when online')
656
747
  }
657
- throw new Error('Network error during token refresh')
658
- })
659
748
 
660
- if (token.error) {
661
- log('error fetching token', token.error)
749
+ await storageAdapter.remove(STORAGE_KEYS.REFRESH_TOKEN)
750
+ await storageAdapter.remove(STORAGE_KEYS.USER_INFO)
751
+ await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
752
+ await storageAdapter.remove(STORAGE_KEYS.SERVER_URL)
753
+ clearCookie('basic_token');
754
+ clearCookie('basic_access_token');
662
755
 
663
- if (token.error.includes('network') || token.error.includes('timeout')) {
664
- setPendingRefresh(true)
665
- throw new Error('Network issue - refresh will be retried when online')
666
- }
756
+ setUser({})
757
+ setIsSignedIn(false)
758
+ setToken(null)
759
+ setIsAuthReady(true)
667
760
 
668
- await storageAdapter.remove(STORAGE_KEYS.REFRESH_TOKEN)
669
- await storageAdapter.remove(STORAGE_KEYS.USER_INFO)
670
- await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
671
- clearCookie('basic_token');
672
- clearCookie('basic_access_token');
761
+ throw new Error(`Token refresh failed: ${token.error}`)
762
+ } else {
763
+ setToken(token)
764
+ setPendingRefresh(false)
673
765
 
674
- setUser({})
675
- setIsSignedIn(false)
676
- setToken(null)
677
- setIsAuthReady(true)
766
+ if (token.refresh_token) {
767
+ await storageAdapter.set(STORAGE_KEYS.REFRESH_TOKEN, token.refresh_token)
768
+ log('Updated refresh token in storage')
769
+ }
678
770
 
679
- throw new Error(`Token refresh failed: ${token.error}`)
680
- } else {
681
- setToken(token)
682
- setPendingRefresh(false)
771
+ // Clean up redirect_uri after successful token exchange
772
+ if (!isRefreshToken) {
773
+ await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
774
+ log('Cleaned up redirect_uri from storage after successful exchange')
775
+ }
683
776
 
684
- if (token.refresh_token) {
685
- await storageAdapter.set(STORAGE_KEYS.REFRESH_TOKEN, token.refresh_token)
686
- log('Updated refresh token in storage')
777
+ setCookie('basic_access_token', token.access_token, { httpOnly: false });
778
+ setCookie('basic_token', JSON.stringify(token));
779
+ log('Updated access token and full token in cookies')
687
780
  }
781
+ return token
782
+ } catch (error) {
783
+ log('Token refresh error:', error)
688
784
 
689
- // Clean up redirect_uri after successful token exchange
690
- if (!isRefreshToken) {
785
+ if (!(error as Error).message.includes('offline') && !(error as Error).message.includes('Network')) {
786
+ await storageAdapter.remove(STORAGE_KEYS.REFRESH_TOKEN)
787
+ await storageAdapter.remove(STORAGE_KEYS.USER_INFO)
691
788
  await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
692
- log('Cleaned up redirect_uri from storage after successful exchange')
789
+ await storageAdapter.remove(STORAGE_KEYS.SERVER_URL)
790
+ clearCookie('basic_token');
791
+ clearCookie('basic_access_token');
792
+
793
+ setUser({})
794
+ setIsSignedIn(false)
795
+ setToken(null)
796
+ setIsAuthReady(true)
693
797
  }
694
798
 
695
- setCookie('basic_access_token', token.access_token, { httpOnly: false });
696
- log('Updated access token in cookie')
697
- }
698
- return token
699
- } catch (error) {
700
- log('Token refresh error:', error)
701
-
702
- if (!(error as Error).message.includes('offline') && !(error as Error).message.includes('Network')) {
703
- await storageAdapter.remove(STORAGE_KEYS.REFRESH_TOKEN)
704
- await storageAdapter.remove(STORAGE_KEYS.USER_INFO)
705
- await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
706
- clearCookie('basic_token');
707
- clearCookie('basic_access_token');
708
-
709
- setUser({})
710
- setIsSignedIn(false)
711
- setToken(null)
712
- setIsAuthReady(true)
799
+ throw error
713
800
  }
714
-
715
- throw error
801
+ })()
802
+
803
+ // Store promise if this is a refresh token request
804
+ if (isRefreshToken) {
805
+ refreshPromiseRef.current = refreshPromise
806
+
807
+ // Clear the promise reference when done (success or failure)
808
+ refreshPromise.finally(() => {
809
+ if (refreshPromiseRef.current === refreshPromise) {
810
+ refreshPromiseRef.current = null
811
+ log('Cleared refresh promise reference')
812
+ }
813
+ })
716
814
  }
815
+
816
+ return refreshPromise
717
817
  }
718
818
 
719
819
  const noDb = ({
package/src/sync/index.ts CHANGED
@@ -56,8 +56,8 @@ export class BasicSync extends Dexie {
56
56
 
57
57
  }
58
58
 
59
- async connect({ access_token }: { access_token: string }) {
60
- const WS_URL = `wss://pds.basic.id/ws`
59
+ async connect({ access_token, ws_url }: { access_token: string, ws_url?: string }) {
60
+ const WS_URL = ws_url || 'wss://pds.basic.id/ws'
61
61
 
62
62
  log('Connecting to', WS_URL)
63
63
 
@@ -67,8 +67,8 @@ export class BasicSync extends Dexie {
67
67
  return this.syncable.connect("websocket", WS_URL, { authToken: access_token, schema: this.basic_schema });
68
68
  }
69
69
 
70
- async disconnect() {
71
- const WS_URL = `wss://pds.basic.id/ws`
70
+ async disconnect({ ws_url }: { ws_url?: string } = {}) {
71
+ const WS_URL = ws_url || 'wss://pds.basic.id/ws'
72
72
 
73
73
  return this.syncable.disconnect(WS_URL)
74
74
  }
@@ -24,6 +24,7 @@ export const STORAGE_KEYS = {
24
24
  USER_INFO: 'basic_user_info',
25
25
  AUTH_STATE: 'basic_auth_state',
26
26
  REDIRECT_URI: 'basic_redirect_uri',
27
+ SERVER_URL: 'basic_server_url',
27
28
  DEBUG: 'basic_debug'
28
29
  } as const
29
30