@prabhask5/stellar-engine 1.0.11 → 1.0.13

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,21 +1,25 @@
1
1
  /**
2
2
  * Single-User Auth Module
3
3
  *
4
- * Implements a local gate (code or password) verified against a SHA-256 hash
5
- * stored in IndexedDB. Uses Supabase anonymous auth for session/token management
6
- * and RLS compliance. Falls back to offline auth when connectivity is unavailable.
4
+ * Uses Supabase email/password auth where the PIN *is* the password (padded).
5
+ * Replaces the previous anonymous auth + client-side SHA-256 hash approach.
6
+ *
7
+ * - Setup: signUp() with email + padded PIN
8
+ * - Unlock: signInWithPassword() with email + padded PIN
9
+ * - Device verification: signInWithOtp() for untrusted devices
10
+ * - Offline fallback: cached credentials in IndexedDB
7
11
  */
8
12
  import { getEngineConfig } from '../config';
9
13
  import { supabase } from '../supabase/client';
10
14
  import { hashValue } from './crypto';
11
15
  import { cacheOfflineCredentials } from './offlineCredentials';
12
16
  import { createOfflineSession } from './offlineSession';
17
+ import { isDeviceTrusted, trustCurrentDevice, touchTrustedDevice, sendDeviceVerification, maskEmail } from './deviceVerification';
13
18
  import { authState } from '../stores/authState';
14
19
  import { syncStatusStore } from '../stores/sync';
15
- import { getSession, isSessionExpired } from '../supabase/auth';
20
+ import { getSession } from '../supabase/auth';
16
21
  import { debugLog, debugWarn, debugError } from '../debug';
17
22
  const CONFIG_ID = 'config';
18
- const SINGLE_USER_EMAIL_DOMAIN = 'single-user.local';
19
23
  // ============================================================
20
24
  // HELPERS
21
25
  // ============================================================
@@ -25,9 +29,20 @@ function getDb() {
25
29
  throw new Error('Database not initialized.');
26
30
  return db;
27
31
  }
