mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-29 21:01:27 +00:00
Centralize auth popup handling and consent dialog
This commit is contained in:
@@ -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}`,
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user