198 lines
5.1 KiB
JavaScript
198 lines
5.1 KiB
JavaScript
const GIS_SCRIPT = 'https://accounts.google.com/gsi/client';
|
|
|
|
let scriptPromise;
|
|
|
|
/** WebView consoles print objects as [object Object]; always log a string. */
|
|
export function logError(tag, details) {
|
|
let text;
|
|
try {
|
|
text = JSON.stringify(details, errorReplacer, 2);
|
|
} catch {
|
|
text = String(details);
|
|
}
|
|
console.error(`${tag} ${text}`);
|
|
}
|
|
|
|
function errorReplacer(_key, value) {
|
|
if (value instanceof Error) {
|
|
return {
|
|
name: value.name,
|
|
message: value.message,
|
|
code: value.code,
|
|
status: value.status,
|
|
stack: value.stack,
|
|
cause: value.cause,
|
|
...Object.fromEntries(Object.entries(value)),
|
|
};
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function loadGoogleScript() {
|
|
if (window.google?.accounts?.id) return Promise.resolve();
|
|
if (scriptPromise) return scriptPromise;
|
|
|
|
scriptPromise = new Promise((resolve, reject) => {
|
|
const script = document.createElement('script');
|
|
script.src = GIS_SCRIPT;
|
|
script.async = true;
|
|
script.defer = true;
|
|
script.onload = () => resolve();
|
|
script.onerror = () => {
|
|
scriptPromise = null;
|
|
// <script onerror> does not expose a message; fetch to get a real Error.
|
|
fetch(GIS_SCRIPT).then(
|
|
(res) =>
|
|
reject(
|
|
new Error(
|
|
res.ok
|
|
? `Failed to execute ${GIS_SCRIPT}`
|
|
: `Failed to load ${GIS_SCRIPT}: HTTP ${res.status} ${res.statusText}`,
|
|
),
|
|
),
|
|
reject,
|
|
);
|
|
};
|
|
document.head.appendChild(script);
|
|
});
|
|
|
|
return scriptPromise;
|
|
}
|
|
|
|
export class GoogleSignInCancelledError extends Error {
|
|
constructor() {
|
|
super('Sign-in was cancelled');
|
|
this.name = 'GoogleSignInCancelledError';
|
|
}
|
|
}
|
|
|
|
export const FEDCM_BLOCKED_MESSAGE =
|
|
'Google sign-in is blocked for this site. Enable third-party sign-in using the icon next to the address bar, or in your browser site settings.';
|
|
|
|
export class GoogleSignInBlockedError extends Error {
|
|
constructor(message = FEDCM_BLOCKED_MESSAGE) {
|
|
super(message);
|
|
this.name = 'GoogleSignInBlockedError';
|
|
}
|
|
}
|
|
|
|
let activeAbort = null;
|
|
|
|
export function abortGoogleSignIn() {
|
|
if (!activeAbort) return;
|
|
window.google?.accounts?.id?.cancel();
|
|
activeAbort();
|
|
}
|
|
|
|
function clickRenderedGoogleButton(clientId, onCredential) {
|
|
const container = document.createElement('div');
|
|
container.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;pointer-events:none;';
|
|
document.body.appendChild(container);
|
|
|
|
window.google.accounts.id.renderButton(container, {
|
|
type: 'standard',
|
|
size: 'large',
|
|
theme: 'outline',
|
|
});
|
|
|
|
const button = container.querySelector('[role="button"]');
|
|
if (!button) {
|
|
container.remove();
|
|
throw new Error('Could not open Google Sign-In');
|
|
}
|
|
|
|
window.google.accounts.id.initialize({
|
|
client_id: clientId,
|
|
callback: (response) => {
|
|
container.remove();
|
|
onCredential(response.credential);
|
|
},
|
|
auto_select: false,
|
|
cancel_on_tap_outside: true,
|
|
});
|
|
|
|
button.click();
|
|
}
|
|
|
|
/**
|
|
* Prompt for a Google ID token (One Tap, falling back to the sign-in popup).
|
|
* In Dash Native (`window.__dashHost`), uses the host bridge with the same web OAuth client ID.
|
|
* @param {string} clientId
|
|
* @param {{ onFedcmBlocked?: () => void }} [options]
|
|
*/
|
|
export async function requestGoogleIdToken(clientId, { onFedcmBlocked } = {}) {
|
|
if (!clientId) {
|
|
throw new Error('Google client ID is not configured');
|
|
}
|
|
|
|
const host = window.__dashHost;
|
|
if (
|
|
host?.isDashNative === true &&
|
|
host.googleSignIn === true &&
|
|
typeof host.requestGoogleIdToken === 'function'
|
|
) {
|
|
try {
|
|
return await host.requestGoogleIdToken(clientId);
|
|
} catch (err) {
|
|
logError('[googleSignIn] native host requestGoogleIdToken failed', {
|
|
clientId,
|
|
origin: window.location.origin,
|
|
error: err,
|
|
});
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
await loadGoogleScript();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
|
|
const finish = (fn, value) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
activeAbort = null;
|
|
fn(value);
|
|
};
|
|
|
|
activeAbort = () => finish(reject, new GoogleSignInCancelledError());
|
|
|
|
window.google.accounts.id.initialize({
|
|
client_id: clientId,
|
|
callback: (response) => finish(resolve, response.credential),
|
|
auto_select: false,
|
|
cancel_on_tap_outside: true,
|
|
});
|
|
|
|
window.google.accounts.id.prompt((notification) => {
|
|
if (settled) return;
|
|
|
|
if (notification.isDismissedMoment()) {
|
|
if (notification.getDismissedReason() === 'credential_returned') return;
|
|
finish(reject, new GoogleSignInCancelledError());
|
|
return;
|
|
}
|
|
|
|
if (notification.isSkippedMoment()) {
|
|
const reason = notification.getSkippedReason?.();
|
|
if (reason === 'issuing_failed') {
|
|
onFedcmBlocked?.();
|
|
}
|
|
}
|
|
|
|
const needsFallback =
|
|
notification.isNotDisplayed() || notification.isSkippedMoment();
|
|
|
|
if (!needsFallback) return;
|
|
|
|
try {
|
|
clickRenderedGoogleButton(clientId, (credential) =>
|
|
finish(resolve, credential),
|
|
);
|
|
} catch (err) {
|
|
finish(reject, err);
|
|
}
|
|
});
|
|
});
|
|
}
|