@basictech/react 0.7.0 → 0.8.0-beta.2

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.
@@ -1,177 +1,95 @@
1
- import React, { createContext, useContext, useEffect, useState, useRef } from 'react'
2
- import { jwtDecode } from 'jwt-decode'
1
+ import React, { useCallback, useEffect, useRef, useState, Suspense, lazy } from 'react'
3
2
 
4
3
  import { BasicSync, initDexieExtensions } from './sync'
5
4
  import { RemoteDB, DBMode, BasicDB } from './core/db'
5
+ import { AuthManager } from './core/auth/AuthManager'
6
+ import type { User, AuthResult, GetTokenOptions, PdsEndpoints } from './core/auth/AuthManager'
6
7
 
7
8
  import { log } from './config'
8
9
  import { version as currentVersion } from '../package.json'
9
10
  import { createVersionUpdater } from './updater/versionUpdater'
10
11
  import { getMigrations } from './updater/updateMigrations'
11
- import { BasicStorage, LocalStorageAdapter, STORAGE_KEYS, getCookie, setCookie, clearCookie } from './utils/storage'
12
- import { isDevelopment, checkForNewVersion, cleanOAuthParamsFromUrl, getSyncStatus } from './utils/network'
13
- import { getSchemaStatus, validateAndCheckSchema } from './utils/schema'
12
+ import { BasicStorage, LocalStorageAdapter } from './utils/storage'
13
+ import { isDevelopment, checkForNewVersion, getSyncStatus } from './utils/network'
14
+ import { validateAndCheckSchema } from './utils/schema'
15
+ import { BasicContext, DBStatus, noDb, type BasicSchemaDevInfo } from './context'
16
+
17
+ const BasicDevToolbar = lazy(() =>
18
+ import('./dev/BasicDevToolbar').then((m) => ({ default: m.BasicDevToolbar }))
19
+ )
14
20
 
15
21
  export type { BasicStorage, LocalStorageAdapter } from './utils/storage'
16
22
  export type { DBMode, BasicDB, Collection } from './core/db'
23
+ export type { Token, User, AuthResult, GetTokenOptions, PdsEndpoints } from './core/auth/AuthManager'
24
+ export type { BasicContextType, BasicSchemaDevInfo } from './context'
25
+ export { DBStatus, useBasic, BasicContext } from './context'
17
26
 
18
27
  export type AuthConfig = {
19
- scopes?: string | string[];
20
- server_url?: string;
21
- ws_url?: string;
28
+ scopes?: string | string[]
29
+ /** @deprecated Use pds_url instead */
30
+ server_url?: string
31
+ /** PDS URL for auth and data (default: https://pds.basic.id) */
32
+ pds_url?: string
33
+ /** Admin server URL for connect reporting (default: https://api.basic.tech) */
34
+ admin_url?: string
35
+ ws_url?: string
22
36
  }
23
37
 
24
38
  export type BasicProviderProps = {
25
- children: React.ReactNode;
26
- /**
27
- * @deprecated Project ID is now extracted from schema.project_id.
39
+ children: React.ReactNode
40
+ /**
41
+ * @deprecated Project ID is now extracted from schema.project_id.
28
42
  * This prop is kept for backward compatibility but can be omitted.
29
43
  */
30
- project_id?: string;
44
+ project_id?: string
31
45
  /** The Basic schema object containing project_id and table definitions */
32
- schema?: any;
33
- debug?: boolean;
34
- storage?: BasicStorage;
35
- auth?: AuthConfig;
46
+ schema?: any
47
+ debug?: boolean
48
+ storage?: BasicStorage
49
+ auth?: AuthConfig
36
50
  /**
37
51
  * Database mode - determines which implementation is used
38
52
  * - 'sync': Uses Dexie + WebSocket for local-first sync (default)
39
53
  * - 'remote': Uses REST API calls directly to server
40
54
  */
41
- dbMode?: DBMode;
55
+ dbMode?: DBMode
56
+ /** Show floating dev toolbar (localhost, NODE_ENV=development, or debug=true). */
57
+ devToolbar?: boolean
42
58
  }
43
59
 
44
60
  const DEFAULT_AUTH_CONFIG = {
45
61
  scopes: 'profile,email,app:admin',
46
- server_url: 'https://api.basic.tech',
47
- ws_url: 'wss://pds.basic.id/ws'
62
+ pds_url: 'https://pds.basic.id',
63
+ admin_url: 'https://api.basic.tech',
64
+ ws_url: 'wss://pds.basic.id/ws',
48
65
  } as const
49
66
 
