Skip to content

Commit 2275f65

Browse files
committed
Centralize auth popup handling and consent dialog
1 parent 71b7d63 commit 2275f65

3 files changed

Lines changed: 213 additions & 122 deletions

File tree

src/puter-js/src/lib/auth-popup.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Shared helpers for opening Puter authentication popup windows.
3+
*
4+
* Browsers only allow `window.open()` to spawn a real popup (instead of
5+
* silently blocking it) while the document has user activation — i.e.
6+
* during or shortly after a user gesture such as a click. Every Puter auth
7+
* flow needs the same activation check and the same popup geometry, so this
8+
* module is the single source of truth for both.
9+
*
10+
* Keeping this in one place is what prevents the flows from drifting apart:
11+
* previously the implicit-auth flow had an activation check + consent-dialog
12+
* fallback while `puter.auth.signIn()` opened the popup unconditionally and
13+
* got popup-blocked when called without a user gesture.
14+
*/
15+
16+
// Auth popup window dimensions.
17+
const POPUP_WIDTH = 600;
18+
const POPUP_HEIGHT = 700;
19+
20+
/**
21+
* Detects whether the document currently has user activation, which the
22+
* browser requires in order to open a popup without blocking it.
23+
*
24+
* @returns {boolean} True if a popup can be opened right now.
25+
*/
26+
export const hasUserActivation = () => {
27+
// Modern browsers expose the User Activation API.
28+
if ( navigator.userActivation ) {
29+
return navigator.userActivation.hasBeenActive && navigator.userActivation.isActive;
30+
}
31+
32+
// Fallback for browsers without the API: probe by attempting to open a
33+
// tiny off-screen popup. If it succeeds, a user gesture is active; close
34+
// it immediately. This is hacky, but it is the only signal available.
35+
try {
36+
const testPopup = window.open('', '_blank', 'width=1,height=1,left=-1000,top=-1000');
37+
if ( testPopup ) {
38+
testPopup.close();
39+
return true;
40+
}
41+
return false;
42+
} catch (e) {
43+
return false;
44+
}
45+
};
46+
47+
/**
48+
* Opens a centered Puter authentication popup window.
49+
*
50+
* This must be called synchronously from within a user gesture (e.g. a click
51+
* handler), or the browser will block the popup. Callers must gate any
52+
* non-gesture invocation behind `hasUserActivation()` and fall back to a
53+
* consent dialog (which collects a gesture) when there is no activation.
54+
*
55+
* @param {string} url - The full URL (including query string) to load.
56+
* @param {string} [title='Puter'] - The popup window name.
57+
* @returns {Window|null} The popup window, or null if the browser blocked it.
58+
*/
59+
export const openAuthPopup = (url, title = 'Puter') => {
60+
const left = (screen.width / 2) - (POPUP_WIDTH / 2);
61+
const top = (screen.height / 2) - (POPUP_HEIGHT / 2);
62+
return window.open(
63+
url,
64+
title,
65+
`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}`,
66+
);
67+
};

src/puter-js/src/modules/Auth.js

Lines changed: 85 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import * as utils from '../lib/utils.js';
2+
import PuterDialog from './PuterDialog.js';
3+
import { hasUserActivation, openAuthPopup } from '../lib/auth-popup.js';
24

