Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

User:Justarandomamerican/globalUserLock.js: Difference between revisions

From WikiOasis Meta
Dark mode
No edit summary
Tag: Reverted
Line 12: Line 12:
  *
  *
  * Usage: Add to your global.js / common.js:
  * Usage: Add to your global.js / common.js:
  *  mw.loader.load('//meta.wikimedia.org/w/index.php?title=User:YourName/globalUserLock.js&action=raw&ctype=text/javascript');
  *  mw.loader.load('//meta.wikioasis.org/w/index.php?title=User:Justarandomamerican/globalUserLock.js&action=raw&ctype=text/javascript');
  *
  *
  * API actions used:
  * API actions used:

Revision as of 19:22, 6 May 2026

/**
 * 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.
 *
 * Activation: adds a "Global user lock" link to the "More" (p-cactions)
 * menu on every page, matching the pattern used by all-in-one.js.
 * Clicking it opens an OOUI dialog.
 *
 * Usage: Add to your global.js / common.js:
 *   mw.loader.load('//meta.wikioasis.org/w/index.php?title=User:Justarandomamerican/globalUserLock.js&action=raw&ctype=text/javascript');
 *
 * API actions used:
 *   - query + list=globalallusers  (find matching accounts)
 *   - centralauth                  (lock accounts)
 *   - query + meta=tokens          (CSRF)
 *
 * Author: WikiOasis T&S tooling
 * License: MIT
 */

