// auth.jsx — email/password accounts + full data sync for the Chrono beta. // // Talks to the accounts backend (same origin, behind Caddy): // POST /api/auth/signup POST /api/auth/login GET /api/auth/me POST /api/auth/logout // GET /api/sync PUT /api/sync // // Logged OUT -> the app behaves exactly as before (local-only, in-memory). // Logged IN -> on login we pull the user's events/categories/settings; on any // change we debounce-push them back. Signup seeds the server with // whatever the user already has locally so nothing is lost. // // The session token lives in localStorage under 'chrono.auth.v1'. Beta keeps // every account on the Pro plan. (function () { const BASE = (typeof window !== 'undefined' && window.CHRONO_API_BASE) || ''; const AUTH_KEY = 'chrono.auth.v1'; const SKIP_KEY = 'chrono.auth.skip.v1'; const DEBOUNCE = 1500; // ── State ──────────────────────────────────────────────────────────── let session = null; // { token, user:{id,email,displayName,isPro} } try { const raw = localStorage.getItem(AUTH_KEY); if (raw) session = JSON.parse(raw); } catch (_) {} let skipped = false; try { skipped = localStorage.getItem(SKIP_KEY) === '1'; } catch (_) {} let status = 'idle'; // 'idle' | 'syncing' | 'error' const listeners = new Set(); const notify = () => listeners.forEach(cb => { try { cb(); } catch (_) {} }); function persist() { try { if (session) localStorage.setItem(AUTH_KEY, JSON.stringify(session)); else localStorage.removeItem(AUTH_KEY); } catch (_) {} } // ── HTTP ───────────────────────────────────────────────────────────── async function api(path, { method = 'GET', body } = {}) { const headers = { 'Accept': 'application/json' }; if (body !== undefined) headers['Content-Type'] = 'application/json'; if (session && session.token) headers['Authorization'] = `Bearer ${session.token}`; const res = await fetch(`${BASE}${path}`, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, }); let data = null; try { data = await res.json(); } catch (_) {} if (!res.ok) { const err = new Error((data && data.error) || `HTTP ${res.status}`); err.status = res.status; throw err; } return data; } // ── Sync engine ────────────────────────────────────────────────────── let suspend = false; // guard against pull -> change -> push loops let unsubs = []; let timer = null; function applyAccountToProfile() { if (!session || !session.user) return; const u = session.user; try { window.setProfile && window.setProfile({ email: u.email || undefined, name: u.displayName || undefined, plan: 'Pro', // Beta: every account is Pro. }); } catch (_) {} } async function pullAll() { if (!session) return; status = 'syncing'; notify(); try { const data = await api('/api/sync'); suspend = true; try { window.replaceAllEvents && window.replaceAllEvents(data.events || []); window.replaceAllCategories && window.replaceAllCategories(data.categories || []); if (data.settings && typeof data.settings === 'object') { window.setSettings && window.setSettings(data.settings); } } finally { // Let the synchronous change notifications flush before re-enabling push. setTimeout(() => { suspend = false; }, 0); } applyAccountToProfile(); status = 'idle'; notify(); } catch (err) { status = 'error'; notify(); if (err.status === 401) await logout(); } } function buildSnapshot() { const events = (window.exportAllEvents && window.exportAllEvents()) || []; const categories = (window.CATEGORIES || []).map(c => ({ id: c.id, name: c.name, color: c.color })); const settings = (window.settingsStore && window.settingsStore.get && window.settingsStore.get()) || {}; return { events, categories, settings }; } async function pushAll() { if (!session) return; status = 'syncing'; notify(); try { await api('/api/sync', { method: 'PUT', body: buildSnapshot() }); status = 'idle'; notify(); } catch (err) { status = 'error'; notify(); if (err.status === 401) await logout(); } } function schedulePush() { if (suspend || !session) return; if (timer) clearTimeout(timer); timer = setTimeout(() => { timer = null; pushAll(); }, DEBOUNCE); } function wirePush() { unwirePush(); if (window.subscribeEvents) unsubs.push(window.subscribeEvents(schedulePush)); if (window.subscribeCategories) unsubs.push(window.subscribeCategories(schedulePush)); if (window.settingsStore && window.settingsStore.subscribe) { unsubs.push(window.settingsStore.subscribe(schedulePush)); } } function unwirePush() { unsubs.forEach(fn => { try { fn(); } catch (_) {} }); unsubs = []; if (timer) { clearTimeout(timer); timer = null; } } // ── Public API ─────────────────────────────────────────────────────── async function signup({ email, password, displayName }) { const data = await api('/api/auth/signup', { method: 'POST', body: { email, password, displayName } }); session = { token: data.token, user: data.user }; persist(); try { localStorage.removeItem(SKIP_KEY); } catch (_) {} skipped = false; applyAccountToProfile(); wirePush(); // New account: seed the server with whatever the user already has locally. await pushAll(); notify(); return data.user; } async function login({ email, password }) { const data = await api('/api/auth/login', { method: 'POST', body: { email, password } }); session = { token: data.token, user: data.user }; persist(); try { localStorage.removeItem(SKIP_KEY); } catch (_) {} skipped = false; wirePush(); await pullAll(); // existing account: server is source of truth notify(); return data.user; } // Wipe the signed-in user's data from this browser (memory + localStorage) so // nothing leaks to the next person on a shared device, and so a subsequent // signup doesn't seed a new account with the previous user's schedule. Safe: // the data is already on the server and is re-pulled on next login. function clearLocalData() { suspend = true; try { if (window.replaceAllEvents) window.replaceAllEvents([]); if (window.replaceAllCategories) window.replaceAllCategories([]); if (window.resetSettings) window.resetSettings(); if (window.setProfile) window.setProfile({ name: 'Your name', email: 'you@chrono.app', plan: 'Pro' }); if (window.setIntegrations) window.setIntegrations({ google:false, icloud:false, canvas:false, outlook:false, canvasSchool:'', canvasCourses:[] }); if (window.clearCanvasEvents) window.clearCanvasEvents(); } catch (_) {} setTimeout(() => { suspend = false; }, 0); } async function logout() { if (timer) { clearTimeout(timer); timer = null; } try { if (session) await api('/api/auth/logout', { method: 'POST' }); } catch (_) {} unwirePush(); session = null; persist(); clearLocalData(); status = 'idle'; notify(); } function skip() { try { localStorage.setItem(SKIP_KEY, '1'); } catch (_) {} skipped = true; notify(); } const chronoAuth = { isAuthed: () => !!(session && session.token), getUser: () => (session ? session.user : null), getStatus: () => status, isSkipped: () => skipped, signup, login, logout, skip, // React hook: re-render on any auth/sync state change. use: () => { const [, setTick] = React.useState(0); React.useEffect(() => { const cb = () => setTick(t => t + 1); listeners.add(cb); return () => listeners.delete(cb); }, []); return chronoAuth; }, }; window.chronoAuth = chronoAuth; // ── Resume an existing session on load ─────────────────────────────── if (session && session.token) { api('/api/auth/me') .then(d => { session.user = d.user; persist(); applyAccountToProfile(); wirePush(); return pullAll(); }) .catch(err => { if (err.status === 401) logout(); }); } // ── AuthGate UI ────────────────────────────────────────────────────── // Full-screen login/signup overlay. Hidden once the user is authenticated // or has chosen to continue without an account. // Props: // embedded — when true, the gate is rendered as the first step of the // onboarding flow (it never auto-hides on its own and calls onDone() // once the user has signed in / signed up / chosen to skip). // onDone — advance callback for embedded mode. function AuthGate({ embedded = false, onDone } = {}) { const auth = window.chronoAuth.use(); const [mode, setMode] = React.useState('login'); // 'login' | 'signup' const [email, setEmail] = React.useState(''); const [name, setName] = React.useState(''); const [pw, setPw] = React.useState(''); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(''); // Standalone gate auto-hides once resolved; embedded gate is driven by the // onboarding orchestrator instead (so it can't overlap the tutorial). if (!embedded && (auth.isAuthed() || auth.isSkipped())) return null; const finish = () => { if (embedded && onDone) onDone(); }; const submit = async (e) => { e.preventDefault(); setErr(''); setBusy(true); try { if (mode === 'signup') await auth.signup({ email, password: pw, displayName: name }); else await auth.login({ email, password: pw }); finish(); } catch (ex) { setErr(ex.message || 'Something went wrong.'); } finally { setBusy(false); } }; const C = { bg:'#06070C', card:'#0e1018', line:'rgba(255,255,255,0.08)', text:'#eef1f8', faint:'rgba(238,241,248,0.55)', accent:'#6366f1', accentText:'#fff', danger:'#f87171' }; const input = { width:'100%', boxSizing:'border-box', background:'#15182280', color:C.text, border:`1px solid ${C.line}`, borderRadius:10, padding:'11px 13px', fontSize:14, outline:'none', marginTop:6 }; const label = { fontSize:11, fontWeight:700, letterSpacing:'0.12em', textTransform:'uppercase', color:C.faint }; return (