PageSourceSearch

https://thediscourse.ai/sso-recovery.js?v=2

js thediscourse.ai collected 2026-10-10 00:42:21 UTC 6,723 bytes, 196 lines download raw bytes

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.