auth.js
1 import { 2 AUTH_INTENT_KEY, 3 AUTH_STATE_KEY, 4 BUTTON_RESET_DELAY_MS, 5 ONBOARDING_KEY, 6 STORAGE_KEY, 7 SUPABASE_ANON_KEY, 8 SUPABASE_URL, 9 SYNC_DEBOUNCE_MS, 10 SYNC_POLL_MS 11 } from "./constants.js"; 12 import { 13 authEmailInput, 14 authRow, 15 authSignOutButton, 16 authStatus, 17 marketingPage, 18 syncStatus 19 } from "./dom.js"; 20 import { 21 defaultState, 22 normalizeImportedState, 23 requestRender, 24 setState, 25 state 26 } from "./state.js"; 27 import { startOfMonth } from "./utils.js"; 28 import { areStatesEqual, mergeDiaryStates, pickLatestCloudRow } from "./sync-core.mjs"; 29 import { 30 closeDeleteModal, 31 closePopover, 32 closeSettingsModal, 33 enterApp, 34 getHasEnteredApp, 35 showMarketingPage, 36 resetToLoggedOut 37 } from "./ui.js"; 38 import { showToast } from "./toast.js"; 39 40 const supabase = window.supabase?.createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { 41 auth: { 42 persistSession: true, 43 autoRefreshToken: true, 44 detectSessionInUrl: true 45 } 46 }); 47 let syncUser = null; 48 let syncTimer = null; 49 let syncPollTimer = null; 50 let lastSyncedAt = null; 51 let syncInFlight = null; 52 let syncInProgress = false; 53 let signOutInProgress = false; 54 let authInitStarted = false; 55 let lastSyncError = ""; 56 57 export async function initSupabaseAuth() { 58 if (authInitStarted) return; 59 authInitStarted = true; 60 if (!supabase) return; 61 const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, "")); 62 const accessToken = hashParams.get("access_token"); 63 const refreshToken = hashParams.get("refresh_token"); 64 const hadMagicLinkTokens = Boolean(accessToken && refreshToken); 65 const shouldFocusTodayOnEntry = 66 hadMagicLinkTokens || 67 (() => { 68 try { 69 return sessionStorage.getItem(AUTH_INTENT_KEY) === "1"; 70 } catch { 71 return false; 72 } 73 })(); 74 if (accessToken && refreshToken) { 75 try { 76 await supabase.auth.setSession({ 77 access_token: accessToken, 78 refresh_token: refreshToken 79 }); 80 } catch { 81 // ignore session errors and continue 82 } finally { 83 try { 84 sessionStorage.removeItem(AUTH_INTENT_KEY); 85 } catch { 86 // ignore 87 } 88 history.replaceState(null, "", window.location.pathname + window.location.search); 89 } 90 } 91 const { data } = await supabase.auth.getSession(); 92 syncUser = data?.session?.user || null; 93 if (!syncUser) { 94 // Prevent stale auth bootstrap state from implying cloud sync is active. 95 try { 96 localStorage.removeItem(AUTH_STATE_KEY); 97 } catch { 98 // ignore 99 } 100 } 101 const enteredFromMarketing = !getHasEnteredApp() && syncUser && !marketingPage?.classList.contains("hidden"); 102 if (enteredFromMarketing) { 103 enterApp({ skipOnboarding: true }); 104 } 105 updateAuthUI(); 106 if (syncUser) { 107 await loadFromCloud({ fromAuthBootstrap: true }); 108 if (shouldFocusTodayOnEntry) { 109 focusPeriodToToday(); 110 clearAuthIntent(); 111 } 112 startSyncPolling(); 113 } 114 supabase.auth.onAuthStateChange(async (_event, session) => { 115 const wasSignedIn = Boolean(syncUser); 116 syncUser = session?.user || null; 117 const enteredFromMarketingNow = !getHasEnteredApp() && syncUser && !marketingPage?.classList.contains("hidden"); 118 if (enteredFromMarketingNow) { 119 enterApp({ skipOnboarding: true }); 120 } 121 try { 122 if (syncUser) { 123 localStorage.setItem(AUTH_STATE_KEY, "1"); 124 } else { 125 localStorage.removeItem(AUTH_STATE_KEY); 126 } 127 } catch { 128 // ignore 129 } 130 updateAuthUI(); 131 if (syncUser) { 132 await loadFromCloud({ fromAuthBootstrap: !wasSignedIn }); 133 if (!wasSignedIn && shouldFocusTodayOnEntry) { 134 focusPeriodToToday(); 135 clearAuthIntent(); 136 } 137 startSyncPolling(); 138 } else { 139 lastSyncError = ""; 140 stopSyncPolling(); 141 setState(structuredClone(defaultState)); 142 requestRender(); 143 showMarketingPage(); 144 resetToLoggedOut(); 145 } 146 }); 147 document.addEventListener("visibilitychange", handleVisibilitySync); 148 } 149 150 export async function refreshAuthSession({ loadCloud = false } = {}) { 151 if (!supabase) return null; 152 const { data } = await supabase.auth.getSession(); 153 syncUser = data?.session?.user || null; 154 try { 155 if (syncUser) { 156 localStorage.setItem(AUTH_STATE_KEY, "1"); 157 } else { 158 localStorage.removeItem(AUTH_STATE_KEY); 159 } 160 } catch { 161 // ignore 162 } 163 updateAuthUI(); 164 if (syncUser) { 165 if (loadCloud) await loadFromCloud({ silentError: true }); 166 startSyncPolling(); 167 } else { 168 stopSyncPolling(); 169 } 170 return syncUser; 171 } 172 173 function focusPeriodToToday() { 174 const todayMonth = startOfMonth(new Date()); 175 state.monthCursor = todayMonth.toISOString(); 176 state.yearCursor = todayMonth.getFullYear(); 177 requestRender(); 178 } 179 180 function clearAuthIntent() { 181 try { 182 sessionStorage.removeItem(AUTH_INTENT_KEY); 183 } catch { 184 // ignore 185 } 186 } 187 188 export async function handleMagicLink(overrideEmail, sourceButton) { 189 if (!supabase) return; 190 const email = overrideEmail?.trim() || authEmailInput?.value?.trim(); 191 if (!email) { 192 showToast("Enter an email first."); 193 return; 194 } 195 if (sourceButton) { 196 if (!sourceButton.dataset.defaultLabel) { 197 sourceButton.dataset.defaultLabel = sourceButton.textContent || ""; 198 } 199 sourceButton.disabled = true; 200 sourceButton.textContent = "Sending..."; 201 } 202 try { 203 sessionStorage.setItem(AUTH_INTENT_KEY, "1"); 204 } catch { 205 // ignore 206 } 207 const { error } = await supabase.auth.signInWithOtp({ 208 email, 209 options: { 210 emailRedirectTo: getMagicLinkRedirectTo() 211 } 212 }); 213 if (error) { 214 const message = error?.message ? `Magic link failed: ${error.message}` : "Could not send magic link."; 215 showToast(message); 216 console.error("Magic link error:", error); 217 if (sourceButton) { 218 sourceButton.textContent = sourceButton.dataset.defaultLabel || "Send magic link"; 219 sourceButton.disabled = false; 220 } 221 } else { 222 showToast("Magic link sent. Check your email."); 223 if (sourceButton) { 224 sourceButton.textContent = "Check your email"; 225 sourceButton.disabled = false; 226 window.setTimeout(() => { 227 sourceButton.textContent = sourceButton.dataset.defaultLabel || "Send magic link"; 228 }, BUTTON_RESET_DELAY_MS); 229 } 230 } 231 } 232 233 export async function signOutSupabase() { 234 if (signOutInProgress) return; 235 signOutInProgress = true; 236 if (authSignOutButton) authSignOutButton.disabled = true; 237 if (syncStatus) syncStatus.textContent = "Signing out..."; 238 try { 239 if (supabase) { 240 // Local scope avoids network dependency and signs out this device reliably. 241 await supabase.auth.signOut({ scope: "local" }); 242 } 243 } catch (error) { 244 console.warn("Supabase sign out failed, continuing local sign out:", error); 245 } finally { 246 try { 247 if (syncTimer) { 248 clearTimeout(syncTimer); 249 syncTimer = null; 250 } 251 stopSyncPolling(); 252 syncInFlight = null; 253 syncInProgress = false; 254 syncUser = null; 255 lastSyncedAt = null; 256 lastSyncError = ""; 257 try { 258 localStorage.removeItem(STORAGE_KEY); 259 localStorage.removeItem(ONBOARDING_KEY); 260 localStorage.removeItem(AUTH_STATE_KEY); 261 } catch { 262 // ignore 263 } 264 setState(structuredClone(defaultState)); 265 closePopover(); 266 closeSettingsModal(); 267 closeDeleteModal(); 268 resetToLoggedOut(); 269 requestRender(); 270 updateAuthUI(); 271 showToast("Signed out."); 272 } finally { 273 signOutInProgress = false; 274 if (authSignOutButton) authSignOutButton.disabled = false; 275 if (syncStatus) syncStatus.textContent = ""; 276 } 277 } 278 } 279 280 export function updateAuthUI() { 281 if (!authStatus || !authSignOutButton) return; 282 if (!supabase) { 283 authStatus.textContent = "Supabase client not available."; 284 authStatus.classList.add("muted"); 285 if (authRow) authRow.classList.remove("hidden"); 286 if (syncStatus) syncStatus.textContent = ""; 287 return; 288 } 289 if (syncUser) { 290 authStatus.textContent = `Signed in as ${syncUser.email || "user"}.`; 291 authStatus.classList.remove("muted"); 292 authSignOutButton.classList.remove("hidden"); 293 if (authRow) authRow.classList.add("hidden"); 294 if (syncStatus) { 295 syncStatus.textContent = formatSyncStatus(); 296 syncStatus.classList.toggle("muted", !lastSyncError); 297 } 298 } else { 299 authStatus.textContent = "Local-only mode on this device. Sign in to sync and back up."; 300 authStatus.classList.add("muted"); 301 authSignOutButton.classList.add("hidden"); 302 if (authRow) authRow.classList.remove("hidden"); 303 if (syncStatus) { 304 syncStatus.textContent = ""; 305 syncStatus.classList.add("muted"); 306 } 307 } 308 } 309 310 export function scheduleSync() { 311 if (!supabase || !syncUser) return false; 312 if (syncTimer) clearTimeout(syncTimer); 313 syncTimer = setTimeout(() => { 314 syncToCloud(); 315 }, SYNC_DEBOUNCE_MS); 316 return true; 317 } 318 319 async function loadFromCloud({ silentError = false, fromAuthBootstrap = false } = {}) { 320 if (!supabase || !syncUser) return; 321 let { data, error } = await supabase 322 .from("user_data") 323 .select("data, updated_at") 324 .eq("user_id", syncUser.id) 325 .order("updated_at", { ascending: false }) 326 .limit(25); 327 if (error) { 328 // Fallback for schemas that do not expose `updated_at`. 329 const fallback = await supabase.from("user_data").select("data").eq("user_id", syncUser.id).limit(200); 330 data = fallback.data; 331 error = fallback.error; 332 } 333 if (error) { 334 lastSyncError = error.message || "Cloud read failed."; 335 if (!silentError) showToast(`Cloud sync failed: ${lastSyncError}`); 336 updateAuthUI(); 337 return; 338 } 339 const latest = pickLatestCloudRow(data); 340 if (!latest?.data) { 341 // Initialize cloud row once if this account has no cloud data yet. 342 await syncToCloud(); 343 if (!fromAuthBootstrap) showToast("Cloud data initialized."); 344 return; 345 } 346 lastSyncError = ""; 347 const remoteState = normalizeImportedState(latest.data); 348 const localDiffersFromRemote = !areStatesEqual(state, remoteState); 349 if (localDiffersFromRemote) { 350 const localMonthCursor = state.monthCursor; 351 const localYearCursor = state.yearCursor; 352 const merged = mergeDiaryStates(state, remoteState, { 353 preferLocalSettings: true, 354 preferLocalConflicts: false 355 }); 356 merged.monthCursor = localMonthCursor; 357 merged.yearCursor = localYearCursor; 358 setState(merged); 359 requestRender(); 360 } 361 lastSyncedAt = new Date().toISOString(); 362 updateAuthUI(); 363 } 364 365 async function syncToCloud() { 366 if (!supabase || !syncUser) return; 367 if (syncInFlight) return syncInFlight; 368 syncInProgress = true; 369 updateAuthUI(); 370 const snapshot = getCloudStateSnapshot(state); 371 const updatedAt = snapshot.lastModified || new Date().toISOString(); 372 const payloadWithUpdatedAt = { 373 user_id: syncUser.id, 374 data: snapshot, 375 updated_at: updatedAt 376 }; 377 const payloadWithoutUpdatedAt = { 378 user_id: syncUser.id, 379 data: snapshot 380 }; 381 syncInFlight = (async () => { 382 let writeError = null; 383 let result = await supabase.from("user_data").upsert(payloadWithUpdatedAt, { onConflict: "user_id" }); 384 writeError = result.error; 385 386 if (writeError) { 387 result = await supabase.from("user_data").upsert(payloadWithoutUpdatedAt, { onConflict: "user_id" }); 388 writeError = result.error; 389 } 390 if (writeError) { 391 // Legacy fallback: update all rows for this user; insert if none exist. 392 result = await supabase.from("user_data").update({ data: snapshot, updated_at: updatedAt }).eq("user_id", syncUser.id); 393 writeError = result.error; 394 } 395 if (writeError) { 396 result = await supabase.from("user_data").update({ data: snapshot }).eq("user_id", syncUser.id); 397 writeError = result.error; 398 } 399 400 if (writeError) { 401 lastSyncError = writeError.message || "Cloud write failed."; 402 showToast(`Could not sync to cloud: ${lastSyncError}`); 403 } else { 404 lastSyncError = ""; 405 lastSyncedAt = new Date().toISOString(); 406 } 407 })(); 408 try { 409 await syncInFlight; 410 } finally { 411 syncInFlight = null; 412 syncInProgress = false; 413 updateAuthUI(); 414 } 415 } 416 417 function startSyncPolling() { 418 if (syncPollTimer || !syncUser) return; 419 syncPollTimer = window.setInterval(() => { 420 if (!document.hidden) { 421 loadFromCloud({ silentError: true }); 422 } 423 }, SYNC_POLL_MS); 424 } 425 426 function stopSyncPolling() { 427 if (!syncPollTimer) return; 428 window.clearInterval(syncPollTimer); 429 syncPollTimer = null; 430 } 431 432 function handleVisibilitySync() { 433 if (document.hidden || !syncUser) return; 434 loadFromCloud({ silentError: true }); 435 } 436 437 function getMagicLinkRedirectTo() { 438 return `${window.location.origin}${window.location.pathname}`; 439 } 440 441 function formatSyncStatus() { 442 if (lastSyncError) return `Sync error: ${lastSyncError}`; 443 return lastSyncedAt ? `Saved to cloud ${formatSyncTime(lastSyncedAt)}.` : "Signed in. Saving changes to cloud."; 444 } 445 function formatSyncTime(iso) { 446 try { 447 const date = new Date(iso); 448 return date.toLocaleString(undefined, { 449 month: "short", 450 day: "numeric", 451 hour: "numeric", 452 minute: "2-digit" 453 }); 454 } catch { 455 return "just now"; 456 } 457 } 458 459 function getCloudStateSnapshot(sourceState) { 460 const { monthCursor, yearCursor, ...data } = sourceState; 461 return structuredClone(data); 462 }