35
class Auth {
46
// Used to generate a unique message id for each message sent to the host environment
@@ -46,55 +48,97 @@ class Auth {
4648
options = options || {};
4749

4850
return new Promise((resolve, reject) => {
49-
let msg_id = this.#messageID++;
50-
let w = 600;
51-
let h = 700;
52-
let title = 'Puter';
53-
var left = (screen.width / 2) - (w / 2);
54-
var top = (screen.height / 2) - (h / 2);
55-
56-
// Store reference to the popup window
57-
const popup = window.open(
58-
`${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' : ''}`,
59-
title,
60-
`toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${w}, height=${h}, top=${top}, left=${left}`,
61-
);
62-
63-
// Set up interval to check if popup was closed
64-
const checkClosed = setInterval(() => {
65-
if ( popup.closed ) {
51+
const msg_id = this.#messageID++;
52+
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' : ''}`;
53+
54+
// Guards against settling the promise more than once across the
55+
// message, popup-closed, and dialog-cancel code paths.
56+
let settled = false;
57+
// Interval id for polling whether the user closed the popup.
58+
let checkClosed = null;
59+
60+
const cleanup = () => {
61+
if ( checkClosed ) {
6662
clearInterval(checkClosed);
67-
// Remove the message listener
68-
window.removeEventListener('message', messageHandler);
69-
reject({ error: 'auth_window_closed', msg: 'Authentication window was closed by the user without completing the process.' });
63+
checkClosed = null;
7064
}
71-
}, 100);
65+
window.removeEventListener('message', messageHandler);
66+
};
7267

7368
function messageHandler (e) {
74-
if ( e.data.msg_id == msg_id ) {
75-
// Clear the interval since we got a response
76-
clearInterval(checkClosed);
77-
78-
// remove redundant attributes
79-
delete e.data.msg_id;
80-
delete e.data.msg;
81-
82-
if ( e.data.success ) {
83-
// set the auth token
84-
puter.setAuthToken(e.data.token);
69+
if ( e.data?.msg_id != msg_id ) {
70+
return;
71+
}
72+
if ( settled ) {
73+
return;
74+
}
75+
settled = true;
76+
cleanup();
77+
78+
// remove redundant attributes
79+
delete e.data.msg_id;
80+
delete e.data.msg;
81+
82+
if ( e.data.success ) {
83+
// set the auth token
84+
puter.setAuthToken(e.data.token);
85+
resolve(e.data);
86+
} else {
87+
reject(e.data);
88+
}
89+
}
90+
window.addEventListener('message', messageHandler);
8591

86-
resolve(e.data);
87-
} else
88-
{
89-
reject(e.data);
92+
// Once the popup exists, watch for the user closing it without
93+
// completing sign-in. `popup` is null if the browser blocked it.
94+
const watchPopup = (popup) => {
95+
if ( settled ) {
96+
return;
97+
}
98+
if ( ! popup ) {
99+
settled = true;
100+
cleanup();
101+
reject({ error: 'popup_blocked', msg: 'The sign-in popup was blocked by the browser.' });
102+
return;
103+
}
104+
checkClosed = setInterval(() => {
105+
if ( ! popup.closed ) {
106+
return;
107+
}
108+
clearInterval(checkClosed);
109+
checkClosed = null;
110+
if ( settled ) {
111+
return;
90112
}
113+
settled = true;
114+
cleanup();
115+
reject({ error: 'auth_window_closed', msg: 'Authentication window was closed by the user without completing the process.' });
116+
}, 100);
117+
};
91118

92-
// delete the listener
93-
window.removeEventListener('message', messageHandler);
94-
}
119+
if ( hasUserActivation() ) {
120+
// A user gesture is active — open the popup immediately.
121+
watchPopup(openAuthPopup(url));
122+
} else {
123+
// No user gesture: a popup opened now would be blocked by the
124+
// browser. Show a consent dialog first; the popup is then
125+
// opened from the user's click on that dialog, which provides
126+
// the gesture the browser requires.
127+
const dialog = new PuterDialog(() => {}, () => {}, {
128+
popupURL: url,
129+
onLaunch: (popup) => watchPopup(popup),
130+
onCancel: () => {
131+
if ( settled ) {
132+
return;
133+
}
134+
settled = true;
135+
cleanup();
136+
reject({ error: 'auth_window_closed', msg: 'Authentication window was closed by the user without completing the process.' });
137+
},
138+
});
139+
document.body.appendChild(dialog);
140+
dialog.open();
95141
}
96-
97-
window.addEventListener('message', messageHandler);
98142
});
99143
};
100144

src/puter-js/src/modules/PuterDialog.js

Lines changed: 61 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,33 @@
1+
import { hasUserActivation, openAuthPopup } from '../lib/auth-popup.js';
2+
13
class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall back to only extending Object in environments without a DOM
24
// Similar to `#messageID` in Auth.js. We start at an arbitrary high number to avoid
35
// collisions.
46
static messageID = Math.floor(Number.MAX_SAFE_INTEGER / 2);
57

6-
/**
7-
* Detects if the current page is loaded using the file:// protocol.
8-
* @returns {boolean} True if using file:// protocol, false otherwise.
9-
*/
10-
isUsingFileProtocol = () => {
11-
return window.location.protocol === 'file:';
12-
};
13-
148
#messageID;
159

16-
constructor (resolve, reject) {
10+
/**
11+
* @param {Function} resolve - Resolves the implicit-auth promise.
12+
* @param {Function} reject - Rejects the implicit-auth promise.
13+
* @param {Object} [options] - Optional configuration.
14+
* @param {string} [options.popupURL] - When set, the dialog acts as a
15+
* generic popup launcher: it opens this URL (instead of the default
16+
* implicit-auth URL), skips the `puter.token` message handling and
17+
* `puterAuthState` bookkeeping (the caller owns the auth result), and
18+
* reports cancellation through `options.onCancel`.
19+
* @param {Function} [options.onLaunch] - Called with the opened popup
20+
* window (or null if the browser blocked it) right after launch.
21+
* @param {Function} [options.onCancel] - Called when the user dismisses
22+
* the dialog without completing authentication.
23+
*/
24+
constructor (resolve, reject, options = {}) {
1725
super();
1826
this.reject = reject;
1927
this.resolve = resolve;
20-
this.popupLaunched = false; // Track if popup was successfully launched
28+
this.options = options;
2129
this.#messageID = this.constructor.messageID++;
2230

23-
/**
24-
* Detects if there's a recent user activation that would allow popup opening
25-
* @returns {boolean} True if user activation is available, false otherwise.
26-
*/
27-
this.hasUserActivation = () => {
28-
// Modern browsers support navigator.userActivation
29-
if ( navigator.userActivation ) {
30-
return navigator.userActivation.hasBeenActive && navigator.userActivation.isActive;
31-
}
32-
33-
// Fallback: try to detect user activation by attempting to open a popup
34-
// This is a bit hacky but works as a fallback
35-
try {
36-
const testPopup = window.open('', '_blank', 'width=1,height=1,left=-1000,top=-1000');
37-
if ( testPopup ) {
38-
testPopup.close();
39-
return true;
40-
}
41-
return false;
42-
} catch (e) {
43-
return false;
44-
}
45-
};
46-
47-
/**
48-
* Launches the authentication popup window
49-
* @returns {Window|null} The popup window reference or null if failed
50-
*/
51-
this.launchPopup = () => {
52-
try {
53-
let w = 600;
54-
let h = 700;
55-
let title = 'Puter';
56-
var left = (screen.width / 2) - (w / 2);
57-
var top = (screen.height / 2) - (h / 2);
58-
const popup = window.open(
59-
`${puter.defaultGUIOrigin }/?embedded_in_popup=true&request_auth=true${ window.crossOriginIsolated ? '&cross_origin_isolated=true' : ''}`,
60-
title,
61-
`toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${ w }, height=${ h }, top=${ top }, left=${ left}`,
62-
);
63-
return popup;
64-
} catch (e) {
65-
console.error('Failed to open popup:', e);
66-
return null;
67-
}
68-
};
69-
7031
this.attachShadow({ mode: 'open' });
7132

7233
let h;
@@ -496,10 +457,31 @@ class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall b
496457

497458
}
498459

460+
/**
461+
* Returns the URL to open in the auth popup. In launcher mode this is the
462+
* caller-supplied URL; otherwise it is the default implicit-auth URL.
463+
* @returns {string}
464+
*/
465+
#popupURL () {
466+
if ( this.options.popupURL ) {
467+
return this.options.popupURL;
468+
}
469+
return `${puter.defaultGUIOrigin}/?embedded_in_popup=true&request_auth=true&msg_id=${this.#messageID}${window.crossOriginIsolated ? '&cross_origin_isolated=true' : ''}`;
470+
}
471+
499472
// Optional: Handle dialog cancellation as rejection
500473
cancelListener = () => {
501474
this.close();
502475
window.removeEventListener('message', this.messageListener);
476+
477+
// Launcher mode: the caller owns the auth promise and any state.
478+
if ( this.options.popupURL ) {
479+
if ( typeof this.options.onCancel === 'function' ) {
480+
this.options.onCancel();
481+
}
482+
return;
483+
}
484+
503485
puter.puterAuthState.authGranted = false;
504486
puter.puterAuthState.isPromptOpen = false;
505487

@@ -515,40 +497,38 @@ class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall b
515497
};
516498

517499
connectedCallback () {
518-
// Add event listener to the button
500+
// Wire the "Continue" button to open the auth popup. Opening here is
501+
// safe from being popup-blocked because it happens inside a click.
519502
this.shadowRoot.querySelector('#launch-auth-popup')?.addEventListener('click', () => {
520-
let w = 600;
521-
let h = 700;
522-
let title = 'Puter';
523-
var left = (screen.width / 2) - (w / 2);
524-
var top = (screen.height / 2) - (h / 2);
525-
window.open(
526-
`${puter.defaultGUIOrigin }/?embedded_in_popup=true&request_auth=true&msg_id=${this.#messageID}${ window.crossOriginIsolated ? '&cross_origin_isolated=true' : ''}`,
527-
title,
528-
`toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${ w }, height=${ h }, top=${ top }, left=${ left}`,
529-
);
503+
const popup = openAuthPopup(this.#popupURL());
504+
505+
// Launcher mode: hand the popup back to the caller and close the
506+
// consent dialog — its only job was to provide the user gesture.
507+
if ( this.options.popupURL ) {
508+
if ( typeof this.options.onLaunch === 'function' ) {
509+
this.options.onLaunch(popup);
510+
}
511+
this.close();
512+
}
530513
});
531514

532-
// Add the event listener to the window object
533-
window.addEventListener('message', this.messageListener);
515+
// The implicit-auth flow listens for the token message from the popup.
516+
// In launcher mode the caller registers its own message handler.
517+
if ( ! this.options.popupURL ) {
518+
window.addEventListener('message', this.messageListener);
519+
}
534520

535521
// Add event listeners for cancel and close buttons
536522
this.shadowRoot.querySelector('#launch-auth-popup-cancel')?.addEventListener('click', this.cancelListener);
537523
this.shadowRoot.querySelector('.close-btn')?.addEventListener('click', this.cancelListener);
538524
}
539525

540526
open () {
541-
if ( this.hasUserActivation() ) {
542-
let w = 600;
543-
let h = 700;
544-
let title = 'Puter';
545-
var left = (screen.width / 2) - (w / 2);
546-
var top = (screen.height / 2) - (h / 2);
547-
window.open(
548-
`${puter.defaultGUIOrigin }/?embedded_in_popup=true&request_auth=true&msg_id=${this.#messageID}${ window.crossOriginIsolated ? '&cross_origin_isolated=true' : ''}`,
549-
title,
550-
`toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${ w }, height=${ h }, top=${ top }, left=${ left}`,
551-
);
527+
if ( hasUserActivation() ) {
528+
const popup = openAuthPopup(this.#popupURL());
529+
if ( this.options.popupURL && typeof this.options.onLaunch === 'function' ) {
530+
this.options.onLaunch(popup);
531+
}
552532
}
553533
else {
554534
this.shadowRoot.querySelector('dialog').showModal();

0 commit comments

Comments
 (0)