1// Recovery layer for the Google Identity Services button. Google renders the 2// control inside a third-party iframe; browser privacy tools or a transient GIS 3// failure can remove that iframe after the React component believes it is 4// ready. Keep the sign-in route usable while the component retries. 5(function installGoogleSsoRecovery() { 6 if (window.location.protocol === 'file:') return; 7 8 const WRAPPER_SELECTOR = '.sso-google-wrap'; 9 const GOOGLE_FRAME_SELECTOR = 10 'iframe[title="Sign in with Google Button"], iframe[src*="accounts.google.com/gsi/button"]'; 11 const INITIAL_GRACE_MS = 5000; 12 const RENDER_GRACE_MS = 1200; 13 // Consumed by SsoButtons.jsx. Re-rendering the button here replaces the one 14 // React created, taking its click_listener with it â so without this signal 15 // a recovery-rendered click never arms the click->credential watch and the 16 // sign-in silently drops out of the funnel. Keep the name in sync with 17 // GOOGLE_RECOVERY_CLICK_EVENT in src/components/auth/SsoButtons.jsx. 18 const CLICK_EVENT = 'discourse:sso-google-click'; 19 const POLL_INTERVAL_MS = 2000; 20 const states = new WeakMap(); 21 let scanTimer; 22 // Only runs while a sign-in wrapper is actually on the page. This script is 23 // loaded from index.html on every route, so an unconditional interval polled 24 // the whole site â dashboards, courses, the homepage â for a button that 25 // exists on two pages. 26 let pollTimer = null; 27 28 const capture = (event, properties = {}) => { 29 try { 30 window.posthog?.capture?.(event, { 31 provider: 'google', 32 context: window.location.pathname || 'unknown', 33 recovery_layer: 'shared_helper', 34 ...properties, 35 }); 36 } catch { 37 // Analytics must never prevent sign-in recovery. 38 } 39 }; 40 41 const hasGoogleFrame = (wrapper) => 42 Boolean(wrapper?.querySelector(GOOGLE_FRAME_SELECTOR)); 43 44 const scheduleScan = (delay = 0) => { 45 window.clearTimeout(scanTimer); 46 scanTimer = window.setTimeout(scan, delay); 47 }; 48 49 const removeRetryButton = (wrapper) => { 50 wrapper.querySelector('.sso-google-recovery-btn')?.remove(); 51 }; 52 53 const showRetryButton = (wrapper, stage) => { 54 if (hasGoogleFrame(wrapper) || wrapper.querySelector('.sso-google-recovery-btn')) return; 55 56 const button = document.createElement('button'); 57 button.type = 'button'; 58 button.className = 'sso-google-recovery-btn'; 59 button.textContent = 'Retry Google sign-in'; 60 button.setAttribute('aria-label', 'Retry Google sign-in'); 61 button.style.cssText = [ 62 'position:absolute', 63 'inset:0', 64 'width:100%', 65 'height:44px', 66 'display:flex', 67 'align-items:center', 68 'justify-content:center', 69 'border:1px solid #dadce0', 70 'border-radius:4px', 71 'background:#fff', 72 'color:#3c4043', 73 'font:600 14px Arial,sans-serif', 74 'cursor:pointer', 75 'z-index:2', 76 ].join(';'); 77 button.addEventListener('click', () => { 78 capture('sso_provider_retry', { stage: 'hotfix_page_reload' }); 79 try { 80 sessionStorage.setItem('google_sso_recovery_retry', '1'); 81 } catch { 82 // Storage can be unavailable in privacy-restricted browsers. 83 } 84 window.location.reload(); 85 }); 86 87 wrapper.appendChild(button); 88 capture('sso_provider_error', { stage }); 89 }; 90 91 const recoverGoogleButton = (wrapper) => { 92 if (!document.contains(wrapper)) return; 93 94 let state = states.get(wrapper); 95 if (!state) { 96 state = { firstMissingAt: Date.now(), attempting: false }; 97 states.set(wrapper, state); 98 } 99 100 if (hasGoogleFrame(wrapper)) { 101 state.firstMissingAt = Date.now(); 102 state.attempting = false; 103 removeRetryButton(wrapper); 104 return; 105 } 106 107 if (Date.now() - state.firstMissingAt < INITIAL_GRACE_MS) { 108 scheduleScan(INITIAL_GRACE_MS); 109 return; 110 } 111 if (state.attempting) return; 112 113 const googleIdentity = window.google?.accounts?.id; 114 const host = Array.from(wrapper.children).find( 115 (element) => 116 element.tagName === 'DIV' && 117 !element.classList.contains('sso-google-recovery-btn') 118 ); 119 120 if (!googleIdentity || !host) { 121 showRetryButton(wrapper, googleIdentity ? 'hotfix_host_missing' : 'hotfix_script_unavailable'); 122 return; 123 } 124 125 state.attempting = true; 126 removeRetryButton(wrapper); 127 try { 128 host.replaceChildren(); 129 googleIdentity.renderButton(host, { 130 theme: 'outline', 131 size: 'large', 132 width: 320, 133 text: 'continue_with', 134 click_listener: () => { 135 // React owns the watch but not this button, so arm it first â if the 136 // listener over there is gone, the click is still captured below. 137 try { 138 document.dispatchEvent(new CustomEvent(CLICK_EVENT)); 139 } catch { 140 // A failed dispatch must not swallow the click event itself. 141 } 142 capture('sso_provider_clicked', { stage: 'hotfix_render' }); 143 }, 144 }); 145 capture('sso_provider_retry', { stage: 'hotfix_render' }); 146 } catch { 147 state.attempting = false;
148 showRetryButton(wrapper, 'hotfix_render_exception'); 149 return; 150 } 151 152 window.setTimeout(() => { 153 state.attempting = false; 154 if (hasGoogleFrame(wrapper)) { 155 removeRetryButton(wrapper); 156 capture('sso_provider_ready', { stage: 'hotfix_render' }); 157 } else { 158 showRetryButton(wrapper, 'hotfix_render_timeout'); 159 } 160 }, RENDER_GRACE_MS); 161 }; 162 163 // Poll only while there is something to recover. The observer below stays 164 // armed either way â it is what notices a wrapper appearing on an SPA 165 // navigation, and it is what stops the polling once one goes away. 166 const syncPolling = (wrapperCount) => { 167 if (wrapperCount > 0 && pollTimer === null) { 168 pollTimer = window.setInterval(scan, POLL_INTERVAL_MS); 169 return; 170 } 171 if (wrapperCount === 0 && pollTimer !== null) { 172 window.clearInterval(pollTimer); 173 pollTimer = null; 174 } 175 }; 176 177 function scan() { 178 const wrappers = document.querySelectorAll(WRAPPER_SELECTOR); 179 wrappers.forEach(recoverGoogleButton); 180 syncPolling(wrappers.length); 181 } 182 183 const start = () => { 184 scan(); 185 new MutationObserver(() => scheduleScan(100)).observe(document.body, { 186 childList: true, 187 subtree: true, 188 }); 189 }; 190 191 if (document.readyState === 'loading') { 192 document.addEventListener('DOMContentLoaded', start, { once: true }); 193 } else { 194 start(); 195 } 196})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.