Localisation updates from https://translatewiki.net.
[mediawiki.git] / resources / src / mediawiki.authenticationPopup / AuthPopup.js
blob8760c0e43b6bdf91106cf9cea2a14d6c6f785e89
1 const { SUCCESS_PAGE_MESSAGE } = require( './constants.js' );
2 const AuthMessageDialog = require( './AuthMessageDialog.js' );
3 const AuthPopupError = require( './AuthPopupError.js' );
5 /**
6  * Open a browser window with the same position and dimensions on the user's screen as the given DOM
7  * element.
8  *
9  * @private
10  * @param {string} url
11  * @param {HTMLElement} el
12  * @param {Event} mouseEvent
13  * @return {Window|null}
14  */
15 function openBrowserWindowCoveringElement( url, el, mouseEvent ) {
16         // Tested on:
17         // * Windows 10 22H2, Firefox and Edge, 100% and 200% scale screens, -/=/+ zoom
18         //   All good.
19         // * Windows 10 22H2, Firefox and Edge, 150% scale screen, -/=/+ zoom (another device, tablet)
20         //   Okay, except:
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
23         //   Okay, except:
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
31                 let innerScreenX;
32                 let innerScreenY;
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;
43                 } else {
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;
49                 }
51                 return {
52                         width: el.offsetWidth * conversionRatio,
53                         height: el.offsetHeight * conversionRatio,
54                         left: ( innerScreenX + el.offsetLeft ) * conversionRatio,
55                         top: ( innerScreenY + el.offsetTop ) * conversionRatio
56                 };
57         }
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.
66         const padding = 10;
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', [
71                 'popup',
72                 'width=' + ( cssPixelsRect.width + 2 * padding ),
73                 'height=' + ( cssPixelsRect.height + 2 * padding ),
74                 'left=' + ( cssPixelsRect.left - padding ),
75                 'top=' + ( cssPixelsRect.top - padding )
76         ].join( ',' ) );
77         if ( !w ) {
78                 return null;
79         }
81         function applyWindowDimensions( rect ) {
82                 w.resizeTo( rect.width + 2 * padding, rect.height + 2 * padding );
83                 w.moveTo( rect.left - padding, rect.top - padding );
84         }
86         // Support: Chrome
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
89         //
90         // Support: Firefox
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.
93         //
94         // Key assumption here is that the new about:blank window usually doesn't have any zoom applied.
95         // Therefore:
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 );
109         // Support: Firefox
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 = () => {
117                 try {
118                         if ( w.location.href === 'about:blank' ) {
119                                 applyWindowDimensions( devicePixelsRect );
120                         } else {
121                                 w.removeEventListener( 'resize', retryApplyWindowDimensions );
122                         }
123                 } catch ( err ) {
124                         w.removeEventListener( 'resize', retryApplyWindowDimensions );
125                 }
126         };
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.
133         w.location = url;
135         return w;
139  * Check if we're probably running on iOS, which has unusual restrictions on popup windows.
141  * @private
142  * @return {boolean}
143  */
144 function isIos() {
145         return /ipad|iphone|ipod/i.test( navigator.userAgent );
149  * @classdesc
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.**
162  * Unstable.
164  * @internal
165  * @class
166  */
167 class AuthPopup {
168         /**
169          * Async function to check for a login success.
170          *
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.
174          */
176         /**
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}
184          */
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 );
204                         return $( message );
205                 } );
206         }
208         /**
209          * Open the login form in a small browser popup window.
210          *
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.
214          *
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.
217          *
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.
221          */
222         startPopupWindow() {
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.
225                 let mouseEvent;
227                 return this.showDialog( {
228                         initOpenWindow: ( m ) => {
229                                 m.$element.one( 'mouseenter', ( e ) => {
230                                         mouseEvent = e;
231                                 } );
232                                 m.$element.on( 'mousemove', ( e ) => {
233                                         mouseEvent = e;
234                                 } );
236                                 if ( isIos() ) {
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' );
243                                 }
244                                 return null;
245                         },
247                         openWindow: ( m ) => {
248                                 const frame = m.$frame[ 0 ];
249                                 return openBrowserWindowCoveringElement( this.loginPopupUrl, frame, mouseEvent );
250                         },
252                         data: {
253                                 title: OO.ui.deferMsg( 'userlogin-authpopup-loggingin-title' ),
254                                 message: this.message
255                         }
256                 } );
257         }
259         /**
260          * Open the login form in a new browser tab or window.
261          *
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.
265          *
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.
268          *
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.
272          */
273         startNewTabOrWindow() {
274                 const openWindow = () => window.open( this.loginPopupUrl, '_blank' );
276                 return this.showDialog( {
277                         initOpenWindow: openWindow,
279                         openWindow: openWindow,
281                         data: {
282                                 title: OO.ui.deferMsg( 'userlogin-authpopup-loggingin-title' ),
283                                 message: this.message
284                         }
285                 } );
286         }
288         /**
289          * Open the login form in an iframe in a modal message dialog.
290          *
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.
293          *
294          * Add a button to provide an alternative method to log in, just in case.
295          *
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.
299          */
300         startIframe() {
301                 const $iframe = $( '<iframe>' )
302                         .attr( 'src', this.loginPopupUrl )
303                         .css( {
304                                 border: '0',
305                                 display: 'block',
306                                 width: '100%',
307                                 height: '100%'
308                         } );
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' );
318                         },
320                         data: {
321                                 title: '',
322                                 message: '',
323                                 actions: [ {
324                                         action: 'fallback',
325                                         href: this.loginFallbackUrl,
326                                         target: '_blank',
327                                         label: OO.ui.deferMsg( 'userlogin-authpopup-loggingin-body-link' ),
328                                         flags: 'safe'
329                                 } ].concat(
330                                         AuthMessageDialog.static.actions.filter( ( a ) => a.action === 'cancel' )
331                                 )
332                         }
333                 } );
334         }
336         /**
337          * Open the backdrop dialog for a customizable popup window.
338          *
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.
341          *
342          * @private
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.
350          */
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.
369                                 if ( !w ) {
370                                         w = openWindow( m );
371                                 }
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', () => {
376                                         if ( w ) {
377                                                 w.close();
378                                         }
379                                 } );
381                                 m.on( 'retry', () => {
382                                         if ( w ) {
383                                                 w.close();
384                                         }
385                                         w = openWindow( m );
386                                 } );
387                                 m.on( 'cancel', () => {
388                                         if ( w ) {
389                                                 w.close();
390                                         }
391                                         m.close();
392                                         resolve( null );
393                                 } );
395                                 // Close orphaned browser windows on the user's desktop if they leave/close the page.
396                                 const onBeforeUnload = () => {
397                                         if ( w ) {
398                                                 w.close();
399                                         }
400                                 };
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 ) => {
408                                                 if ( loggedIn ) {
409                                                         if ( w ) {
410                                                                 w.close();
411                                                         }
412                                                         m.close();
413                                                         resolve( loggedIn );
414                                                 }
415                                         } ).catch( reject );
416                                 };
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 ) {
425                                                 return;
426                                         }
427                                         if ( event.data !== SUCCESS_PAGE_MESSAGE ) {
428                                                 return;
429                                         }
431                                         if ( w ) {
432                                                 w.close();
433                                         }
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 ) => {
438                                                 m.close();
439                                                 if ( loggedIn ) {
440                                                         // Yes!
441                                                         resolve( loggedIn );
442                                                 } else {
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' ) );
448                                                 }
449                                         } ).catch( reject );
450                                 };
451                                 window.addEventListener( 'message', onMessage );
452                                 instance.closed.then( () => window.removeEventListener( 'message', onMessage ) );
453                         } );
454                 } );
455         }
459 module.exports = AuthPopup;