web-manager 4.3.2 → 4.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,250 @@
1
+ // Dev mode credentials by runtime
2
+ const DEV_CREDENTIALS = {
3
+ 'browser-extension': {
4
+ id: 'G-5NWE9SPEEM',
5
+ secret: '33E5W1cCQGKPK4lyMMOWOQ',
6
+ },
7
+ 'electron': {
8
+ id: 'G-WMNERKK9J2',
9
+ secret: 'UeKzq8UvS3GD5D2aHcuZgQ',
10
+ },
11
+ };
12
+
13
+ // Supported runtimes for analytics
14
+ const SUPPORTED_RUNTIMES = ['browser-extension', 'electron'];
15
+
16
+ class Analytics {
17
+ constructor(manager) {
18
+ this.manager = manager;
19
+ this.initialized = false;
20
+ this.devMode = false;
21
+ this.runtime = null;
22
+ this.config = null;
23
+ this.measurementId = null;
24
+ this.secret = null;
25
+ this.clientId = null;
26
+ }
27
+
28
+ // Check if runtime is supported
29
+ _isSupported() {
30
+ return SUPPORTED_RUNTIMES.includes(this.runtime);
31
+ }
32
+
33
+ // Initialize analytics
34
+ init(config = {}) {
35
+ // Store config
36
+ this.config = config;
37
+
38
+ // Get runtime
39
+ this.runtime = this.manager.utilities().getRuntime();
40
+
41
+ // TODO: Add web runtime support
42
+ if (!this._isSupported()) {
43
+ console.log(`[Analytics] Runtime "${this.runtime}" not supported yet, skipping`);
44
+ return;
45
+ }
46
+
47
+ // Skip if already initialized
48
+ if (this.initialized) {
49
+ console.log('[Analytics] Already initialized');
50
+ return;
51
+ }
52
+
53
+ // Check for development mode
54
+ this.devMode = this.manager.isDevelopment();
55
+
56
+ // Get measurement ID and secret (use dev credentials in dev mode)
57
+ if (this.devMode) {
58
+ const devCreds = DEV_CREDENTIALS[this.runtime];
59
+ if (devCreds) {
60
+ this.measurementId = devCreds.id;
61
+ this.secret = devCreds.secret;
62
+ console.log(`[Analytics] Dev mode: using ${this.runtime} dev credentials`);
63
+ } else {
64
+ // No dev credentials for this runtime, use provided config
65
+ this.measurementId = config.measurementId || config.id;
66
+ this.secret = config.secret;
67
+ }
68
+ } else {
69
+ this.measurementId = config.measurementId || config.id;
70
+ this.secret = config.secret;
71
+ }
72
+
73
+ // Skip if no measurement ID
74
+ if (!this.measurementId) {
75
+ console.log('[Analytics] No measurement ID provided, skipping initialization');
76
+ return;
77
+ }
78
+
79
+ // Generate or retrieve client ID
80
+ this.clientId = this._getClientId();
81
+
82
+ // Log initialization
83
+ console.log(`[Analytics] Initializing with measurement ID: ${this.measurementId}${this.devMode ? ' (dev mode)' : ''} [${this.runtime}]`);
84
+
85
+ // Mark as initialized
86
+ this.initialized = true;
87
+
88
+ // Send initial pageview
89
+ this.event('page_view');
90
+ }
91
+
92
+ // Get or generate client ID
93
+ _getClientId() {
94
+ const storageKey = '_ga_client_id';
95
+
96
+ // Try to get existing client ID
97
+ let clientId = null;
98
+ try {
99
+ clientId = localStorage.getItem(storageKey);
100
+ } catch (e) {
101
+ // localStorage not available
102
+ }
103
+
104
+ // Generate new client ID if needed
105
+ if (!clientId) {
106
+ clientId = `${Math.random().toString(36).substring(2)}.${Date.now()}`;
107
+ try {
108
+ localStorage.setItem(storageKey, clientId);
109
+ } catch (e) {
110
+ // localStorage not available
111
+ }
112
+ }
113
+
114
+ return clientId;
115
+ }
116
+
117
+ // Get page data to include with all events
118
+ _getPageData() {
119
+ return {
120
+ page_path: window.location.pathname,
121
+ page_title: document.title,
122
+ page_location: window.location.href,
123
+ };
124
+ }
125
+
126
+ // Track an event
127
+ event(eventName, params = {}) {
128
+ // TODO: Add web runtime support
129
+ if (!this._isSupported()) {
130
+ return;
131
+ }
132
+
133
+ if (!this.initialized) {
134
+ return;
135
+ }
136
+
137
+ // Merge page data with provided params
138
+ const eventParams = {
139
+ ...this._getPageData(),
140
+ ...params,
141
+ };
142
+
143
+ // Log event
144
+ console.log(`[Analytics] Event: ${eventName}${this.devMode ? ' (dev mode)' : ''}`, eventParams);
145
+
146
+ // Send via Measurement Protocol (fetch)
147
+ this._sendViaFetch(eventName, eventParams);
148
+ }
149
+
150
+ // Send event via Measurement Protocol (fetch)
151
+ _sendViaFetch(eventName, params = {}) {
152
+ // Measurement Protocol requires api_secret
153
+ if (!this.secret) {
154
+ console.warn('[Analytics] No API secret provided, cannot send via Measurement Protocol');
155
+ return;
156
+ }
157
+
158
+ const url = `https://www.google-analytics.com/mp/collect?measurement_id=${this.measurementId}&api_secret=${this.secret}`;
159
+
160
+ const payload = {
161
+ client_id: this.clientId,
162
+ events: [{
163
+ name: eventName,
164
+ params: {
165
+ ...params,
166
+ engagement_time_msec: 100,
167
+ session_id: this._getSessionId(),
168
+ },
169
+ }],
170
+ };
171
+
172
+ // Send via fetch (fire and forget)
173
+ fetch(url, {
174
+ method: 'POST',
175
+ body: JSON.stringify(payload),
176
+ }).catch((err) => {
177
+ console.warn('[Analytics] Failed to send event:', err);
178
+ });
179
+ }
180
+
181
+ // Get or generate session ID
182
+ _getSessionId() {
183
+ const storageKey = '_ga_session_id';
184
+ const sessionTimeout = 30 * 60 * 1000; // 30 minutes
185
+
186
+ let sessionData = null;
187
+ try {
188
+ sessionData = JSON.parse(sessionStorage.getItem(storageKey) || 'null');
189
+ } catch (e) {
190
+ // sessionStorage not available
191
+ }
192
+
193
+ const now = Date.now();
194
+
195
+ // Check if session is still valid
196
+ if (sessionData && (now - sessionData.lastActive) < sessionTimeout) {
197
+ sessionData.lastActive = now;
198
+ try {
199
+ sessionStorage.setItem(storageKey, JSON.stringify(sessionData));
200
+ } catch (e) {
201
+ // sessionStorage not available
202
+ }
203
+ return sessionData.id;
204
+ }
205
+
206
+ // Create new session
207
+ const newSession = {
208
+ id: `${Date.now()}`,
209
+ lastActive: now,
210
+ };
211
+
212
+ try {
213
+ sessionStorage.setItem(storageKey, JSON.stringify(newSession));
214
+ } catch (e) {
215
+ // sessionStorage not available
216
+ }
217
+
218
+ return newSession.id;
219
+ }
220
+
221
+ // Set user properties
222
+ setUserProperties(properties = {}) {
223
+ // TODO: Add web runtime support
224
+ if (!this._isSupported()) {
225
+ return;
226
+ }
227
+
228
+ if (!this.initialized) {
229
+ return;
230
+ }
231
+
232
+ // TODO: Implement for Measurement Protocol
233
+ }
234
+
235
+ // Set user ID
236
+ setUserId(userId) {
237
+ // TODO: Add web runtime support
238
+ if (!this._isSupported()) {
239
+ return;
240
+ }
241
+
242
+ if (!this.initialized) {
243
+ return;
244
+ }
245
+
246
+ // TODO: Implement for Measurement Protocol
247
+ }
248
+ }
249
+
250
+ export default Analytics;
@@ -0,0 +1,420 @@
1
+ const DEFAULT_ACCOUNT = {
2
+ auth: { uid: null, email: null, temporary: false },
3
+ subscription: {
4
+ product: { id: 'basic', name: 'Basic' },
5
+ status: 'active',
6
+ expires: { timestamp: null, timestampUNIX: null },
7
+ trial: { claimed: false, expires: { timestamp: null, timestampUNIX: null } },
8
+ cancellation: { pending: false, date: { timestamp: null, timestampUNIX: null } },
9
+ payment: {
10
+ processor: null,
11
+ orderId: null,
12
+ resourceId: null,
13
+ frequency: null,
14
+ price: 0,
15
+ startDate: { timestamp: null, timestampUNIX: null },
16
+ updatedBy: {
17
+ event: { name: null, id: null },
18
+ date: { timestamp: null, timestampUNIX: null },
19
+ },
20
+ },
21
+ },
22
+ roles: { admin: false, betaTester: false, developer: false },
23
+ affiliate: { code: null, referrals: [] },
24
+ activity: {
25
+ },
26
+ metadata: {
27
+ created: { timestamp: null, timestampUNIX: null },
28
+ updated: { timestamp: null, timestampUNIX: null },
29
+ },
30
+ api: { clientId: null, privateKey: null },
31
+ usage: {},
32
+ personal: { name: { first: null, last: null } },
33
+ oauth2: {},
34
+ attribution: {
35
+ affiliate: { code: null, timestamp: null, url: null, page: null },
36
+ utm: { tags: {}, timestamp: null, url: null, page: null },
37
+ },
38
+ consent: {
39
+ legal: {
40
+ status: 'revoked',
41
+ grantedAt: { timestamp: null, timestampUNIX: null, source: null, ip: null, text: null },
42
+ },
43
+ marketing: {
44
+ status: 'revoked',
45
+ grantedAt: { timestamp: null, timestampUNIX: null, source: null, ip: null, text: null },
46
+ revokedAt: { timestamp: null, timestampUNIX: null, source: null, ip: null, text: null },
47
+ },
48
+ },
49
+ };
50
+
51
+ function resolveAccount(rawData, firebaseUser) {
52
+ const user = firebaseUser || {};
53
+ const data = rawData || {};
54
+
55
+ // Deep merge: rawData values take precedence over defaults
56
+ function deepMerge(target, source) {
57
+ const result = { ...target };
58
+ for (const key in source) {
59
+ if (!source.hasOwnProperty(key)) continue;
60
+ if (result[key] === null || result[key] === undefined) {
61
+ result[key] = source[key];
62
+ } else if (typeof result[key] === 'object' && !Array.isArray(result[key])
63
+ && typeof source[key] === 'object' && !Array.isArray(source[key])) {
64
+ result[key] = deepMerge(result[key], source[key]);
65
+ }
66
+ }
67
+ return result;
68
+ }
69
+
70
+ const account = deepMerge(data, DEFAULT_ACCOUNT);
71
+
72
+ // Set auth from firebase user if not already set
73
+ account.auth = account.auth || {};
74
+ account.auth.uid = account.auth.uid || user.uid || null;
75
+ account.auth.email = account.auth.email || user.email || null;
76
+
77
+ return account;
78
+ }
79
+
80
+ class Auth {
81
+ constructor(manager) {
82
+ this.manager = manager;
83
+ this._authStateCallbacks = [];
84
+ this._hasProcessedStateChange = false;
85
+ }
86
+
87
+ // Check if user is authenticated
88
+ isAuthenticated() {
89
+ return !!this.getUser();
90
+ }
91
+
92
+ // Get current user
93
+ getUser() {
94
+ const user = this.manager.firebaseAuth?.currentUser;
95
+ if (!user) return null;
96
+
97
+ // Get displayName and photoURL from providerData if not set on main user
98
+ let displayName = user.displayName;
99
+ let photoURL = user.photoURL;
100
+
101
+ // If no displayName or photoURL, check providerData
102
+ if ((!displayName || !photoURL) && user.providerData && user.providerData.length > 0) {
103
+ for (const provider of user.providerData) {
104
+ if (!displayName && provider.displayName) {
105
+ displayName = provider.displayName;
106
+ }
107
+ if (!photoURL && provider.photoURL) {
108
+ photoURL = provider.photoURL;
109
+ }
110
+ // Stop if we found both
111
+ if (displayName && photoURL) break;
112
+ }
113
+ }
114
+
115
+ // If still no displayName, use email or fallback
116
+ if (!displayName) {
117
+ displayName = user.email ? user.email.split('@')[0] : 'User';
118
+ }
119
+
120
+ // If still no photoURL, use a default avatar service
121
+ if (!photoURL) {
122
+ // Use ui-avatars.com which generates avatars from initials
123
+ const name = displayName || user.email.split('@')[0] || 'ME';
124
+ const initials = name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase();
125
+ photoURL = `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&size=200&background=random&color=000`;
126
+ }
127
+
128
+ return {
129
+ uid: user.uid,
130
+ email: user.email,
131
+ displayName: displayName,
132
+ photoURL: photoURL,
133
+ emailVerified: user.emailVerified,
134
+ metadata: user.metadata,
135
+ providerData: user.providerData,
136
+ };
137
+ }
138
+
139
+ // Listen for auth state changes (waits for settled state before first callback)
140
+ listen(options = {}, callback) {
141
+ // Handle overloaded signatures - if first param is a function, it's the callback
142
+ if (typeof options === 'function') {
143
+ callback = options;
144
+ options = {};
145
+ }
146
+
147
+ // If Firebase isn't configured (no firebaseConfig blob), call callback immediately with null.
148
+ if (!this.manager._resolveFirebaseConfig()) {
149
+ callback({
150
+ user: null,
151
+ account: resolveAccount({}, {}),
152
+ });
153
+
154
+ return () => {};
155
+ }
156
+
157
+ // Build auth state and call the provided callback
158
+ const run = async (user) => {
159
+ const state = { user: this.getUser() };
160
+
161
+ // Fetch account data if the user is logged in and Firestore is available
162
+ if (user && this.manager.firebaseFirestore) {
163
+ try {
164
+ state.account = await this._getAccountData(user.uid);
165
+ } catch (error) {
166
+ this.manager.sentry().captureException(new Error('Failed to get account data', { cause: error }));
167
+ }
168
+ }
169
+
170
+ // Ensure account is always a resolved object
171
+ state.account = state.account || resolveAccount({}, { uid: user?.uid });
172
+
173
+ // Derive resolved subscription state for bindings and consumers
174
+ state.resolved = this.resolveSubscription(state.account);
175
+
176
+ // Update bindings and storage once per auth state change
177
+ if (!this._hasProcessedStateChange) {
178
+ this.manager.bindings().update({
179
+ auth: state,
180
+ usage: this._resolveUsage(state),
181
+ });
182
+ this.manager.storage().set('auth', state);
183
+
184
+ this._hasProcessedStateChange = true;
185
+ }
186
+
187
+ callback(state);
188
+ };
189
+
190
+ // Once listeners: wait for auth to settle, fire once, done
191
+ if (options.once) {
192
+ this.manager._authReady.then(() => {
193
+ run(this.manager.firebaseAuth?.currentUser || null);
194
+ });
195
+
196
+ return () => {};
197
+ }
198
+
199
+ // Persistent listeners: subscribe to all auth state changes (initial + future)
200
+ // If auth already settled, fire the first callback via the promise to catch up
201
+ const unsubscribe = this._subscribe(run);
202
+
203
+ if (this.manager._firebaseAuthInitialized) {
204
+ this.manager._authReady.then(() => {
205
+ run(this.manager.firebaseAuth?.currentUser || null);
206
+ });
207
+ }
208
+
209
+ return unsubscribe;
210
+ }
211
+
212
+ // Subscribe to ongoing auth state changes (sign-in, sign-out after initial settle)
213
+ _subscribe(callback) {
214
+ this._authStateCallbacks.push(callback);
215
+
216
+ return () => {
217
+ const index = this._authStateCallbacks.indexOf(callback);
218
+ if (index > -1) {
219
+ this._authStateCallbacks.splice(index, 1);
220
+ }
221
+ };
222
+ }
223
+
224
+ // Called by Manager when Firebase auth state changes
225
+ _handleAuthStateChange(user) {
226
+ // Reset state processing flag for new auth state
227
+ this._hasProcessedStateChange = false;
228
+
229
+ // Call all persistent listener callbacks
230
+ this._authStateCallbacks.forEach(callback => {
231
+ try {
232
+ callback(user);
233
+ } catch (error) {
234
+ console.error('Auth state callback error:', error);
235
+ }
236
+ });
237
+ }
238
+
239
+ // Resolves calculated subscription fields that require derivation logic
240
+ // Raw data (product.id, status, trial, cancellation) is on account.subscription directly
241
+ // Returns: { plan, active, trialing, cancelling }
242
+ // - plan: the plan ID the user effectively has access to RIGHT NOW ('basic' if cancelled/suspended)
243
+ // - active: user has active access (active, trialing, or cancelling)
244
+ // - trialing: user is in an active trial (backend status is 'active' but trial hasn't expired)
245
+ // - cancelling: cancellation is pending (backend status is 'active' but cancellation.pending is true)
246
+ resolveSubscription(account) {
247
+ const subscription = (account || this.manager.storage().get('auth', {})?.account)?.subscription || {};
248
+ const productId = subscription.product?.id || 'basic';
249
+
250
+ // Derive trial and cancelling states from raw backend data
251
+ let trialing = false;
252
+ let cancelling = false;
253
+
254
+ if (productId !== 'basic' && subscription.status === 'active') {
255
+ trialing = !!(subscription.trial?.claimed
256
+ && subscription.trial?.expires?.timestampUNIX > Math.floor(Date.now() / 1000));
257
+ cancelling = !trialing && !!subscription.cancellation?.pending;
258
+ }
259
+
260
+ const active = (productId !== 'basic' && subscription.status === 'active');
261
+
262
+ return {
263
+ plan: active ? productId : 'basic',
264
+ active,
265
+ trialing,
266
+ cancelling,
267
+ };
268
+ }
269
+
270
+ // Resolve usage bindings from account data + product limits from config.
271
+ // Returns: { credits: { monthly: 5, limit: 100 }, ... }
272
+ //
273
+ // The product catalog lives at `config.payment.products` (OMEGA canonical
274
+ // shape — matches BEM, UJM, and EM). Each product entry has `{ id, limits: {...} }`.
275
+ _resolveUsage(state) {
276
+ const accountUsage = state.account?.usage || {};
277
+ const productId = state.resolved?.plan || 'basic';
278
+ const products = this.manager.config.payment?.products || [];
279
+ const product = products.find(p => p.id === productId) || {};
280
+ const limits = product.limits || {};
281
+
282
+ // Merge current usage with limits for each feature
283
+ const usage = {};
284
+ const keys = new Set([...Object.keys(accountUsage), ...Object.keys(limits)]);
285
+
286
+ for (const key of keys) {
287
+ usage[key] = {
288
+ ...(accountUsage[key] || {}),
289
+ limit: limits[key] || 0,
290
+ };
291
+ }
292
+
293
+ return usage;
294
+ }
295
+
296
+ // Get ID token for the current user
297
+ async getIdToken(forceRefresh = false) {
298
+ try {
299
+ const user = this.manager.firebaseAuth.currentUser;
300
+
301
+ const { getIdToken } = await import('firebase/auth');
302
+ return await getIdToken(user, forceRefresh);
303
+ } catch (error) {
304
+ console.error('Get ID token error:', error);
305
+ throw error;
306
+ }
307
+ }
308
+
309
+ // Sign in with custom token
310
+ async signInWithCustomToken(token) {
311
+ try {
312
+ if (!this.manager.firebaseAuth) {
313
+ throw new Error('Firebase Auth is not initialized');
314
+ }
315
+
316
+ const { signInWithCustomToken } = await import('firebase/auth');
317
+ const userCredential = await signInWithCustomToken(this.manager.firebaseAuth, token);
318
+ return userCredential.user;
319
+ } catch (error) {
320
+ console.error('Sign in with custom token error:', error);
321
+ throw error;
322
+ }
323
+ }
324
+
325
+ // Sign in with email and password
326
+ async signInWithEmailAndPassword(email, password) {
327
+ try {
328
+ if (!this.manager.firebaseAuth) {
329
+ throw new Error('Firebase Auth is not initialized');
330
+ }
331
+
332
+ const { signInWithEmailAndPassword } = await import('firebase/auth');
333
+ const userCredential = await signInWithEmailAndPassword(this.manager.firebaseAuth, email, password);
334
+ return userCredential.user;
335
+ } catch (error) {
336
+ console.error('Sign in with email and password error:', error);
337
+ throw error;
338
+ }
339
+ }
340
+
341
+ // Sign out the current user
342
+ async signOut() {
343
+ try {
344
+ const { signOut } = await import('firebase/auth');
345
+ await signOut(this.manager.firebaseAuth);
346
+ return true;
347
+ } catch (error) {
348
+ console.error('Sign out error:', error);
349
+ throw error;
350
+ }
351
+ }
352
+
353
+ // Get account data from Firestore
354
+ async _getAccountData(uid) {
355
+ try {
356
+ if (!this.manager.firebaseFirestore) {
357
+ return null;
358
+ }
359
+
360
+ const { doc, getDoc } = await import('firebase/firestore');
361
+
362
+ const accountDoc = doc(this.manager.firebaseFirestore, 'users', uid);
363
+ const snapshot = await getDoc(accountDoc);
364
+
365
+ // Get current Firebase user to pass uid and email to resolver
366
+ const firebaseUser = this.manager.firebaseAuth?.currentUser || { uid };
367
+
368
+ if (snapshot.exists()) {
369
+ // Resolve the account data to ensure proper structure and defaults
370
+ const rawData = snapshot.data();
371
+ const resolvedAccount = resolveAccount(rawData, firebaseUser);
372
+ return resolvedAccount;
373
+ }
374
+
375
+ // If no account exists, return resolved empty object for consistent structure
376
+ return resolveAccount({}, firebaseUser);
377
+ } catch (error) {
378
+ console.error('Get account data error:', error);
379
+ return null;
380
+ }
381
+ }
382
+
383
+ // Set up DOM event listeners for auth buttons
384
+ setupEventListeners() {
385
+ // Only set up once DOM is ready
386
+ if (typeof document === 'undefined') return;
387
+
388
+ // Set up sign out button listeners using event delegation
389
+ document.addEventListener('click', async (event) => {
390
+ // Use closest to handle clicks on child elements
391
+ const signOutBtn = event.target.closest('.auth-signout-btn');
392
+
393
+ if (signOutBtn) {
394
+ event.preventDefault();
395
+ event.stopPropagation();
396
+
397
+ try {
398
+ // Show confirmation
399
+ if (!confirm('Are you sure you want to sign out?')) {
400
+ return;
401
+ }
402
+
403
+ // Sign out
404
+ await this.signOut();
405
+
406
+ // Show success notification
407
+ this.manager.utilities().showNotification('Successfully signed out.', 'success');
408
+
409
+ } catch (error) {
410
+ console.error('Sign out error:', error);
411
+ // Show error notification if utilities are available
412
+ this.manager.utilities().showNotification('Failed to sign out. Please try again.', 'danger');
413
+ }
414
+ }
415
+ });
416
+ }
417
+
418
+ }
419
+
420
+ export default Auth;