User:Justarandomamerican/globalUserLock.js: Difference between revisions
From WikiOasis Meta
More actions
Fix |
No edit summary |
||
| (10 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. | ||
*/ | */ | ||
| Line 27: | 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 MAX_ACCOUNTS = 200; | const MAX_ACCOUNTS = 200; | ||
// ── | // ── Load Dependencies First ──────────────────────────────────────────────── | ||
$.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 () { | ||
// | // 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 58: | Line 84: | ||
} ); | } ); | ||
// ── 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' ); | |||
} | |||
function | // ── Main handlers ────────────────────────────────────────────────────── | ||
if ( | function onSearch() { | ||
const query = $( '#gul-query' ).val().trim(); | |||
if ( !query ) { | |||
setStatus( 'Please enter a search string.', 'error' ); | |||
return; | return; | ||
} | } | ||
$( '#gul-btn-search, #gul-btn-lock' ).prop( 'disabled', true ); | |||
$( '#gul-progress' ).removeClass( 'visible' ); | |||
clearStatus(); | |||
$ | |||
if ( | 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 { | } else { | ||
setStatus( 'No accounts found matching "' + mw.html.escape( query ) + '".', 'warning' ); | |||
} | } | ||
} ) | } ) | ||
.fail( function ( | .fail( function ( err ) { setStatus( 'Search failed: ' + mw.html.escape( String( err ) ), 'error' ); } ) | ||
.always( function () { $( '#gul-btn-search' ).prop( 'disabled', false ); } ); | |||
} | } | ||
const | 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; | |||
reason | |||
} | } | ||
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 ); | |||
} | } | ||
const | 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() | |||
}() ); | }() ); | ||
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()
}() );