@basictech/react 0.7.0-beta.3 → 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,17 +16,28 @@ 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
- const DEFAULT_AUTH_CONFIG: Required<AuthConfig> = {
22
- scopes: 'profile email app:admin',
23
- server_url: 'https://api.basic.tech'
22
+ export type BasicProviderProps = {
23
+ children: React.ReactNode;
24
+ project_id?: string;
25
+ schema?: any;
26
+ debug?: boolean;
27
+ storage?: BasicStorage;
28
+ auth?: AuthConfig;
24
29
  }
25
30
 
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
36
+
26
37
 
27
38
  type BasicSyncType = {
28
39
  basic_schema: any;
29
- connect: (options: { access_token: string }) => void;
40
+ connect: (options: { access_token: string; ws_url?: string }) => void;
30
41
  debugeroo: () => void;
31
42
  collection: (name: string) => {
32
43
  ref: {
@@ -102,14 +113,7 @@ export function BasicProvider({
102
113
  debug = false,
103
114
  storage,
104
115
  auth
105
- }: {
106
- children: React.ReactNode,
107
- project_id?: string,
108
- schema?: any,
109
- debug?: boolean,
110
- storage?: BasicStorage,
111
- auth?: AuthConfig
112
- }) {
116
+ }: BasicProviderProps) {
113
117
  const [isAuthReady, setIsAuthReady] = useState(false)
114
118
  const [isSignedIn, setIsSignedIn] = useState<boolean>(false)
115
119
  const [token, setToken] = useState<Token | null>(null)
@@ -126,9 +130,10 @@ export function BasicProvider({
126
130
  const storageAdapter = storage || new LocalStorageAdapter();
127
131
 
128
132
  // Merge auth config with defaults
129
- const authConfig: Required<AuthConfig> = {
133
+ const authConfig = {
130
134
  scopes: auth?.scopes || DEFAULT_AUTH_CONFIG.scopes,
131
- 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
132
137
  }
133
138
 
134
139
  // Normalize scopes to space-separated string
@@ -136,6 +141,9 @@ export function BasicProvider({
136
141
  ? authConfig.scopes.join(' ')
137
142
  : authConfig.scopes;
138
143
 
144
+ // Token refresh mutex to prevent concurrent refreshes
145
+ const refreshPromiseRef = useRef<Promise<Token | null> | null>(null);
146
+
139
147
  const isDevMode = () => isDevelopment(debug)
140
148
 
141
149
  const cleanOAuthParams = () => cleanOAuthParamsFromUrl()
@@ -243,7 +251,10 @@ export function BasicProvider({
243
251
 
244
252
  log('connecting to db...')
245
253
 
246
- syncRef.current?.connect({ access_token: tok })
254
+ syncRef.current?.connect({
255
+ access_token: tok,
256
+ ws_url: authConfig.ws_url
257
+ })
247
258
  .catch((e) => {
248
259
  log('error connecting to db', e)
249
260
  })
@@ -257,6 +268,19 @@ export function BasicProvider({
257
268
  const initializeAuth = async () => {
258
269
  await storageAdapter.set(STORAGE_KEYS.DEBUG, debug ? 'true' : 'false')
259
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
+
260
284
  try {
261
285
  const versionUpdater = createVersionUpdater(storageAdapter, currentVersion, getMigrations())
262
286
  const updateResult = await versionUpdater.checkAndUpdate()
@@ -335,19 +359,25 @@ export function BasicProvider({
335
359
  useEffect(() => {
336
360
  async function fetchUser(acc_token: string) {
337
361
  console.info('fetching user')
338
- const user = await fetch(`${authConfig.server_url}/auth/userInfo`, {
339
- method: 'GET',
340
- headers: {
341
- '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}`)
342
379
  }
343
- })
344
- .then(response => response.json())
345
- .catch(error => log('Error:', error))
346
380
 
347
- if (user.error) {
348
- log('error fetching user', user.error)
349
- return
350
- } else {
351
381
  if (token?.refresh_token) {
352
382
  await storageAdapter.set(STORAGE_KEYS.REFRESH_TOKEN, token.refresh_token)
353
383
  }
@@ -360,7 +390,10 @@ export function BasicProvider({
360
390
 
361
391
  setUser(user)
362
392
  setIsSignedIn(true)
363
-
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
364
397
  setIsAuthReady(true)
365
398
  }
366
399
  }
@@ -374,7 +407,9 @@ export function BasicProvider({
374
407
  }
375
408
 
376
409
  const decoded = jwtDecode(token?.access_token)
377
- 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
378
413
 
379
414
  if (isExpired) {
380
415
  log('token is expired - refreshing ...')
@@ -450,7 +485,10 @@ export function BasicProvider({
450
485
  const signInLink = await getSignInLink()
451
486
  log('Generated sign-in link:', signInLink)
452
487
 
453
- if (!signInLink || !signInLink.startsWith('https://')) {
488
+ // Validate URL format (supports https://, http://, and custom URI schemes)
489
+ try {
490
+ new URL(signInLink)
491
+ } catch {
454
492
  log('Error: Invalid sign-in link generated')
455
493
  throw new Error('Failed to generate valid sign-in URL')
456
494
  }
@@ -520,6 +558,7 @@ export function BasicProvider({
520
558
  await storageAdapter.remove(STORAGE_KEYS.REFRESH_TOKEN)
521
559
  await storageAdapter.remove(STORAGE_KEYS.USER_INFO)
522
560
  await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
561
+ await storageAdapter.remove(STORAGE_KEYS.SERVER_URL)
523
562
  if (syncRef.current) {
524
563
  (async () => {
525
564
  try {
@@ -542,6 +581,21 @@ export function BasicProvider({
542
581
  const refreshToken = await storageAdapter.get(STORAGE_KEYS.REFRESH_TOKEN)
543
582
  if (refreshToken) {
544
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
+
545
599
  try {
546
600
  const newToken = await fetchToken(refreshToken, true)
547
601
  if (newToken?.access_token) {
@@ -567,10 +621,31 @@ export function BasicProvider({
567
621
  }
568
622
 
569
623
  const decoded = jwtDecode(token?.access_token)
570
- 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
571
627
 
572
628
  if (isExpired) {
573
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
+
574
649
  const refreshToken = token?.refresh_token || await storageAdapter.get(STORAGE_KEYS.REFRESH_TOKEN)
575
650
  if (refreshToken) {
576
651
  try {
@@ -594,124 +669,151 @@ export function BasicProvider({
594
669
  return token?.access_token || ''
595
670
  }
596
671
 
597
- const fetchToken = async (codeOrRefreshToken: string, isRefreshToken: boolean = false) => {
598
- try {
599
- if (!isOnline) {
600
- log('Network is offline, marking refresh as pending')
601
- setPendingRefresh(true)
602
- throw new Error('Network offline - refresh will be retried when online')
603
- }
604
-
605
- 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
+ }
606
678
 
607
- if (isRefreshToken) {
608
- // Refresh token request
609
- requestBody = {
610
- grant_type: 'refresh_token',
611
- refresh_token: codeOrRefreshToken
612
- }
613
- // Include client_id if available for validation
614
- if (project_id) {
615
- requestBody.client_id = project_id
616
- }
617
- } else {
618
- // Authorization code exchange
619
- requestBody = {
620
- grant_type: 'authorization_code',
621
- 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')
622
686
  }
623
-
624
- // Retrieve stored redirect_uri (required by OAuth2 spec)
625
- const storedRedirectUri = await storageAdapter.get(STORAGE_KEYS.REDIRECT_URI)
626
- if (storedRedirectUri) {
627
- requestBody.redirect_uri = storedRedirectUri
628
- 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
+ }
629
700
  } else {
630
- log('Warning: No redirect_uri found in storage for token exchange')
631
- }
632
-
633
- // Include client_id for validation
634
- if (project_id) {
635
- 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
+ }
636
720
  }
637
- }
638
721
 
639
- 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 })
640
723
 
641
- const token = await fetch(`${authConfig.server_url}/auth/token`, {
642
- method: 'POST',
643
- headers: {
644
- 'Content-Type': 'application/json'
645
- },
646
- body: JSON.stringify(requestBody)
647
- })
648
- .then(response => response.json())
649
- .catch(error => {
650
- log('Network error fetching token:', error)
651
- 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')) {
652
745
  setPendingRefresh(true)
653
- throw new Error('Network offline - refresh will be retried when online')
746
+ throw new Error('Network issue - refresh will be retried when online')
654
747
  }
655
- throw new Error('Network error during token refresh')
656
- })
657
748
 
658
- if (token.error) {
659
- 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');
660
755
 
661
- if (token.error.includes('network') || token.error.includes('timeout')) {
662
- setPendingRefresh(true)
663
- throw new Error('Network issue - refresh will be retried when online')
664
- }
756
+ setUser({})
757
+ setIsSignedIn(false)
758
+ setToken(null)
759
+ setIsAuthReady(true)
665
760
 
666
- await storageAdapter.remove(STORAGE_KEYS.REFRESH_TOKEN)
667
- await storageAdapter.remove(STORAGE_KEYS.USER_INFO)
668
- await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
669
- clearCookie('basic_token');
670
- clearCookie('basic_access_token');
761
+ throw new Error(`Token refresh failed: ${token.error}`)
762
+ } else {
763
+ setToken(token)
764
+ setPendingRefresh(false)
671
765
 
672
- setUser({})
673
- setIsSignedIn(false)
674
- setToken(null)
675
- 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
+ }
676
770
 
677
- throw new Error(`Token refresh failed: ${token.error}`)
678
- } else {
679
- setToken(token)
680
- 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
+ }
681
776
 
682
- if (token.refresh_token) {
683
- await storageAdapter.set(STORAGE_KEYS.REFRESH_TOKEN, token.refresh_token)
684
- 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')
685
780
  }
781
+ return token
782
+ } catch (error) {
783
+ log('Token refresh error:', error)
686
784
 
687
- // Clean up redirect_uri after successful token exchange
688
- 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)
689
788
  await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
690
- 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)
691
797
  }
692
798
 
693
- setCookie('basic_access_token', token.access_token, { httpOnly: false });
694
- log('Updated access token in cookie')
695
- }
696
- return token
697
- } catch (error) {
698
- log('Token refresh error:', error)
699
-
700
- if (!(error as Error).message.includes('offline') && !(error as Error).message.includes('Network')) {
701
- await storageAdapter.remove(STORAGE_KEYS.REFRESH_TOKEN)
702
- await storageAdapter.remove(STORAGE_KEYS.USER_INFO)
703
- await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
704
- clearCookie('basic_token');
705
- clearCookie('basic_access_token');
706
-
707
- setUser({})
708
- setIsSignedIn(false)
709
- setToken(null)
710
- setIsAuthReady(true)
799
+ throw error
711
800
  }
712
-
713
- 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
+ })
714
814
  }
815
+
816
+ return refreshPromise
715
817
  }
716
818
 
717
819
  const noDb = ({
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { useState } from "react";
2
- import { useBasic, BasicProvider, BasicStorage, LocalStorageAdapter, AuthConfig } from "./AuthContext";
2
+ import { useBasic, BasicProvider, BasicStorage, LocalStorageAdapter, AuthConfig, BasicProviderProps } from "./AuthContext";
3
3
  import { useLiveQuery as useQuery } from "dexie-react-hooks";
4
4
  // import { createVersionUpdater, VersionUpdater, Migration } from "./versionUpdater";
5
5
 
@@ -9,9 +9,5 @@ export {
9
9
  }
10
10
 
11
11
  export type {
12
- AuthConfig, BasicStorage, LocalStorageAdapter
13
- }
14
-
15
- // export type {
16
- // VersionUpdater, Migration
17
- // }
12
+ AuthConfig, BasicStorage, LocalStorageAdapter, BasicProviderProps
13
+ }
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