User:Justarandomamerican/globalUserLock.js: Difference between revisions
From WikiOasis Meta
More actions
No edit summary |
Fix |
||
| Line 7: | Line 7: | ||
* by stewards or staff with global lock permissions. | * by stewards or staff with global lock permissions. | ||
* | * | ||
* Usage: Add to your | * Activation: adds a "Global user lock" link to the "More" (p-cactions) | ||
* mw.loader.load(' | * menu on every page, matching the pattern used by all-in-one.js. | ||
* Clicking it opens an OOUI dialog. | |||
* | |||
* Usage: Add to your global.js / common.js: | |||
* mw.loader.load('//meta.wikimedia.org/w/index.php?title=User:YourName/globalUserLock.js&action=raw&ctype=text/javascript'); | |||
* | * | ||
* API actions used: | * API actions used: | ||
* - query + list=globalallusers (find matching accounts) | * - query + list=globalallusers (find matching accounts) | ||
* - centralauth (lock accounts) | * - centralauth (lock accounts) | ||
* - | * - query + meta=tokens (CSRF) | ||
* | * | ||
* Author: | * Author: WikiOasis T&S tooling | ||
* License: | * License: MIT | ||
*/ | */ | ||
| Line 24: | Line 28: | ||
// ── Constants ────────────────────────────────────────────────────────────── | // ── Constants ────────────────────────────────────────────────────────────── | ||
const | const TOOL_TITLE = 'Global User Lock'; | ||
const | const PORTLET_ID = 'ca-global-user-lock'; | ||
const API_ENDPOINT = mw.config.get( 'wgScriptPath' ) + '/api.php'; | const API_ENDPOINT = mw.config.get( 'wgScriptPath' ) + '/api.php'; | ||
| Line 31: | Line 35: | ||
const MAX_ACCOUNTS = 200; | const MAX_ACCOUNTS = 200; | ||
// ── | // ── Entry point ──────────────────────────────────────────────────────────── | ||
// Matches the all-in-one.js pattern exactly: | |||
// $.when( mw.loader.using([...]), $.ready ).then( function () { ... } ); | |||
$.when( | |||
mw.loader.using( [ 'mediawiki.util', 'oojs-ui-core', 'oojs-ui-widgets', 'oojs-ui-windows' ] ), | |||
$.ready | |||
).then( function () { | |||
// Add "Global user lock" to the "More" (p-cactions) dropdown | |||
const $link = $( mw.util.addPortletLink( | |||
'p-cactions', | |||
'#', | |||
TOOL_TITLE, | |||
PORTLET_ID, | |||
'Find and globally lock accounts by username substring' | |||
) ); | |||
$link.on( 'click', function ( e ) { | |||
e.preventDefault(); | |||
openDialog(); | |||
} ); | |||
} ); | |||
// ── OOUI Dialog ──────────────────────────────────────────────────────────── | |||
function GULDialog( config ) { | |||
GULDialog.super.call( this, config ); | |||
} | |||
OO.inheritClass( GULDialog, OO.ui.ProcessDialog ); | |||
GULDialog.static.name = 'gulDialog'; | |||
GULDialog.static.title = TOOL_TITLE; | |||
GULDialog.static.size = 'large'; | |||
GULDialog.static.actions = [ | |||
{ action: 'close', label: 'Close', flags: [ 'safe', 'close' ] } | |||
]; | |||
GULDialog.prototype.initialize = function () { | |||
GULDialog.super.prototype.initialize.call( this ); | |||
injectCSS(); | |||
this.$body.append( buildPanelContent() ); | |||
bindEvents(); | |||
}; | |||
GULDialog.prototype.getActionProcess = function ( action ) { | |||
if ( action === 'close' ) { | |||
return new OO.ui.Process( function () { | |||
this.close(); | |||
}, this ); | |||
} | } | ||
return GULDialog.super.prototype.getActionProcess.call( this, action ); | |||
}; | |||
// Allow the dialog body to grow with results | |||
GULDialog.prototype.getBodyHeight = function () { | |||
return Math.min( Math.round( window.innerHeight * 0.82 ), 680 ); | |||
}; | |||
let windowManager = null; | |||
function openDialog() { | |||
if ( !windowManager ) { | |||
windowManager = new OO.ui.WindowManager(); | |||
$( document.body ).append( windowManager.$element ); | |||
windowManager.addWindows( [ new GULDialog() ] ); | |||
} | } | ||
windowManager.openWindow( 'gulDialog' ); | |||
} | |||
// ── CSS (injected once) ──────────────────────────────────────────────────── | |||
let cssInjected = false; | |||
function | function injectCSS() { | ||
if ( cssInjected ) { return; } | |||
cssInjected = true; | |||
mw.util.addCSS( ` | |||
.gul-body { | |||
padding: 14px 16px; | |||
font-family: inherit; | |||
} | |||
.gul-body fieldset { | |||
border: 1px solid #c8ccd1; | |||
border-radius: 3px; | |||
padding: 10px 14px; | |||
margin: 0 0 14px; | |||
background: #fff; | |||
} | |||
.gul-body legend { | |||
font-weight: bold; | |||
padding: 0 4px; | |||
font-size: 0.95em; | |||
} | |||
.gul-body label { | |||
display: block; | |||
margin-bottom: 4px; | |||
font-size: 0.92em; | |||
} | |||
.gul-body input[type="text"], | |||
.gul-body textarea { | |||
width: 100%; | |||
box-sizing: border-box; | |||
padding: 5px 7px; | |||
border: 1px solid #a2a9b1; | |||
border-radius: 2px; | |||
font-size: 0.93em; | |||
font-family: inherit; | |||
background: #fff; | |||
} | |||
.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; | |||
} | |||
.gul-checkbox-row input[type="checkbox"] { | |||
width: auto; | |||
margin: 0; | |||
} | |||
.gul-actions { | |||
display: flex; | |||
gap: 8px; | |||
flex-wrap: wrap; | |||
margin-top: 6px; | |||
} | |||
.gul-actions button { | |||
padding: 6px 14px; | |||
border: 1px solid #a2a9b1; | |||
border-radius: 2px; | |||
cursor: pointer; | |||
font-size: 0.92em; | |||
font-family: inherit; | |||
background: #fff; | |||
} | |||
.gul-actions button.gul-primary { | |||
background: #36c; | |||
color: #fff; | |||
border-color: #2a4b8d; | |||
font-weight: bold; | |||
} | |||
.gul-actions button.gul-primary:hover { background: #2a4b8d; } | |||
.gul-actions button.gul-danger { | |||
background: #d73333; | |||
color: #fff; | |||
border-color: #b32424; | |||
font-weight: bold; | |||
} | |||
.gul-actions button.gul-danger:hover { background: #b32424; } | |||
.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: #eaf3fb; border: 1px solid #72a0d3; display: block; } | |||
.gul-status.success { background: #d5fdf4; border: 1px solid #14866d; display: block; } | |||
.gul-status.error { background: #fee7e6; border: 1px solid #d73333; display: block; } | |||
.gul-status.warning { background: #fef6e7; border: 1px solid #fc3; display: block; } | |||
.gul-progress { | |||
height: 8px; | |||
background: #eaecf0; | |||
border-radius: 4px; | |||
overflow: hidden; | |||
margin-top: 8px; | |||
display: none; | |||
} | |||
.gul-progress.visible { display: block; } | |||
.gul-progress-bar { | |||
height: 100%; | |||
background: #36c; | |||
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; | |||
} | |||
.gul-count { | |||
color: #555; | |||
font-size: 0.88em; | |||
margin-left: auto; | |||
} | |||
.gul-results-table { | |||
width: 100%; | |||
border-collapse: collapse; | |||
font-size: 0.89em; | |||
background: #fff; | |||
} | |||
.gul-results-table th, | |||
.gul-results-table td { | |||
border: 1px solid #c8ccd1; | |||
padding: 5px 8px; | |||
text-align: left; | |||
vertical-align: middle; | |||
} | |||
.gul-results-table th { background: #eaecf0; font-weight: bold; } | |||
.gul-results-table tr:nth-child(even) td { background: #f8f9fa; } | |||
.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 { background: #eaecf0; color: #555; } | |||
.gul-badge-active { background: #eaecf0; color: #555; } | |||
.gul-badge-locked { background: #d5fdf4; color: #14866d; } | |||
.gul-badge-failed { background: #fee7e6; color: #d73333; } | |||
.gul-badge-skipped { background: #fef6e7; color: #b45a00; } | |||
.gul-summary { | |||
font-size: 0.9em; | |||
margin-top: 10px; | |||
padding: 8px 12px; | |||
background: #fff; | |||
border: 1px solid #c8ccd1; | |||
border-radius: 2px; | |||
} | |||
` ); | ` ); | ||
} | |||
// ── Build panel content ──────────────────────────────────────────────────── | |||
function buildPanelContent() { | |||
const $body = $( '<div>' ).addClass( 'gul-body' ); | const $body = $( '<div>' ).addClass( 'gul-body' ); | ||
// | // Search fieldset | ||
const $searchFs = $( '<fieldset>' ).append( '<legend>Search Parameters</legend>' ); | const $searchFs = $( '<fieldset>' ).append( '<legend>Search Parameters</legend>' ); | ||
$searchFs.append( ` | $searchFs.append( ` | ||
<label for="gul-query">Username contains (substring match, case-insensitive):</label> | <label for="gul-query">Username contains (substring match, case-insensitive):</label> | ||
<input type="text" id="gul-query" placeholder="e.g. spambot, vandal2024" autocomplete="off" /> | <input type="text" id="gul-query" placeholder="e.g. spambot, vandal2024" autocomplete="off" /> | ||
` ); | ` ); | ||
$searchFs.append( ` | $searchFs.append( ` | ||
<label style="margin-top:10px;" for="gul-from"> | <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" /> | |||
<input type="text" id="gul-from" placeholder="Leave blank to start from beginning" autocomplete="off" /> | |||
` ); | ` ); | ||
$body.append( $searchFs ); | $body.append( $searchFs ); | ||
// | // Lock options fieldset | ||
const $lockFs = $( '<fieldset>' ).append( '<legend>Lock Options</legend>' ); | const $lockFs = $( '<fieldset>' ).append( '<legend>Lock Options</legend>' ); | ||
$lockFs.append( ` | $lockFs.append( ` | ||
<label for="gul-reason">Lock reason (will appear in CentralAuth log):</label> | <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> | <textarea id="gul-reason" rows="2" | ||
placeholder="e.g. Cross-wiki spam; username policy violation"></textarea> | |||
` ); | ` ); | ||
$lockFs.append( ` | $lockFs.append( ` | ||
<div class="gul-checkbox-row"> | <div class="gul-checkbox-row"> | ||
<input type="checkbox" id="gul-hide" /> | <input type="checkbox" id="gul-hide" /> | ||
<label for="gul-hide" style="margin:0;">Hide username ( | <label for="gul-hide" style="margin:0;"> | ||
Hide username (<code>hidden</code> flag — requires suppressor right) | |||
</label> | |||
</div> | </div> | ||
<div class="gul-checkbox-row"> | <div class="gul-checkbox-row"> | ||
<input type="checkbox" id="gul-suppress" /> | <input type="checkbox" id="gul-suppress" /> | ||
<label for="gul-suppress" style="margin:0;">Suppress username ( | <label for="gul-suppress" style="margin:0;"> | ||
Suppress username (<code>suppressed</code> flag — requires suppressor right) | |||
</label> | |||
</div> | </div> | ||
<div class="gul-checkbox-row"> | <div class="gul-checkbox-row"> | ||
<input type="checkbox" id="gul-skip-locked" checked /> | <input type="checkbox" id="gul-skip-locked" checked /> | ||
<label for="gul-skip-locked" style="margin:0;">Skip accounts already globally locked</label> | <label for="gul-skip-locked" style="margin:0;"> | ||
Skip accounts that are already globally locked | |||
</label> | |||
</div> | </div> | ||
` ); | ` ); | ||
$body.append( $lockFs ); | $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 | // Status + progress bar | ||
$body.append( $( '<div>' ).addClass( 'gul-status' ).attr( 'id', 'gul-status' ) ); | |||
$body.append( $ | $body.append( | ||
$( '<div>' ).addClass( 'gul-progress' ).attr( 'id', 'gul-progress' ).append( | |||
$( '<div>' ).addClass( 'gul-progress-bar' ).attr( 'id', 'gul-progress-bar' ) | |||
) | |||
); | |||
// | // Results area | ||
$body.append( $( '<div>' ).addClass( 'gul-results-wrap' ).attr( 'id', 'gul-results-wrap' ) ); | |||
return $body; | |||
} | |||
// ── Bind events (delegated so they survive dialog re-opens) ─────────────── | |||
function bindEvents() { | |||
$( document ) | |||
.off( '.gul' ) | |||
.on( 'click.gul', '#gul-btn-search', onSearch ) | |||
.on( 'click.gul', '#gul-btn-lock', onLock ) | |||
.on( 'click.gul', '#gul-btn-clear', onClear ) | |||
.on( 'keydown.gul', '#gul-query', function ( e ) { | |||
if ( e.key === 'Enter' ) { onSearch(); } | |||
} ) | |||
.on( 'change.gul', '#gul-select-all', function () { | |||
$( '.gul-user-cb' ).prop( 'checked', this.checked ); | |||
} ); | |||
} | } | ||
// ── | // ── UI helpers ───────────────────────────────────────────────────────────── | ||
function setStatus( msg, type ) { | function setStatus( msg, type ) { | ||
$( '#gul-status' ) | |||
.removeClass( 'info success error warning' ) | |||
.addClass( type ) | |||
.html( msg ); | |||
} | } | ||
function clearStatus() { | function clearStatus() { | ||
$( '#gul-status' ).removeClass( 'info success error warning' ).hide(). | $( '#gul-status' ) | ||
.removeClass( 'info success error warning' ) | |||
.html( '' ) | |||
.hide(); | |||
} | |||
// Row IDs must be safe CSS identifiers; replace anything that isn't \w or - | |||
function rowId( username ) { | |||
return 'gul-row-' + username.replace( /[^\w-]/g, '_' ); | |||
} | } | ||
function setBadge( username, type, text ) { | function setBadge( username, type, text ) { | ||
$( '#' + rowId( username ) + ' .gul-status-cell' ) | |||
.html( '<span class="gul-badge gul-badge-' + type + '">' + text + '</span>' ); | |||
} | } | ||
function setProgress( done, total ) { | function setProgress( done, total ) { | ||
const pct = total > 0 ? Math.round | const pct = total > 0 ? Math.round( done / total * 100 ) : 0; | ||
$( '#gul-progress-bar' ).css( 'width', pct + '%' ); | $( '#gul-progress-bar' ).css( 'width', pct + '%' ); | ||
} | } | ||
| Line 329: | Line 395: | ||
// ── API helpers ──────────────────────────────────────────────────────────── | // ── API helpers ──────────────────────────────────────────────────────────── | ||
function getCsrfToken() { | function getCsrfToken() { | ||
return $.ajax( { | return $.ajax( { | ||
url: API_ENDPOINT, | url: API_ENDPOINT, | ||
data: { | data: { action: 'query', meta: 'tokens', type: 'csrf', format: 'json' } | ||
} ).then( function ( data ) { | } ).then( function ( data ) { | ||
return data.query.tokens.csrftoken; | return data.query.tokens.csrftoken; | ||
| Line 348: | Line 405: | ||
/** | /** | ||
* | * 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 ) { | function fetchMatchingUsers( query, fromUser ) { | ||
const deferred | const deferred = $.Deferred(); | ||
const results | const results = []; | ||
const queryLc | const queryLc = query.toLowerCase(); | ||
let | let continueFrom = fromUser || undefined; | ||
let | let exhausted = false; | ||
let page | let page = 0; | ||
setStatus( 'Searching CentralAuth for matching usernames…', 'info' ); | setStatus( 'Searching CentralAuth for matching usernames…', 'info' ); | ||
function fetchPage() { | function fetchPage() { | ||
if ( | if ( exhausted || results.length >= MAX_ACCOUNTS ) { | ||
deferred.resolve( results.slice( 0, MAX_ACCOUNTS ) ); | deferred.resolve( results.slice( 0, MAX_ACCOUNTS ) ); | ||
return; | return; | ||
| Line 380: | Line 427: | ||
const params = { | const params = { | ||
action: | action: 'query', | ||
list: | list: 'globalallusers', | ||
agulimit: | agulimit: '500', | ||
aguprop: | aguprop: 'lockinfo|registration', | ||
format: | format: 'json' | ||
}; | }; | ||
if ( | if ( continueFrom ) { | ||
params.agufrom = | params.agufrom = continueFrom; | ||
} | } | ||
| Line 394: | Line 441: | ||
const users = ( data.query || {} ).globalallusers || []; | const users = ( data.query || {} ).globalallusers || []; | ||
page++; | page++; | ||
users.forEach( function ( u ) { | users.forEach( function ( u ) { | ||
| Line 408: | Line 449: | ||
setStatus( | setStatus( | ||
'Searched ~' + ( page * 500 ) + ' usernames, found ' + | |||
results.length + ' match(es) so far…', | |||
'info' | 'info' | ||
); | ); | ||
if ( !users.length || !( data.continue && data.continue.agufrom ) ) { | |||
if ( data.continue && data.continue.agufrom ) { | exhausted = true; | ||
deferred.resolve( results.slice( 0, MAX_ACCOUNTS ) ); | |||
} else { | |||
continueFrom = data.continue.agufrom; | |||
fetchPage(); | fetchPage(); | ||
} | } | ||
} ) | } ) | ||
| Line 432: | Line 473: | ||
/** | /** | ||
* Lock a single global account via the CentralAuth API. | * Lock a single global account via the CentralAuth API. | ||
*/ | */ | ||
function lockAccount( username, token, reason, hide, suppress ) { | function lockAccount( username, token, reason, hide, suppress ) { | ||
const states = { locked: 'lock' }; | const states = { locked: 'lock' }; | ||
if ( suppress ) { | if ( suppress ) { | ||
| Line 450: | Line 482: | ||
} | } | ||
const | const statechange = Object.entries( states ) | ||
.map( function ( pair ) { return pair[ 0 ] + '=' + pair[ 1 ]; } ) | |||
.map( ( [ | |||
.join( '|' ); | .join( '|' ); | ||
return $.ajax( { | return $.ajax( { | ||
url: API_ENDPOINT, | url: API_ENDPOINT, | ||
method: 'POST', | method: 'POST', | ||
data: | data: { | ||
action: 'centralauth', | |||
user: username, | |||
reason: reason, | |||
statechange: statechange, | |||
token: token, | |||
format: 'json' | |||
} | |||
} ); | } ); | ||
} | } | ||
| Line 474: | Line 503: | ||
function renderResultsTable( users ) { | function renderResultsTable( users ) { | ||
const $wrap = $( '#gul-results-wrap' ) | const $wrap = $( '#gul-results-wrap' ).empty().removeClass( 'visible' ); | ||
if ( users.length === 0 ) { | if ( users.length === 0 ) { | ||
$wrap.append( '<p><em>No matching accounts found.</em></p>' ) | $wrap.append( '<p><em>No matching accounts found.</em></p>' ).addClass( 'visible' ); | ||
return; | return; | ||
} | } | ||
// Select-all row | // Select-all header row | ||
$wrap.append( | |||
$( '<div>' ).addClass( 'gul-select-all-row' ).append( | |||
$( '<input>' ).attr( { type: 'checkbox', id: 'gul-select-all' } ).prop( 'checked', true ), | |||
$( '<label>' ).attr( 'for', 'gul-select-all' ).css( 'margin', '0' ) | |||
.text( 'Select / deselect all' ), | |||
$( '<span>' ).addClass( 'gul-count' ).text( users.length + ' account(s) found' ) | |||
) | |||
); | ); | ||
// Table | // Table | ||
const $tbody = $( '<tbody>' ); | const $tbody = $( '<tbody>' ); | ||
users.forEach( function ( u ) { | users.forEach( function ( u ) { | ||
const isLocked = !!u.locked; | const isLocked = !!u.locked; | ||
const $row = $( '<tr>' ).attr( 'id', rowId( u.name ) ); | |||
const $row | |||
$row.append( | $row.append( | ||
$( '<td>' ).append( $cb ), | $( '<td>' ).append( | ||
$( '<td>' ).text( u.name ), | $( '<input>' ).attr( { type: 'checkbox', class: 'gul-user-cb' } ) | ||
.val( u.name ).prop( 'checked', true ) | |||
), | |||
$( '<td>' ).append( | |||
$( '<a>' ) | |||
.attr( { | |||
href: mw.util.getUrl( 'Special:CentralAuth/' + u.name ), | |||
target: '_blank', | |||
rel: 'noopener' | |||
} ) | |||
.text( u.name ) | |||
), | |||
$( '<td>' ).text( u.registration ? u.registration.slice( 0, 10 ) : '—' ), | $( '<td>' ).text( u.registration ? u.registration.slice( 0, 10 ) : '—' ), | ||
$( '<td | $( '<td>' ).css( 'text-align', 'center' ).html( | ||
isLocked | isLocked | ||
? '<span class="badge badge-locked">Locked</span>' | ? '<span class="gul-badge gul-badge-locked">Locked</span>' | ||
: '<span class="badge badge- | : '<span class="gul-badge gul-badge-active">Active</span>' | ||
), | ), | ||
$( '<td>' ).addClass( 'gul-status-cell' ) | $( '<td>' ).addClass( 'gul-status-cell' ) | ||
.html( '<span class="badge badge-pending">Pending</span>' ) | .html( '<span class="gul-badge gul-badge-pending">Pending</span>' ) | ||
); | ); | ||
$tbody.append( $row ); | $tbody.append( $row ); | ||
} ); | } ); | ||
$table.append( $ | $wrap.append( | ||
$( '<table>' ).addClass( 'gul-results-table' ).append( | |||
$( '<thead>' ).append( | |||
$( '<tr>' ).append( | |||
$( '<th>' ).css( 'width', '28px' ), | |||
$( '<th>' ).text( 'Username' ), | |||
$( '<th>' ).text( 'Registered' ), | |||
$( '<th>' ).text( 'Currently locked?' ), | |||
$( '<th>' ).css( 'width', '110px' ).text( 'Action status' ) | |||
) | |||
), | |||
$tbody | |||
) | |||
).addClass( 'visible' ); | |||
} | } | ||
// ── Main | // ── Main handlers ────────────────────────────────────────────────────────── | ||
function onSearch() { | function onSearch() { | ||
| Line 552: | Line 578: | ||
$( '#gul-btn-search' ).prop( 'disabled', true ); | $( '#gul-btn-search' ).prop( 'disabled', true ); | ||
$( '#gul-btn-lock' ).prop( 'disabled', true | $( '#gul-btn-lock' ).prop( 'disabled', true ); | ||
$( '#gul-progress' ).removeClass( 'visible' ); | |||
$( '#gul-progress' ).removeClass( 'visible' ); | |||
clearStatus(); | clearStatus(); | ||
| Line 563: | Line 588: | ||
$( '#gul-btn-lock' ).prop( 'disabled', false ); | $( '#gul-btn-lock' ).prop( 'disabled', false ); | ||
setStatus( | setStatus( | ||
'Found <strong>' + users.length + '</strong> account(s) matching ' + | |||
( users.length >= MAX_ACCOUNTS ? | '"<strong>' + mw.html.escape( query ) + '</strong>".' + | ||
( users.length >= MAX_ACCOUNTS | |||
? ' (Results capped at ' + MAX_ACCOUNTS + '.)' : '' ), | |||
'info' | 'info' | ||
); | ); | ||
} else { | } else { | ||
setStatus( | setStatus( 'No accounts found matching "' + mw.html.escape( query ) + '".', 'warning' ); | ||
} | } | ||
} ) | } ) | ||
.fail( function ( err ) { | .fail( function ( err ) { | ||
setStatus( 'Search failed: ' + mw.html.escape( err ), 'error' ); | setStatus( 'Search failed: ' + mw.html.escape( String( err ) ), 'error' ); | ||
} ) | } ) | ||
.always( function () { | .always( function () { | ||
| Line 580: | Line 607: | ||
function onLock() { | function onLock() { | ||
const reason | const reason = $( '#gul-reason' ).val().trim(); | ||
const hide | const hide = $( '#gul-hide' ).prop( 'checked' ); | ||
const suppress = $( '#gul-suppress' ).prop( 'checked' ); | const suppress = $( '#gul-suppress' ).prop( 'checked' ); | ||
const skipLocked = $( '#gul-skip-locked' ).prop( 'checked' ); | const skipLocked = $( '#gul-skip-locked' ).prop( 'checked' ); | ||
| Line 590: | Line 617: | ||
} | } | ||
const selected = []; | const selected = []; | ||
$( '.gul-user-cb:checked' ).each( function () { | $( '.gul-user-cb:checked' ).each( function () { selected.push( $( this ).val() ); } ); | ||
if ( selected.length === 0 ) { | if ( selected.length === 0 ) { | ||
| Line 602: | Line 626: | ||
if ( !confirm( | if ( !confirm( | ||
'You are about to globally lock ' + selected.length + ' account(s).\n\n' + | |||
'Reason: ' + reason + '\n' + | |||
( hide ? 'Hide username: YES\n' : '' ) + | ( hide ? 'Hide username: YES\n' : '' ) + | ||
( suppress ? 'Suppress username: YES\n' : '' ) + | ( suppress ? 'Suppress username: YES\n' : '' ) + | ||
'\nThis action will be logged. Proceed?' | '\nThis action will be logged. Proceed?' | ||
) ) { | ) ) { return; } | ||
$( '#gul-btn-lock, #gul-btn-search' ).prop( 'disabled', true ); | $( '#gul-btn-lock, #gul-btn-search' ).prop( 'disabled', true ); | ||
| Line 618: | Line 640: | ||
getCsrfToken() | getCsrfToken() | ||
.then( function ( token ) { | .then( function ( token ) { | ||
setStatus( | setStatus( 'Locking ' + selected.length + ' account(s)…', 'info' ); | ||
let done | let done = 0, locked = 0, skipped = 0, failed = 0; | ||
function processNext( i ) { | |||
function processNext( | if ( i >= selected.length ) { | ||
if ( | |||
setProgress( done, selected.length ); | setProgress( done, selected.length ); | ||
setStatus( | setStatus( | ||
'Done. <strong>' + locked + '</strong> locked, ' + | |||
'<strong>' + skipped + '</strong> skipped, ' + | |||
'<strong>' + failed + '</strong> failed.', | |||
failed > 0 ? 'warning' : 'success' | failed > 0 ? 'warning' : 'success' | ||
); | ); | ||
$( '#gul-btn-search' ).prop( 'disabled', false ); | $( '#gul-btn-search' ).prop( 'disabled', false ); | ||
$( '#gul-lock-summary' ).remove(); | |||
$( '#gul-results-wrap' ).append( | $( '#gul-results-wrap' ).append( | ||
$( '<div | $( '<div id="gul-lock-summary">' ).addClass( 'gul-summary' ).html( | ||
'<strong>Summary:</strong> ' + locked + ' locked · ' + | |||
skipped + ' skipped · ' + failed + ' failed' | |||
) | ) | ||
); | ); | ||
| Line 649: | Line 664: | ||
} | } | ||
const username = selected[ | const username = selected[ i ]; | ||
const | const wasLocked = $( '#' + rowId( username ) + ' td:nth-child(4)' ) | ||
.text().trim() === 'Locked'; | |||
if ( skipLocked && | if ( skipLocked && wasLocked ) { | ||
skipped++; | skipped++; done++; | ||
setBadge( username, 'skipped', 'Skipped' ); | setBadge( username, 'skipped', 'Skipped' ); | ||
setProgress( done, selected.length ); | setProgress( done, selected.length ); | ||
processNext( | processNext( i + 1 ); | ||
return; | return; | ||
} | } | ||
| Line 666: | Line 680: | ||
lockAccount( username, token, reason, hide, suppress ) | lockAccount( username, token, reason, hide, suppress ) | ||
.done( function ( data ) { | .done( function ( data ) { | ||
if ( data.error ) { | |||
failed++; | failed++; | ||
setBadge( username, 'failed', 'Error: ' + mw.html.escape( data.error.info || data.error.code ) ); | setBadge( username, 'failed', | ||
'Error: ' + mw.html.escape( data.error.info || data.error.code ) ); | |||
} else { | } else { | ||
locked++; | locked++; | ||
setBadge( username, 'locked', 'Locked ✓' ); | setBadge( username, 'locked', 'Locked ✓' ); | ||
| Line 686: | Line 696: | ||
done++; | done++; | ||
setProgress( done, selected.length ); | setProgress( done, selected.length ); | ||
setStatus( | setStatus( 'Processing ' + done + ' / ' + selected.length + '…', 'info' ); | ||
// 300 ms delay between requests to be polite to the API | |||
setTimeout( function () { processNext( i + 1 ); }, 300 ); | |||
// | |||
setTimeout( function () { | |||
} ); | } ); | ||
} | } | ||
| Line 700: | Line 705: | ||
} ) | } ) | ||
.fail( function () { | .fail( function () { | ||
setStatus( 'Failed to obtain CSRF token. Are you logged in with sufficient rights?', 'error' ); | setStatus( | ||
'Failed to obtain CSRF token. Are you logged in with sufficient rights?', | |||
'error' | |||
); | |||
$( '#gul-btn-lock, #gul-btn-search' ).prop( 'disabled', false ); | $( '#gul-btn-lock, #gul-btn-search' ).prop( 'disabled', false ); | ||
} ); | } ); | ||
| Line 715: | Line 723: | ||
$( '#gul-btn-lock' ).prop( 'disabled', true ); | $( '#gul-btn-lock' ).prop( 'disabled', true ); | ||
} | } | ||
}() ); | }() ); | ||
Revision as of 19:15, 6 May 2026
/**
* globalUserLock.js
* A MediaWiki userscript for finding and globally locking accounts
* whose usernames contain a specified string.
*
* Designed for use on Meta-Wiki or any CentralAuth-enabled wiki
* by stewards or staff with global lock permissions.
*
* Activation: adds a "Global user lock" link to the "More" (p-cactions)
* menu on every page, matching the pattern used by all-in-one.js.
* Clicking it opens an OOUI dialog.
*
* Usage: Add to your global.js / common.js:
* mw.loader.load('//meta.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
*/
( function () {
'use strict';
// ── Constants ──────────────────────────────────────────────────────────────
const TOOL_TITLE = 'Global User Lock';
const PORTLET_ID = 'ca-global-user-lock';
const API_ENDPOINT = mw.config.get( 'wgScriptPath' ) + '/api.php';
// Maximum accounts to process in a single run (safety cap)
const MAX_ACCOUNTS = 200;
// ── Entry point ────────────────────────────────────────────────────────────
// Matches the all-in-one.js pattern exactly:
// $.when( mw.loader.using([...]), $.ready ).then( function () { ... } );
$.when(
mw.loader.using( [ 'mediawiki.util', 'oojs-ui-core', 'oojs-ui-widgets', 'oojs-ui-windows' ] ),
$.ready
).then( function () {
// Add "Global user lock" to the "More" (p-cactions) dropdown
const $link = $( mw.util.addPortletLink(
'p-cactions',
'#',
TOOL_TITLE,
PORTLET_ID,
'Find and globally lock accounts by username substring'
) );
$link.on( 'click', function ( e ) {
e.preventDefault();
openDialog();
} );
} );
// ── OOUI Dialog ────────────────────────────────────────────────────────────
function GULDialog( config ) {
GULDialog.super.call( this, config );
}
OO.inheritClass( GULDialog, OO.ui.ProcessDialog );
GULDialog.static.name = 'gulDialog';
GULDialog.static.title = TOOL_TITLE;
GULDialog.static.size = 'large';
GULDialog.static.actions = [
{ action: 'close', label: 'Close', flags: [ 'safe', 'close' ] }
];
GULDialog.prototype.initialize = function () {
GULDialog.super.prototype.initialize.call( this );
injectCSS();
this.$body.append( buildPanelContent() );
bindEvents();
};
GULDialog.prototype.getActionProcess = function ( action ) {
if ( action === 'close' ) {
return new OO.ui.Process( function () {
this.close();
}, this );
}
return GULDialog.super.prototype.getActionProcess.call( this, action );
};
// Allow the dialog body to grow with results
GULDialog.prototype.getBodyHeight = function () {
return Math.min( Math.round( window.innerHeight * 0.82 ), 680 );
};
let windowManager = null;
function openDialog() {
if ( !windowManager ) {
windowManager = new OO.ui.WindowManager();
$( document.body ).append( windowManager.$element );
windowManager.addWindows( [ new GULDialog() ] );
}
windowManager.openWindow( 'gulDialog' );
}
// ── CSS (injected once) ────────────────────────────────────────────────────
let cssInjected = false;
function injectCSS() {
if ( cssInjected ) { return; }
cssInjected = true;
mw.util.addCSS( `
.gul-body {
padding: 14px 16px;
font-family: inherit;
}
.gul-body fieldset {
border: 1px solid #c8ccd1;
border-radius: 3px;
padding: 10px 14px;
margin: 0 0 14px;
background: #fff;
}
.gul-body legend {
font-weight: bold;
padding: 0 4px;
font-size: 0.95em;
}
.gul-body label {
display: block;
margin-bottom: 4px;
font-size: 0.92em;
}
.gul-body input[type="text"],
.gul-body textarea {
width: 100%;
box-sizing: border-box;
padding: 5px 7px;
border: 1px solid #a2a9b1;
border-radius: 2px;
font-size: 0.93em;
font-family: inherit;
background: #fff;
}
.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;
}
.gul-checkbox-row input[type="checkbox"] {
width: auto;
margin: 0;
}
.gul-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 6px;
}
.gul-actions button {
padding: 6px 14px;
border: 1px solid #a2a9b1;
border-radius: 2px;
cursor: pointer;
font-size: 0.92em;
font-family: inherit;
background: #fff;
}
.gul-actions button.gul-primary {
background: #36c;
color: #fff;
border-color: #2a4b8d;
font-weight: bold;
}
.gul-actions button.gul-primary:hover { background: #2a4b8d; }
.gul-actions button.gul-danger {
background: #d73333;
color: #fff;
border-color: #b32424;
font-weight: bold;
}
.gul-actions button.gul-danger:hover { background: #b32424; }
.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: #eaf3fb; border: 1px solid #72a0d3; display: block; }
.gul-status.success { background: #d5fdf4; border: 1px solid #14866d; display: block; }
.gul-status.error { background: #fee7e6; border: 1px solid #d73333; display: block; }
.gul-status.warning { background: #fef6e7; border: 1px solid #fc3; display: block; }
.gul-progress {
height: 8px;
background: #eaecf0;
border-radius: 4px;
overflow: hidden;
margin-top: 8px;
display: none;
}
.gul-progress.visible { display: block; }
.gul-progress-bar {
height: 100%;
background: #36c;
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;
}
.gul-count {
color: #555;
font-size: 0.88em;
margin-left: auto;
}
.gul-results-table {
width: 100%;
border-collapse: collapse;
font-size: 0.89em;
background: #fff;
}
.gul-results-table th,
.gul-results-table td {
border: 1px solid #c8ccd1;
padding: 5px 8px;
text-align: left;
vertical-align: middle;
}
.gul-results-table th { background: #eaecf0; font-weight: bold; }
.gul-results-table tr:nth-child(even) td { background: #f8f9fa; }
.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 { background: #eaecf0; color: #555; }
.gul-badge-active { background: #eaecf0; color: #555; }
.gul-badge-locked { background: #d5fdf4; color: #14866d; }
.gul-badge-failed { background: #fee7e6; color: #d73333; }
.gul-badge-skipped { background: #fef6e7; color: #b45a00; }
.gul-summary {
font-size: 0.9em;
margin-top: 10px;
padding: 8px 12px;
background: #fff;
border: 1px solid #c8ccd1;
border-radius: 2px;
}
` );
}
// ── Build panel content ────────────────────────────────────────────────────
function buildPanelContent() {
const $body = $( '<div>' ).addClass( 'gul-body' );
// Search fieldset
const $searchFs = $( '<fieldset>' ).append( '<legend>Search Parameters</legend>' );
$searchFs.append( `
<label for="gul-query">Username contains (substring match, case-insensitive):</label>
<input type="text" id="gul-query" placeholder="e.g. spambot, vandal2024" autocomplete="off" />
` );
$searchFs.append( `
<label style="margin-top:10px;" for="gul-from">Start from username (optional — for pagination):</label>
<input type="text" id="gul-from" placeholder="Leave blank to start from the beginning" autocomplete="off" />
` );
$body.append( $searchFs );
// Lock options fieldset
const $lockFs = $( '<fieldset>' ).append( '<legend>Lock Options</legend>' );
$lockFs.append( `
<label for="gul-reason">Lock reason (will appear in CentralAuth log):</label>
<textarea id="gul-reason" rows="2"
placeholder="e.g. Cross-wiki spam; username policy violation"></textarea>
` );
$lockFs.append( `
<div class="gul-checkbox-row">
<input type="checkbox" id="gul-hide" />
<label for="gul-hide" style="margin:0;">
Hide username (<code>hidden</code> flag — requires suppressor right)
</label>
</div>
<div class="gul-checkbox-row">
<input type="checkbox" id="gul-suppress" />
<label for="gul-suppress" style="margin:0;">
Suppress username (<code>suppressed</code> flag — requires suppressor right)
</label>
</div>
<div class="gul-checkbox-row">
<input type="checkbox" id="gul-skip-locked" checked />
<label for="gul-skip-locked" style="margin:0;">
Skip accounts that are already globally locked
</label>
</div>
` );
$body.append( $lockFs );
// Buttons
$body.append(
$( '<div>' ).addClass( 'gul-actions' ).append(
$( '<button>' ).addClass( 'gul-primary' ).attr( 'id', 'gul-btn-search' ).text( '🔍 Search Accounts' ),
$( '<button>' ).addClass( 'gul-danger' ).attr( 'id', 'gul-btn-lock' ).text( '🔒 Lock Selected' ).prop( 'disabled', true ),
$( '<button>' ) .attr( 'id', 'gul-btn-clear' ).text( 'Clear' )
)
);
// Status + progress bar
$body.append( $( '<div>' ).addClass( 'gul-status' ).attr( 'id', 'gul-status' ) );
$body.append(
$( '<div>' ).addClass( 'gul-progress' ).attr( 'id', 'gul-progress' ).append(
$( '<div>' ).addClass( 'gul-progress-bar' ).attr( 'id', 'gul-progress-bar' )
)
);
// Results area
$body.append( $( '<div>' ).addClass( 'gul-results-wrap' ).attr( 'id', 'gul-results-wrap' ) );
return $body;
}
// ── Bind events (delegated so they survive dialog re-opens) ───────────────
function bindEvents() {
$( document )
.off( '.gul' )
.on( 'click.gul', '#gul-btn-search', onSearch )
.on( 'click.gul', '#gul-btn-lock', onLock )
.on( 'click.gul', '#gul-btn-clear', onClear )
.on( 'keydown.gul', '#gul-query', function ( e ) {
if ( e.key === 'Enter' ) { onSearch(); }
} )
.on( 'change.gul', '#gul-select-all', function () {
$( '.gul-user-cb' ).prop( 'checked', this.checked );
} );
}
// ── UI helpers ─────────────────────────────────────────────────────────────
function setStatus( msg, type ) {
$( '#gul-status' )
.removeClass( 'info success error warning' )
.addClass( type )
.html( msg );
}
function clearStatus() {
$( '#gul-status' )
.removeClass( 'info success error warning' )
.html( '' )
.hide();
}
// Row IDs must be safe CSS identifiers; replace anything that isn't \w or -
function rowId( username ) {
return 'gul-row-' + username.replace( /[^\w-]/g, '_' );
}
function setBadge( username, type, text ) {
$( '#' + rowId( username ) + ' .gul-status-cell' )
.html( '<span class="gul-badge gul-badge-' + type + '">' + text + '</span>' );
}
function setProgress( done, total ) {
const pct = total > 0 ? Math.round( done / total * 100 ) : 0;
$( '#gul-progress-bar' ).css( 'width', pct + '%' );
}
// ── API helpers ────────────────────────────────────────────────────────────
function getCsrfToken() {
return $.ajax( {
url: API_ENDPOINT,
data: { action: 'query', meta: 'tokens', type: 'csrf', format: 'json' }
} ).then( function ( data ) {
return data.query.tokens.csrftoken;
} );
}
/**
* Page through globalallusers, filtering client-side for substring matches.
* The API only supports prefix search (agufrom/aguto), not substring, so we
* iterate all pages and filter locally — the same approach used by similar
* steward tools.
*/
function fetchMatchingUsers( query, fromUser ) {
const deferred = $.Deferred();
const results = [];
const queryLc = query.toLowerCase();
let continueFrom = fromUser || undefined;
let exhausted = false;
let page = 0;
setStatus( 'Searching CentralAuth for matching usernames…', 'info' );
function fetchPage() {
if ( exhausted || results.length >= MAX_ACCOUNTS ) {
deferred.resolve( results.slice( 0, MAX_ACCOUNTS ) );
return;
}
const params = {
action: 'query',
list: 'globalallusers',
agulimit: '500',
aguprop: 'lockinfo|registration',
format: 'json'
};
if ( continueFrom ) {
params.agufrom = continueFrom;
}
$.ajax( { url: API_ENDPOINT, data: params } )
.done( function ( data ) {
const users = ( data.query || {} ).globalallusers || [];
page++;
users.forEach( function ( u ) {
if ( u.name.toLowerCase().includes( queryLc ) ) {
results.push( u );
}
} );
setStatus(
'Searched ~' + ( page * 500 ) + ' usernames, found ' +
results.length + ' match(es) so far…',
'info'
);
if ( !users.length || !( data.continue && data.continue.agufrom ) ) {
exhausted = true;
deferred.resolve( results.slice( 0, MAX_ACCOUNTS ) );
} else {
continueFrom = data.continue.agufrom;
fetchPage();
}
} )
.fail( function ( xhr ) {
deferred.reject( 'API request failed: ' + xhr.statusText );
} );
}
fetchPage();
return deferred.promise();
}
/**
* Lock a single global account via the CentralAuth API.
*/
function lockAccount( username, token, reason, hide, suppress ) {
const states = { locked: 'lock' };
if ( suppress ) {
states.hidden = 'suppress';
} else if ( hide ) {
states.hidden = 'hide';
}
const statechange = Object.entries( states )
.map( function ( pair ) { return pair[ 0 ] + '=' + pair[ 1 ]; } )
.join( '|' );
return $.ajax( {
url: API_ENDPOINT,
method: 'POST',
data: {
action: 'centralauth',
user: username,
reason: reason,
statechange: statechange,
token: token,
format: 'json'
}
} );
}
// ── Render results table ───────────────────────────────────────────────────
function renderResultsTable( users ) {
const $wrap = $( '#gul-results-wrap' ).empty().removeClass( 'visible' );
if ( users.length === 0 ) {
$wrap.append( '<p><em>No matching accounts found.</em></p>' ).addClass( 'visible' );
return;
}
// Select-all header row
$wrap.append(
$( '<div>' ).addClass( 'gul-select-all-row' ).append(
$( '<input>' ).attr( { type: 'checkbox', id: 'gul-select-all' } ).prop( 'checked', true ),
$( '<label>' ).attr( 'for', 'gul-select-all' ).css( 'margin', '0' )
.text( 'Select / deselect all' ),
$( '<span>' ).addClass( 'gul-count' ).text( users.length + ' account(s) found' )
)
);
// Table
const $tbody = $( '<tbody>' );
users.forEach( function ( u ) {
const isLocked = !!u.locked;
const $row = $( '<tr>' ).attr( 'id', rowId( u.name ) );
$row.append(
$( '<td>' ).append(
$( '<input>' ).attr( { type: 'checkbox', class: 'gul-user-cb' } )
.val( u.name ).prop( 'checked', true )
),
$( '<td>' ).append(
$( '<a>' )
.attr( {
href: mw.util.getUrl( 'Special:CentralAuth/' + u.name ),
target: '_blank',
rel: 'noopener'
} )
.text( u.name )
),
$( '<td>' ).text( u.registration ? u.registration.slice( 0, 10 ) : '—' ),
$( '<td>' ).css( 'text-align', 'center' ).html(
isLocked
? '<span class="gul-badge gul-badge-locked">Locked</span>'
: '<span class="gul-badge gul-badge-active">Active</span>'
),
$( '<td>' ).addClass( 'gul-status-cell' )
.html( '<span class="gul-badge gul-badge-pending">Pending</span>' )
);
$tbody.append( $row );
} );
$wrap.append(
$( '<table>' ).addClass( 'gul-results-table' ).append(
$( '<thead>' ).append(
$( '<tr>' ).append(
$( '<th>' ).css( 'width', '28px' ),
$( '<th>' ).text( 'Username' ),
$( '<th>' ).text( 'Registered' ),
$( '<th>' ).text( 'Currently locked?' ),
$( '<th>' ).css( 'width', '110px' ).text( 'Action status' )
)
),
$tbody
)
).addClass( 'visible' );
}
// ── Main handlers ──────────────────────────────────────────────────────────
function onSearch() {
const query = $( '#gul-query' ).val().trim();
if ( !query ) {
setStatus( 'Please enter a search string.', 'error' );
return;
}
$( '#gul-btn-search' ).prop( 'disabled', true );
$( '#gul-btn-lock' ).prop( 'disabled', true );
$( '#gul-progress' ).removeClass( 'visible' );
clearStatus();
fetchMatchingUsers( query, $( '#gul-from' ).val().trim() )
.done( function ( users ) {
renderResultsTable( users );
if ( users.length > 0 ) {
$( '#gul-btn-lock' ).prop( 'disabled', false );
setStatus(
'Found <strong>' + users.length + '</strong> account(s) matching ' +
'"<strong>' + mw.html.escape( query ) + '</strong>".' +
( users.length >= MAX_ACCOUNTS
? ' (Results capped at ' + MAX_ACCOUNTS + '.)' : '' ),
'info'
);
} else {
setStatus( 'No accounts found matching "' + mw.html.escape( query ) + '".', 'warning' );
}
} )
.fail( function ( err ) {
setStatus( 'Search failed: ' + mw.html.escape( String( err ) ), 'error' );
} )
.always( function () {
$( '#gul-btn-search' ).prop( 'disabled', false );
} );
}
function onLock() {
const reason = $( '#gul-reason' ).val().trim();
const hide = $( '#gul-hide' ).prop( 'checked' );
const suppress = $( '#gul-suppress' ).prop( 'checked' );
const skipLocked = $( '#gul-skip-locked' ).prop( 'checked' );
if ( !reason ) {
setStatus( 'A lock reason is required.', 'error' );
return;
}
const selected = [];
$( '.gul-user-cb:checked' ).each( function () { selected.push( $( this ).val() ); } );
if ( selected.length === 0 ) {
setStatus( 'No accounts selected.', 'warning' );
return;
}
if ( !confirm(
'You are about to globally lock ' + selected.length + ' account(s).\n\n' +
'Reason: ' + reason + '\n' +
( hide ? 'Hide username: YES\n' : '' ) +
( suppress ? 'Suppress username: YES\n' : '' ) +
'\nThis action will be logged. Proceed?'
) ) { return; }
$( '#gul-btn-lock, #gul-btn-search' ).prop( 'disabled', true );
$( '#gul-progress' ).addClass( 'visible' );
setProgress( 0, selected.length );
setStatus( 'Fetching CSRF token…', 'info' );
getCsrfToken()
.then( function ( token ) {
setStatus( 'Locking ' + selected.length + ' account(s)…', 'info' );
let done = 0, locked = 0, skipped = 0, failed = 0;
function processNext( i ) {
if ( i >= selected.length ) {
setProgress( done, selected.length );
setStatus(
'Done. <strong>' + locked + '</strong> locked, ' +
'<strong>' + skipped + '</strong> skipped, ' +
'<strong>' + failed + '</strong> failed.',
failed > 0 ? 'warning' : 'success'
);
$( '#gul-btn-search' ).prop( 'disabled', false );
$( '#gul-lock-summary' ).remove();
$( '#gul-results-wrap' ).append(
$( '<div id="gul-lock-summary">' ).addClass( 'gul-summary' ).html(
'<strong>Summary:</strong> ' + locked + ' locked · ' +
skipped + ' skipped · ' + failed + ' failed'
)
);
return;
}
const username = selected[ i ];
const wasLocked = $( '#' + rowId( username ) + ' td:nth-child(4)' )
.text().trim() === 'Locked';
if ( skipLocked && wasLocked ) {
skipped++; done++;
setBadge( username, 'skipped', 'Skipped' );
setProgress( done, selected.length );
processNext( i + 1 );
return;
}
setBadge( username, 'pending', 'Locking…' );
lockAccount( username, token, reason, hide, suppress )
.done( function ( data ) {
if ( data.error ) {
failed++;
setBadge( username, 'failed',
'Error: ' + mw.html.escape( data.error.info || data.error.code ) );
} else {
locked++;
setBadge( username, 'locked', 'Locked ✓' );
}
} )
.fail( function () {
failed++;
setBadge( username, 'failed', 'Network error' );
} )
.always( function () {
done++;
setProgress( done, selected.length );
setStatus( 'Processing ' + done + ' / ' + selected.length + '…', 'info' );
// 300 ms delay between requests to be polite to the API
setTimeout( function () { processNext( i + 1 ); }, 300 );
} );
}
processNext( 0 );
} )
.fail( function () {
setStatus(
'Failed to obtain CSRF token. Are you logged in with sufficient rights?',
'error'
);
$( '#gul-btn-lock, #gul-btn-search' ).prop( 'disabled', false );
} );
}
function onClear() {
$( '#gul-query, #gul-from, #gul-reason' ).val( '' );
$( '#gul-hide, #gul-suppress' ).prop( 'checked', false );
$( '#gul-skip-locked' ).prop( 'checked', true );
$( '#gul-results-wrap' ).removeClass( 'visible' ).empty();
$( '#gul-progress' ).removeClass( 'visible' );
$( '#gul-progress-bar' ).css( 'width', '0%' );
clearStatus();
$( '#gul-btn-lock' ).prop( 'disabled', true );
}
}() );