@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.
- package/README.md +31 -28
- package/dist/auth/deviceVerification.d.ts +57 -0
- package/dist/auth/deviceVerification.d.ts.map +1 -0
- package/dist/auth/deviceVerification.js +258 -0
- package/dist/auth/deviceVerification.js.map +1 -0
- package/dist/auth/resolveAuthState.d.ts.map +1 -1
- package/dist/auth/resolveAuthState.js +25 -83
- package/dist/auth/resolveAuthState.js.map +1 -1
- package/dist/auth/singleUser.d.ts +58 -8
- package/dist/auth/singleUser.d.ts.map +1 -1
- package/dist/auth/singleUser.js +345 -179
- package/dist/auth/singleUser.js.map +1 -1
- package/dist/config.d.ts +9 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/entries/auth.d.ts +2 -2
- package/dist/entries/auth.d.ts.map +1 -1
- package/dist/entries/auth.js +2 -2
- package/dist/entries/auth.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/supabase/auth.d.ts +19 -1
- package/dist/supabase/auth.d.ts.map +1 -1
- package/dist/supabase/auth.js +66 -3
- package/dist/supabase/auth.js.map +1 -1
- package/dist/supabase/validate.d.ts +1 -1
- package/dist/supabase/validate.js +4 -4
- package/dist/supabase/validate.js.map +1 -1
- package/dist/types.d.ts +10 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/auth/singleUser.js
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Single-User Auth Module
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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:
|
|
72
|
-
*
|
|
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
|
|
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.
|
|
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]
|
|
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
|
-
|
|
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
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
//
|
|
316
|
+
// Cache offline credentials
|
|
269
317
|
try {
|
|
270
|
-
await cacheOfflineCredentials(
|
|
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
|
-
//
|
|
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:
|
|
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
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|