1 const { SUCCESS_PAGE_MESSAGE } = require( './constants.js' );
2 const AuthMessageDialog = require( './AuthMessageDialog.js' );
3 const AuthPopupError = require( './AuthPopupError.js' );
6 * Open a browser window with the same position and dimensions on the user's screen as the given DOM
11 * @param {HTMLElement} el
12 * @param {Event} mouseEvent
13 * @return {Window|null}
15 function openBrowserWindowCoveringElement( url, el, mouseEvent ) {
17 // * Windows 10 22H2, Firefox and Edge, 100% and 200% scale screens, -/=/+ zoom
19 // * Windows 10 22H2, Firefox and Edge, 150% scale screen, -/=/+ zoom (another device, tablet)
21 // - On Edge, when using the touch screen, we don't get a mouse event, so the popup is off.
22 // * Ubuntu 22.04, Firefox and Chromium, 100% scale screen, -/=/+ zoom
24 // - On Firefox, when zoomed in, popup window size is slightly off.
25 // * (I couldn't get OS scaling to work on Ubuntu, it bricked my VM when enabled.)
27 function getWindowDimensions( conversionRatio ) {
28 // Find the position of the viewport (not just the browser window) on the screen, accounting for
29 // browser toolbars and sidebars.
30 // Workaround for a spec deficiency: https://github.com/w3c/csswg-drafts/issues/809
33 if ( window.mozInnerScreenX !== undefined && window.mozInnerScreenY !== undefined ) {
34 // Use Firefox's non-standard property designed for this use case.
35 innerScreenX = window.mozInnerScreenX;
36 innerScreenY = window.mozInnerScreenY;
37 } else if ( mouseEvent && mouseEvent.clientX && mouseEvent.screenX && mouseEvent.clientY && mouseEvent.screenY ) {
38 // Obtain the difference from a mouse event, if we got one (and it isn't a simulated event).
39 // This is seemingly the only thing in all of web APIs that relates the two positions.
40 // https://github.com/w3c/csswg-drafts/issues/809#issuecomment-2134169650
41 innerScreenX = mouseEvent.screenX / conversionRatio - mouseEvent.clientX;
42 innerScreenY = mouseEvent.screenY / conversionRatio - mouseEvent.clientY;
44 // Fall back to the position of the browser window.
45 // It will be off by an unpredictable amount, depending on browser toolbars and sidebars
46 // (e.g. if you have dev tools open and pinned on the left, it will be way off).
47 innerScreenX = window.screenX;
48 innerScreenY = window.screenY;
52 width: el.offsetWidth * conversionRatio,
53 height: el.offsetHeight * conversionRatio,
54 left: ( innerScreenX + el.offsetLeft ) * conversionRatio,
55 top: ( innerScreenY + el.offsetTop ) * conversionRatio
59 // Calculate the dimensions of the window assuming that all the APIs measure things in CSS pixels,
60 // as they should per the draft CSSOM View spec: https://drafts.csswg.org/cssom-view/
61 // If the assumption is right, we can avoid moving/resizing the window later, which looks ugly.
62 const cssPixelsRect = getWindowDimensions( 1.0 );
64 // Add a bit of padding to ensure the popup window covers the backdrop dialog,
65 // even if the OS chrome has rounded corners or includes semi-transparent shadows.
68 // window.open() sometimes "adjusts" the given dimensions far more than it's reasonable.
69 // We will re-apply them later using window.resizeTo()/moveTo(), which respect them a bit more.
70 const w = window.open( 'about:blank', '_blank', [
72 'width=' + ( cssPixelsRect.width + 2 * padding ),
73 'height=' + ( cssPixelsRect.height + 2 * padding ),
74 'left=' + ( cssPixelsRect.left - padding ),
75 'top=' + ( cssPixelsRect.top - padding )
81 function applyWindowDimensions( rect ) {
82 w.resizeTo( rect.width + 2 * padding, rect.height + 2 * padding );
83 w.moveTo( rect.left - padding, rect.top - padding );
87 // Once we have the window open, we can try to handle browsers that don't implement the spec yet,
88 // and measure things in device pixels. For example, Chrome: https://crbug.com/343009010
91 // On Firefox window.open() *really* doesn't respect the given dimensions, so recalculate
92 // them using this method even though they're ostensibly correct.
94 // Key assumption here is that the new about:blank window usually doesn't have any zoom applied.
96 // * Outside the popup window, we can use its devicePixelRatio to calculate the browser zoom
97 // ratio, allowing us to convert CSS pixels to device pixels. We couldn't just use
98 // window.devicePixelRatio, because it combines OS scaling ratio and browser zoom ratio.
99 // * Inside the popup window, CSS pixels and device pixels are equivalent, so the result is
100 // correct regardless of whether the browser follows the new spec or the legacy behavior.
102 // Read devicePixelRatio from the popup window to get just the OS scaling ratio. Then cancel it
103 // out from the main window's devicePixelRatio, leaving just the browser zoom ratio.
104 const browserZoomRatio = window.devicePixelRatio / w.devicePixelRatio;
106 // Recalculate the dimensions of the window, converting the result to device pixels.
107 const devicePixelsRect = getWindowDimensions( browserZoomRatio );
110 // On Firefox, window.moveTo()/resizeTo() are async (https://bugzilla.mozilla.org/1899178).
111 // Because of that, sometimes an attempt to move and resize at the same time will result in
112 // incorrect position or size, because when it attempts to fit the window to screen dimensions,
113 // and does so using outdated values. Try to move/resize again after the first resize happens.
114 // However, don't do it after the new page has loaded, because it will set wrong dimensions if
115 // browser zoom is active.
116 const retryApplyWindowDimensions = () => {
118 if ( w.location.href === 'about:blank' ) {
119 applyWindowDimensions( devicePixelsRect );
121 w.removeEventListener( 'resize', retryApplyWindowDimensions );
124 w.removeEventListener( 'resize', retryApplyWindowDimensions );
127 w.addEventListener( 'resize', retryApplyWindowDimensions );
129 // Apply the size again, using the new dimensions.
130 applyWindowDimensions( devicePixelsRect );
132 // Actually navigate the window away from about:blank once we're done calculating its position.
139 * Check if we're probably running on iOS, which has unusual restrictions on popup windows.
145 return /ipad|iphone|ipod/i.test( navigator.userAgent );
150 * Allows opening the login form without leaving the page.
152 * The page opened in the popup should communicate success using the authSuccess.js script. If it
153 * doesn't, we also check for a login success when the user interacts with the parent window.
155 * The constructor is not publicly accessible in MediaWiki. Use the instance exposed by the
156 * {@link module:mediawiki.authenticationPopup mediawiki.authenticationPopup} module.
158 * **This library is not stable yet (as of May 2024). We're still testing which of the
159 * methods work from the technical side, and which methods are understandable for users.
160 * Some methods or the whole library may be removed in the future.**
169 * Async function to check for a login success.
171 * @callback AuthPopup~CheckLoggedIn
172 * @return {Promise<any>} A promise resolved with a truthy value if the user is
173 * logged in and resolved with a falsy value if the user isn’t logged in.
177 * @param {Object} config
178 * @param {string} config.loginPopupUrl URL of the login form to be opened as a popup
179 * @param {string} [config.loginFallbackUrl] URL of a fallback login form to link to if the popup
180 * can't be opened. Defaults to `loginPopupUrl` if not provided.
181 * @param {AuthPopup~CheckLoggedIn} config.checkLoggedIn Async function to check for a login success.
182 * @param {jQuery|string|Function|null} [config.message] Custom message to replace the contents of
183 * the backdrop message dialog, passed to {@link OO.ui.MessageDialog}
185 constructor( config ) {
186 this.loginPopupUrl = config.loginPopupUrl;
187 this.loginFallbackUrl = config.loginFallbackUrl || config.loginPopupUrl;
188 this.checkLoggedIn = config.checkLoggedIn;
189 this.message = config.message || ( () => {
190 const message = document.createElement( 'div' );
192 const intro = document.createElement( 'p' );
193 intro.innerText = OO.ui.msg( 'userlogin-authpopup-loggingin-body' );
194 message.appendChild( intro );
196 const fallbackLink = document.createElement( 'a' );
197 fallbackLink.setAttribute( 'target', '_blank' );
198 fallbackLink.setAttribute( 'href', this.loginFallbackUrl );
199 fallbackLink.innerText = OO.ui.msg( 'userlogin-authpopup-loggingin-body-link' );
200 const fallback = document.createElement( 'p' );
201 fallback.appendChild( fallbackLink );
202 message.appendChild( fallback );
209 * Open the login form in a small browser popup window.
211 * In the parent window, display a backdrop message dialog with the same dimensions,
212 * to provide an alternative method to log in if the browser refuses to open the window,
213 * and to allow the user to restart the process if they lose track of the popup window.
215 * This should only be called in response to a user-initiated event like 'click',
216 * otherwise the user's browser will always refuse to open the window.
218 * @return {Promise<any>} Resolved when the login succeeds with the value returned by the
219 * `checkLoggedIn` callback. Resolved with a falsy value if the user cancels the process.
220 * Rejected when an unexpected error stops the login process.
223 // Obtain a mouse event, which we need to calculate where the current browser window appears
224 // on the user's screen. (No joke.) 'mouseenter' event should be fired when the dialog opens.
227 return this.showDialog( {
228 initOpenWindow: ( m ) => {
229 m.$element.one( 'mouseenter', ( e ) => {
232 m.$element.on( 'mousemove', ( e ) => {
237 // iOS Safari only allows window.open() when it occurs immediately in response to a
238 // user-initiated event like 'click', not async, not respecting the HTML5 user activation
239 // rules. Therefore we must open the window right here, and we can't wait for the message to
240 // be displayed by the code below. On the other hand, the opened window will always be
241 // fullscreen anyway even if we were to ask for a popup, so it's not a big deal.
242 return window.open( this.loginPopupUrl, '_blank' );
247 openWindow: ( m ) => {
248 const frame = m.$frame[ 0 ];
249 return openBrowserWindowCoveringElement( this.loginPopupUrl, frame, mouseEvent );
253 title: OO.ui.deferMsg( 'userlogin-authpopup-loggingin-title' ),
254 message: this.message
260 * Open the login form in a new browser tab or window.
262 * In the parent window, display a backdrop message dialog,
263 * to provide an alternative method to log in if the browser refuses to open the window,
264 * and to allow the user to restart the process if they lose track of the new tab or window.
266 * This should only be called in response to a user-initiated event like 'click',
267 * otherwise the user's browser will always refuse to open the window.
269 * @return {Promise<any>} Resolved when the login succeeds with the value returned by the
270 * `checkLoggedIn` callback. Resolved with a falsy value if the user cancels the process.
271 * Rejected when an unexpected error stops the login process.
273 startNewTabOrWindow() {
274 const openWindow = () => window.open( this.loginPopupUrl, '_blank' );
276 return this.showDialog( {
277 initOpenWindow: openWindow,
279 openWindow: openWindow,
282 title: OO.ui.deferMsg( 'userlogin-authpopup-loggingin-title' ),
283 message: this.message
289 * Open the login form in an iframe in a modal message dialog.
291 * In order for this to work, the wiki must be configured to allow the login page to be framed
292 * ($wgEditPageFrameOptions), which has security implications.
294 * Add a button to provide an alternative method to log in, just in case.
296 * @return {Promise<any>} Resolved when the login succeeds with the value returned by the
297 * `checkLoggedIn` callback. Resolved with a falsy value if the user cancels the process.
298 * Rejected when an unexpected error stops the login process.
301 const $iframe = $( '<iframe>' )
302 .attr( 'src', this.loginPopupUrl )
310 return this.showDialog( {
311 initOpenWindow: () => {},
313 openWindow: ( m ) => {
314 // We can't pass it as .data.message, because that has wrappers that mess up the styles
315 m.$body.empty().append( $iframe );
316 // Allow default click handling on the fallback link-action (eww)
317 m.actions.get( { actions: 'fallback' } )[ 0 ].off( 'click' );
325 href: this.loginFallbackUrl,
327 label: OO.ui.deferMsg( 'userlogin-authpopup-loggingin-body-link' ),
330 AuthMessageDialog.static.actions.filter( ( a ) => a.action === 'cancel' )
337 * Open the backdrop dialog for a customizable popup window.
339 * Caller must provide callback functions that open their popup window, and/or provide the dialog
340 * opening data to display something in the dialog.
343 * @param {Object} config
344 * @param {Function} config.initOpenWindow Called before opening the dialog
345 * @param {Function} config.openWindow Called after opening the dialog and upon user retry
346 * @param {Object} config.data Opening data for the MessageDialog
347 * @return {Promise<any>} Resolved when the login succeeds with the value returned by the
348 * `checkLoggedIn` callback. Resolved with a falsy value if the user cancels the process.
349 * Rejected when an unexpected error stops the login process.
351 showDialog( config ) {
352 const { initOpenWindow, openWindow, data } = config;
354 // Display a message in the current browser window, so that if the popup window doesn't open,
355 // or if the user loses it on their desktop somehow, they can still see what was supposed to happen,
356 // and have a way to retry or cancel it. This message stays open throughout the process.
357 const windowManager = new OO.ui.WindowManager();
358 $( OO.ui.getTeleportTarget() ).append( windowManager.$element );
359 const m = new AuthMessageDialog();
360 windowManager.addWindows( { authMessageDialog: m } );
362 let w = initOpenWindow( m );
364 return new Promise( ( resolve, reject ) => {
365 const instance = windowManager.openWindow( 'authMessageDialog', data );
367 instance.opened.then( () => {
368 // Open a browser window covering the message we displayed.
373 // When the fallback link is clicked, opening the login form in a fullscreen window,
374 // close the popup window.
375 m.$body.find( 'a' ).on( 'click', () => {
381 m.on( 'retry', () => {
387 m.on( 'cancel', () => {
395 // Close orphaned browser windows on the user's desktop if they leave/close the page.
396 const onBeforeUnload = () => {
401 window.addEventListener( 'beforeunload', onBeforeUnload );
402 instance.closed.then( () => window.removeEventListener( 'beforeunload', onBeforeUnload ) );
404 // If the user leaves this window and then comes back, check if they have logged in
405 // the old-fashioned way in the meantime.
406 const onFocus = () => {
407 this.checkLoggedIn().then( ( loggedIn ) => {
417 window.addEventListener( 'focus', onFocus );
418 instance.closed.then( () => window.removeEventListener( 'focus', onFocus ) );
420 // Wait for a message from authSuccess.js.
421 // Beware that it may never come if the initial popup was blocked,
422 // in which case we rely on checking in the 'focus' event.
423 const onMessage = ( event ) => {
424 if ( event.origin !== window.origin ) {
427 if ( event.data !== SUCCESS_PAGE_MESSAGE ) {
435 // Okay, they went through the workflow. Confirm that they're logged in from our perspective,
436 // because browsers are weird about cookies and they're also weird about popups.
437 this.checkLoggedIn().then( ( loggedIn ) => {
443 // If they're not logged in, despite (presumably) providing correct credentials
444 // and reaching the success page, something is pretty wrong. It could be a
445 // server-side problem, or maybe the user's browser must be doing something funky.
446 // It's definitely unexpected and should be logged as an error.
447 reject( new AuthPopupError( 'Expected a successful login at this point' ) );
451 window.addEventListener( 'message', onMessage );
452 instance.closed.then( () => window.removeEventListener( 'message', onMessage ) );
459 module.exports = AuthPopup;