28
- function getSingleUserEmail() {
29
- const config = getEngineConfig();
30
- return `single-user@${config.prefix || 'app'}.${SINGLE_USER_EMAIL_DOMAIN}`;
32
+ /**
33
+ * Pad a PIN to meet Supabase's minimum password length.
34
+ * e.g. "1234" → "1234_stellar" (12 chars, well above the 6-char minimum)
35
+ */
36
+ export function padPin(pin) {
37
+ const prefix = getEngineConfig().prefix || 'app';
38
+ return `${pin}_${prefix}`;
39
+ }
40
+ function getConfirmRedirectUrl() {
41
+ if (typeof window !== 'undefined') {
42
+ const path = getEngineConfig().auth?.confirmRedirectPath || '/confirm';
43
+ return `${window.location.origin}${path}`;
44
+ }
45
+ return '/confirm';
31
46
  }
32
47
  async function readConfig() {
33
48
  const db = getDb();
@@ -64,20 +79,28 @@ export async function getSingleUserInfo() {
64
79
  return {
65
80
  profile: config.profile,
66
81
  gateType: config.gateType,
67
- codeLength: config.codeLength
82
+ codeLength: config.codeLength,
83
+ email: config.email,
84
+ maskedEmail: config.email ? maskEmail(config.email) : undefined,
68
85
  };
69
86
  }
70
87
  /**
71
- * First-time setup: hash gate, create anonymous Supabase user (if online),
72
- * store config, and set auth state.
88
+ * First-time setup: create Supabase user with email/password auth.
89
+ *
90
+ * Uses signUp() which sends a confirmation email if emailConfirmation is enabled.
91
+ * The PIN is padded to meet Supabase's minimum password length.
92
+ *
93
+ * @returns confirmationRequired — true if the caller should show a "check your email" modal
73
94
  */
74
- export async function setupSingleUser(gate, profile) {
95
+ export async function setupSingleUser(gate, profile, email) {
75
96
  try {
76
97
  const engineConfig = getEngineConfig();
77
98
  const singleUserOpts = engineConfig.auth?.singleUser;
78
99
  const gateType = singleUserOpts?.gateType || 'code';
79
100
  const codeLength = singleUserOpts?.codeLength;
80
- const gateHash = await hashValue(gate);
101
+ const emailConfirmationEnabled = engineConfig.auth?.emailConfirmation?.enabled ?? false;
102
+ const paddedPassword = padPin(gate);
103
+ const gateHash = await hashValue(gate); // Keep hash for offline fallback
81
104
  const now = new Date().toISOString();
82
105
  const isOffline = typeof navigator !== 'undefined' && !navigator.onLine;
83
106
  // Build profile metadata for Supabase user_metadata
@@ -85,60 +108,66 @@ export async function setupSingleUser(gate, profile) {
85
108
  const metadata = profileToMetadata ? profileToMetadata(profile) : profile;
86
109
  if (!isOffline) {
87
110
  // --- ONLINE SETUP ---
88
- const { data, error } = await supabase.auth.signInAnonymously();
111
+ const { data, error } = await supabase.auth.signUp({
112
+ email,
113
+ password: paddedPassword,
114
+ options: {
115
+ emailRedirectTo: getConfirmRedirectUrl(),
116
+ data: metadata,
117
+ },
118
+ });
89
119
  if (error) {
90
- debugError('[SingleUser] Anonymous sign-in failed:', error.message);
91
- return { error: `Setup failed: ${error.message}` };
120
+ debugError('[SingleUser] signUp failed:', error.message);
121
+ return { error: `Setup failed: ${error.message}`, confirmationRequired: false };
92
122
  }
93
- const session = data.session;
94
123
  const user = data.user;
95
- // Store profile in user_metadata so userDisplayInfo works unchanged
96
- const { error: updateError } = await supabase.auth.updateUser({ data: metadata });
97
- if (updateError) {
98
- debugWarn('[SingleUser] Failed to set user_metadata:', updateError.message);
99
- }
124
+ const session = data.session; // null if email confirmation required
100
125
  // Store config in IndexedDB
101
126
  const config = {
102
127
  id: CONFIG_ID,
103
128
  gateType,
104
129
  codeLength,
105
130
  gateHash,
131
+ email,
106
132
  profile,
107
133
  supabaseUserId: user.id,
108
134
  setupAt: now,
109
- updatedAt: now
135
+ updatedAt: now,
110
136
  };
111
137
  await writeConfig(config);
112
- // Write gate config to Supabase for extension access
113
- try {
114
- await supabase.from('single_user_config').upsert({
115
- id: 'config',
116
- gate_type: gateType,
117
- code_length: codeLength,
118
- gate_hash: gateHash,
119
- profile,
120
- });
138
+ // If email confirmation is required, session will be null
139
+ // The caller should show a "check your email" modal
140
+ if (emailConfirmationEnabled && !session) {
141
+ debugLog('[SingleUser] Setup initiated, awaiting email confirmation for:', email);
142
+ return { error: null, confirmationRequired: true };
121
143
  }
122
- catch (e) {
123
- debugWarn('[SingleUser] Failed to write gate config to Supabase:', e);
124
- }
125
- // Cache offline credentials for offline fallback
126
- try {
127
- await cacheOfflineCredentials(getSingleUserEmail(), gate, user, session);
128
- }
129
- catch (e) {
130
- debugWarn('[SingleUser] Failed to cache offline credentials:', e);
131
- }
132
- // Create offline session for offline fallback
133
- try {
134
- await createOfflineSession(user.id);
135
- }
136
- catch (e) {
137
- debugWarn('[SingleUser] Failed to create offline session:', e);
144
+ // No confirmation needed (or already confirmed) — proceed immediately
145
+ if (session) {
146
+ // Cache offline credentials
147
+ try {
148
+ await cacheOfflineCredentials(email, gate, user, session);
149
+ }
150
+ catch (e) {
151
+ debugWarn('[SingleUser] Failed to cache offline credentials:', e);
152
+ }
153
+ // Create offline session
154
+ try {
155
+ await createOfflineSession(user.id);
156
+ }
157
+ catch (e) {
158
+ debugWarn('[SingleUser] Failed to create offline session:', e);
159
+ }
160
+ // Auto-trust current device
161
+ try {
162
+ await trustCurrentDevice(user.id);
163
+ }
164
+ catch (e) {
165
+ debugWarn('[SingleUser] Failed to trust device:', e);
166
+ }
167
+ authState.setSupabaseAuth(session);
168
+ debugLog('[SingleUser] Setup complete (online, no confirmation needed), userId:', user.id);
138
169
  }
139
- // Set auth state
140
- authState.setSupabaseAuth(session);
141
- debugLog('[SingleUser] Setup complete (online), userId:', user.id);
170
+ return { error: null, confirmationRequired: false };
142
171
  }
143
172
  else {
144
173
  // --- OFFLINE SETUP ---
@@ -148,35 +177,88 @@ export async function setupSingleUser(gate, profile) {
148
177
  gateType,
149
178
  codeLength,
150
179
  gateHash,
180
+ email,
151
181
  profile,
152
- // supabaseUserId deferred until online
153
182
  setupAt: now,
154
- updatedAt: now
183
+ updatedAt: now,
155
184
  };
156
185
  await writeConfig(config);
157
- // Create offline session with temp ID
158
186
  await createOfflineSession(tempUserId);
159
- // Build offline profile for authState
160
187
  const offlineProfile = {
161
188
  id: 'current_user',
162
189
  userId: tempUserId,
163
- email: getSingleUserEmail(),
190
+ email,
164
191
  password: gateHash,
165
192
  profile,
166
- cachedAt: now
193
+ cachedAt: now,
167
194
  };
168
195
  authState.setOfflineAuth(offlineProfile);
169
196
  debugLog('[SingleUser] Setup complete (offline), temp userId:', tempUserId);
197
+ return { error: null, confirmationRequired: false };
170
198
  }
171
- return { error: null };
172
199
  }
173
200
  catch (e) {
174
201
  debugError('[SingleUser] Setup error:', e);
175
- return { error: e instanceof Error ? e.message : 'Setup failed' };
202
+ return { error: e instanceof Error ? e.message : 'Setup failed', confirmationRequired: false };
176
203
  }
177
204
  }
178
205
  /**
179
- * Unlock: verify gate hash, restore Supabase session or fall back to offline auth.
206
+ * Complete setup after email confirmation succeeds.
207
+ * Called when the original tab receives AUTH_CONFIRMED via BroadcastChannel.
208
+ */
209
+ export async function completeSingleUserSetup() {
210
+ try {
211
+ const config = await readConfig();
212
+ if (!config) {
213
+ return { error: 'Single-user config not found' };
214
+ }
215
+ // After email confirmation, the session should now be available
216
+ const { data: { session }, error: sessionError } = await supabase.auth.getSession();
217
+ if (sessionError || !session) {
218
+ debugError('[SingleUser] No session after confirmation:', sessionError?.message);
219
+ return { error: 'Session not found after confirmation. Please try logging in.' };
220
+ }
221
+ const user = session.user;
222
+ // Update config with user ID if needed
223
+ if (!config.supabaseUserId) {
224
+ config.supabaseUserId = user.id;
225
+ config.updatedAt = new Date().toISOString();
226
+ await writeConfig(config);
227
+ }
228
+ // Cache offline credentials
229
+ try {
230
+ await cacheOfflineCredentials(config.email || '', config.gateHash || '', user, session);
231
+ }
232
+ catch (e) {
233
+ debugWarn('[SingleUser] Failed to cache offline credentials after confirmation:', e);
234
+ }
235
+ // Create offline session
236
+ try {
237
+ await createOfflineSession(user.id);
238
+ }
239
+ catch (e) {
240
+ debugWarn('[SingleUser] Failed to create offline session after confirmation:', e);
241
+ }
242
+ // Auto-trust current device
243
+ try {
244
+ await trustCurrentDevice(user.id);
245
+ }
246
+ catch (e) {
247
+ debugWarn('[SingleUser] Failed to trust device after confirmation:', e);
248
+ }
249
+ authState.setSupabaseAuth(session);
250
+ debugLog('[SingleUser] Setup completed after email confirmation, userId:', user.id);
251
+ return { error: null };
252
+ }
253
+ catch (e) {
254
+ debugError('[SingleUser] Complete setup error:', e);
255
+ return { error: e instanceof Error ? e.message : 'Failed to complete setup' };
256
+ }
257
+ }
258
+ /**
259
+ * Unlock: verify PIN via signInWithPassword, handle device verification.
260
+ *
261
+ * Returns deviceVerificationRequired if the device is untrusted.
180
262
  */
181
263
  export async function unlockSingleUser(gate) {
182
264
  try {
@@ -184,80 +266,46 @@ export async function unlockSingleUser(gate) {
184
266
  if (!config) {
185
267
  return { error: 'Single-user mode is not set up' };
186
268
  }
187
- // Verify gate
188
- const inputHash = await hashValue(gate);
189
- if (inputHash !== config.gateHash) {
190
- return { error: 'Incorrect code' };
191
- }
192
269
  const isOffline = typeof navigator !== 'undefined' && !navigator.onLine;
193
270
  const engineConfig = getEngineConfig();
194
- if (!isOffline) {
195
- // --- ONLINE UNLOCK ---
196
- // Try existing session first
197
- const existingSession = await getSession();
198
- if (existingSession && !isSessionExpired(existingSession)) {
199
- authState.setSupabaseAuth(existingSession);
200
- debugLog('[SingleUser] Unlocked with existing session');
201
- return { error: null };
202
- }
203
- // Session expired try refreshing to keep the SAME anonymous user.
204
- // signInAnonymously() creates a NEW user who can't see the old user's data,
205
- // which causes all synced items to disappear.
206
- let session;
207
- let user;
208
- if (existingSession) {
209
- debugLog('[SingleUser] Session expired, attempting refresh...');
210
- const { data: refreshData, error: refreshError } = await supabase.auth.refreshSession();
211
- if (!refreshError && refreshData.session) {
212
- session = refreshData.session;
213
- user = refreshData.session.user;
214
- debugLog('[SingleUser] Session refreshed, same user:', user.id);
215
- }
216
- else {
217
- // Refresh failed — fall back to signInAnonymously as last resort
218
- debugWarn('[SingleUser] Refresh failed, signing in anonymously:', refreshError?.message);
219
- const { data, error } = await supabase.auth.signInAnonymously();
220
- if (error) {
221
- debugError('[SingleUser] Anonymous sign-in failed on unlock:', error.message);
222
- return { error: `Unlock failed: ${error.message}` };
223
- }
224
- session = data.session;
225
- user = data.user;
226
- }
227
- }
228
- else {
229
- // No session at all — sign in anonymously
230
- const { data, error } = await supabase.auth.signInAnonymously();
231
- if (error) {
232
- debugError('[SingleUser] Anonymous sign-in failed on unlock:', error.message);
233
- return { error: `Unlock failed: ${error.message}` };
234
- }
235
- session = data.session;
236
- user = data.user;
271
+ if (!isOffline && config.email) {
272
+ // --- ONLINE UNLOCK via Supabase signInWithPassword ---
273
+ const paddedPassword = padPin(gate);
274
+ const { data, error } = await supabase.auth.signInWithPassword({
275
+ email: config.email,
276
+ password: paddedPassword,
277
+ });
278
+ if (error) {
279
+ debugWarn('[SingleUser] signInWithPassword failed:', error.message);
280
+ return { error: 'Incorrect code' };
237
281
  }
238
- // If user ID changed (new anonymous user), update config
239
- if (config.supabaseUserId && user.id !== config.supabaseUserId) {
240
- debugWarn('[SingleUser] New anonymous user ID, updating config');
282
+ const session = data.session;
283
+ const user = data.user;
284
+ // Update supabaseUserId if needed
285
+ if (config.supabaseUserId !== user.id) {
241
286
  config.supabaseUserId = user.id;
242
287
  config.updatedAt = new Date().toISOString();
243
288
  await writeConfig(config);
244
- // Reset sync cursor for new user (dynamic import to avoid circular deps)
245
- try {
246
- if (typeof localStorage !== 'undefined') {
247
- // Clear old user cursor
248
- const keys = Object.keys(localStorage).filter(k => k.startsWith('lastSyncCursor_'));
249
- keys.forEach(k => localStorage.removeItem(k));
289
+ }
290
+ // Check device verification
291
+ const deviceVerificationEnabled = engineConfig.auth?.deviceVerification?.enabled ?? false;
292
+ if (deviceVerificationEnabled) {
293
+ const trusted = await isDeviceTrusted(user.id);
294
+ if (!trusted) {
295
+ // Untrusted device — sign out, send OTP
296
+ debugLog('[SingleUser] Untrusted device detected, sending OTP');
297
+ const { error: otpError } = await sendDeviceVerification(config.email);
298
+ if (otpError) {
299
+ debugError('[SingleUser] Failed to send device verification:', otpError);
250
300
  }
301
+ return {
302
+ error: null,
303
+ deviceVerificationRequired: true,
304
+ maskedEmail: maskEmail(config.email),
305
+ };
251
306
  }
252
- catch {
253
- // Ignore storage errors
254
- }
255
- }
256
- else if (!config.supabaseUserId) {
257
- // First time online after offline setup
258
- config.supabaseUserId = user.id;
259
- config.updatedAt = new Date().toISOString();
260
- await writeConfig(config);
307
+ // Trusted — touch device
308
+ await touchTrustedDevice(user.id);
261
309
  }
262
310
  // Re-apply profile to user_metadata
263
311
  const profileToMetadata = engineConfig.auth?.profileToMetadata;
@@ -265,9 +313,9 @@ export async function unlockSingleUser(gate) {
265
313
  await supabase.auth.updateUser({ data: metadata }).catch((e) => {
266
314
  debugWarn('[SingleUser] Failed to update user_metadata on unlock:', e);
267
315
  });
268
- // Update offline credentials cache
316
+ // Cache offline credentials
269
317
  try {
270
- await cacheOfflineCredentials(getSingleUserEmail(), gate, user, session);
318
+ await cacheOfflineCredentials(config.email, gate, user, session);
271
319
  }
272
320
  catch (e) {
273
321
  debugWarn('[SingleUser] Failed to update offline credentials:', e);
@@ -279,12 +327,25 @@ export async function unlockSingleUser(gate) {
279
327
  catch (e) {
280
328
  debugWarn('[SingleUser] Failed to update offline session:', e);
281
329
  }
330
+ // Update local gateHash for offline fallback
331
+ const newHash = await hashValue(gate);
332
+ if (config.gateHash !== newHash) {
333
+ config.gateHash = newHash;
334
+ config.updatedAt = new Date().toISOString();
335
+ await writeConfig(config);
336
+ }
282
337
  authState.setSupabaseAuth(session);
283
338
  debugLog('[SingleUser] Unlocked online, userId:', user.id);
339
+ return { error: null };
284
340
  }
285
341
  else {
286
- // --- OFFLINE UNLOCK ---
287
- // Try cached Supabase session from localStorage
342
+ // --- OFFLINE UNLOCK (or no email — legacy migration) ---
343
+ // Fall back to local hash verification
344
+ const inputHash = await hashValue(gate);
345
+ if (config.gateHash && inputHash !== config.gateHash) {
346
+ return { error: 'Incorrect code' };
347
+ }
348
+ // Try cached Supabase session
288
349
  const cachedSession = await getSession();
289
350
  if (cachedSession) {
290
351
  authState.setSupabaseAuth(cachedSession);
@@ -297,28 +358,74 @@ export async function unlockSingleUser(gate) {
297
358
  const offlineProfile = {
298
359
  id: 'current_user',
299
360
  userId,
300
- email: getSingleUserEmail(),
301
- password: config.gateHash,
361
+ email: config.email || '',
362
+ password: config.gateHash || inputHash,
302
363
  profile: config.profile,
303
- cachedAt: new Date().toISOString()
364
+ cachedAt: new Date().toISOString(),
304
365
  };
305
366
  authState.setOfflineAuth(offlineProfile);
306
367
  debugLog('[SingleUser] Unlocked offline with offline session');
368
+ return { error: null };
307
369
  }
308
- return { error: null };
309
370
  }
310
371
  catch (e) {
311
372
  debugError('[SingleUser] Unlock error:', e);
312
373
  return { error: e instanceof Error ? e.message : 'Unlock failed' };
313
374
  }
314
375
  }
376
+ /**
377
+ * Complete device verification after OTP email link is clicked.
378
+ * Called when the original tab receives AUTH_CONFIRMED via BroadcastChannel.
379
+ */
380
+ export async function completeDeviceVerification(tokenHash) {
381
+ try {
382
+ // If tokenHash is provided, verify it (called from confirm page)
383
+ if (tokenHash) {
384
+ const { verifyDeviceCode } = await import('./deviceVerification');
385
+ const { error } = await verifyDeviceCode(tokenHash);
386
+ if (error)
387
+ return { error };
388
+ }
389
+ // After OTP verification, session should be available
390
+ const { data: { session }, error: sessionError } = await supabase.auth.getSession();
391
+ if (sessionError || !session) {
392
+ return { error: 'Session not found after verification' };
393
+ }
394
+ const user = session.user;
395
+ // Trust the device
396
+ await trustCurrentDevice(user.id);
397
+ // Cache credentials
398
+ const config = await readConfig();
399
+ if (config?.email) {
400
+ try {
401
+ await cacheOfflineCredentials(config.email, config.gateHash || '', user, session);
402
+ }
403
+ catch (e) {
404
+ debugWarn('[SingleUser] Failed to cache credentials after device verification:', e);
405
+ }
406
+ }
407
+ // Create offline session
408
+ try {
409
+ await createOfflineSession(user.id);
410
+ }
411
+ catch (e) {
412
+ debugWarn('[SingleUser] Failed to create offline session after device verification:', e);
413
+ }
414
+ authState.setSupabaseAuth(session);
415
+ debugLog('[SingleUser] Device verification complete, userId:', user.id);
416
+ return { error: null };
417
+ }
418
+ catch (e) {
419
+ debugError('[SingleUser] Device verification error:', e);
420
+ return { error: e instanceof Error ? e.message : 'Device verification failed' };
421
+ }
422
+ }
315
423
  /**
316
424
  * Lock: stop sync engine, reset auth state to 'none'.
317
425
  * Does NOT destroy session, data, or sign out of Supabase.
318
426
  */
319
427
  export async function lockSingleUser() {
320
428
  try {
321
- // Dynamic import to avoid circular deps
322
429
  const { stopSyncEngine } = await import('../engine');
323
430
  await stopSyncEngine();
324
431
  }
@@ -330,7 +437,7 @@ export async function lockSingleUser() {
330
437
  debugLog('[SingleUser] Locked');
331
438
  }
332
439
  /**
333
- * Change the gate (code/password). Verifies old gate first.
440
+ * Change the gate (code/password). Verifies old gate via signInWithPassword.
334
441
  */
335
442
  export async function changeSingleUserGate(oldGate, newGate) {
336
443
  try {
@@ -338,37 +445,44 @@ export async function changeSingleUserGate(oldGate, newGate) {
338
445
  if (!config) {
339
446
  return { error: 'Single-user mode is not set up' };
340
447
  }
341
- // Verify old gate
342
- const oldHash = await hashValue(oldGate);
343
- if (oldHash !== config.gateHash) {
344
- return { error: 'Current code is incorrect' };
448
+ const isOffline = typeof navigator !== 'undefined' && !navigator.onLine;
449
+ if (!isOffline && config.email) {
450
+ // Online: verify old gate via Supabase, then update password
451
+ const { error: verifyError } = await supabase.auth.signInWithPassword({
452
+ email: config.email,
453
+ password: padPin(oldGate),
454
+ });
455
+ if (verifyError) {
456
+ return { error: 'Current code is incorrect' };
457
+ }
458
+ // Update password in Supabase
459
+ const { error: updateError } = await supabase.auth.updateUser({
460
+ password: padPin(newGate),
461
+ });
462
+ if (updateError) {
463
+ return { error: `Failed to update code: ${updateError.message}` };
464
+ }
345
465
  }
346
- // Update hash
466
+ else {
467
+ // Offline: verify against local hash
468
+ const oldHash = await hashValue(oldGate);
469
+ if (config.gateHash && oldHash !== config.gateHash) {
470
+ return { error: 'Current code is incorrect' };
471
+ }
472
+ }
473
+ // Update local hash
347
474
  const newHash = await hashValue(newGate);
348
475
  config.gateHash = newHash;
349
476
  config.updatedAt = new Date().toISOString();
350
477
  await writeConfig(config);
351
- // Update Supabase gate config
352
- const isOffline = typeof navigator !== 'undefined' && !navigator.onLine;
353
- if (!isOffline) {
354
- try {
355
- await supabase.from('single_user_config').update({
356
- gate_hash: newHash,
357
- updated_at: new Date().toISOString(),
358
- }).eq('id', 'config');
359
- }
360
- catch (e) {
361
- debugWarn('[SingleUser] Failed to update gate hash in Supabase:', e);
362
- }
363
- }
364
- // Update offline credentials cache if it exists
478
+ // Update offline credentials cache
365
479
  try {
366
480
  const db = getDb();
367
481
  const creds = await db.table('offlineCredentials').get('current_user');
368
482
  if (creds) {
369
483
  await db.table('offlineCredentials').update('current_user', {
370
484
  password: newHash,
371
- cachedAt: new Date().toISOString()
485
+ cachedAt: new Date().toISOString(),
372
486
  });
373
487
  }
374
488
  }
@@ -392,11 +506,9 @@ export async function updateSingleUserProfile(profile) {
392
506
  if (!config) {
393
507
  return { error: 'Single-user mode is not set up' };
394
508
  }
395
- // Update IndexedDB
396
509
  config.profile = profile;
397
510
  config.updatedAt = new Date().toISOString();
398
511
  await writeConfig(config);
399
- // Update Supabase user_metadata and single_user_config if online
400
512
  const isOffline = typeof navigator !== 'undefined' && !navigator.onLine;
401
513
  if (!isOffline) {
402
514
  const engineConfig = getEngineConfig();
@@ -407,19 +519,8 @@ export async function updateSingleUserProfile(profile) {
407
519
  debugWarn('[SingleUser] Failed to update Supabase profile:', error.message);
408
520
  }
409
521
  else {
410
- // Update auth state to reflect changes in UI
411
522
  authState.updateUserProfile(metadata);
412
523
  }
413
- // Update single_user_config profile in Supabase
414
- try {
415
- await supabase.from('single_user_config').update({
416
- profile,
417
- updated_at: new Date().toISOString(),
418
- }).eq('id', 'config');
419
- }
420
- catch (e) {
421
- debugWarn('[SingleUser] Failed to update profile in Supabase config:', e);
422
- }
423
524
  }
424
525
  // Update offline credentials cache
425
526
  try {
@@ -428,7 +529,7 @@ export async function updateSingleUserProfile(profile) {
428
529
  if (creds) {
429
530
  await db.table('offlineCredentials').update('current_user', {
430
531
  profile,
431
- cachedAt: new Date().toISOString()
532
+ cachedAt: new Date().toISOString(),
432
533
  });
433
534
  }
434
535
  }
@@ -443,15 +544,87 @@ export async function updateSingleUserProfile(profile) {
443
544
  return { error: e instanceof Error ? e.message : 'Failed to update profile' };
444
545
  }
445
546
  }
547
+ /**
548
+ * Initiate an email change. Requires online state.
549
+ * Supabase sends a confirmation email to the new address.
550
+ */
551
+ export async function changeSingleUserEmail(newEmail) {
552
+ try {
553
+ const isOffline = typeof navigator !== 'undefined' && !navigator.onLine;
554
+ if (isOffline) {
555
+ return { error: 'Email change requires an internet connection', confirmationRequired: false };
556
+ }
557
+ const config = await readConfig();
558
+ if (!config) {
559
+ return { error: 'Single-user mode is not set up', confirmationRequired: false };
560
+ }
561
+ const { error } = await supabase.auth.updateUser({ email: newEmail });
562
+ if (error) {
563
+ debugError('[SingleUser] Email change failed:', error.message);
564
+ return { error: `Email change failed: ${error.message}`, confirmationRequired: false };
565
+ }
566
+ debugLog('[SingleUser] Email change initiated, confirmation required for:', newEmail);
567
+ return { error: null, confirmationRequired: true };
568
+ }
569
+ catch (e) {
570
+ debugError('[SingleUser] Email change error:', e);
571
+ return { error: e instanceof Error ? e.message : 'Email change failed', confirmationRequired: false };
572
+ }
573
+ }
574
+ /**
575
+ * Complete email change after the user confirms via the email link.
576
+ * Called when the original tab receives AUTH_CONFIRMED with type 'email_change'.
577
+ */
578
+ export async function completeSingleUserEmailChange() {
579
+ try {
580
+ // Refresh session to get updated user data
581
+ const { data, error: refreshError } = await supabase.auth.refreshSession();
582
+ if (refreshError || !data.session) {
583
+ debugError('[SingleUser] Failed to refresh session after email change:', refreshError?.message);
584
+ return { error: 'Failed to refresh session after email change', newEmail: null };
585
+ }
586
+ const session = data.session;
587
+ const newEmail = session.user.email;
588
+ if (!newEmail) {
589
+ return { error: 'No email found in updated session', newEmail: null };
590
+ }
591
+ // Update local IndexedDB config
592
+ const config = await readConfig();
593
+ if (config) {
594
+ config.email = newEmail;
595
+ config.updatedAt = new Date().toISOString();
596
+ await writeConfig(config);
597
+ }
598
+ // Update offline credentials cache
599
+ try {
600
+ const db = getDb();
601
+ const creds = await db.table('offlineCredentials').get('current_user');
602
+ if (creds) {
603
+ await db.table('offlineCredentials').update('current_user', {
604
+ email: newEmail,
605
+ cachedAt: new Date().toISOString(),
606
+ });
607
+ }
608
+ }
609
+ catch (e) {
610
+ debugWarn('[SingleUser] Failed to update offline credentials after email change:', e);
611
+ }
612
+ authState.setSupabaseAuth(session);
613
+ debugLog('[SingleUser] Email change completed, new email:', newEmail);
614
+ return { error: null, newEmail };
615
+ }
616
+ catch (e) {
617
+ debugError('[SingleUser] Complete email change error:', e);
618
+ return { error: e instanceof Error ? e.message : 'Failed to complete email change', newEmail: null };
619
+ }
620
+ }
446
621
  /**
447
622
  * Full reset: clear config, sign out of Supabase, clear all data.
448
623
  */
449
624
  export async function resetSingleUser() {
450
625
  try {
451
- // Import signOut which handles full cleanup
452
626
  const { signOut } = await import('../supabase/auth');
453
627
  const result = await signOut();
454
- // Clear single-user config from IndexedDB
455
628
  try {
456
629
  const db = getDb();
457
630
  await db.table('singleUserConfig').delete(CONFIG_ID);
@@ -459,13 +632,6 @@ export async function resetSingleUser() {
459
632
  catch (e) {
460
633
  debugWarn('[SingleUser] Failed to clear config on reset:', e);
461
634
  }
462
- // Clear single_user_config from Supabase
463
- try {
464
- await supabase.from('single_user_config').delete().eq('id', 'config');
465
- }
466
- catch (e) {
467
- debugWarn('[SingleUser] Failed to delete Supabase config on reset:', e);
468
- }
469
635
  debugLog('[SingleUser] Reset complete');
470
636
  return { error: result.error };
471
637
  }