Files
dashfront/src/auth/googleSignIn.js
2026-07-19 23:42:17 +02:00

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);
}
});
});
}