UrbanFlow / frontend /js /auth.js
Subh775's picture
RELEASE: auth; pf section added; bug/crash/ major improvements & fixes; refactoring pending..
5cd1866
Raw
History Blame Contribute Delete
12.2 kB
/**
* 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;
}