@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.
- package/.turbo/turbo-build.log +13 -12
- package/AUTH_IMPLEMENTATION_GUIDE.md +20 -18
- package/changelog.md +24 -2
- package/dist/index.d.mts +121 -48
- package/dist/index.d.ts +121 -48
- package/dist/index.js +1996 -758
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1981 -749
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -3
- package/readme.md +50 -1
- package/src/AuthContext.tsx +294 -818
- package/src/config.ts +1 -19
- package/src/context.tsx +104 -0
- package/src/core/auth/AuthManager.ts +858 -0
- package/src/core/db/RemoteCollection.ts +30 -16
- package/src/core/db/index.ts +1 -1
- package/src/core/db/types.ts +13 -1
- package/src/dev/BasicDevToolbar.tsx +665 -0
- package/src/index.ts +10 -3
- package/src/sync/index.ts +15 -29
- package/src/sync/syncProtocol.js +84 -22
- package/src/sync/tokenRegistry.ts +20 -0
- package/src/updater/updateMigrations.ts +3 -3
- package/src/updater/versionUpdater.ts +3 -10
- package/src/utils/network.ts +68 -15
- package/src/utils/normalizeClientId.ts +22 -0
- package/src/utils/resolveDid.ts +101 -0
- package/src/utils/schema.ts +3 -4
- package/src/utils/storage.ts +4 -1
package/src/AuthContext.tsx
CHANGED
|
@@ -1,177 +1,95 @@
|
|
|
1
|
-
import 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
|
|
12
|
-
import { isDevelopment, checkForNewVersion,
|
|
13
|
-
import {
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
216
|
-
const
|
|
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
|
|
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
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
|
265
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
300
|
-
|
|
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((
|
|
315
|
-
errorMessage += `${index + 1}: ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
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
|
|
383
|
+
const handleSignIn = async () => {
|
|
595
384
|
try {
|
|
596
|
-
|
|
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:
|
|
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
|
|
399
|
+
const handleSignInWithHandle = async (handle: string) => {
|
|
632
400
|
try {
|
|
633
|
-
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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
|
}
|