/ src / auth.js
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  }