/** * auth.js — Google Identity Services sign-in and quota display module * * Responsibilities: * - Render the Google Sign-In button for unauthenticated users. * - Store the Google ID token in sessionStorage after sign-in. * - Attach Authorization: Bearer header to all API requests. * - Fetch and render the remaining lifetime quota counter AND the account's * plan (free|paid) in the header. * - Show the "Contact for full product / extended use" mailto: link on HTTP 429. * - Dispatch a 'blogrefresher:profile' window event with {plan, quota} * whenever the plan becomes known (sign-in) or unknown (sign-out), so * console.html can lock/unlock its own UI without duplicating this fetch. * * Usage: Include this script after the Google GIS SDK: * * * * GOOGLE_CLIENT_ID must be embedded in the page as a tag: * */ (function () { 'use strict'; // ── Config ────────────────────────────────────────────────────────────────── const SESSION_KEY = 'google_id_token'; const QUOTA_POLL_INTERVAL_MS = 30_000; // Refresh quota display every 30s. // ── State ─────────────────────────────────────────────────────────────────── let _idToken = sessionStorage.getItem(SESSION_KEY) || null; let _quotaState = null; let _plan = null; /** Notify the rest of the page (console.html) of the current plan/quota. */ function _publishProfile() { window.dispatchEvent(new CustomEvent('blogrefresher:profile', { detail: { plan: _plan, quota: _quotaState } })); } // ── Helpers ────────────────────────────────────────────────────────────────── function _clientId() { const meta = document.querySelector('meta[name="google-client-id"]'); return meta ? meta.getAttribute('content') : ''; } // Shared API access key. When the service is gated by API_ACCESS_KEY, the // console captures the key from the ?key=... URL param and stores it in // sessionStorage (see console.html). Every /api/ request — including the // ones this module makes directly, like /api/user/me — must present it as // X-API-Key or the server's middleware rejects it with 401 before auth runs. function _accessKey() { try { return (sessionStorage.getItem('api_access_key') || '').trim(); } catch (_) { return ''; } } function _el(id) { return document.getElementById(id); } // ── Token management ───────────────────────────────────────────────────────── /** * Called by Google GIS after successful sign-in. * @param {Object} response - Google credential response with .credential (JWT). */ function handleGoogleSignIn(response) { _idToken = response.credential; sessionStorage.setItem(SESSION_KEY, _idToken); _renderAuthState(); fetchAndRenderQuota(); } // Expose globally so Google GIS can call it. window.handleGoogleSignIn = handleGoogleSignIn; /** Get the stored ID token, or null if not signed in. */ function getIdToken() { return _idToken; } /** Sign out — clear stored token and re-render the sign-in button. */ function signOut() { _idToken = null; _quotaState = null; _plan = null; sessionStorage.removeItem(SESSION_KEY); _renderAuthState(); _publishProfile(); // tells console.html to re-lock — plan is now unknown } // ── Quota fetching ──────────────────────────────────────────────────────────── /** Fetch /api/user/me and update the quota badge in the header. */ async function fetchAndRenderQuota() { if (!_idToken) return; try { const resp = await fetch('/api/user/me', { headers: { 'Authorization': `Bearer ${_idToken}` } }); if (resp.status === 401) { // Token expired — sign out and prompt re-login. signOut(); return; } if (resp.status === 403) { // Valid Google account, but not on the allowlist. Re-signing-in won't // help — tell them plainly and drop back to the signed-out state. _showAuthError('This Google account is not authorized to use this service. ' + 'Contact the administrator to request access.'); signOut(); return; } if (!resp.ok) return; const data = await resp.json(); _quotaState = data.quota; _plan = data.plan; _renderQuotaBadge(data.quota); _renderPlanBadge(data.plan); _publishProfile(); } catch (e) { console.warn('[auth.js] Failed to fetch quota:', e); } } // ── Rendering ───────────────────────────────────────────────────────────────── function _renderAuthState() { const loginContainer = _el('auth-login-container'); const userContainer = _el('auth-user-container'); if (!loginContainer || !userContainer) return; if (_idToken) { loginContainer.style.display = 'none'; userContainer.style.display = 'flex'; } else { loginContainer.style.display = 'flex'; userContainer.style.display = 'none'; // Re-render the Google button. _initGoogleButton(); } } /** Render the FREE/PAID pill next to the quota badge (FR-003: the plan * must be visible without the person having to click a locked action). */ function _renderPlanBadge(plan) { const badge = _el('plan-badge'); if (!badge) return; const isPaid = plan === 'paid'; badge.textContent = isPaid ? 'PAID' : 'FREE'; badge.style.cssText = `font-size:10.5px;font-weight:700;letter-spacing:.04em;` + `padding:2px 8px;border-radius:5px;margin-right:8px;` + (isPaid ? `color:#000;background:var(--lime,#C8FF00);` : `color:var(--mut,#8A8A8A);border:1px solid var(--line2,#333);`); } function _renderQuotaBadge(quota) { const badge = _el('quota-badge'); if (!badge) return; const lifetime = quota.lifetime_quota_remaining; const exhausted = quota.is_quota_exhausted; if (exhausted) { badge.innerHTML = ` Quota exhausted Contact for extended use → `; } else { const lifetimeColor = lifetime <= 2 ? 'var(--warn)' : 'var(--mut)'; badge.innerHTML = ` ${lifetime}/${quota.lifetime_quota_limit} lifetime `; } } /** Build and inject the auth header HTML dynamically. */ function _injectAuthHeader() { // Create a login section with a Google button placeholder. const loginContainer = document.createElement('div'); loginContainer.id = 'auth-login-container'; loginContainer.style.cssText = 'display:flex;align-items:center;gap:10px;margin-left:auto;'; loginContainer.innerHTML = ` Sign in to scan blogs
`; // Create the signed-in user section. const userContainer = document.createElement('div'); userContainer.id = 'auth-user-container'; userContainer.style.cssText = 'display:none;align-items:center;gap:12px;margin-left:auto;'; userContainer.innerHTML = `
`; const header = document.querySelector('header'); if (header) { // Remove existing placeholder input if any. const existingInput = header.querySelector('input#api'); if (existingInput) existingInput.remove(); header.appendChild(loginContainer); header.appendChild(userContainer); } } /** Render the Google Sign-In button into #google-signin-button. */ function _initGoogleButton() { const clientId = _clientId(); if (!clientId || typeof google === 'undefined') return; try { google.accounts.id.initialize({ client_id: clientId, callback: handleGoogleSignIn, auto_select: false, cancel_on_tap_outside: true, }); google.accounts.id.renderButton( document.getElementById('google-signin-button'), { theme: 'filled_black', size: 'medium', text: 'signin_with', shape: 'rectangular', logo_alignment: 'left', } ); } catch (e) { console.warn('[auth.js] Google GIS not available yet:', e); } } // ── Fetch interceptor ───────────────────────────────────────────────────────── /** * Wrap the native fetch to automatically attach the Authorization Bearer token * to /api/ requests, and handle HTTP 429 (quota exhausted) globally. */ function _isApiUrl(urlStr) { try { // Resolve against the current origin so absolute URLs (e.g. // "http://host:port/api/scan", built via api()+'/api/scan' in the // console) are matched on their path, not the whole string. return new URL(urlStr, window.location.origin).pathname.startsWith('/api/'); } catch (_) { return urlStr.startsWith('/api/'); } } const _nativeFetch = window.fetch; window.fetch = async function (url, opts = {}) { const urlStr = typeof url === 'string' ? url : (url.url || ''); const isApiCall = _isApiUrl(urlStr); if (isApiCall) { opts = opts || {}; opts.headers = opts.headers || {}; const asHeaders = opts.headers instanceof Headers; const setHeader = (k, v) => asHeaders ? opts.headers.set(k, v) : (opts.headers[k] = v); // Coarse gate: the shared API access key, required on every /api/ call // whenever the service is key-gated. Don't clobber a key a caller // already set (the console's apiFetch also attaches it). const key = _accessKey(); const hasKey = asHeaders ? opts.headers.has('X-API-Key') : ('X-API-Key' in opts.headers); if (key && !hasKey) setHeader('X-API-Key', key); // Fine gate: the signed-in user's Google ID token. if (_idToken) setHeader('Authorization', `Bearer ${_idToken}`); } const response = await _nativeFetch(url, opts); // Handle 429 — quota exhausted — show a banner. if (response.status === 429 && isApiCall) { try { const clone = response.clone(); const data = await clone.json(); const detail = data.detail || {}; if (detail.is_quota_exhausted) { const mailto = detail.contact_mailto || ''; const message = detail.message || 'Quota limit reached.'; _showQuotaBanner(message, mailto); } } catch (_) {} } // Handle 403. Two DIFFERENT causes share this status code, and only one // of them means the account itself is rejected: // - allowlist rejection (auth.py _email_allowed): the account may // never use the service at all — sign out, since re-trying won't help. // - plan-gated refusal (app.py _raise_plan_required, detail.error == // "plan_required"): the account is fine, it just tried a capability // its plan doesn't include. Signing out here would be actively wrong // — console.html's own paywall handling deals with this case, not us. if (response.status === 403 && isApiCall) { let isPlanRefusal = false; try { const clone = response.clone(); const data = await clone.json(); isPlanRefusal = (data.detail || {}).error === 'plan_required'; } catch (_) {} if (!isPlanRefusal) { _showAuthError('This Google account is not authorized to use this service. ' + 'Contact the administrator to request access.'); signOut(); } } // Handle 401 — token expired — prompt re-login. if (response.status === 401 && isApiCall) { signOut(); } return response; }; // ── Quota exhausted banner ──────────────────────────────────────────────────── function _showQuotaBanner(message, mailto) { let banner = _el('quota-exhausted-banner'); if (!banner) { banner = document.createElement('div'); banner.id = 'quota-exhausted-banner'; banner.style.cssText = ` position:fixed;top:0;left:0;right:0;z-index:9999; background:rgba(255,77,77,0.95);color:#fff; padding:12px 24px;display:flex;align-items:center;gap:16px; font-family:'Manrope',sans-serif;font-size:13px;font-weight:500; box-shadow:0 2px 12px rgba(0,0,0,0.4);`; document.body.prepend(banner); } banner.innerHTML = ` ⚠️ ${message} ${mailto ? ` Contact for full product / extended use →` : ''} `; // Refresh quota display after a 429. fetchAndRenderQuota(); } // ── Not-authorized banner (403) ─────────────────────────────────────────────── function _showAuthError(message) { let banner = _el('auth-error-banner'); if (!banner) { banner = document.createElement('div'); banner.id = 'auth-error-banner'; banner.style.cssText = ` position:fixed;top:0;left:0;right:0;z-index:9999; background:rgba(255,77,77,0.97);color:#fff; padding:12px 24px;display:flex;align-items:center;gap:16px; font-family:'Manrope',sans-serif;font-size:13px;font-weight:500; box-shadow:0 2px 12px rgba(0,0,0,0.4);`; document.body.prepend(banner); } banner.innerHTML = ` 🔒 ${message} `; } // ── Boot ────────────────────────────────────────────────────────────────────── function init() { _injectAuthHeader(); // If Google GIS is already loaded, initialize immediately. if (typeof google !== 'undefined' && google.accounts) { _initGoogleButton(); } else { // Wait for GIS to load. window.addEventListener('load', () => { setTimeout(_initGoogleButton, 500); }); } _renderAuthState(); // If already signed in, fetch quota immediately and then poll periodically. if (_idToken) { fetchAndRenderQuota(); setInterval(fetchAndRenderQuota, QUOTA_POLL_INTERVAL_MS); } } // Public module API window.AuthModule = { getIdToken, signOut, fetchAndRenderQuota, getPlan: () => _plan }; // Run on DOMContentLoaded. if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();