Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.
Revision as of 18:54, 6 May 2026 by Justarandomamerican (talk | contribs) (Create (thanks Claude))
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/**
 * globalUserLock.js
 * A MediaWiki userscript for finding and globally locking accounts
 * whose usernames contain a specified string.
 *
 * Designed for use on Meta-Wiki or any CentralAuth-enabled wiki
 * by stewards or staff with global lock permissions.
 *
 * Usage: Add to your common.js or Special:MyPage/common.js
 *   mw.loader.load('https://your-wiki/path/to/globalUserLock.js');
 *
 * API actions used:
 *   - query + list=globalallusers  (find matching accounts)
 *   - centralauth (lock accounts)
 *   - checktoken / tokens           (CSRF)
 *
 * Author: WikiOasis T&S tooling
 * License: MIT
 */

( function () {
	'use strict';

	// ── Constants ──────────────────────────────────────────────────────────────

	const TOOL_ID      = 'global-user-lock-tool';
	const TOOL_TITLE   = 'Global User Lock Tool';
	const API_ENDPOINT = mw.config.get( 'wgScriptPath' ) + '/api.php';

	// Maximum accounts to process in a single run (safety cap)
	const MAX_ACCOUNTS = 200;

	// ── Styles ─────────────────────────────────────────────────────────────────

	mw.util.addCSS( `
		#${ TOOL_ID } {
			font-family: inherit;
			max-width: 780px;
			margin: 1.5em auto;
			border: 1px solid #a2a9b1;
			border-radius: 3px;
			background: #f8f9fa;
		}
		#${ TOOL_ID } .gul-header {
			background: #36c;
			color: #fff;
			padding: 10px 16px;
			font-size: 1.1em;
			font-weight: bold;
			border-radius: 3px 3px 0 0;
			display: flex;
			align-items: center;
			gap: 8px;
		}
		#${ TOOL_ID } .gul-header svg {
			flex-shrink: 0;
		}
		#${ TOOL_ID } .gul-body {
			padding: 16px;
		}
		#${ TOOL_ID } fieldset {
			border: 1px solid #c8ccd1;
			border-radius: 3px;
			padding: 10px 14px;
			margin: 0 0 14px;
			background: #fff;
		}
		#${ TOOL_ID } legend {
			font-weight: bold;
			padding: 0 4px;
			font-size: 0.95em;
		}
		#${ TOOL_ID } label {
			display: block;
			margin-bottom: 4px;
			font-size: 0.92em;
		}
		#${ TOOL_ID } input[type="text"],
		#${ TOOL_ID } textarea,
		#${ TOOL_ID } select {
			width: 100%;
			box-sizing: border-box;
			padding: 5px 7px;
			border: 1px solid #a2a9b1;
			border-radius: 2px;
			font-size: 0.93em;
			font-family: inherit;
			background: #fff;
		}
		#${ TOOL_ID } textarea {
			resize: vertical;
			min-height: 64px;
		}
		#${ TOOL_ID } .gul-checkbox-row {
			display: flex;
			align-items: center;
			gap: 6px;
			margin-top: 8px;
			font-size: 0.92em;
		}
		#${ TOOL_ID } .gul-checkbox-row input[type="checkbox"] {
			width: auto;
		}
		#${ TOOL_ID } .gul-actions {
			display: flex;
			gap: 8px;
			flex-wrap: wrap;
			margin-top: 6px;
		}
		#${ TOOL_ID } button {
			padding: 6px 14px;
			border: 1px solid #a2a9b1;
			border-radius: 2px;
			cursor: pointer;
			font-size: 0.92em;
			font-family: inherit;
			background: #fff;
		}
		#${ TOOL_ID } button.gul-primary {
			background: #36c;
			color: #fff;
			border-color: #2a4b8d;
			font-weight: bold;
		}
		#${ TOOL_ID } button.gul-primary:hover { background: #2a4b8d; }
		#${ TOOL_ID } button.gul-danger {
			background: #d73333;
			color: #fff;
			border-color: #b32424;
			font-weight: bold;
		}
		#${ TOOL_ID } button.gul-danger:hover { background: #b32424; }
		#${ TOOL_ID } button:disabled {
			opacity: 0.5;
			cursor: not-allowed;
		}
		#${ TOOL_ID } .gul-status {
			margin: 10px 0 0;
			padding: 8px 12px;
			border-radius: 2px;
			font-size: 0.91em;
			display: none;
		}
		#${ TOOL_ID } .gul-status.info    { background: #eaf3fb; border: 1px solid #72a0d3; display: block; }
		#${ TOOL_ID } .gul-status.success { background: #d5fdf4; border: 1px solid #14866d; display: block; }
		#${ TOOL_ID } .gul-status.error   { background: #fee7e6; border: 1px solid #d73333; display: block; }
		#${ TOOL_ID } .gul-status.warning { background: #fef6e7; border: 1px solid #fc3; display: block; }
		#${ TOOL_ID } .gul-results-wrap {
			display: none;
			margin-top: 14px;
		}
		#${ TOOL_ID } .gul-results-wrap.visible { display: block; }
		#${ TOOL_ID } .gul-results-table {
			width: 100%;
			border-collapse: collapse;
			font-size: 0.89em;
			background: #fff;
		}
		#${ TOOL_ID } .gul-results-table th,
		#${ TOOL_ID } .gul-results-table td {
			border: 1px solid #c8ccd1;
			padding: 5px 8px;
			text-align: left;
			vertical-align: middle;
		}
		#${ TOOL_ID } .gul-results-table th {
			background: #eaecf0;
			font-weight: bold;
		}
		#${ TOOL_ID } .gul-results-table tr:nth-child(even) td { background: #f8f9fa; }
		#${ TOOL_ID } .gul-results-table .gul-status-cell {
			text-align: center;
			white-space: nowrap;
		}
		#${ TOOL_ID } .badge {
			display: inline-block;
			padding: 1px 7px;
			border-radius: 10px;
			font-size: 0.85em;
			font-weight: bold;
		}
		#${ TOOL_ID } .badge-pending  { background: #eaecf0; color: #555; }
		#${ TOOL_ID } .badge-locked   { background: #d5fdf4; color: #14866d; }
		#${ TOOL_ID } .badge-failed   { background: #fee7e6; color: #d73333; }
		#${ TOOL_ID } .badge-skipped  { background: #fef6e7; color: #b45a00; }
		#${ TOOL_ID } .gul-progress {
			height: 8px;
			background: #eaecf0;
			border-radius: 4px;
			overflow: hidden;
			margin-top: 8px;
			display: none;
		}
		#${ TOOL_ID } .gul-progress.visible { display: block; }
		#${ TOOL_ID } .gul-progress-bar {
			height: 100%;
			background: #36c;
			width: 0%;
			transition: width 0.3s ease;
		}
		#${ TOOL_ID } .gul-select-all-row {
			display: flex;
			align-items: center;
			gap: 8px;
			margin-bottom: 6px;
			font-size: 0.91em;
		}
		#${ TOOL_ID } .gul-count {
			color: #555;
			font-size: 0.88em;
			margin-left: auto;
		}
		#${ TOOL_ID } .gul-summary {
			font-size: 0.9em;
			margin-top: 10px;
			padding: 8px 12px;
			background: #fff;
			border: 1px solid #c8ccd1;
			border-radius: 2px;
		}
	` );

	// ── Build UI ───────────────────────────────────────────────────────────────

	function buildUI() {
		const $container = $( '<div>' ).attr( 'id', TOOL_ID );

		// Header
		$container.append( `
			<div class="gul-header">
				<svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor">
					<path d="M10 2a4 4 0 0 1 4 4v1h1a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1V6a4 4 0 0 1 4-4zm0 9a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm0-7a2 2 0 0 0-2 2v1h4V6a2 2 0 0 0-2-2z"/>
				</svg>
				${ TOOL_TITLE }
			</div>
		` );

		const $body = $( '<div>' ).addClass( 'gul-body' );

		// ── Search fieldset
		const $searchFs = $( '<fieldset>' ).append( '<legend>Search Parameters</legend>' );

		$searchFs.append( `
			<label for="gul-query">Username contains (substring match, case-insensitive):</label>
			<input type="text" id="gul-query" placeholder="e.g. spambot, vandal2024" autocomplete="off" />
		` );

		$searchFs.append( `
			<label style="margin-top:10px;" for="gul-from">
				Start from username (optional, for pagination):
			</label>
			<input type="text" id="gul-from" placeholder="Leave blank to start from beginning" autocomplete="off" />
		` );

		$body.append( $searchFs );

		// ── Lock options fieldset
		const $lockFs = $( '<fieldset>' ).append( '<legend>Lock Options</legend>' );

		$lockFs.append( `
			<label for="gul-reason">Lock reason (will appear in CentralAuth log):</label>
			<textarea id="gul-reason" rows="2" placeholder="e.g. Cross-wiki spam; username policy violation"></textarea>
		` );

		$lockFs.append( `
			<div class="gul-checkbox-row">
				<input type="checkbox" id="gul-hide" />
				<label for="gul-hide" style="margin:0;">Hide username (sets <code>hidden</code> flag — requires suppressor right)</label>
			</div>
			<div class="gul-checkbox-row">
				<input type="checkbox" id="gul-suppress" />
				<label for="gul-suppress" style="margin:0;">Suppress username (sets <code>suppressed</code> flag — requires suppressor right)</label>
			</div>
			<div class="gul-checkbox-row">
				<input type="checkbox" id="gul-skip-locked" checked />
				<label for="gul-skip-locked" style="margin:0;">Skip accounts already globally locked</label>
			</div>
		` );

		$body.append( $lockFs );

		// ── Actions
		const $actions = $( '<div>' ).addClass( 'gul-actions' );
		const $btnSearch = $( '<button>' ).addClass( 'gul-primary' ).attr( 'id', 'gul-btn-search' ).text( '🔍 Search Accounts' );
		const $btnLock   = $( '<button>' ).addClass( 'gul-danger' ).attr( 'id', 'gul-btn-lock' ).text( '🔒 Lock Selected' ).prop( 'disabled', true );
		const $btnClear  = $( '<button>' ).attr( 'id', 'gul-btn-clear' ).text( 'Clear' );
		$actions.append( $btnSearch, $btnLock, $btnClear );
		$body.append( $actions );

		// Status
		const $status = $( '<div>' ).addClass( 'gul-status' ).attr( 'id', 'gul-status' );
		$body.append( $status );

		// Progress bar
		const $progress = $( '<div>' ).addClass( 'gul-progress' ).attr( 'id', 'gul-progress' );
		$progress.append( $( '<div>' ).addClass( 'gul-progress-bar' ).attr( 'id', 'gul-progress-bar' ) );
		$body.append( $progress );

		// Results
		const $resultsWrap = $( '<div>' ).addClass( 'gul-results-wrap' ).attr( 'id', 'gul-results-wrap' );
		$body.append( $resultsWrap );

		$container.append( $body );

		return $container;
	}

	// ── Helpers ────────────────────────────────────────────────────────────────

	function setStatus( msg, type ) {
		const $s = $( '#gul-status' );
		$s.removeClass( 'info success error warning' ).addClass( type ).html( msg );
	}

	function clearStatus() {
		$( '#gul-status' ).removeClass( 'info success error warning' ).hide().html( '' );
	}

	function setBadge( username, type, text ) {
		const $cell = $( `#gul-row-${ CSS.escape( username ) } .gul-status-cell` );
		$cell.html( `<span class="badge badge-${ type }">${ text }</span>` );
	}

	function setProgress( done, total ) {
		const pct = total > 0 ? Math.round( ( done / total ) * 100 ) : 0;
		$( '#gul-progress-bar' ).css( 'width', pct + '%' );
	}

	// ── API helpers ────────────────────────────────────────────────────────────

	/**
	 * Fetch a CSRF token for CentralAuth write operations.
	 * @return {jQuery.Promise<string>}
	 */
	function getCsrfToken() {
		return $.ajax( {
			url: API_ENDPOINT,
			data: {
				action: 'query',
				meta:   'tokens',
				type:   'csrf',
				format: 'json'
			}
		} ).then( function ( data ) {
			return data.query.tokens.csrftoken;
		} );
	}

	/**
	 * Fetch all global users whose name contains `query`.
	 * Uses globalallusers with a "from" trick:  we search from `aufrom`
	 * and filter client-side since the API doesn't support substring
	 * search — we iterate until names no longer contain the substring
	 * using the `auprefix` param where possible, and fall back to
	 * fetching batches and filtering.
	 *
	 * Note: The CentralAuth API exposes `list=globalallusers` which
	 * supports `agufrom` and `aguto` but not substring; we therefore
	 * page through results and filter locally.
	 *
	 * @param {string} query
	 * @param {string} fromUser
	 * @return {jQuery.Promise<Array>}
	 */
	function fetchMatchingUsers( query, fromUser ) {
		const deferred  = $.Deferred();
		const results   = [];
		const queryLc   = query.toLowerCase();
		let   continueToken = fromUser || undefined;
		let   done      = false;
		let   page      = 0;

		setStatus( 'Searching CentralAuth for matching usernames…', 'info' );

		function fetchPage() {
			if ( done || results.length >= MAX_ACCOUNTS ) {
				deferred.resolve( results.slice( 0, MAX_ACCOUNTS ) );
				return;
			}

			const params = {
				action:    'query',
				list:      'globalallusers',
				agulimit:  '500',
				aguprop:   'lockinfo|registration',
				format:    'json'
			};
			if ( continueToken ) {
				params.agufrom = continueToken;
			}

			$.ajax( { url: API_ENDPOINT, data: params } )
				.done( function ( data ) {
					const users = ( data.query || {} ).globalallusers || [];
					page++;

					if ( users.length === 0 ) {
						done = true;
						deferred.resolve( results.slice( 0, MAX_ACCOUNTS ) );
						return;
					}

					users.forEach( function ( u ) {
						if ( u.name.toLowerCase().includes( queryLc ) ) {
							results.push( u );
						}
					} );

					setStatus(
						`Searched ${ page * 500 } usernames, found ${ results.length } match(es) so far…`,
						'info'
					);

					// Continue token
					if ( data.continue && data.continue.agufrom ) {
						continueToken = data.continue.agufrom;
						fetchPage();
					} else {
						done = true;
						deferred.resolve( results.slice( 0, MAX_ACCOUNTS ) );
					}
				} )
				.fail( function ( xhr ) {
					deferred.reject( 'API request failed: ' + xhr.statusText );
				} );
		}

		fetchPage();
		return deferred.promise();
	}

	/**
	 * Lock a single global account via the CentralAuth API.
	 *
	 * @param {string}  username
	 * @param {string}  token     CSRF token
	 * @param {string}  reason
	 * @param {boolean} hide      Apply "hidden" flag
	 * @param {boolean} suppress  Apply "suppressed" flag
	 * @return {jQuery.Promise}
	 */
	function lockAccount( username, token, reason, hide, suppress ) {
		// Build the "statechange" value
		// Possible states: lock, unlock, hide, suppress, unsuppress
		const states = { locked: 'lock' };
		if ( suppress ) {
			states.hidden = 'suppress';
		} else if ( hide ) {
			states.hidden = 'hide';
		}

		const params = {
			action:        'centralauth',
			user:          username,
			reason:        reason,
			token:         token,
			format:        'json'
		};

		// Encode state changes as pipe-separated key=value pairs
		const stateStr = Object.entries( states )
			.map( ( [ k, v ] ) => `${ k }=${ v }` )
			.join( '|' );
		params.statechange = stateStr;

		return $.ajax( {
			url:    API_ENDPOINT,
			method: 'POST',
			data:   params
		} );
	}

	// ── Render results table ───────────────────────────────────────────────────

	function renderResultsTable( users ) {
		const $wrap = $( '#gul-results-wrap' );
		$wrap.empty();

		if ( users.length === 0 ) {
			$wrap.append( '<p><em>No matching accounts found.</em></p>' );
			$wrap.addClass( 'visible' );
			return;
		}

		// Select-all row
		const $selRow = $( '<div>' ).addClass( 'gul-select-all-row' );
		const $selAll = $( '<input type="checkbox" id="gul-select-all">' ).prop( 'checked', true );
		$selRow.append(
			$selAll,
			$( '<label>' ).attr( 'for', 'gul-select-all' ).css( 'margin', '0' ).text( 'Select / deselect all' ),
			$( '<span>' ).addClass( 'gul-count' ).text( `${ users.length } account(s) found` )
		);
		$wrap.append( $selRow );

		// Table
		const $table = $( '<table>' ).addClass( 'gul-results-table' );
		$table.append( `
			<thead>
				<tr>
					<th style="width:28px;"></th>
					<th>Username</th>
					<th>Registered</th>
					<th>Currently locked?</th>
					<th style="width:100px;">Action status</th>
				</tr>
			</thead>
		` );

		const $tbody = $( '<tbody>' );
		users.forEach( function ( u ) {
			const isLocked = !!u.locked;
			const rowId    = `gul-row-${ CSS.escape( u.name ) }`;
			const $row     = $( '<tr>' ).attr( 'id', rowId );

			const $cb = $( '<input type="checkbox" class="gul-user-cb">' )
				.val( u.name )
				.prop( 'checked', true );

			$row.append(
				$( '<td>' ).append( $cb ),
				$( '<td>' ).text( u.name ),
				$( '<td>' ).text( u.registration ? u.registration.slice( 0, 10 ) : '—' ),
				$( '<td style="text-align:center;">' ).html(
					isLocked
						? '<span class="badge badge-locked">Locked</span>'
						: '<span class="badge badge-pending">Active</span>'
				),
				$( '<td>' ).addClass( 'gul-status-cell' )
					.html( '<span class="badge badge-pending">Pending</span>' )
			);
			$tbody.append( $row );
		} );

		$table.append( $tbody );
		$wrap.append( $table );
		$wrap.addClass( 'visible' );

		// Select-all toggle
		$( '#gul-select-all' ).on( 'change', function () {
			$( '.gul-user-cb' ).prop( 'checked', this.checked );
		} );
	}

	// ── Main logic ─────────────────────────────────────────────────────────────

	function onSearch() {
		const query = $( '#gul-query' ).val().trim();
		if ( !query ) {
			setStatus( 'Please enter a search string.', 'error' );
			return;
		}

		$( '#gul-btn-search' ).prop( 'disabled', true );
		$( '#gul-btn-lock' ).prop( 'disabled', true );
		$( '#gul-results-wrap' ).removeClass( 'visible' ).empty();
		$( '#gul-progress' ).removeClass( 'visible' );
		clearStatus();

		fetchMatchingUsers( query, $( '#gul-from' ).val().trim() )
			.done( function ( users ) {
				renderResultsTable( users );
				if ( users.length > 0 ) {
					$( '#gul-btn-lock' ).prop( 'disabled', false );
					setStatus(
						`Found <strong>${ users.length }</strong> account(s) matching "<strong>${ mw.html.escape( query ) }</strong>".` +
						( users.length >= MAX_ACCOUNTS ? ` (Results capped at ${ MAX_ACCOUNTS }.)` : '' ),
						'info'
					);
				} else {
					setStatus( `No accounts found matching "${ mw.html.escape( query ) }".`, 'warning' );
				}
			} )
			.fail( function ( err ) {
				setStatus( 'Search failed: ' + mw.html.escape( err ), 'error' );
			} )
			.always( function () {
				$( '#gul-btn-search' ).prop( 'disabled', false );
			} );
	}

	function onLock() {
		const reason   = $( '#gul-reason' ).val().trim();
		const hide     = $( '#gul-hide' ).prop( 'checked' );
		const suppress = $( '#gul-suppress' ).prop( 'checked' );
		const skipLocked = $( '#gul-skip-locked' ).prop( 'checked' );

		if ( !reason ) {
			setStatus( 'A lock reason is required.', 'error' );
			return;
		}

		// Collect selected usernames
		const selected = [];
		$( '.gul-user-cb:checked' ).each( function () {
			selected.push( $( this ).val() );
		} );

		if ( selected.length === 0 ) {
			setStatus( 'No accounts selected.', 'warning' );
			return;
		}

		if ( !confirm(
			`You are about to globally lock ${ selected.length } account(s).\n\n` +
			`Reason: ${ reason }\n` +
			( hide     ? 'Hide username: YES\n'     : '' ) +
			( suppress ? 'Suppress username: YES\n' : '' ) +
			'\nThis action will be logged. Proceed?'
		) ) {
			return;
		}

		$( '#gul-btn-lock, #gul-btn-search' ).prop( 'disabled', true );
		$( '#gul-progress' ).addClass( 'visible' );
		setProgress( 0, selected.length );
		setStatus( 'Fetching CSRF token…', 'info' );

		getCsrfToken()
			.then( function ( token ) {
				setStatus( `Locking ${ selected.length } account(s)…`, 'info' );

				let done    = 0;
				let locked  = 0;
				let skipped = 0;
				let failed  = 0;

				// Sequential processing to avoid API rate limits
				function processNext( index ) {
					if ( index >= selected.length ) {
						// Finished
						setProgress( done, selected.length );
						setStatus(
							`Done. <strong>${ locked }</strong> locked, ` +
							`<strong>${ skipped }</strong> skipped, ` +
							`<strong>${ failed }</strong> failed.`,
							failed > 0 ? 'warning' : 'success'
						);
						$( '#gul-btn-search' ).prop( 'disabled', false );

						// Add summary row
						const $existing = $( '#gul-lock-summary' );
						if ( $existing.length ) $existing.remove();
						$( '#gul-results-wrap' ).append(
							$( '<div>' ).attr( 'id', 'gul-lock-summary' ).addClass( 'gul-summary' ).html(
								`<strong>Summary:</strong> ${ locked } locked · ${ skipped } skipped · ${ failed } failed`
							)
						);
						return;
					}

					const username = selected[ index ];
					const $row     = $( `#gul-row-${ CSS.escape( username ) }` );
					const isLocked = $row.find( 'td:nth-child(4)' ).text().trim() === 'Locked';

					if ( skipLocked && isLocked ) {
						skipped++;
						done++;
						setBadge( username, 'skipped', 'Skipped' );
						setProgress( done, selected.length );
						processNext( index + 1 );
						return;
					}

					setBadge( username, 'pending', 'Locking…' );

					lockAccount( username, token, reason, hide, suppress )
						.done( function ( data ) {
							if ( data.centralauth && data.centralauth.status === 'ok' ) {
								locked++;
								setBadge( username, 'locked', 'Locked ✓' );
							} else if ( data.error ) {
								failed++;
								setBadge( username, 'failed', 'Error: ' + mw.html.escape( data.error.info || data.error.code ) );
							} else {
								// Treat an empty-but-non-error response as likely success
								// (some MediaWiki versions return differently shaped responses)
								locked++;
								setBadge( username, 'locked', 'Locked ✓' );
							}
						} )
						.fail( function () {
							failed++;
							setBadge( username, 'failed', 'Network error' );
						} )
						.always( function () {
							done++;
							setProgress( done, selected.length );
							setStatus(
								`Processing ${ done } / ${ selected.length }…`,
								'info'
							);
							// Small delay to be polite to the API
							setTimeout( function () {
								processNext( index + 1 );
							}, 300 );
						} );
				}

				processNext( 0 );
			} )
			.fail( function () {
				setStatus( 'Failed to obtain CSRF token. Are you logged in with sufficient rights?', 'error' );
				$( '#gul-btn-lock, #gul-btn-search' ).prop( 'disabled', false );
			} );
	}

	function onClear() {
		$( '#gul-query, #gul-from, #gul-reason' ).val( '' );
		$( '#gul-hide, #gul-suppress' ).prop( 'checked', false );
		$( '#gul-skip-locked' ).prop( 'checked', true );
		$( '#gul-results-wrap' ).removeClass( 'visible' ).empty();
		$( '#gul-progress' ).removeClass( 'visible' );
		$( '#gul-progress-bar' ).css( 'width', '0%' );
		clearStatus();
		$( '#gul-btn-lock' ).prop( 'disabled', true );
	}

	// ── Initialise ─────────────────────────────────────────────────────────────

	function init() {
		// Only load on Special:CentralAuth or Special:GlobalUserRights
		// (or remove the guard below to load everywhere)
		const special = mw.config.get( 'wgCanonicalSpecialPageName' );
		if ( special !== 'CentralAuth' && special !== 'GlobalUserRights' ) {
			// Load on any page via mw.util.addPortletLink trigger
			// Uncomment to restrict:
			// return;
		}

		const $ui = buildUI();

		// Try to insert after the heading on Special pages, else append to content
		const $heading = $( '#firstHeading, h1.firstHeading' ).first();
		if ( $heading.length ) {
			$heading.after( $ui );
		} else {
			$( '#mw-content-text' ).prepend( $ui );
		}

		// Bind events
		$( '#gul-btn-search' ).on( 'click', onSearch );
		$( '#gul-btn-lock'   ).on( 'click', onLock   );
		$( '#gul-btn-clear'  ).on( 'click', onClear  );

		// Allow Enter key in the query field to trigger search
		$( '#gul-query' ).on( 'keydown', function ( e ) {
			if ( e.key === 'Enter' ) onSearch();
		} );
	}

	// Wait for DOM + MW core
	$( init );

}() );
Cookies help us deliver our services. By using our services, you agree to our use of cookies.