50
-
51
- type BasicSyncType = {
52
- basic_schema: any;
53
- connect: (options: { access_token: string; ws_url?: string }) => void;
54
- debugeroo: () => void;
55
- collection: (name: string) => {
56
- ref: {
57
- toArray: () => Promise<any[]>;
58
- count: () => Promise<number>;
59
- };
60
- };
61
- [key: string]: any;
62
- };
63
-
64
-
65
- enum DBStatus {
66
- LOADING = "LOADING",
67
- OFFLINE = "OFFLINE",
68
- CONNECTING = "CONNECTING",
69
- ONLINE = "ONLINE",
70
- SYNCING = "SYNCING",
71
- ERROR = "ERROR"
72
- }
73
-
74
- type User = {
75
- name?: string,
76
- email?: string,
77
- id?: string,
78
- primaryEmailAddress?: {
79
- emailAddress: string
80
- },
81
- fullName?: string
82
- }
83
- type Token = {
84
- access_token: string,
85
- token_type: string,
86
- expires_in: number,
87
- refresh_token: string,
88
- }
89
-
90
- /**
91
- * Auth result type for signInWithCode
92
- */
93
- export type AuthResult = {
94
- success: boolean;
95
- error?: string;
96
- code?: string;
67
+ type ErrorObject = {
68
+ code: string
69
+ title: string
70
+ message: string
97
71
  }
98
72
 
99
- /**
100
- * Context type for useBasic hook
101
- */
102
- export type BasicContextType = {
103
- // Auth state
104
- isReady: boolean;
105
- isSignedIn: boolean;
106
- user: User | null;
107
-
108
- // Auth actions (new camelCase naming)
109
- signIn: () => Promise<void>;
110
- signOut: () => Promise<void>;
111
- signInWithCode: (code: string, state?: string) => Promise<AuthResult>;
112
-
113
- // Token management
114
- getToken: () => Promise<string>;
115
- getSignInUrl: (redirectUri?: string) => Promise<string>;
116
-
117
- // DB access
118
- db: BasicDB;
119
- dbStatus: DBStatus;
120
- dbMode: DBMode;
121
-
122
- // Legacy aliases (deprecated - will be removed in future version)
123
- /** @deprecated Use isReady instead */
124
- isAuthReady: boolean;
125
- /** @deprecated Use signIn instead */
126
- signin: () => Promise<void>;
127
- /** @deprecated Use signOut instead */
128
- signout: () => Promise<void>;
129
- /** @deprecated Use signInWithCode instead */
130
- signinWithCode: (code: string, state?: string) => Promise<AuthResult>;
131
- /** @deprecated Use getSignInUrl instead */
132
- getSignInLink: (redirectUri?: string) => Promise<string>;
73
+ type AuthSnapshot = {
74
+ isSignedIn: boolean
75
+ hasToken: boolean
76
+ isAuthReady: boolean
77
+ user: User | null
78
+ did: string | null
79
+ tokenScope: string | null
133
80
  }
134
81
 
135
- const noDb: BasicDB = {
136
- collection: () => {
137
- throw new Error('no basicdb found - initialization failed. double check your schema.')
82
+ function snapshotAuth(mgr: AuthManager): AuthSnapshot {
83
+ return {
84
+ isSignedIn: mgr.isSignedIn,
85
+ hasToken: !!mgr.token,
86
+ isAuthReady: mgr.isAuthReady,
87
+ user: mgr.user,
88
+ did: mgr.did,
89
+ tokenScope: mgr.tokenScope,
138
90
  }
139
91
  }
140
92
 
141
- export const BasicContext = createContext<BasicContextType>({
142
- // Auth state
143
- isReady: false,
144
- isSignedIn: false,
145
- user: null,
146
-
147
- // Auth actions
148
- signIn: () => Promise.resolve(),
149
- signOut: () => Promise.resolve(),
150
- signInWithCode: () => Promise.resolve({ success: false }),
151
-
152
- // Token management
153
- getToken: () => Promise.reject(new Error('no token')),
154
- getSignInUrl: () => Promise.resolve(""),
155
-
156
- // DB access
157
- db: noDb,
158
- dbStatus: DBStatus.LOADING,
159
- dbMode: 'sync',
160
-
161
- // Legacy aliases
162
- isAuthReady: false,
163
- signin: () => Promise.resolve(),
164
- signout: () => Promise.resolve(),
165
- signinWithCode: () => Promise.resolve({ success: false }),
166
- getSignInLink: () => Promise.resolve("")
167
- });
168
-
169
- type ErrorObject = {
170
- code: string;
171
- title: string;
172
- message: string;
173
- }
174
-
175
93
  export function BasicProvider({
176
94
  children,
177
95
  project_id: project_id_prop,
@@ -179,90 +97,143 @@ export function BasicProvider({
179
97
  debug = false,
180
98
  storage,
181
99
  auth,
182
- dbMode = 'sync'
100
+ dbMode = 'sync',
101
+ devToolbar = false,
183
102
  }: BasicProviderProps) {
184
- // Extract project_id from schema, fall back to prop for backward compatibility
185
103
  const project_id = schema?.project_id || project_id_prop
186
-
187
- const [isAuthReady, setIsAuthReady] = useState(false)
188
- const [isSignedIn, setIsSignedIn] = useState<boolean>(false)
189
- const [token, setToken] = useState<Token | null>(null)
190
- const [user, setUser] = useState<User>({})
191
- const [shouldConnect, setShouldConnect] = useState<boolean>(false)
192
- const [isReady, setIsReady] = useState<boolean>(false)
193
104
 
194
- const [dbStatus, setDbStatus] = useState<DBStatus>(DBStatus.OFFLINE)
195
- const [error, setError] = useState<ErrorObject | null>(null)
196
- const [isOnline, setIsOnline] = useState<boolean>(navigator.onLine)
197
- const [pendingRefresh, setPendingRefresh] = useState<boolean>(false)
198
-
199
- const syncRef = useRef<BasicSync | null>(null);
200
- const remoteDbRef = useRef<RemoteDB | null>(null);
201
- const storageAdapter = storage || new LocalStorageAdapter();
202
-
203
- // Merge auth config with defaults
105
+ if (auth?.server_url && !auth?.pds_url) {
106
+ log('Warning: auth.server_url is deprecated, use auth.pds_url instead')
107
+ }
204
108
  const authConfig = {
205
109
  scopes: auth?.scopes || DEFAULT_AUTH_CONFIG.scopes,
206
- server_url: auth?.server_url || DEFAULT_AUTH_CONFIG.server_url,
207
- ws_url: auth?.ws_url || DEFAULT_AUTH_CONFIG.ws_url
110
+ pds_url: auth?.pds_url || auth?.server_url || DEFAULT_AUTH_CONFIG.pds_url,
111
+ admin_url: auth?.admin_url || DEFAULT_AUTH_CONFIG.admin_url,
112
+ ws_url: auth?.ws_url || DEFAULT_AUTH_CONFIG.ws_url,
113
+ }
114
+
115
+ const scopesString = Array.isArray(authConfig.scopes)
116
+ ? authConfig.scopes.join(' ')
117
+ : authConfig.scopes
118
+
119
+ const storageRef = useRef<BasicStorage>(storage || new LocalStorageAdapter())
120
+ const storageAdapter = storageRef.current
121
+
122
+ const schemaRef = useRef(schema)
123
+ schemaRef.current = schema
124
+
125
+ const [authState, setAuthState] = useState<AuthSnapshot>({
126
+ isSignedIn: false,
127
+ hasToken: false,
128
+ isAuthReady: false,
129
+ user: null,
130
+ did: null,
131
+ tokenScope: null,
132
+ })
133
+
134
+ const authRef = useRef<AuthManager>(null!)
135
+ if (!authRef.current) {
136
+ authRef.current = new AuthManager(
137
+ {
138
+ projectId: project_id,
139
+ scopes: scopesString,
140
+ pdsUrl: authConfig.pds_url,
141
+ adminUrl: authConfig.admin_url,
142
+ debug,
143
+ },
144
+ storageAdapter,
145
+ () => setAuthState(snapshotAuth(authRef.current)),
146
+ )
208
147
  }
209
-
210
- // Normalize scopes to space-separated string
211
- const scopesString = Array.isArray(authConfig.scopes)
212
- ? authConfig.scopes.join(' ')
213
- : authConfig.scopes;
214
148
 
215
- // Token refresh mutex to prevent concurrent refreshes
216
- const refreshPromiseRef = useRef<Promise<Token | null> | null>(null);
149
+ const syncRef = useRef<BasicSync | null>(null)
150
+ const remoteDbRef = useRef<RemoteDB | null>(null)
151
+ const [shouldConnect, setShouldConnect] = useState(false)
152
+ const [dbStatus, setDbStatus] = useState<DBStatus>(DBStatus.OFFLINE)
153
+ const [isReady, setIsReady] = useState(false)
154
+ const [error, setError] = useState<ErrorObject | null>(null)
155
+ const [schemaDevInfo, setSchemaDevInfo] = useState<BasicSchemaDevInfo | null>(null)
217
156
 
218
157
  const isDevMode = () => isDevelopment(debug)
219
158
 
220
- const cleanOAuthParams = () => cleanOAuthParamsFromUrl()
159
+ const refreshSchemaStatus = useCallback(async () => {
160
+ const s = schemaRef.current
161
+ if (!s) {
162
+ setSchemaDevInfo(
163
+ project_id
164
+ ? {
165
+ projectId: project_id,
166
+ localVersion: undefined,
167
+ status: 'no_schema',
168
+ valid: false,
169
+ lastCheckedAt: Date.now(),
170
+ }
171
+ : null,
172
+ )
173
+ return
174
+ }
175
+ const result = await validateAndCheckSchema(s)
176
+ if (!result.isValid) {
177
+ const errText =
178
+ result.errors?.map((e: { message?: string }) => e.message || '').join('; ') || 'invalid'
179
+ setSchemaDevInfo({
180
+ projectId: s.project_id ?? null,
181
+ localVersion: s.version,
182
+ status: 'invalid',
183
+ valid: false,
184
+ lastCheckedAt: Date.now(),
185
+ error: errText,
186
+ })
187
+ return
188
+ }
189
+ setSchemaDevInfo({
190
+ projectId: s.project_id ?? null,
191
+ localVersion: s.version,
192
+ status: result.schemaStatus.status ?? 'unknown',
193
+ valid: result.schemaStatus.valid,
194
+ lastCheckedAt: Date.now(),
195
+ })
196
+ }, [project_id])
221
197
 
222
198
  useEffect(() => {
223
- const handleOnline = () => {
224
- log('Network came back online')
225
- setIsOnline(true)
226
- if (pendingRefresh) {
227
- log('Retrying pending token refresh')
228
- setPendingRefresh(false)
229
- if (token) {
230
- const refreshToken = token.refresh_token || localStorage.getItem('basic_refresh_token')
231
- if (refreshToken) {
232
- fetchToken(refreshToken, true).catch(error => {
233
- log('Retry refresh failed:', error)
234
- })
235
- }
199
+ const runVersionUpdater = async () => {
200
+ try {
201
+ const versionUpdater = createVersionUpdater(storageAdapter, currentVersion, getMigrations())
202
+ const updateResult = await versionUpdater.checkAndUpdate()
203
+
204
+ if (updateResult.updated) {
205
+ log(`App updated from ${updateResult.fromVersion} to ${updateResult.toVersion}`)
206
+ } else {
207
+ log(`App version ${updateResult.toVersion} is current`)
236
208
  }
209
+ } catch (error) {
210
+ log('Version update failed:', error)
237
211
  }
238
212
  }
239
213
 
240
- const handleOffline = () => {
241
- log('Network went offline')
242
- setIsOnline(false)
243
- }
244
-
245
- window.addEventListener('online', handleOnline)
246
- window.addEventListener('offline', handleOffline)
214
+ runVersionUpdater()
215
+ authRef.current.initialize()
247
216
 
248
- return () => {
249
- window.removeEventListener('online', handleOnline)
250
- window.removeEventListener('offline', handleOffline)
251
- }
252
- }, [pendingRefresh, token])
217
+ return authRef.current.setupNetworkListeners()
218
+ }, [])
253
219
 
254
220
  useEffect(() => {
255
221
  async function initSyncDb(options: { shouldConnect: boolean }) {
256
222
  if (!syncRef.current) {
257
223
  log('Initializing Basic Sync DB')
258
-
259
- // Initialize Dexie extensions before creating BasicSync
224
+
260
225
  await initDexieExtensions()
261
-
262
- syncRef.current = new BasicSync('basicdb', { schema: schema });
263
226
 
264
- syncRef.current.syncable.on('statusChanged', (status: number, url: string) => {
265
- setDbStatus(getSyncStatus(status) as DBStatus)
227
+ syncRef.current = new BasicSync('basicdb', { schema: schema })
228
+
229
+ syncRef.current.syncable.on('statusChanged', (status: number) => {
230
+ const newStatus = getSyncStatus(status) as DBStatus
231
+ setDbStatus(newStatus)
232
+
233
+ if (newStatus === DBStatus.ERROR_WILL_RETRY) {
234
+ log('Sync entered ERROR_WILL_RETRY - proactively refreshing token')
235
+ authRef.current.getToken({ forceRefresh: true }).catch(() => {})
236
+ }
266
237
  })
267
238
 
268
239
  if (options.shouldConnect) {
@@ -281,7 +252,8 @@ export function BasicProvider({
281
252
  setError({
282
253
  code: 'missing_project_id',
283
254
  title: 'Project ID Required',
284
- message: 'Remote mode requires a project_id. Provide it via schema.project_id or the project_id prop.'
255
+ message:
256
+ 'Remote mode requires a project_id. Provide it via schema.project_id or the project_id prop.',
285
257
  })
286
258
  setIsReady(true)
287
259
  return
@@ -289,16 +261,15 @@ export function BasicProvider({
289
261
 
290
262
  log('Initializing Basic Remote DB')
291
263
  remoteDbRef.current = new RemoteDB({
292
- serverUrl: authConfig.server_url,
264
+ serverUrl: authConfig.pds_url,
293
265
  projectId: project_id,
294
- getToken: getToken,
266
+ getToken: (opts) => authRef.current.getToken(opts),
295
267
  schema: schema,
296
268
  debug: debug,
297
269
  onAuthError: (error) => {
298
270
  log('RemoteDB auth error:', error)
299
- // Sign out user when authentication fails after retry
300
- signout()
301
- }
271
+ handleSignOut()
272
+ },
302
273
  })
303
274
  setDbStatus(DBStatus.ONLINE)
304
275
  setIsReady(true)
@@ -311,28 +282,46 @@ export function BasicProvider({
311
282
  if (!result.isValid) {
312
283
  let errorMessage = ''
313
284
  if (result.errors) {
314
- result.errors.forEach((error, index) => {
315
- errorMessage += `${index + 1}: ${error.message} - at ${error.instancePath}\n`
285
+ result.errors.forEach((err: any, index: number) => {
286
+ errorMessage += `${index + 1}: ${err.message} - at ${err.instancePath}\n`
316
287
  })
317
288
  }
289
+ setSchemaDevInfo({
290
+ projectId: schema?.project_id ?? null,
291
+ localVersion: schema?.version,
292
+ status: 'invalid',
293
+ valid: false,
294
+ lastCheckedAt: Date.now(),
295
+ error: errorMessage.trim() || undefined,
296
+ })
318
297
  setError({
319
298
  code: 'schema_invalid',
320
299
  title: 'Basic Schema is invalid!',
321
- message: errorMessage
300
+ message: errorMessage,
322
301
  })
323
302
  setIsReady(true)
324
303
  return null
325
304
  }
326
305
 
327
- // Initialize the appropriate DB based on mode
306
+ setSchemaDevInfo({
307
+ projectId: schema?.project_id ?? null,
308
+ localVersion: schema?.version,
309
+ status: result.schemaStatus.status ?? 'unknown',
310
+ valid: result.schemaStatus.valid,
311
+ lastCheckedAt: Date.now(),
312
+ })
313
+
328
314
  if (dbMode === 'remote') {
329
315
  initRemoteDb()
330
316
  } else {
331
- // Sync mode
332
317
  if (result.schemaStatus.valid) {
333
318
  await initSyncDb({ shouldConnect: true })
334
319
  } else {
335
- log('Schema is invalid!', result.schemaStatus)
320
+ if (result.schemaStatus.status === 'unpublished') {
321
+ log('Schema not published yet (version 0) - sync is disabled. Publish your schema to enable sync.')
322
+ } else {
323
+ log('Schema is invalid!', result.schemaStatus)
324
+ }
336
325
  await initSyncDb({ shouldConnect: false })
337
326
  }
338
327
  }
@@ -343,605 +332,86 @@ export function BasicProvider({
343
332
  if (schema) {
344
333
  checkSchema()
345
334
  } else {
346
- // No schema - still initialize remote DB if in remote mode
335
+ setSchemaDevInfo(
336
+ project_id
337
+ ? {
338
+ projectId: project_id,
339
+ localVersion: undefined,
340
+ status: 'no_schema',
341
+ valid: false,
342
+ lastCheckedAt: Date.now(),
343
+ }
344
+ : null,
345
+ )
347
346
  if (dbMode === 'remote' && project_id) {
348
347
  initRemoteDb()
349
348
  } else {
350
349
  setIsReady(true)
351
350
  }
352
351
  }
353
- }, []);
352
+ }, [])
354
353
 
355
354
  useEffect(() => {
356
- async function connectToDb() {
357
- if (token && syncRef.current && isSignedIn && shouldConnect) {
358
- const tok = await getToken()
359
- if (!tok) {
360
- log('no token found')
361
- return
362
- }
355
+ if (authState.hasToken && syncRef.current && authState.isSignedIn && shouldConnect) {
356
+ log('connecting to db...')
363
357
 
364
- log('connecting to db...')
365
-
366
- syncRef.current?.connect({
367
- access_token: tok,
368
- ws_url: authConfig.ws_url
358
+ syncRef.current
359
+ ?.connect({
360
+ getToken: (opts?: GetTokenOptions) => authRef.current.getToken(opts),
361
+ ws_url: authConfig.ws_url,
362
+ })
363
+ .catch((e: any) => {
364
+ log('error connecting to db', e)
369
365
  })
370
- .catch((e) => {
371
- log('error connecting to db', e)
372
- })
373
- }
374
- }
375
- connectToDb()
376
-
377
- }, [isSignedIn, shouldConnect])
378
-
379
- useEffect(() => {
380
- const initializeAuth = async () => {
381
- await storageAdapter.set(STORAGE_KEYS.DEBUG, debug ? 'true' : 'false')
382
-
383
- // Check if server URL has changed - if so, clear tokens
384
- const storedServerUrl = await storageAdapter.get(STORAGE_KEYS.SERVER_URL)
385
- if (storedServerUrl && storedServerUrl !== authConfig.server_url) {
386
- log('Server URL changed, clearing stored tokens')
387
- await storageAdapter.remove(STORAGE_KEYS.REFRESH_TOKEN)
388
- await storageAdapter.remove(STORAGE_KEYS.USER_INFO)
389
- await storageAdapter.remove(STORAGE_KEYS.AUTH_STATE)
390
- await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
391
- clearCookie('basic_token')
392
- clearCookie('basic_access_token')
393
- }
394
- await storageAdapter.set(STORAGE_KEYS.SERVER_URL, authConfig.server_url)
395
-
396
- try {
397
- const versionUpdater = createVersionUpdater(storageAdapter, currentVersion, getMigrations())
398
- const updateResult = await versionUpdater.checkAndUpdate()
399
-
400
- if (updateResult.updated) {
401
- log(`App updated from ${updateResult.fromVersion} to ${updateResult.toVersion}`)
402
- } else {
403
- log(`App version ${updateResult.toVersion} is current`)
404
- }
405
- } catch (error) {
406
- log('Version update failed:', error)
407
- }
408
-
409
- try {
410
- if (window.location.search.includes('code')) {
411
- let code = window.location?.search?.split('code=')[1]?.split('&')[0]
412
- if (!code) return
413
-
414
- const state = await storageAdapter.get(STORAGE_KEYS.AUTH_STATE)
415
- const urlState = window.location.search.split('state=')[1]?.split('&')[0]
416
- if (!state || state !== urlState) {
417
- log('error: auth state does not match')
418
- setIsAuthReady(true)
419
-
420
- await storageAdapter.remove(STORAGE_KEYS.AUTH_STATE)
421
- cleanOAuthParams()
422
- return
423
- }
424
-
425
- await storageAdapter.remove(STORAGE_KEYS.AUTH_STATE)
426
- cleanOAuthParams()
427
-
428
- fetchToken(code, false).catch((error) => {
429
- log('Error fetching token:', error)
430
- })
431
- } else {
432
- const refreshToken = await storageAdapter.get(STORAGE_KEYS.REFRESH_TOKEN)
433
- if (refreshToken) {
434
- log('Found refresh token in storage, attempting to refresh access token')
435
- fetchToken(refreshToken, true).catch((error) => {
436
- log('Error fetching refresh token:', error)
437
- })
438
- } else {
439
- let cookie_token = getCookie('basic_token')
440
- if (cookie_token !== '') {
441
- const tokenData = JSON.parse(cookie_token)
442
- setToken(tokenData)
443
- if (tokenData.refresh_token) {
444
- await storageAdapter.set(STORAGE_KEYS.REFRESH_TOKEN, tokenData.refresh_token)
445
- }
446
- } else {
447
- const cachedUserInfo = await storageAdapter.get(STORAGE_KEYS.USER_INFO)
448
- if (cachedUserInfo) {
449
- try {
450
- const userData = JSON.parse(cachedUserInfo)
451
- setUser(userData)
452
- setIsSignedIn(true)
453
- log('Loaded cached user info for offline mode')
454
- } catch (error) {
455
- log('Error parsing cached user info:', error)
456
- }
457
- }
458
- setIsAuthReady(true)
459
- }
460
- }
461
- }
462
-
463
- } catch (e) {
464
- log('error getting token', e)
465
- }
466
366
  }
367
+ }, [authState.isSignedIn, authState.hasToken, shouldConnect])
467
368
 
468
- initializeAuth()
469
- }, [])
470
-
471
- useEffect(() => {
472
- async function fetchUser(acc_token: string) {
473
- console.info('fetching user')
369
+ const handleSignOut = async () => {
370
+ await authRef.current.signOut()
371
+ if (syncRef.current) {
474
372
  try {
475
- const response = await fetch(`${authConfig.server_url}/auth/userInfo`, {
476
- method: 'GET',
477
- headers: {
478
- 'Authorization': `Bearer ${acc_token}`
479
- }
480
- })
481
-
482
- if (!response.ok) {
483
- throw new Error(`Failed to fetch user info: ${response.status}`)
484
- }
485
-
486
- const user = await response.json()
487
-
488
- if (user.error) {
489
- log('error fetching user', user.error)
490
- throw new Error(`User info error: ${user.error}`)
491
- }
492
-
493
- if (token?.refresh_token) {
494
- await storageAdapter.set(STORAGE_KEYS.REFRESH_TOKEN, token.refresh_token)
495
- }
496
-
497
- await storageAdapter.set(STORAGE_KEYS.USER_INFO, JSON.stringify(user))
498
- log('Cached user info in storage')
499
-
500
- setCookie('basic_access_token', token?.access_token || '', { httpOnly: false });
501
- setCookie('basic_token', JSON.stringify(token));
502
-
503
- setUser(user)
504
- setIsSignedIn(true)
505
- setIsAuthReady(true)
373
+ await syncRef.current.close()
374
+ await syncRef.current.delete({ disableAutoOpen: false })
375
+ syncRef.current = null
376
+ window?.location?.reload()
506
377
  } catch (error) {
507
- log('Failed to fetch user info:', error)
508
- // Don't clear tokens here - may be temporary network issue
509
- setIsAuthReady(true)
378
+ console.error('Error during database cleanup:', error)
510
379
  }
511
380
  }
512
-
513
- async function checkToken() {
514
- if (!token) {
515
- log('error: no user token found')
516
-
517
- setIsAuthReady(true)
518
- return
519
- }
520
-
521
- const decoded = jwtDecode(token?.access_token)
522
- // Add 5 second buffer to prevent edge cases
523
- const expirationBuffer = 5
524
- const isExpired = decoded.exp && decoded.exp < (Date.now() / 1000) + expirationBuffer
525
-
526
- if (isExpired) {
527
- log('token is expired - refreshing ...')
528
- const refreshToken = token?.refresh_token
529
- if (!refreshToken) {
530
- log('Error: No refresh token available for expired token')
531
- setIsAuthReady(true)
532
- return
533
- }
534
- try {
535
- const newToken = await fetchToken(refreshToken, true)
536
- fetchUser(newToken?.access_token || '')
537
- } catch (error) {
538
- log('Failed to refresh token in checkToken:', error)
539
-
540
- if ((error as Error).message.includes('offline') || (error as Error).message.includes('Network')) {
541
- log('Network issue - continuing with expired token until online')
542
- fetchUser(token?.access_token || '')
543
- } else {
544
- setIsAuthReady(true)
545
- }
546
- }
547
- } else {
548
- fetchUser(token?.access_token || '')
549
- }
550
- }
551
-
552
- if (token) {
553
- checkToken()
554
- }
555
- }, [token])
556
-
557
- const getSignInLink = async (redirectUri?: string) => {
558
- try {
559
- log('getting sign in link...')
560
-
561
- if (!project_id) {
562
- throw new Error('Project ID is required to generate sign-in link')
563
- }
564
-
565
- const randomState = Math.random().toString(36).substring(6);
566
- await storageAdapter.set(STORAGE_KEYS.AUTH_STATE, randomState)
567
-
568
- const redirectUrl = redirectUri || window.location.href
569
-
570
- if (!redirectUrl || (!redirectUrl.startsWith('http://') && !redirectUrl.startsWith('https://'))) {
571
- throw new Error('Invalid redirect URI provided')
572
- }
573
-
574
- // Store redirect_uri for token exchange
575
- await storageAdapter.set(STORAGE_KEYS.REDIRECT_URI, redirectUrl)
576
- log('Stored redirect_uri for token exchange:', redirectUrl)
577
-
578
- let baseUrl = `${authConfig.server_url}/auth/authorize`
579
- baseUrl += `?client_id=${project_id}`
580
- baseUrl += `&redirect_uri=${encodeURIComponent(redirectUrl)}`
581
- baseUrl += `&response_type=code`
582
- baseUrl += `&scope=${encodeURIComponent(scopesString)}`
583
- baseUrl += `&state=${randomState}`
584
-
585
- log('Generated sign-in link successfully with scopes:', scopesString)
586
- return baseUrl;
587
-
588
- } catch (error) {
589
- log('Error generating sign-in link:', error)
590
- throw error
591
- }
592
381
  }
593
382
 
594
- const signin = async () => {
383
+ const handleSignIn = async () => {
595
384
  try {
596
- log('signing in...')
597
-
598
- if (!project_id) {
599
- log('Error: project_id is required for sign-in')
600
- throw new Error('Project ID is required for authentication')
601
- }
602
-
603
- const signInLink = await getSignInLink()
604
- log('Generated sign-in link:', signInLink)
605
-
606
- // Validate URL format (supports https://, http://, and custom URI schemes)
607
- try {
608
- new URL(signInLink)
609
- } catch {
610
- log('Error: Invalid sign-in link generated')
611
- throw new Error('Failed to generate valid sign-in URL')
612
- }
613
-
614
- window.location.href = signInLink
615
-
385
+ await authRef.current.signIn()
616
386
  } catch (error) {
617
- log('Error during sign-in:', error)
618
-
619
387
  if (isDevMode()) {
620
388
  setError({
621
389
  code: 'signin_error',
622
390
  title: 'Sign-in Failed',
623
- message: (error as Error).message || 'An error occurred during sign-in. Please try again.'
391
+ message:
392
+ (error as Error).message || 'An error occurred during sign-in. Please try again.',
624
393
  })
625
394
  }
626
-
627
395
  throw error
628
396
  }
629
397
  }
630
398
 
631
- const signinWithCode = async (code: string, state?: string): Promise<{ success: boolean, error?: string }> => {
399
+ const handleSignInWithHandle = async (handle: string) => {
632
400
  try {
633
- log('signinWithCode called with code:', code)
634
-
635
- if (!code || typeof code !== 'string') {
636
- return { success: false, error: 'Invalid authorization code' }
637
- }
638
-
639
- if (state) {
640
- const storedState = await storageAdapter.get(STORAGE_KEYS.AUTH_STATE)
641
- if (storedState && storedState !== state) {
642
- log('State parameter mismatch:', { provided: state, stored: storedState })
643
- return { success: false, error: 'State parameter mismatch' }
644
- }
645
- }
646
-
647
- await storageAdapter.remove(STORAGE_KEYS.AUTH_STATE)
648
- cleanOAuthParams()
649
-
650
- const token = await fetchToken(code, false)
651
-
652
- if (token) {
653
- log('signinWithCode successful')
654
- return { success: true }
655
- } else {
656
- return { success: false, error: 'Failed to exchange code for token' }
657
- }
401
+ await authRef.current.signInWithHandle(handle)
658
402
  } catch (error) {
659
- log('signinWithCode error:', error)
660
- return {
661
- success: false,
662
- error: (error as Error).message || 'Authentication failed'
663
- }
664
- }
665
- }
666
-
667
- const signout = async () => {
668
- log('signing out!')
669
- setUser({})
670
- setIsSignedIn(false)
671
- setToken(null)
672
-
673
- clearCookie('basic_token');
674
- clearCookie('basic_access_token');
675
- await storageAdapter.remove(STORAGE_KEYS.AUTH_STATE)
676
- await storageAdapter.remove(STORAGE_KEYS.REFRESH_TOKEN)
677
- await storageAdapter.remove(STORAGE_KEYS.USER_INFO)
678
- await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
679
- await storageAdapter.remove(STORAGE_KEYS.SERVER_URL)
680
- if (syncRef.current) {
681
- (async () => {
682
- try {
683
- await syncRef.current?.close()
684
- await syncRef.current?.delete({ disableAutoOpen: false })
685
- syncRef.current = null
686
- window?.location?.reload()
687
- } catch (error) {
688
- console.error('Error during database cleanup:', error)
689
- }
690
- })()
691
- }
692
- }
693
-
694
- const getToken = async (): Promise<string> => {
695
- log('getting token...')
696
-
697
- if (!token) {
698
- // Try to recover from storage refresh token
699
- const refreshToken = await storageAdapter.get(STORAGE_KEYS.REFRESH_TOKEN)
700
- if (refreshToken) {
701
- log('No token in memory, attempting to refresh from storage')
702
-
703
- // Check if refresh is already in progress
704
- if (refreshPromiseRef.current) {
705
- log('Token refresh already in progress, waiting...')
706
- try {
707
- const newToken = await refreshPromiseRef.current
708
- if (newToken?.access_token) {
709
- return newToken.access_token
710
- }
711
- } catch (error) {
712
- log('In-flight refresh failed:', error)
713
- throw error
714
- }
715
- }
716
-
717
- try {
718
- const newToken = await fetchToken(refreshToken, true)
719
- if (newToken?.access_token) {
720
- return newToken.access_token
721
- }
722
- } catch (error) {
723
- log('Failed to refresh token from storage:', error)
724
-
725
- if ((error as Error).message.includes('offline') || (error as Error).message.includes('Network')) {
726
- log('Network issue - continuing with potentially expired token')
727
- const lastToken = localStorage.getItem('basic_access_token')
728
- if (lastToken) {
729
- return lastToken
730
- }
731
- throw new Error('Network offline - authentication will be retried when online')
732
- }
733
-
734
- throw new Error('Authentication expired. Please sign in again.')
735
- }
736
- }
737
- log('no token found')
738
- throw new Error('no token found')
739
- }
740
-
741
- const decoded = jwtDecode(token?.access_token)
742
- // Add 5 second buffer to prevent edge cases where token expires during request
743
- const expirationBuffer = 5
744
- const isExpired = decoded.exp && decoded.exp < (Date.now() / 1000) + expirationBuffer
745
-
746
- if (isExpired) {
747
- log('token is expired - refreshing ...')
748
-
749
- // Check if refresh is already in progress
750
- if (refreshPromiseRef.current) {
751
- log('Token refresh already in progress, waiting...')
752
- try {
753
- const newToken = await refreshPromiseRef.current
754
- return newToken?.access_token || ''
755
- } catch (error) {
756
- log('In-flight refresh failed:', error)
757
-
758
- if ((error as Error).message.includes('offline') || (error as Error).message.includes('Network')) {
759
- log('Network issue - using expired token until network is restored')
760
- return token.access_token
761
- }
762
-
763
- throw error
764
- }
765
- }
766
-
767
- const refreshToken = token?.refresh_token || await storageAdapter.get(STORAGE_KEYS.REFRESH_TOKEN)
768
- if (refreshToken) {
769
- try {
770
- const newToken = await fetchToken(refreshToken, true)
771
- return newToken?.access_token || ''
772
- } catch (error) {
773
- log('Failed to refresh expired token:', error)
774
-
775
- if ((error as Error).message.includes('offline') || (error as Error).message.includes('Network')) {
776
- log('Network issue - using expired token until network is restored')
777
- return token.access_token
778
- }
779
-
780
- throw new Error('Authentication expired. Please sign in again.')
781
- }
782
- } else {
783
- throw new Error('no refresh token available')
784
- }
785
- }
786
-
787
- return token?.access_token || ''
788
- }
789
-
790
- const fetchToken = async (codeOrRefreshToken: string, isRefreshToken: boolean = false): Promise<Token | null> => {
791
- // Validate input
792
- if (!codeOrRefreshToken || codeOrRefreshToken.trim() === '') {
793
- const errorMsg = isRefreshToken ? 'Refresh token is empty or undefined' : 'Authorization code is empty or undefined'
794
- log('Error:', errorMsg)
795
- throw new Error(errorMsg)
796
- }
797
-
798
- // If this is a refresh token request and one is already in progress, return that promise
799
- if (isRefreshToken && refreshPromiseRef.current) {
800
- log('Reusing in-flight refresh token request')
801
- return refreshPromiseRef.current
802
- }
803
-
804
- // Create new promise for this refresh attempt
805
- const refreshPromise = (async (): Promise<Token | null> => {
806
- try {
807
- if (!isOnline) {
808
- log('Network is offline, marking refresh as pending')
809
- setPendingRefresh(true)
810
- throw new Error('Network offline - refresh will be retried when online')
811
- }
812
-
813
- let requestBody: any
814
-
815
- if (isRefreshToken) {
816
- // Refresh token request
817
- requestBody = {
818
- grant_type: 'refresh_token',
819
- refresh_token: codeOrRefreshToken
820
- }
821
- // Include client_id if available for validation
822
- if (project_id) {
823
- requestBody.client_id = project_id
824
- }
825
- } else {
826
- // Authorization code exchange
827
- requestBody = {
828
- grant_type: 'authorization_code',
829
- code: codeOrRefreshToken
830
- }
831
-
832
- // Retrieve stored redirect_uri (required by OAuth2 spec)
833
- const storedRedirectUri = await storageAdapter.get(STORAGE_KEYS.REDIRECT_URI)
834
- if (storedRedirectUri) {
835
- requestBody.redirect_uri = storedRedirectUri
836
- log('Including redirect_uri in token exchange:', storedRedirectUri)
837
- } else {
838
- log('Warning: No redirect_uri found in storage for token exchange')
839
- }
840
-
841
- // Include client_id for validation
842
- if (project_id) {
843
- requestBody.client_id = project_id
844
- }
845
- }
846
-
847
- log('Token exchange request body:', { ...requestBody, refresh_token: isRefreshToken ? '[REDACTED]' : undefined, code: !isRefreshToken ? '[REDACTED]' : undefined })
848
-
849
- const token = await fetch(`${authConfig.server_url}/auth/token`, {
850
- method: 'POST',
851
- headers: {
852
- 'Content-Type': 'application/json'
853
- },
854
- body: JSON.stringify(requestBody)
403
+ if (isDevMode()) {
404
+ setError({
405
+ code: 'signin_error',
406
+ title: 'Sign-in Failed',
407
+ message:
408
+ (error as Error).message || 'An error occurred during sign-in. Please try again.',
855
409
  })
856
- .then(response => response.json())
857
- .catch(error => {
858
- log('Network error fetching token:', error)
859
- if (!isOnline) {
860
- setPendingRefresh(true)
861
- throw new Error('Network offline - refresh will be retried when online')
862
- }
863
- throw new Error('Network error during token refresh')
864
- })
865
-
866
- if (token.error) {
867
- log('error fetching token', token.error)
868
-
869
- if (token.error.includes('network') || token.error.includes('timeout')) {
870
- setPendingRefresh(true)
871
- throw new Error('Network issue - refresh will be retried when online')
872
- }
873
-
874
- await storageAdapter.remove(STORAGE_KEYS.REFRESH_TOKEN)
875
- await storageAdapter.remove(STORAGE_KEYS.USER_INFO)
876
- await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
877
- await storageAdapter.remove(STORAGE_KEYS.SERVER_URL)
878
- clearCookie('basic_token');
879
- clearCookie('basic_access_token');
880
-
881
- setUser({})
882
- setIsSignedIn(false)
883
- setToken(null)
884
- setIsAuthReady(true)
885
-
886
- throw new Error(`Token refresh failed: ${token.error}`)
887
- } else {
888
- setToken(token)
889
- setPendingRefresh(false)
890
-
891
- if (token.refresh_token) {
892
- await storageAdapter.set(STORAGE_KEYS.REFRESH_TOKEN, token.refresh_token)
893
- log('Updated refresh token in storage')
894
- }
895
-
896
- // Clean up redirect_uri after successful token exchange
897
- if (!isRefreshToken) {
898
- await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
899
- log('Cleaned up redirect_uri from storage after successful exchange')
900
- }
901
-
902
- setCookie('basic_access_token', token.access_token, { httpOnly: false });
903
- setCookie('basic_token', JSON.stringify(token));
904
- log('Updated access token and full token in cookies')
905
- }
906
- return token
907
- } catch (error) {
908
- log('Token refresh error:', error)
909
-
910
- if (!(error as Error).message.includes('offline') && !(error as Error).message.includes('Network')) {
911
- await storageAdapter.remove(STORAGE_KEYS.REFRESH_TOKEN)
912
- await storageAdapter.remove(STORAGE_KEYS.USER_INFO)
913
- await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
914
- await storageAdapter.remove(STORAGE_KEYS.SERVER_URL)
915
- clearCookie('basic_token');
916
- clearCookie('basic_access_token');
917
-
918
- setUser({})
919
- setIsSignedIn(false)
920
- setToken(null)
921
- setIsAuthReady(true)
922
- }
923
-
924
- throw error
925
410
  }
926
- })()
927
-
928
- // Store promise if this is a refresh token request
929
- if (isRefreshToken) {
930
- refreshPromiseRef.current = refreshPromise
931
-
932
- // Clear the promise reference when done (success or failure)
933
- refreshPromise.finally(() => {
934
- if (refreshPromiseRef.current === refreshPromise) {
935
- refreshPromiseRef.current = null
936
- log('Cleared refresh promise reference')
937
- }
938
- })
411
+ throw error
939
412
  }
940
-
941
- return refreshPromise
942
413
  }
943
414
 
944
- // Get the current DB instance based on mode
945
415
  const getCurrentDb = (): BasicDB => {
946
416
  if (dbMode === 'remote') {
947
417
  return remoteDbRef.current || noDb
@@ -949,65 +419,71 @@ export function BasicProvider({
949
419
  return syncRef.current || noDb
950
420
  }
951
421
 
952
- // Create context value with new names and legacy aliases
953
- const contextValue: BasicContextType = {
954
- // Auth state (new naming)
955
- isReady: isAuthReady,
956
- isSignedIn,
957
- user,
958
-
959
- // Auth actions (new camelCase naming)
960
- signIn: signin,
961
- signOut: signout,
962
- signInWithCode: signinWithCode,
963
-
964
- // Token management
965
- getToken,
966
- getSignInUrl: getSignInLink,
967
-
968
- // DB access
422
+ const contextValue = {
423
+ isReady: authState.isAuthReady,
424
+ isSignedIn: authState.isSignedIn,
425
+ user: authState.user,
426
+ did: authState.did,
427
+ scope: authState.tokenScope,
428
+ hasScope: (s: string) => authRef.current.hasScope(s),
429
+ missingScopes: () => authRef.current.missingScopes(),
430
+
431
+ signIn: handleSignIn,
432
+ signInWithHandle: handleSignInWithHandle,
433
+ signOut: handleSignOut,
434
+ signInWithCode: (code: string, state?: string) => authRef.current.signInWithCode(code, state),
435
+
436
+ getToken: (opts?: GetTokenOptions) => authRef.current.getToken(opts),
437
+ getSignInUrl: (redirectUri?: string) => authRef.current.getSignInUrl(redirectUri),
438
+
969
439
  db: getCurrentDb(),
970
440
  dbStatus,
971
441
  dbMode,
972
-
973
- // Legacy aliases (deprecated)
974
- isAuthReady,
975
- signin,
976
- signout,
977
- signinWithCode,
978
- getSignInLink,
442
+
443
+ devInfo: schemaDevInfo,
444
+ refreshSchemaStatus,
445
+
446
+ isAuthReady: authState.isAuthReady,
447
+ signin: handleSignIn,
448
+ signout: handleSignOut,
449
+ signinWithCode: (code: string, state?: string) => authRef.current.signInWithCode(code, state),
450
+ getSignInLink: (redirectUri?: string) => authRef.current.getSignInUrl(redirectUri),
979
451
  }
980
452
 
981
453
  return (
982
454
  <BasicContext.Provider value={contextValue}>
983
455
  {error && isDevMode() && <ErrorDisplay error={error} />}
456
+ {devToolbar && isDevMode() && (
457
+ <Suspense fallback={null}>
458
+ <BasicDevToolbar debug={debug} />
459
+ </Suspense>
460
+ )}
984
461
  {isReady && children}
985
462
  </BasicContext.Provider>
986
463
  )
987
464
  }
988
465
 
989
466
  function ErrorDisplay({ error }: { error: ErrorObject }) {
990
- return <div style={{
991
- position: 'absolute',
992
- top: 20,
993
- left: 20,
994
- color: 'black',
995
- backgroundColor: '#f8d7da',
996
- border: '1px solid #f5c6cb',
997
- borderRadius: '4px',
998
- padding: '20px',
999
- maxWidth: '400px',
1000
- margin: '20px auto',
1001
- boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
1002
- fontFamily: 'monospace',
1003
- }}>
1004
- <h3 style={{ fontSize: '0.8rem', opacity: 0.8 }}>code: {error.code}</h3>
1005
- <h1 style={{ fontSize: '1.2rem', lineHeight: '1.5' }}>{error.title}</h1>
1006
- <p>{error.message}</p>
1007
- </div>
1008
- }
1009
-
1010
-
1011
- export function useBasic() {
1012
- return useContext(BasicContext);
467
+ return (
468
+ <div
469
+ style={{
470
+ position: 'absolute',
471
+ top: 20,
472
+ left: 20,
473
+ color: 'black',
474
+ backgroundColor: '#f8d7da',
475
+ border: '1px solid #f5c6cb',
476
+ borderRadius: '4px',
477
+ padding: '20px',
478
+ maxWidth: '400px',
479
+ margin: '20px auto',
480
+ boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
481
+ fontFamily: 'monospace',
482
+ }}
483
+ >
484
+ <h3 style={{ fontSize: '0.8rem', opacity: 0.8 }}>code: {error.code}</h3>
485
+ <h1 style={{ fontSize: '1.2rem', lineHeight: 1.5 }}>{error.title}</h1>
486
+ <p>{error.message}</p>
487
+ </div>
488
+ )
1013
489
  }