/** * UrbanFlow — auth.js * Client-side Google Identity Services integration. * * Session is persisted in localStorage so it survives page reloads * and HF Space container restarts. The backend verifies the JWT on * every sign-in and returns the canonical user record. */ const UF_AUTH_KEYS = { email: 'uf_user_email', name: 'uf_user_name', picture: 'uf_user_picture', username: 'uf_user_username', }; // ---- Session helpers ---- function getAuthSession() { const email = localStorage.getItem(UF_AUTH_KEYS.email); if (!email) return null; return { email: email, name: localStorage.getItem(UF_AUTH_KEYS.name) || '', picture: localStorage.getItem(UF_AUTH_KEYS.picture) || '', username: localStorage.getItem(UF_AUTH_KEYS.username) || '', }; } function isAuthenticated() { return !!localStorage.getItem(UF_AUTH_KEYS.email); } function saveAuthSession(user) { localStorage.setItem(UF_AUTH_KEYS.email, user.email || ''); localStorage.setItem(UF_AUTH_KEYS.name, user.name || ''); localStorage.setItem(UF_AUTH_KEYS.picture, user.picture || ''); localStorage.setItem(UF_AUTH_KEYS.username, user.username || ''); } function clearAuthSession() { Object.values(UF_AUTH_KEYS).forEach(k => localStorage.removeItem(k)); } // ---- Google Identity Services ---- let _gsiInitialized = false; /** * Load GIS script and initialize. Returns a Promise that resolves * once `google.accounts.id` is ready. */ function initGoogleAuth() { return new Promise((resolve, reject) => { if (_gsiInitialized) { resolve(); return; } fetch('api/auth/client-id') .then(r => r.json()) .then(data => { if (data.error) { reject(data.error); return; } const script = document.createElement('script'); script.src = 'https://accounts.google.com/gsi/client'; script.async = true; script.defer = true; script.onload = () => { google.accounts.id.initialize({ client_id: data.client_id, callback: _handleCredentialResponse, auto_select: false, cancel_on_tap_outside: false, }); _gsiInitialized = true; resolve(); }; script.onerror = () => reject('Failed to load Google Identity Services'); document.head.appendChild(script); }) .catch(reject); }); } /** * Click the hidden real Google button — this is the only reliable way * to open the account chooser popup from a user gesture. */ function _triggerGoogleSignInPopup() { const realBtn = document.querySelector('#gsi-hidden-btn [role="button"]'); if (realBtn) { realBtn.click(); } else { // Fallback: re-render and click once rendered _renderHiddenGoogleBtn(function() { const btn = document.querySelector('#gsi-hidden-btn [role="button"]'); if (btn) btn.click(); }); } } /** * Render Google's real button into a hidden offscreen container, * then call `cb` once the iframe/button is ready. */ function _renderHiddenGoogleBtn(cb) { let container = document.getElementById('gsi-hidden-btn'); if (!container) { container = document.createElement('div'); container.id = 'gsi-hidden-btn'; container.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0;pointer-events:none;'; document.body.appendChild(container); } container.innerHTML = ''; google.accounts.id.renderButton(container, { type: 'standard', theme: 'filled_black', size: 'large', text: 'signin_with', shape: 'pill', width: 240, }); // Give the iframe a tick to mount setTimeout(function() { container.style.pointerEvents = 'auto'; if (cb) cb(); }, 100); } // Internal: will be overridden by the page that triggers sign-in let _onAuthSuccess = null; function _handleCredentialResponse(response) { fetch('api/auth/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ credential: response.credential }), }) .then(r => { if (!r.ok) throw new Error('Verification failed'); return r.json(); }) .then(user => { saveAuthSession(user); const handleSuccess = (u) => { _hideAuthOverlay(); if (_onAuthSuccess) _onAuthSuccess(u); else { if (typeof populateProfileUI === 'function') populateProfileUI(); } }; if (user.new_user) { _showOnboardingForm(user, function(u) { // After onboarding, check consent _checkConsentThenProceed(u, handleSuccess); }); } else { // Existing user — check consent _checkConsentThenProceed(user, handleSuccess); } }) .catch(err => { console.error('[AUTH]', err); const overlay = document.getElementById('auth-overlay'); const errEl = overlay ? overlay.querySelector('.auth-error') : null; if (errEl) { errEl.textContent = 'Sign-in failed. Please try again.'; errEl.classList.remove('hidden'); } }); } /** * Show the auth overlay, render the custom button that delegates * to the real Google button click for a reliable account chooser. */ function promptGoogleSignIn(onSuccess) { _onAuthSuccess = (user) => { if (user.new_user) { _showOnboardingForm(user, onSuccess); } else { _hideAuthOverlay(); onSuccess(user); } }; _showAuthOverlay(); initGoogleAuth().then(() => { const btnContainer = document.getElementById('auth-google-btn'); if (!btnContainer) return; // Render the hidden real Google button first so it's ready _renderHiddenGoogleBtn(null); // Show our styled button that delegates to it btnContainer.innerHTML = TEMPLATES.authGoogleBtn; }); } // ---- Auth Overlay (injected into DOM) ---- function _showAuthOverlay() { let overlay = document.getElementById('auth-overlay'); if (!overlay) { overlay = document.createElement('div'); overlay.id = 'auth-overlay'; overlay.className = 'auth-overlay'; overlay.innerHTML = TEMPLATES.authOverlay; document.body.appendChild(overlay); overlay.addEventListener('click', function(e) { if (e.target === overlay) _hideAuthOverlay(); }); } overlay.style.display = 'flex'; document.body.style.overflow = 'hidden'; } function _hideAuthOverlay() { const overlay = document.getElementById('auth-overlay'); if (overlay) { overlay.style.display = 'none'; document.body.style.overflow = ''; } } // ---- Onboarding Form (username) ---- function _showOnboardingForm(user, onSuccess) { const overlay = document.getElementById('auth-overlay'); if (!overlay) return; const card = overlay.querySelector('.auth-card'); card.innerHTML = getOnboardFormTemplate(user); overlay._onboardUser = user; overlay._onboardCallback = onSuccess; } function _submitOnboarding() { const overlay = document.getElementById('auth-overlay'); const input = document.getElementById('auth-username-input'); const errEl = document.getElementById('auth-onboard-error'); const username = (input ? input.value : '').trim(); if (!username || username.length < 2) { if (errEl) { errEl.textContent = 'Please enter at least 2 characters.'; errEl.classList.remove('hidden'); } return; } const user = overlay._onboardUser; const btn = document.getElementById('auth-onboard-submit'); if (btn) { btn.disabled = true; btn.textContent = 'Saving...'; } fetch('api/auth/onboard', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: user.email, username: username }), }) .then(r => { if (!r.ok) throw new Error('Onboarding failed'); return r.json(); }) .then(() => { user.username = username; saveAuthSession(user); _hideAuthOverlay(); if (overlay._onboardCallback) overlay._onboardCallback(user); }) .catch(() => { if (errEl) { errEl.textContent = 'Something went wrong. Please try again.'; errEl.classList.remove('hidden'); } if (btn) { btn.disabled = false; btn.textContent = 'Continue'; } }); } // ---- Logout ---- function showLogoutConfirm() { let modal = document.getElementById('logout-confirm-modal'); if (!modal) { modal = document.createElement('div'); modal.id = 'logout-confirm-modal'; modal.className = 'auth-overlay'; modal.onclick = (e) => { if (e.target === modal) hideLogoutConfirm(); }; modal.innerHTML = TEMPLATES.logoutModal; document.body.appendChild(modal); } modal.style.display = 'flex'; document.body.style.overflow = 'hidden'; } function hideLogoutConfirm() { const modal = document.getElementById('logout-confirm-modal'); if (modal) { modal.style.display = 'none'; document.body.style.overflow = ''; } } function executeLogout() { clearAuthSession(); sessionStorage.clear(); hideLogoutConfirm(); if (typeof showOnboardingPhase === 'function') { showOnboardingPhase(); if (typeof initApp === 'function') { const sp = document.getElementById('sidebar-profile'); if (sp) sp.style.display = 'none'; } } else { window.location.replace('/'); } } // ---- Consent Modal ---- let _consentCallback = null; let _consentUser = null; function _checkConsentThenProceed(user, callback) { if (localStorage.getItem('uf_terms_accepted')) { callback(user); return; } // Show consent modal _consentCallback = callback; _consentUser = user; _showConsentModal(); } function _showConsentModal() { let overlay = document.getElementById('consent-overlay'); if (!overlay) { overlay = document.createElement('div'); overlay.id = 'consent-overlay'; overlay.className = 'auth-overlay'; overlay.innerHTML = TEMPLATES.consentModal; document.body.appendChild(overlay); // No click-to-close — user must agree } overlay.style.display = 'flex'; document.body.style.overflow = 'hidden'; } function onConsentCheckboxChange() { const cb = document.getElementById('consent-checkbox'); const btn = document.getElementById('consent-accept-btn'); const label = document.getElementById('consent-label'); if (!cb || !btn) return; if (cb.checked) { btn.disabled = false; btn.style.background = '#0a0a0a'; btn.style.border = '1px solid var(--cocoa)'; btn.style.color = 'var(--cocoa-l)'; btn.style.cursor = 'pointer'; if (label) label.style.borderColor = 'var(--cocoa)'; } else { btn.disabled = true; btn.style.background = '#1a1a1a'; btn.style.border = '1px solid #222'; btn.style.color = '#555'; btn.style.cursor = 'not-allowed'; if (label) label.style.borderColor = '#222'; } } function acceptConsent() { localStorage.setItem('uf_terms_accepted', 'true'); const overlay = document.getElementById('consent-overlay'); if (overlay) { overlay.style.display = 'none'; document.body.style.overflow = ''; } if (_consentCallback && _consentUser) { _consentCallback(_consentUser); } _consentCallback = null; _consentUser = null; }