( function () {
	'use strict';

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

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

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

	// ── Entry point ────────────────────────────────────────────────────────────
	// Matches the all-in-one.js pattern exactly:
	//   $.when( mw.loader.using([...]), $.ready ).then( function () { ... } );

	$.when(
		mw.loader.using( [ 'mediawiki.util', 'oojs-ui-core', 'oojs-ui-widgets', 'oojs-ui-windows' ] ),
		$.ready
	).then( function () {

		// Add "Global user lock" to the "More" (p-cactions) dropdown
		const $link = $( mw.util.addPortletLink(
			'p-cactions',
			'#',
			TOOL_TITLE,
			PORTLET_ID,
			'Find and globally lock accounts by username substring'
		) );

		$link.on( 'click', function ( e ) {
			e.preventDefault();
			openDialog();
		} );

	} );

	// ── OOUI Dialog ────────────────────────────────────────────────────────────

	function GULDialog( config ) {
		GULDialog.super.call( this, config );
	}
	OO.inheritClass( GULDialog, OO.ui.ProcessDialog );

	GULDialog.static.name    = 'gulDialog';
	GULDialog.static.title   = TOOL_TITLE;
	GULDialog.static.size    = 'large';
	GULDialog.static.actions = [
		{ action: 'close', label: 'Close', flags: [ 'safe', 'close' ] }
	];

	GULDialog.prototype.initialize = function () {
		GULDialog.super.prototype.initialize.call( this );
		injectCSS();
		this.$body.append( buildPanelContent() );
		bindEvents();
	};

	GULDialog.prototype.getActionProcess = function ( action ) {
		if ( action === 'close' ) {
			return new OO.ui.Process( function () {
				this.close();
			}, this );
		}
		return GULDialog.super.prototype.getActionProcess.call( this, action );
	};

	// Allow the dialog body to grow with results
	GULDialog.prototype.getBodyHeight = function () {
		return Math.min( Math.round( window.innerHeight * 0.82 ), 680 );
	};

	let windowManager = null;

	function openDialog() {
		if ( !windowManager ) {
			windowManager = new OO.ui.WindowManager();
			$( document.body ).append( windowManager.$element );
			windowManager.addWindows( [ new GULDialog() ] );
		}
		windowManager.openWindow( 'gulDialog' );
	}

	// ── CSS (injected once) ────────────────────────────────────────────────────

	let cssInjected = false;

	function injectCSS() {
		if ( cssInjected ) { return; }
		cssInjected = true;

		mw.util.addCSS( `
			/* ── GUL colour tokens (light defaults) ─────────────────────────────── */
			.gul-body,
			.gul-results-wrap,
			.gul-select-all-row,
			.gul-actions,
			.gul-checkbox-row {
				--gul-bg:           #fff;
				--gul-bg-subtle:    #f8f9fa;
				--gul-bg-muted:     #eaecf0;
				--gul-border:       #c8ccd1;
				--gul-border-input: #a2a9b1;
				--gul-text:         #202122;
				--gul-text-muted:   #555;

				/* status — info */
				--gul-info-bg:      #eaf3fb;
				--gul-info-border:  #72a0d3;
				--gul-info-text:    #0645ad;

				/* status — success */
				--gul-ok-bg:        #d5fdf4;
				--gul-ok-border:    #14866d;
				--gul-ok-text:      #14866d;

				/* status — error */
				--gul-err-bg:       #fee7e6;
				--gul-err-border:   #d73333;
				--gul-err-text:     #b32424;

				/* status — warning */
				--gul-warn-bg:      #fef6e7;
				--gul-warn-border:  #fc3;
				--gul-warn-text:    #b45a00;

				/* badges */
				--gul-badge-neutral-bg:   #eaecf0;
				--gul-badge-neutral-text: #555;
				--gul-badge-locked-bg:    #d5fdf4;
				--gul-badge-locked-text:  #14866d;
				--gul-badge-failed-bg:    #fee7e6;
				--gul-badge-failed-text:  #d73333;
				--gul-badge-skipped-bg:   #fef6e7;
				--gul-badge-skipped-text: #b45a00;

				/* buttons */
				--gul-btn-bg:           #fff;
				--gul-btn-border:       #a2a9b1;
				--gul-btn-text:         #202122;
				--gul-primary-bg:       #3366cc;
				--gul-primary-bg-hover: #2a4b8d;
				--gul-danger-bg:        #d73333;
				--gul-danger-bg-hover:  #b32424;

				/* progress track */
				--gul-track-bg:   #eaecf0;
				--gul-track-fill: #3366cc;
			}

			/* ── Dark mode: system preference ───────────────────────────────────── */
			@media ( prefers-color-scheme: dark ) {
				.gul-body,
				.gul-results-wrap,
				.gul-select-all-row,
				.gul-actions,
				.gul-checkbox-row {
					--gul-bg:           #1e2124;
					--gul-bg-subtle:    #27292d;
					--gul-bg-muted:     #2e3136;
					--gul-border:       #3d4146;
					--gul-border-input: #54595d;
					--gul-text:         #eaecf0;
					--gul-text-muted:   #a2a9b1;

					--gul-info-bg:      #162032;
					--gul-info-border:  #3a6ea8;
					--gul-info-text:    #8ab4f8;

					--gul-ok-bg:        #0d2b24;
					--gul-ok-border:    #1d6b58;
					--gul-ok-text:      #4dd0a8;

					--gul-err-bg:       #2d1212;
					--gul-err-border:   #a02020;
					--gul-err-text:     #f08080;

					--gul-warn-bg:      #2b2100;
					--gul-warn-border:  #a87f00;
					--gul-warn-text:    #f0c040;

					--gul-badge-neutral-bg:   #2e3136;
					--gul-badge-neutral-text: #a2a9b1;
					--gul-badge-locked-bg:    #0d2b24;
					--gul-badge-locked-text:  #4dd0a8;
					--gul-badge-failed-bg:    #2d1212;
					--gul-badge-failed-text:  #f08080;
					--gul-badge-skipped-bg:   #2b2100;
					--gul-badge-skipped-text: #f0c040;

					--gul-btn-bg:           #2e3136;
					--gul-btn-border:       #54595d;
					--gul-btn-text:         #eaecf0;
					--gul-primary-bg:       #3a6ea8;
					--gul-primary-bg-hover: #2a5298;
					--gul-danger-bg:        #a02020;
					--gul-danger-bg-hover:  #7a1818;

					--gul-track-bg:   #2e3136;
					--gul-track-fill: #3a6ea8;
				}
			}

			/* ── Dark mode: Vector 2022 manual toggle (.skin-theme-clientpref-night)
			       and Timeless/Monobook night variants ──────────────────────────── */
			.skin-theme-clientpref-night .gul-body,
			.skin-theme-clientpref-night .gul-results-wrap,
			.skin-theme-clientpref-night .gul-select-all-row,
			.skin-theme-clientpref-night .gul-actions,
			.skin-theme-clientpref-night .gul-checkbox-row {
				--gul-bg:           #1e2124;
				--gul-bg-subtle:    #27292d;
				--gul-bg-muted:     #2e3136;
				--gul-border:       #3d4146;
				--gul-border-input: #54595d;
				--gul-text:         #eaecf0;
				--gul-text-muted:   #a2a9b1;

				--gul-info-bg:      #162032;
				--gul-info-border:  #3a6ea8;
				--gul-info-text:    #8ab4f8;

				--gul-ok-bg:        #0d2b24;
				--gul-ok-border:    #1d6b58;
				--gul-ok-text:      #4dd0a8;

				--gul-err-bg:       #2d1212;
				--gul-err-border:   #a02020;
				--gul-err-text:     #f08080;

				--gul-warn-bg:      #2b2100;
				--gul-warn-border:  #a87f00;
				--gul-warn-text:    #f0c040;

				--gul-badge-neutral-bg:   #2e3136;
				--gul-badge-neutral-text: #a2a9b1;
				--gul-badge-locked-bg:    #0d2b24;
				--gul-badge-locked-text:  #4dd0a8;
				--gul-badge-failed-bg:    #2d1212;
				--gul-badge-failed-text:  #f08080;
				--gul-badge-skipped-bg:   #2b2100;
				--gul-badge-skipped-text: #f0c040;

				--gul-btn-bg:           #2e3136;
				--gul-btn-border:       #54595d;
				--gul-btn-text:         #eaecf0;
				--gul-primary-bg:       #3a6ea8;
				--gul-primary-bg-hover: #2a5298;
				--gul-danger-bg:        #a02020;
				--gul-danger-bg-hover:  #7a1818;

				--gul-track-bg:   #2e3136;
				--gul-track-fill: #3a6ea8;
			}

			/* ── Layout & structure ─────────────────────────────────────────────── */
			.gul-body {
				padding: 14px 16px;
				font-family: inherit;
				color: var( --gul-text );
			}
			.gul-body fieldset {
				border: 1px solid var( --gul-border );
				border-radius: 3px;
				padding: 10px 14px;
				margin: 0 0 14px;
				background: var( --gul-bg );
				color: var( --gul-text );
			}
			.gul-body legend {
				font-weight: bold;
				padding: 0 4px;
				font-size: 0.95em;
				color: var( --gul-text );
			}
			.gul-body label {
				display: block;
				margin-bottom: 4px;
				font-size: 0.92em;
				color: var( --gul-text );
			}
			.gul-body input[type="text"],
			.gul-body textarea {
				width: 100%;
				box-sizing: border-box;
				padding: 5px 7px;
				border: 1px solid var( --gul-border-input );
				border-radius: 2px;
				font-size: 0.93em;
				font-family: inherit;
				background: var( --gul-bg );
				color: var( --gul-text );
			}
			.gul-body input[type="text"]:focus,
			.gul-body textarea:focus {
				outline: 2px solid var( --gul-track-fill );
				outline-offset: 1px;
			}
			.gul-body textarea {
				resize: vertical;
				min-height: 60px;
			}

			/* ── Checkboxes ─────────────────────────────────────────────────────── */
			.gul-checkbox-row {
				display: flex;
				align-items: center;
				gap: 6px;
				margin-top: 8px;
				font-size: 0.92em;
				color: var( --gul-text );
			}
			.gul-checkbox-row input[type="checkbox"] {
				width: auto;
				margin: 0;
				accent-color: var( --gul-track-fill );
			}

			/* ── Buttons ────────────────────────────────────────────────────────── */
			.gul-actions {
				display: flex;
				gap: 8px;
				flex-wrap: wrap;
				margin-top: 6px;
			}
			.gul-actions button {
				padding: 6px 14px;
				border: 1px solid var( --gul-btn-border );
				border-radius: 2px;
				cursor: pointer;
				font-size: 0.92em;
				font-family: inherit;
				background: var( --gul-btn-bg );
				color: var( --gul-btn-text );
			}
			.gul-actions button.gul-primary {
				background: var( --gul-primary-bg );
				color: #fff;
				border-color: var( --gul-primary-bg-hover );
				font-weight: bold;
			}
			.gul-actions button.gul-primary:hover { background: var( --gul-primary-bg-hover ); }
			.gul-actions button.gul-danger {
				background: var( --gul-danger-bg );
				color: #fff;
				border-color: var( --gul-danger-bg-hover );
				font-weight: bold;
			}
			.gul-actions button.gul-danger:hover { background: var( --gul-danger-bg-hover ); }
			.gul-actions button:disabled {
				opacity: 0.5;
				cursor: not-allowed;
			}

			/* ── Status banners ─────────────────────────────────────────────────── */
			.gul-status {
				margin: 10px 0 0;
				padding: 8px 12px;
				border-radius: 2px;
				font-size: 0.91em;
				display: none;
			}
			.gul-status.info {
				background: var( --gul-info-bg );
				border: 1px solid var( --gul-info-border );
				color: var( --gul-info-text );
				display: block;
			}
			.gul-status.success {
				background: var( --gul-ok-bg );
				border: 1px solid var( --gul-ok-border );
				color: var( --gul-ok-text );
				display: block;
			}
			.gul-status.error {
				background: var( --gul-err-bg );
				border: 1px solid var( --gul-err-border );
				color: var( --gul-err-text );
				display: block;
			}
			.gul-status.warning {
				background: var( --gul-warn-bg );
				border: 1px solid var( --gul-warn-border );
				color: var( --gul-warn-text );
				display: block;
			}

			/* ── Progress bar ───────────────────────────────────────────────────── */
			.gul-progress {
				height: 8px;
				background: var( --gul-track-bg );
				border-radius: 4px;
				overflow: hidden;
				margin-top: 8px;
				display: none;
			}
			.gul-progress.visible { display: block; }
			.gul-progress-bar {
				height: 100%;
				background: var( --gul-track-fill );
				width: 0%;
				transition: width 0.3s ease;
			}

			/* ── Results table ──────────────────────────────────────────────────── */
			.gul-results-wrap {
				display: none;
				margin-top: 14px;
			}
			.gul-results-wrap.visible { display: block; }
			.gul-select-all-row {
				display: flex;
				align-items: center;
				gap: 8px;
				margin-bottom: 6px;
				font-size: 0.91em;
				color: var( --gul-text );
			}
			.gul-count {
				color: var( --gul-text-muted );
				font-size: 0.88em;
				margin-left: auto;
			}
			.gul-results-table {
				width: 100%;
				border-collapse: collapse;
				font-size: 0.89em;
				background: var( --gul-bg );
				color: var( --gul-text );
			}
			.gul-results-table th,
			.gul-results-table td {
				border: 1px solid var( --gul-border );
				padding: 5px 8px;
				text-align: left;
				vertical-align: middle;
			}
			.gul-results-table th {
				background: var( --gul-bg-muted );
				font-weight: bold;
				color: var( --gul-text );
			}
			.gul-results-table tr:nth-child(even) td {
				background: var( --gul-bg-subtle );
			}
			.gul-status-cell { text-align: center; white-space: nowrap; }

			/* ── Badges ─────────────────────────────────────────────────────────── */
			.gul-badge {
				display: inline-block;
				padding: 1px 7px;
				border-radius: 10px;
				font-size: 0.85em;
				font-weight: bold;
			}
			.gul-badge-pending,
			.gul-badge-active  {
				background: var( --gul-badge-neutral-bg );
				color: var( --gul-badge-neutral-text );
			}
			.gul-badge-locked  {
				background: var( --gul-badge-locked-bg );
				color: var( --gul-badge-locked-text );
			}
			.gul-badge-failed  {
				background: var( --gul-badge-failed-bg );
				color: var( --gul-badge-failed-text );
			}
			.gul-badge-skipped {
				background: var( --gul-badge-skipped-bg );
				color: var( --gul-badge-skipped-text );
			}

			/* ── Summary footer ─────────────────────────────────────────────────── */
			.gul-summary {
				font-size: 0.9em;
				margin-top: 10px;
				padding: 8px 12px;
				background: var( --gul-bg );
				border: 1px solid var( --gul-border );
				border-radius: 2px;
				color: var( --gul-text );
			}
		` );
	}

	// ── Build panel content ────────────────────────────────────────────────────

	function buildPanelContent() {
		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 the 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 (<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 (<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 that are already globally locked
				</label>
			</div>
		` );
		$body.append( $lockFs );

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

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

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

		return $body;
	}

	// ── Bind events (delegated so they survive dialog re-opens) ───────────────

	function bindEvents() {
		$( document )
			.off( '.gul' )
			.on( 'click.gul',   '#gul-btn-search', onSearch )
			.on( 'click.gul',   '#gul-btn-lock',   onLock   )
			.on( 'click.gul',   '#gul-btn-clear',  onClear  )
			.on( 'keydown.gul', '#gul-query', function ( e ) {
				if ( e.key === 'Enter' ) { onSearch(); }
			} )
			.on( 'change.gul', '#gul-select-all', function () {
				$( '.gul-user-cb' ).prop( 'checked', this.checked );
			} );
	}

	// ── UI helpers ─────────────────────────────────────────────────────────────

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

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

	// Row IDs must be safe CSS identifiers; replace anything that isn't \w or -
	function rowId( username ) {
		return 'gul-row-' + username.replace( /[^\w-]/g, '_' );
	}

	function setBadge( username, type, text ) {
		$( '#' + rowId( username ) + ' .gul-status-cell' )
			.html( '<span class="gul-badge gul-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 ────────────────────────────────────────────────────────────

	function getCsrfToken() {
		return $.ajax( {
			url:  API_ENDPOINT,
			data: { action: 'query', meta: 'tokens', type: 'csrf', format: 'json' }
		} ).then( function ( data ) {
			return data.query.tokens.csrftoken;
		} );
	}

	/**
	 * Page through globalallusers, filtering client-side for substring matches.
	 * The API only supports prefix search (agufrom/aguto), not substring, so we
	 * iterate all pages and filter locally — the same approach used by similar
	 * steward tools.
	 */
	function fetchMatchingUsers( query, fromUser ) {
		const deferred    = $.Deferred();
		const results     = [];
		const queryLc     = query.toLowerCase();
		let   continueFrom = fromUser || undefined;
		let   exhausted   = false;
		let   page        = 0;

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

		function fetchPage() {
			if ( exhausted || 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 ( continueFrom ) {
				params.agufrom = continueFrom;
			}

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

					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'
					);

					if ( !users.length || !( data.continue && data.continue.agufrom ) ) {
						exhausted = true;
						deferred.resolve( results.slice( 0, MAX_ACCOUNTS ) );
					} else {
						continueFrom = data.continue.agufrom;
						fetchPage();
					}
				} )
				.fail( function ( xhr ) {
					deferred.reject( 'API request failed: ' + xhr.statusText );
				} );
		}

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

	/**
	 * Lock a single global account via the CentralAuth API.
	 */
	function lockAccount( username, token, reason, hide, suppress ) {
		const states = { locked: 'lock' };
		if ( suppress ) {
			states.hidden = 'suppress';
		} else if ( hide ) {
			states.hidden = 'hide';
		}

		const statechange = Object.entries( states )
			.map( function ( pair ) { return pair[ 0 ] + '=' + pair[ 1 ]; } )
			.join( '|' );

		return $.ajax( {
			url:    API_ENDPOINT,
			method: 'POST',
			data: {
				action:      'centralauth',
				user:        username,
				reason:      reason,
				statechange: statechange,
				token:       token,
				format:      'json'
			}
		} );
	}

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

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

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

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

		// Table
		const $tbody = $( '<tbody>' );
		users.forEach( function ( u ) {
			const isLocked = !!u.locked;
			const $row = $( '<tr>' ).attr( 'id', rowId( u.name ) );

			$row.append(
				$( '<td>' ).append(
					$( '<input>' ).attr( { type: 'checkbox', class: 'gul-user-cb' } )
						.val( u.name ).prop( 'checked', true )
				),
				$( '<td>' ).append(
					$( '<a>' )
						.attr( {
							href:   mw.util.getUrl( 'Special:CentralAuth/' + u.name ),
							target: '_blank',
							rel:    'noopener'
						} )
						.text( u.name )
				),
				$( '<td>' ).text( u.registration ? u.registration.slice( 0, 10 ) : '—' ),
				$( '<td>' ).css( 'text-align', 'center' ).html(
					isLocked
						? '<span class="gul-badge gul-badge-locked">Locked</span>'
						: '<span class="gul-badge gul-badge-active">Active</span>'
				),
				$( '<td>' ).addClass( 'gul-status-cell' )
					.html( '<span class="gul-badge gul-badge-pending">Pending</span>' )
			);
			$tbody.append( $row );
		} );

		$wrap.append(
			$( '<table>' ).addClass( 'gul-results-table' ).append(
				$( '<thead>' ).append(
					$( '<tr>' ).append(
						$( '<th>' ).css( 'width', '28px' ),
						$( '<th>' ).text( 'Username' ),
						$( '<th>' ).text( 'Registered' ),
						$( '<th>' ).text( 'Currently locked?' ),
						$( '<th>' ).css( 'width', '110px' ).text( 'Action status' )
					)
				),
				$tbody
			)
		).addClass( 'visible' );
	}

	// ── Main handlers ──────────────────────────────────────────────────────────

	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-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( String( 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;
		}

		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, locked = 0, skipped = 0, failed = 0;

				function processNext( i ) {
					if ( i >= selected.length ) {
						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 );
						$( '#gul-lock-summary' ).remove();
						$( '#gul-results-wrap' ).append(
							$( '<div id="gul-lock-summary">' ).addClass( 'gul-summary' ).html(
								'<strong>Summary:</strong> ' + locked  + ' locked · ' +
								skipped + ' skipped · ' + failed + ' failed'
							)
						);
						return;
					}

					const username = selected[ i ];
					const wasLocked = $( '#' + rowId( username ) + ' td:nth-child(4)' )
						.text().trim() === 'Locked';

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

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

					lockAccount( username, token, reason, hide, suppress )
						.done( function ( data ) {
							if ( data.error ) {
								failed++;
								setBadge( username, 'failed',
									'Error: ' + mw.html.escape( data.error.info || data.error.code ) );
							} else {
								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' );
							// 300 ms delay between requests to be polite to the API
							setTimeout( function () { processNext( i + 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 );
	}

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