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
No edit summary
No edit summary
 
(5 intermediate revisions by the same user not shown)
Line 6: Line 6:
  * Designed for use on Meta-Wiki or any CentralAuth-enabled wiki
  * Designed for use on Meta-Wiki or any CentralAuth-enabled wiki
  * by stewards or staff with global lock permissions.
  * by stewards or staff with global lock permissions.
*
* Activation: adds a "Global user lock" link to the "More" (p-cactions)
* menu on every page. Clicking it opens an OOUI dialog.
*
* 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');
*
* API actions used:
* - query + list=globalallusers  (find matching accounts)
* - centralauth                  (lock accounts)
* - query + meta=tokens          (CSRF)
*
* Author: WikiOasis T&S tooling
* License: MIT
  */
  */


Line 26: Line 12:


// ── Constants ──────────────────────────────────────────────────────────────
// ── Constants ──────────────────────────────────────────────────────────────
const TOOL_TITLE  = 'Global User Lock';
const TOOL_TITLE  = 'Global User Lock';
const PORTLET_ID  = 'ca-global-user-lock';
const PORTLET_ID  = 'ca-global-user-lock';
const API_ENDPOINT = mw.util.wikiScript( 'api' );
// Maximum accounts to process in a single run (safety cap)
const MAX_ACCOUNTS = 200;
const MAX_ACCOUNTS = 200;


// ── Entry point ────────────────────────────────────────────────────────────
// ── Load Dependencies First ────────────────────────────────────────────────
// Matches the all-in-one.js pattern exactly:
//  $.when( mw.loader.using([...]), $.ready ).then( function () { ... } );
 
$.when(
$.when(
mw.loader.using( [ 'mediawiki.util', 'oojs-ui-core', 'oojs-ui-widgets', 'oojs-ui-windows' ] ),
mw.loader.using( [ 'mediawiki.util', 'mediawiki.api', 'oojs-ui-core', 'oojs-ui-widgets', 'oojs-ui-windows' ] ),
$.ready
$.ready
).then( function () {
).then( function () {


// Add "Global user lock" to the "More" (p-cactions) dropdown
// Initialize the MediaWiki API helper
const api = new mw.Api();
 
// ── OOUI Dialog Definition ─────────────────────────────────────────────
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 );
};
 
GULDialog.prototype.getBodyHeight = function () {
return Math.min( Math.round( window.innerHeight * 0.82 ), 680 );
};
 
// ── Dialog Manager ─────────────────────────────────────────────────────
let windowManager = null;
 
function openDialog() {
if ( !windowManager ) {
windowManager = new OO.ui.WindowManager();
$( document.body ).append( windowManager.$element );
windowManager.addWindows( [ new GULDialog() ] );
}
windowManager.openWindow( 'gulDialog' );
}
 
// ── Entry point ────────────────────────────────────────────────────────
const $link = $( mw.util.addPortletLink(
const $link = $( mw.util.addPortletLink(
'p-cactions',
'p-cactions',
Line 57: Line 84:
} );
} );


} );
// ── CSS (injected once) ────────────────────────────────────────────────
let cssInjected = false;
function injectCSS() {
if ( cssInjected ) { return; }
cssInjected = true;


// ── OOUI Dialog ────────────────────────────────────────────────────────────
mw.util.addCSS( `
.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;
--gul-info-bg:      #eaf3fb;
--gul-info-border:  #72a0d3;
--gul-info-text:    #0645ad;
--gul-ok-bg:        #d5fdf4;
--gul-ok-border:    #14866d;
--gul-ok-text:      #14866d;
--gul-err-bg:      #fee7e6;
--gul-err-border:  #d73333;
--gul-err-text:    #b32424;
--gul-warn-bg:      #fef6e7;
--gul-warn-border:  #fc3;
--gul-warn-text:    #b45a00;
--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;
--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;
--gul-track-bg:  #eaecf0;
--gul-track-fill: #3366cc;
}
@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;
}
}
.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;
}
.gul-body { padding: 14px 16px; font-family: inherit; color: var(--gul-text); overflow-y: auto; }
.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; }
.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); }
.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; }
.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; }
.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; }
.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; }
.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); }
.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); }
` );
}


