Centralize auth popup handling and consent dialog

This commit is contained in:
Nariman Jelveh
2026-05-21 12:06:47 -07:00
parent 71b7d6367f
commit 2275f65169
3 changed files with 210 additions and 119 deletions
+67
View File
@@ -0,0 +1,67 @@
/**
* Shared helpers for opening Puter authentication popup windows.
*
* Browsers only allow `window.open()` to spawn a real popup (instead of
* silently blocking it) while the document has user activation — i.e.
* during or shortly after a user gesture such as a click. Every Puter auth
* flow needs the same activation check and the same popup geometry, so this
* module is the single source of truth for both.
*
* Keeping this in one place is what prevents the flows from drifting apart:
* previously the implicit-auth flow had an activation check + consent-dialog
* fallback while `puter.auth.signIn()` opened the popup unconditionally and
* got popup-blocked when called without a user gesture.
*/
// Auth popup window dimensions.
const POPUP_WIDTH = 600;
const POPUP_HEIGHT = 700;
/**
* Detects whether the document currently has user activation, which the
* browser requires in order to open a popup without blocking it.
*
* @returns {boolean} True if a popup can be opened right now.
*/
export const hasUserActivation = () => {
// Modern browsers expose the User Activation API.
if ( navigator.userActivation ) {
return navigator.userActivation.hasBeenActive && navigator.userActivation.isActive;
}
// Fallback for browsers without the API: probe by attempting to open a
// tiny off-screen popup. If it succeeds, a user gesture is active; close
// it immediately. This is hacky, but it is the only signal available.
try {
const testPopup = window.open('', '_blank', 'width=1,height=1,left=-1000,top=-1000');
if ( testPopup ) {
testPopup.close();
return true;
}
return false;
} catch (e) {
return false;
}
};
/**
* Opens a centered Puter authentication popup window.
*
* This must be called synchronously from within a user gesture (e.g. a click
* handler), or the browser will block the popup. Callers must gate any
* non-gesture invocation behind `hasUserActivation()` and fall back to a
* consent dialog (which collects a gesture) when there is no activation.
*
* @param {string} url - The full URL (including query string) to load.
* @param {string} [title='Puter'] - The popup window name.
* @returns {Window|null} The popup window, or null if the browser blocked it.
*/
export const openAuthPopup = (url, title = 'Puter') => {
const left = (screen.width / 2) - (POPUP_WIDTH / 2);
const top = (screen.height / 2) - (POPUP_HEIGHT / 2);
return window.open(
url,
title,
`toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${POPUP_WIDTH}, height=${POPUP_HEIGHT}, top=${top}, left=${left}`,
);
};
+82 -38
View File
@@ -1,4 +1,6 @@
import * as utils from '../lib/utils.js';
import PuterDialog from './PuterDialog.js';
import { hasUserActivation, openAuthPopup } from '../lib/auth-popup.js';
class Auth {
// Used to generate a unique message id for each message sent to the host environment
@@ -46,55 +48,97 @@ class Auth {
options = options || {};
return new Promise((resolve, reject) => {
let msg_id = this.#messageID++;
let w = 600;
let h = 700;
let title = 'Puter';
var left = (screen.width / 2) - (w / 2);
var top = (screen.height / 2) - (h / 2);
const msg_id = this.#messageID++;
const url = `${puter.defaultGUIOrigin}/action/sign-in?embedded_in_popup=true&msg_id=${msg_id}${window.crossOriginIsolated ? '&cross_origin_isolated=true' : ''}${options.attempt_temp_user_creation ? '&attempt_temp_user_creation=true' : ''}`;
// Store reference to the popup window
const popup = window.open(
`${puter.defaultGUIOrigin}/action/sign-in?embedded_in_popup=true&msg_id=${msg_id}${window.crossOriginIsolated ? '&cross_origin_isolated=true' : ''}${options.attempt_temp_user_creation ? '&attempt_temp_user_creation=true' : ''}`,
title,
`toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${w}, height=${h}, top=${top}, left=${left}`,
);
// Guards against settling the promise more than once across the
// message, popup-closed, and dialog-cancel code paths.
let settled = false;
// Interval id for polling whether the user closed the popup.
let checkClosed = null;
// Set up interval to check if popup was closed
const checkClosed = setInterval(() => {
if ( popup.closed ) {
const cleanup = () => {
if ( checkClosed ) {
clearInterval(checkClosed);
// Remove the message listener
window.removeEventListener('message', messageHandler);
reject({ error: 'auth_window_closed', msg: 'Authentication window was closed by the user without completing the process.' });
checkClosed = null;
}
}, 100);
window.removeEventListener('message', messageHandler);
};
function messageHandler (e) {
if ( e.data.msg_id == msg_id ) {
// Clear the interval since we got a response
clearInterval(checkClosed);
if ( e.data?.msg_id != msg_id ) {
return;
}
if ( settled ) {
return;
}
settled = true;
cleanup();
// remove redundant attributes
delete e.data.msg_id;
delete e.data.msg;
// remove redundant attributes
delete e.data.msg_id;
delete e.data.msg;
if ( e.data.success ) {
// set the auth token
puter.setAuthToken(e.data.token);
resolve(e.data);
} else
{
reject(e.data);
}
// delete the listener
window.removeEventListener('message', messageHandler);
if ( e.data.success ) {
// set the auth token
puter.setAuthToken(e.data.token);
resolve(e.data);
} else {
reject(e.data);
}
}
window.addEventListener('message', messageHandler);
// Once the popup exists, watch for the user closing it without
// completing sign-in. `popup` is null if the browser blocked it.
const watchPopup = (popup) => {
if ( settled ) {
return;
}
if ( ! popup ) {
settled = true;
cleanup();
reject({ error: 'popup_blocked', msg: 'The sign-in popup was blocked by the browser.' });
return;
}
checkClosed = setInterval(() => {
if ( ! popup.closed ) {
return;
}
clearInterval(checkClosed);
checkClosed = null;
if ( settled ) {
return;
}
settled = true;
cleanup();
reject({ error: 'auth_window_closed', msg: 'Authentication window was closed by the user without completing the process.' });
}, 100);
};
if ( hasUserActivation() ) {
// A user gesture is active — open the popup immediately.
watchPopup(openAuthPopup(url));
} else {
// No user gesture: a popup opened now would be blocked by the
// browser. Show a consent dialog first; the popup is then
// opened from the user's click on that dialog, which provides
// the gesture the browser requires.
const dialog = new PuterDialog(() => {}, () => {}, {
popupURL: url,
onLaunch: (popup) => watchPopup(popup),
onCancel: () => {
if ( settled ) {
return;
}
settled = true;
cleanup();
reject({ error: 'auth_window_closed', msg: 'Authentication window was closed by the user without completing the process.' });
},
});
document.body.appendChild(dialog);
dialog.open();
}
});
};
+61 -81
View File
@@ -1,72 +1,33 @@
import { hasUserActivation, openAuthPopup } from '../lib/auth-popup.js';
class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall back to only extending Object in environments without a DOM
// Similar to `#messageID` in Auth.js. We start at an arbitrary high number to avoid
// collisions.
static messageID = Math.floor(Number.MAX_SAFE_INTEGER / 2);
/**
* Detects if the current page is loaded using the file:// protocol.
* @returns {boolean} True if using file:// protocol, false otherwise.
*/
isUsingFileProtocol = () => {
return window.location.protocol === 'file:';
};
#messageID;
constructor (resolve, reject) {
/**
* @param {Function} resolve - Resolves the implicit-auth promise.
* @param {Function} reject - Rejects the implicit-auth promise.
* @param {Object} [options] - Optional configuration.
* @param {string} [options.popupURL] - When set, the dialog acts as a
* generic popup launcher: it opens this URL (instead of the default
* implicit-auth URL), skips the `puter.token` message handling and
* `puterAuthState` bookkeeping (the caller owns the auth result), and
* reports cancellation through `options.onCancel`.
* @param {Function} [options.onLaunch] - Called with the opened popup
* window (or null if the browser blocked it) right after launch.
* @param {Function} [options.onCancel] - Called when the user dismisses
* the dialog without completing authentication.
*/
constructor (resolve, reject, options = {}) {
super();
this.reject = reject;
this.resolve = resolve;
this.popupLaunched = false; // Track if popup was successfully launched
this.options = options;
this.#messageID = this.constructor.messageID++;
/**
* Detects if there's a recent user activation that would allow popup opening
* @returns {boolean} True if user activation is available, false otherwise.
*/
this.hasUserActivation = () => {
// Modern browsers support navigator.userActivation
if ( navigator.userActivation ) {
return navigator.userActivation.hasBeenActive && navigator.userActivation.isActive;
}
// Fallback: try to detect user activation by attempting to open a popup
// This is a bit hacky but works as a fallback
try {
const testPopup = window.open('', '_blank', 'width=1,height=1,left=-1000,top=-1000');
if ( testPopup ) {
testPopup.close();
return true;
}
return false;
} catch (e) {
return false;
}
};
/**
* Launches the authentication popup window
* @returns {Window|null} The popup window reference or null if failed
*/
this.launchPopup = () => {
try {
let w = 600;
let h = 700;
let title = 'Puter';
var left = (screen.width / 2) - (w / 2);
var top = (screen.height / 2) - (h / 2);
const popup = window.open(
`${puter.defaultGUIOrigin }/?embedded_in_popup=true&request_auth=true${ window.crossOriginIsolated ? '&cross_origin_isolated=true' : ''}`,
title,
`toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${ w }, height=${ h }, top=${ top }, left=${ left}`,
);
return popup;
} catch (e) {
console.error('Failed to open popup:', e);
return null;
}
};
this.attachShadow({ mode: 'open' });
let h;
@@ -496,10 +457,31 @@ class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall b
}
/**
* Returns the URL to open in the auth popup. In launcher mode this is the
* caller-supplied URL; otherwise it is the default implicit-auth URL.
* @returns {string}
*/
#popupURL () {
if ( this.options.popupURL ) {
return this.options.popupURL;
}
return `${puter.defaultGUIOrigin}/?embedded_in_popup=true&request_auth=true&msg_id=${this.#messageID}${window.crossOriginIsolated ? '&cross_origin_isolated=true' : ''}`;
}
// Optional: Handle dialog cancellation as rejection
cancelListener = () => {
this.close();
window.removeEventListener('message', this.messageListener);
// Launcher mode: the caller owns the auth promise and any state.
if ( this.options.popupURL ) {
if ( typeof this.options.onCancel === 'function' ) {
this.options.onCancel();
}
return;
}
puter.puterAuthState.authGranted = false;
puter.puterAuthState.isPromptOpen = false;
@@ -515,22 +497,26 @@ class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall b
};
connectedCallback () {
// Add event listener to the button
// Wire the "Continue" button to open the auth popup. Opening here is
// safe from being popup-blocked because it happens inside a click.
this.shadowRoot.querySelector('#launch-auth-popup')?.addEventListener('click', () => {
let w = 600;
let h = 700;
let title = 'Puter';
var left = (screen.width / 2) - (w / 2);
var top = (screen.height / 2) - (h / 2);
window.open(
`${puter.defaultGUIOrigin }/?embedded_in_popup=true&request_auth=true&msg_id=${this.#messageID}${ window.crossOriginIsolated ? '&cross_origin_isolated=true' : ''}`,
title,
`toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${ w }, height=${ h }, top=${ top }, left=${ left}`,
);
const popup = openAuthPopup(this.#popupURL());
// Launcher mode: hand the popup back to the caller and close the
// consent dialog — its only job was to provide the user gesture.
if ( this.options.popupURL ) {
if ( typeof this.options.onLaunch === 'function' ) {
this.options.onLaunch(popup);
}
this.close();
}
});
// Add the event listener to the window object
window.addEventListener('message', this.messageListener);
// The implicit-auth flow listens for the token message from the popup.
// In launcher mode the caller registers its own message handler.
if ( ! this.options.popupURL ) {
window.addEventListener('message', this.messageListener);
}
// Add event listeners for cancel and close buttons
this.shadowRoot.querySelector('#launch-auth-popup-cancel')?.addEventListener('click', this.cancelListener);
@@ -538,17 +524,11 @@ class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall b
}
open () {
if ( this.hasUserActivation() ) {
let w = 600;
let h = 700;
let title = 'Puter';
var left = (screen.width / 2) - (w / 2);
var top = (screen.height / 2) - (h / 2);
window.open(
`${puter.defaultGUIOrigin }/?embedded_in_popup=true&request_auth=true&msg_id=${this.#messageID}${ window.crossOriginIsolated ? '&cross_origin_isolated=true' : ''}`,
title,
`toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${ w }, height=${ h }, top=${ top }, left=${ left}`,
);
if ( hasUserActivation() ) {
const popup = openAuthPopup(this.#popupURL());
if ( this.options.popupURL && typeof this.options.onLaunch === 'function' ) {
this.options.onLaunch(popup);
}
}
else {
this.shadowRoot.querySelector('dialog').showModal();