Spaces:
Running
Running
| /** | |
| * 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; | |
| } |