function GULDialog( config ) {
// ── Build panel content ────────────────────────────────────────────────
GULDialog.super.call( this, config );
function buildPanelContent() {
}
const $body = $( '<div>' ).addClass( 'gul-body' );
OO.inheritClass( GULDialog, OO.ui.ProcessDialog );


GULDialog.static.name    = 'gulDialog';
const $searchFs = $( '<fieldset>' ).append( '<legend>Search Parameters</legend>' );
GULDialog.static.title  = TOOL_TITLE;
$searchFs.append( `
GULDialog.static.size    = 'large';
<label for="gul-query">Username contains (substring match, case-insensitive):</label>
GULDialog.static.actions = [
<input type="text" id="gul-query" placeholder="e.g. spambot, vandal2024" autocomplete="off" />
{ action: 'close', label: 'Close', flags: [ 'safe', 'close' ] }
` );
];
$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 );


GULDialog.prototype.initialize = function () {
const $lockFs = $( '<fieldset>' ).append( '<legend>Lock Options</legend>' );
GULDialog.super.prototype.initialize.call( this );
$lockFs.append( `
injectCSS();
<label for="gul-reason">Lock reason (will appear in CentralAuth log):</label>
this.$body.append( buildPanelContent() );
<textarea id="gul-reason" rows="2" placeholder="e.g. Cross-wiki spam; username policy violation"></textarea>
bindEvents();
` );
};
$lockFs.append( `
<div class="gul-checkbox-row">
<input type="checkbox" id="gul-hide" />
<label for="gul-hide" style="margin:0;">Hide username (<code>hidden = 1</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>hidden = 2</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 );


GULDialog.prototype.getActionProcess = function ( action ) {
$body.append(
if ( action === 'close' ) {
$( '<div>' ).addClass( 'gul-actions' ).append(
return new OO.ui.Process( function () {
$( '<button>' ).addClass( 'gul-primary' ).attr( 'id', 'gul-btn-search' ).text( '🔍 Search Accounts' ),
this.close();
$( '<button>' ).addClass( 'gul-danger'  ).attr( 'id', 'gul-btn-lock'  ).text( '🔒 Lock Selected' ).prop( 'disabled', true ),
}, this );
$( '<button>' )                          .attr( 'id', 'gul-btn-clear'  ).text( 'Clear' )
}
)
return GULDialog.super.prototype.getActionProcess.call( this, action );
);
};


// Allow the dialog body to grow with results
$body.append( $( '<div>' ).addClass( 'gul-status' ).attr( 'id', 'gul-status' ) );
GULDialog.prototype.getBodyHeight = function () {
$body.append( $( '<div>' ).addClass( 'gul-progress' ).attr( 'id', 'gul-progress' ).append(
return Math.min( Math.round( window.innerHeight * 0.82 ), 680 );
$( '<div>' ).addClass( 'gul-progress-bar' ).attr( 'id', 'gul-progress-bar' )
};
) );


let windowManager = null;
$body.append( $( '<div>' ).addClass( 'gul-results-wrap' ).attr( 'id', 'gul-results-wrap' ) );


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


// ── CSS (injected once) ────────────────────────────────────────────────────
// ── Bind events ────────────────────────────────────────────────────────
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 );
} );
}


let cssInjected = false;
// ── 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();
}
function setBadge( username, type, text ) {
$( 'tr[data-user="' + $.escapeSelector( 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 + '%' );
}


function injectCSS() {
// ── API helpers ────────────────────────────────────────────────────────
if ( cssInjected ) { return; }
function fetchMatchingUsers( query, fromUser ) {
cssInjected = true;
const deferred    = $.Deferred();
const results    = [];
const queryLc    = query.toLowerCase();
let  continueFrom = fromUser || undefined;
let  exhausted  = false;
let  page        = 0;


mw.util.addCSS( `
setStatus( 'Searching CentralAuth for matching usernames…', 'info' );
/* ── 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 */
function fetchPage() {
--gul-info-bg:      #eaf3fb;
if ( exhausted || results.length >= MAX_ACCOUNTS ) {
--gul-info-border:  #72a0d3;
deferred.resolve( results.slice( 0, MAX_ACCOUNTS ) );
--gul-info-text:    #0645ad;
return;
 
/* 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)
const params = {
      and Timeless/Monobook night variants ──────────────────────────── */
action: 'query',
.skin-theme-clientpref-night .gul-body,
list:    'globalallusers',
.skin-theme-clientpref-night .gul-results-wrap,
agulimit: '500',
.skin-theme-clientpref-night .gul-select-all-row,
aguprop: 'lockinfo',
.skin-theme-clientpref-night .gul-actions,
format: 'json'
.skin-theme-clientpref-night .gul-checkbox-row {
};
--gul-bg:           #1e2124;
if ( continueFrom ) { params.agufrom = continueFrom; }
--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;
api.get( params )
--gul-info-border:  #3a6ea8;
.done( function ( data ) {
--gul-info-text:    #8ab4f8;
const users = ( data.query || {} ).globalallusers || [];
page++;


--gul-ok-bg:        #0d2b24;
users.forEach( function ( u ) {
--gul-ok-border:    #1d6b58;
if ( u.name.toLowerCase().includes( queryLc ) ) {
--gul-ok-text:      #4dd0a8;
results.push( u );
}
} );


--gul-err-bg:      #2d1212;
setStatus(
--gul-err-border:  #a02020;
'Searched ~' + ( page * 500 ) + ' usernames, found ' +
--gul-err-text:    #f08080;
results.length + ' match(es) so far…', 'info'
);


--gul-warn-bg:      #2b2100;
if ( !users.length || !( data.continue && data.continue.agufrom ) ) {
--gul-warn-border:  #a87f00;
exhausted = true;
--gul-warn-text:    #f0c040;
deferred.resolve( results.slice( 0, MAX_ACCOUNTS ) );
 
} else {
--gul-badge-neutral-bg:  #2e3136;
continueFrom = data.continue.agufrom;
--gul-badge-neutral-text: #a2a9b1;
fetchPage();
--gul-badge-locked-bg:    #0d2b24;
}
--gul-badge-locked-text:  #4dd0a8;
} )
--gul-badge-failed-bg:    #2d1212;
.fail( function ( code ) {
--gul-badge-failed-text:  #f08080;
deferred.reject( 'API request failed: ' + code );
--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 ─────────────────────────────────────────────── */
fetchPage();
.gul-body {
return deferred.promise();
padding: 14px 16px;
}
font-family: inherit;
color: var( --gul-text );
overflow-y: auto;
}
.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 ─────────────────────────────────────────────────────── */
function lockAccount( username, reason, hide, suppress ) {
.gul-checkbox-row {
const payload = {
display: flex;
action: 'setglobalaccountstatus',
align-items: center;
user:   username,
gap: 6px;
locked: 'lock',
margin-top: 8px;
reason: reason,
font-size: 0.92em;
format: 'json'
color: var( --gul-text );
};
}
.gul-checkbox-row input[type="checkbox"] {
width: auto;
margin: 0;
accent-color: var( --gul-track-fill );
}


/* ── Buttons ────────────────────────────────────────────────────────── */
if ( suppress ) {
.gul-actions {
payload.hidden = 'suppressed';
display: flex;
} else if ( hide ) {
gap: 8px;
payload.hidden = 'lists';
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 ─────────────────────────────────────────────────── */
return api.postWithToken( 'setglobalaccountstatus', payload );
.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 ───────────────────────────────────────────────────── */
// ── Render results table ───────────────────────────────────────────────
.gul-progress {
function renderResultsTable( users ) {
height: 8px;
const $wrap = $( '#gul-results-wrap' ).empty().removeClass( 'visible' );
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 ──────────────────────────────────────────────────── */
if ( users.length === 0 ) {
.gul-results-wrap {
$wrap.append( '<p><em>No matching accounts found.</em></p>' ).addClass( 'visible' );
display: none;
return;
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 ─────────────────────────────────────────────────────────── */
$wrap.append(
.gul-badge {
$( '<div>' ).addClass( 'gul-select-all-row' ).append(
display: inline-block;
$( '<input>' ).attr( { type: 'checkbox', id: 'gul-select-all' } ).prop( 'checked', true ),
padding: 1px 7px;
$( '<label>' ).attr( 'for', 'gul-select-all' ).css( 'margin', '0' ).text( 'Select / deselect all' ),
border-radius: 10px;
$( '<span>' ).addClass( 'gul-count' ).text( users.length + ' account(s) found' )
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 ─────────────────────────────────────────────────── */
const $tbody = $( '<tbody>' );
.gul-summary {
users.forEach( function ( u ) {
font-size: 0.9em;
const isLocked = u.locked !== undefined;
margin-top: 10px;
const $row = $( '<tr>' ).attr( 'data-user', u.name );
padding: 8px 12px;
background: var( --gul-bg );
border: 1px solid var( --gul-border );
border-radius: 2px;
color: var( --gul-text );
}
` );
}


// ── Build panel content ────────────────────────────────────────────────────
$row.append(
 
$( '<td>' ).append( $( '<input>' ).attr( { type: 'checkbox', class: 'gul-user-cb' } ).val( u.name ).prop( 'checked', true ) ),
function buildPanelContent() {
$( '<td>' ).append( $( '<a>' ).attr( { href: mw.util.getUrl( 'Special:CentralAuth/' + u.name ), target: '_blank', rel: 'noopener' } ).text( u.name ) ),
const $body = $( '<div>' ).addClass( 'gul-body' );
$( '<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>' )
// Search fieldset
);
const $searchFs = $( '<fieldset>' ).append( '<legend>Search Parameters</legend>' );
$tbody.append( $row );
$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 ─────────────────────────────────────────────────────────────
$wrap.append(
 
$( '<table>' ).addClass( 'gul-results-table' ).append(
function setStatus( msg, type ) {
$( '<thead>' ).append( $( '<tr>' ).append(
$( '#gul-status' )
$( '<th>' ).css( 'width', '28px' ), $( '<th>' ).text( 'Username' ),
.removeClass( 'info success error warning' )
$( '<th>' ).text( 'Currently locked?' ), $( '<th>' ).css( 'width', '110px' ).text( 'Action status' )
.addClass( type )
) ),
.html( msg );
$tbody
}
)
 
).addClass( 'visible' );
function clearStatus() {
}
$( '#gul-status' )
.removeClass( 'info success error warning' )
.html( '' )
.hide();
}
 
function setBadge( username, type, text ) {
$( 'tr[data-user="' + $.escapeSelector( username ) + '"] .gul-status-cell' )
.html( '<span class="gul-badge gul-badge-' + type + '">' + text + '</span>' );
}


function setProgress( done, total ) {
// ── Main handlers ──────────────────────────────────────────────────────
const pct = total > 0 ? Math.round( done / total * 100 ) : 0;
function onSearch() {
$( '#gul-progress-bar' ).css( 'width', pct + '%' );
const query = $( '#gul-query' ).val().trim();
}
if ( !query ) {
 
setStatus( 'Please enter a search string.', 'error' );
// ── 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;
return;
}
}


const params = {
$( '#gul-btn-search, #gul-btn-lock' ).prop( 'disabled', true );
action:  'query',
$( '#gul-progress' ).removeClass( 'visible' );
list:    'globalallusers',
clearStatus();
agulimit: '500',
aguprop: 'lockinfo', // NOTE: 'registration' is not a valid globalallusers property
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 ) ) {
fetchMatchingUsers( query, $( '#gul-from' ).val().trim() )
exhausted = true;
.done( function ( users ) {
deferred.resolve( results.slice( 0, MAX_ACCOUNTS ) );
renderResultsTable( users );
if ( users.length > 0 ) {
$( '#gul-btn-lock' ).prop( 'disabled', false );
setStatus( 'Found <strong>' + users.length + '</strong> account(s) matching "' + mw.html.escape( query ) + '".' + ( users.length >= MAX_ACCOUNTS ? ' (Capped at ' + MAX_ACCOUNTS + ')' : '' ), 'info' );
} else {
} else {
continueFrom = data.continue.agufrom;
setStatus( 'No accounts found matching "' + mw.html.escape( query ) + '".', 'warning' );
fetchPage();
}
}
} )
} )
.fail( function ( xhr ) {
.fail( function ( err ) { setStatus( 'Search failed: ' + mw.html.escape( String( err ) ), 'error' ); } )
deferred.reject( 'API request failed: ' + xhr.statusText );
.always( function () { $( '#gul-btn-search' ).prop( 'disabled', false ); } );
} );
}
}


fetchPage();
function onLock() {
return deferred.promise();
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 ) {  
* Lock a single global account via the CentralAuth API.
setStatus( '🛑 A lock reason is required before locking.', 'error' );
* Uses action=setglobalaccountstatus per Extension:CentralAuth/API.
$( '#gul-reason' ).trigger( 'focus' );
*/
return;
function lockAccount( username, token, reason, hide, suppress ) {
}
const payload = {
action: 'setglobalaccountstatus',
user:  username,
locked: 'lock',
reason: reason,
token:  token,
format: 'json'
};


// Only include the hidden parameter if we intend to actively hide/suppress.  
const selected = [];
// Sending an empty string would accidentally UNHIDE previously hidden accounts.
$( '.gul-user-cb:checked' ).each( function () { selected.push( $( this ).val() ); } );
if ( suppress ) {
payload.hidden = 'suppressed';
} else if ( hide ) {
payload.hidden = 'lists';
}


return $.ajax( {
if ( selected.length === 0 ) { setStatus( 'No accounts selected.', 'warning' ); return; }
url:    API_ENDPOINT,
method: 'POST',
data:  payload
} );
}


// ── Render results table ───────────────────────────────────────────────────
const $btn = $( '#gul-btn-lock' );


function renderResultsTable( users ) {
// ── INLINE CONFIRMATION ──
const $wrap = $( '#gul-results-wrap' ).empty().removeClass( 'visible' );
// First click: Change button state and wait for second click
if ( !$btn.data( 'ready-to-lock' ) ) {
$btn.data( 'ready-to-lock', true )
.text( '⚠️ Confirm Lock (' + selected.length + ')' )
.css( { 'background-color': '#5a1010', 'border-color': '#3a0000' } );


if ( users.length === 0 ) {
setStatus( '⚠️ <strong>WARNING:</strong> You are about to lock ' + selected.length + ' account(s). Click the button again to proceed.', 'warning' );
$wrap.append( '<p><em>No matching accounts found.</em></p>' ).addClass( 'visible' );
return;
}


// Select-all header row
// Reset if they don't click within 5 seconds
$wrap.append(
setTimeout( function () {
$( '<div>' ).addClass( 'gul-select-all-row' ).append(
if ( $btn.data( 'ready-to-lock' ) ) {
$( '<input>' ).attr( { type: 'checkbox', id: 'gul-select-all' } ).prop( 'checked', true ),
$btn.data( 'ready-to-lock', false )
$( '<label>' ).attr( 'for', 'gul-select-all' ).css( 'margin', '0' )
.text( '🔒 Lock Selected' )
.text( 'Select / deselect all' ),
.css( { 'background-color': '', 'border-color': '' } );
$( '<span>' ).addClass( 'gul-count' ).text( users.length + ' account(s) found' )
clearStatus();
)
}
);
}, 5000 );
return;
}


// Table
// Second click: Reset button visuals and proceed
const $tbody = $( '<tbody>' );
$btn.data( 'ready-to-lock', false ).css( { 'background-color': '', 'border-color': '' } );
users.forEach( function ( u ) {
// MW API boolean flags return an empty string ("") if true. Must check for property existence.
const isLocked = u.locked !== undefined;
// Use data-user safely instead of building an ID to avoid regex collision bugs
$( '#gul-btn-lock, #gul-btn-search' ).prop( 'disabled', true );
const $row = $( '<tr>' ).attr( 'data-user', u.name );
$( '#gul-progress' ).addClass( 'visible' );
setProgress( 0, selected.length );
setStatus( 'Locking ' + selected.length + ' account(s)…', 'info' );
let done = 0, locked = 0, skipped = 0, failed = 0;


$row.append(
function processNext( i ) {
$( '<td>' ).append(
if ( i >= selected.length ) {
$( '<input>' ).attr( { type: 'checkbox', class: 'gul-user-cb' } )
setProgress( done, selected.length );
.val( u.name ).prop( 'checked', true )
setStatus( 'Done. <strong>' + locked + '</strong> locked, <strong>' + skipped + '</strong> skipped, <strong>' + failed + '</strong> failed.', failed > 0 ? 'warning' : 'success' );
),
$( '#gul-btn-search' ).prop( 'disabled', false );
$( '<td>' ).append(
$( '#gul-lock-summary' ).remove();
$( '<a>' )
$( '#gul-results-wrap' ).append( $( '<div id="gul-lock-summary">' ).addClass( 'gul-summary' ).html( '<strong>Summary:</strong> ' + locked + ' locked · ' + skipped + ' skipped · ' + failed + ' failed' ) );
.attr( {
return;
href:  mw.util.getUrl( 'Special:CentralAuth/' + u.name ),
}
target: '_blank',
rel:   'noopener'
} )
.text( u.name )
),
$( '<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(
const username = selected[ i ];
$( '<table>' ).addClass( 'gul-results-table' ).append(
const wasLocked = $( 'tr[data-user="' + $.escapeSelector( username ) + '"] td:nth-child(3)' ).text().trim() === 'Locked';
$( '<thead>' ).append(
$( '<tr>' ).append(
$( '<th>' ).css( 'width', '28px' ),
$( '<th>' ).text( 'Username' ),
$( '<th>' ).text( 'Currently locked?' ),
$( '<th>' ).css( 'width', '110px' ).text( 'Action status' )
)
),
$tbody
)
).addClass( 'visible' );
}


// ── Main handlers ──────────────────────────────────────────────────────────
if ( skipLocked && wasLocked ) {
 
skipped++; done++;
function onSearch() {
setBadge( username, 'skipped', 'Skipped' );
const query = $( '#gul-query' ).val().trim();
setProgress( done, selected.length );
if ( !query ) {
processNext( i + 1 );
setStatus( 'Please enter a search string.', 'error' );
return;
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() {
setBadge( username, 'pending', 'Locking…' );
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 ) {
lockAccount( username, reason, hide, suppress )
setStatus( 'A lock reason is required.', 'error' );
.done( function ( result ) {
return;
if ( result.error ) {
failed++;
setBadge( username, 'failed', 'Error: ' + mw.html.escape( result.error.info || result.error.code ) );
} else {
locked++;
setBadge( username, 'locked', 'Locked ✓' );
}
} )
.fail( function ( code, result ) {
failed++;
const errMsg = (result && result.error && result.error.info) ? result.error.info : code;
setBadge( username, 'failed', 'Error: ' + mw.html.escape( errMsg ) );
} )
.always( function () {
done++;
setProgress( done, selected.length );
setStatus( 'Processing ' + done + ' / ' + selected.length + '…', 'info' );
setTimeout( function () { processNext( i + 1 ); }, 300 );
} );
}
processNext( 0 );
}
}


const selected = [];
function onClear() {
$( '.gul-user-cb:checked' ).each( function () { selected.push( $( this ).val() ); } );
// Reset the confirmation button state if clear is clicked
const $btn = $( '#gul-btn-lock' );
$btn.data( 'ready-to-lock', false )
.text( '🔒 Lock Selected' )
.css( { 'background-color': '', 'border-color': '' } )
.prop( 'disabled', true );


if ( selected.length === 0 ) {
$( '#gul-query, #gul-from, #gul-reason' ).val( '' );
setStatus( 'No accounts selected.', 'warning' );
$( '#gul-hide, #gul-suppress' ).prop( 'checked', false );
return;
$( '#gul-skip-locked' ).prop( 'checked', true );
$( '#gul-results-wrap' ).removeClass( 'visible' ).empty();
$( '#gul-progress' ).removeClass( 'visible' );
$( '#gul-progress-bar' ).css( 'width', '0%' );
clearStatus();
}
}


if ( !confirm(
} ); // End of mw.loader.using().then()
'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 = $( 'tr[data-user="' + $.escapeSelector( username ) + '"] td:nth-child(3)' )
.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 if ( data.setglobalaccountstatus ) {
locked++;
setBadge( username, 'locked', 'Locked ✓' );
} else {
// Unexpected response shape — treat as success if no error key
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 token. Are you logged in with the centralauth-lock right?',
'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 );
}


}() );
}() );

Latest revision as of 20:31, 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.
 */

( function () {
	'use strict';

	// ── Constants ──────────────────────────────────────────────────────────────
	const TOOL_TITLE   = 'Global User Lock';
	const PORTLET_ID   = 'ca-global-user-lock';
	const MAX_ACCOUNTS = 200;

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

		// Initialize the MediaWiki API helper
		const api = new mw.Api();

		// ── OOUI Dialog Definition ─────────────────────────────────────────────
		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 );
		};

		GULDialog.prototype.getBodyHeight = function () {
			return Math.min( Math.round( window.innerHeight * 0.82 ), 680 );
		};

		// ── Dialog Manager ─────────────────────────────────────────────────────
		let windowManager = null;

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

		// ── Entry point ────────────────────────────────────────────────────────
		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();
		} );

		// ── CSS (injected once) ────────────────────────────────────────────────
		let cssInjected = false;
		function injectCSS() {
			if ( cssInjected ) { return; }
			cssInjected = true;

			mw.util.addCSS( `
				.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;
					--gul-info-bg:      #eaf3fb;
					--gul-info-border:  #72a0d3;
					--gul-info-text:    #0645ad;
					--gul-ok-bg:        #d5fdf4;
					--gul-ok-border:    #14866d;
					--gul-ok-text:      #14866d;
					--gul-err-bg:       #fee7e6;
					--gul-err-border:   #d73333;
					--gul-err-text:     #b32424;
					--gul-warn-bg:      #fef6e7;
					--gul-warn-border:  #fc3;
					--gul-warn-text:    #b45a00;
					--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;
					--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;
					--gul-track-bg:   #eaecf0;
					--gul-track-fill: #3366cc;
				}
				@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;
					}
				}
				.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;
				}
				.gul-body { padding: 14px 16px; font-family: inherit; color: var(--gul-text); overflow-y: auto; }
				.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; }
				.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); }
				.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; }
				.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; }
				.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; }
				.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; }
				.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); }
				.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' );

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

			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 = 1</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>hidden = 2</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 );

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

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

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

			return $body;
		}

		// ── Bind events ────────────────────────────────────────────────────────
		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();
		}
		function setBadge( username, type, text ) {
			$( 'tr[data-user="' + $.escapeSelector( 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 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',
					format:  'json'
				};
				if ( continueFrom ) { params.agufrom = continueFrom; }

				api.get( 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 ( code ) {
						deferred.reject( 'API request failed: ' + code );
					} );
			}

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

		function lockAccount( username, reason, hide, suppress ) {
			const payload = {
				action: 'setglobalaccountstatus',
				user:   username,
				locked: 'lock',
				reason: reason,
				format: 'json'
			};

			if ( suppress ) {
				payload.hidden = 'suppressed';
			} else if ( hide ) {
				payload.hidden = 'lists';
			}

			return api.postWithToken( 'setglobalaccountstatus', payload );
		}

		// ── 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;
			}

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

			const $tbody = $( '<tbody>' );
			users.forEach( function ( u ) {
				const isLocked = u.locked !== undefined;
				const $row = $( '<tr>' ).attr( 'data-user', 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>' ).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( '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, #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 "' + mw.html.escape( query ) + '".' + ( users.length >= MAX_ACCOUNTS ? ' (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 before locking.', 'error' ); 
				$( '#gul-reason' ).trigger( 'focus' );
				return; 
			}

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

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

			const $btn = $( '#gul-btn-lock' );

			// ── INLINE CONFIRMATION ──
			// First click: Change button state and wait for second click
			if ( !$btn.data( 'ready-to-lock' ) ) {
				$btn.data( 'ready-to-lock', true )
					.text( '⚠️ Confirm Lock (' + selected.length + ')' )
					.css( { 'background-color': '#5a1010', 'border-color': '#3a0000' } );

				setStatus( '⚠️ <strong>WARNING:</strong> You are about to lock ' + selected.length + ' account(s). Click the button again to proceed.', 'warning' );

				// Reset if they don't click within 5 seconds
				setTimeout( function () {
					if ( $btn.data( 'ready-to-lock' ) ) {
						$btn.data( 'ready-to-lock', false )
							.text( '🔒 Lock Selected' )
							.css( { 'background-color': '', 'border-color': '' } );
						clearStatus();
					}
				}, 5000 );
				return;
			}

			// Second click: Reset button visuals and proceed
			$btn.data( 'ready-to-lock', false ).css( { 'background-color': '', 'border-color': '' } );
			
			$( '#gul-btn-lock, #gul-btn-search' ).prop( 'disabled', true );
			$( '#gul-progress' ).addClass( 'visible' );
			setProgress( 0, selected.length );
			
			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 = $( 'tr[data-user="' + $.escapeSelector( username ) + '"] td:nth-child(3)' ).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, reason, hide, suppress )
					.done( function ( result ) {
						if ( result.error ) {
							failed++;
							setBadge( username, 'failed', 'Error: ' + mw.html.escape( result.error.info || result.error.code ) );
						} else {
							locked++;
							setBadge( username, 'locked', 'Locked ✓' );
						}
					} )
					.fail( function ( code, result ) { 
						failed++; 
						const errMsg = (result && result.error && result.error.info) ? result.error.info : code;
						setBadge( username, 'failed', 'Error: ' + mw.html.escape( errMsg ) ); 
					} )
					.always( function () {
						done++;
						setProgress( done, selected.length );
						setStatus( 'Processing ' + done + ' / ' + selected.length + '…', 'info' );
						setTimeout( function () { processNext( i + 1 ); }, 300 );
					} );
			}
			processNext( 0 );
		}

		function onClear() {
			// Reset the confirmation button state if clear is clicked
			const $btn = $( '#gul-btn-lock' );
			$btn.data( 'ready-to-lock', false )
				.text( '🔒 Lock Selected' )
				.css( { 'background-color': '', 'border-color': '' } )
				.prop( 'disabled', true );

			$( '#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();
		}

	} ); // End of mw.loader.using().then()

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