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

User:Justarandomamerican/globalUserLock.js: Difference between revisions

From WikiOasis Meta
No edit summary
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 common.js or Special:MyPage/common.js
* Activation: adds a "Global user lock" link to the "More" (p-cactions)
  *  mw.loader.load('https://your-wiki/path/to/globalUserLock.js');
* 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)
  *  - checktoken / tokens           (CSRF)
  *  - query + meta=tokens         (CSRF)
  *
  *
  * Author: AI
  * Author: WikiOasis T&S tooling
  * License: Public domain
  * License: MIT
  */
  */


Line 24: Line 28:
// ── Constants ──────────────────────────────────────────────────────────────
// ── Constants ──────────────────────────────────────────────────────────────


const TOOL_ID      = 'global-user-lock-tool';
const TOOL_TITLE  = 'Global User Lock';
const TOOL_TITLE   = 'Global User Lock Tool';
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;


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


mw.util.addCSS( `
GULDialog.prototype.getActionProcess = function ( action ) {
#${ TOOL_ID } {
if ( action === 'close' ) {
font-family: inherit;
return new OO.ui.Process( function () {
max-width: 780px;
this.close();
margin: 1.5em auto;
}, this );
border: 1px solid #a2a9b1;
border-radius: 3px;
background: #f8f9fa;
}
}
#${ TOOL_ID } .gul-header {
return GULDialog.super.prototype.getActionProcess.call( this, action );
background: #36c;
};
color: #fff;
 
padding: 10px 16px;
// Allow the dialog body to grow with results
font-size: 1.1em;
GULDialog.prototype.getBodyHeight = function () {
font-weight: bold;
return Math.min( Math.round( window.innerHeight * 0.82 ), 680 );
border-radius: 3px 3px 0 0;
};
display: flex;
 
align-items: center;
let windowManager = null;
gap: 8px;
 
function openDialog() {
if ( !windowManager ) {
windowManager = new OO.ui.WindowManager();
$( document.body ).append( windowManager.$element );
windowManager.addWindows( [ new GULDialog() ] );
}
}
#${ TOOL_ID } .gul-header svg {
windowManager.openWindow( 'gulDialog' );
flex-shrink: 0;
}
}
 
#${ TOOL_ID } .gul-body {
// ── CSS (injected once) ────────────────────────────────────────────────────
padding: 16px;
}
#${ TOOL_ID } fieldset {
border: 1px solid #c8ccd1;
border-radius: 3px;
padding: 10px 14px;
margin: 0 0 14px;
background: #fff;
}
#${ TOOL_ID } legend {
font-weight: bold;
padding: 0 4px;
font-size: 0.95em;
}
#${ TOOL_ID } label {
display: block;
margin-bottom: 4px;
font-size: 0.92em;
}
#${ TOOL_ID } input[type="text"],
#${ TOOL_ID } textarea,
#${ TOOL_ID } select {
width: 100%;
box-sizing: border-box;
padding: 5px 7px;
border: 1px solid #a2a9b1;
border-radius: 2px;
font-size: 0.93em;
font-family: inherit;
background: #fff;
}
#${ TOOL_ID } textarea {
resize: vertical;
min-height: 64px;
}
#${ TOOL_ID } .gul-checkbox-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
font-size: 0.92em;
}
#${ TOOL_ID } .gul-checkbox-row input[type="checkbox"] {
width: auto;
}
#${ TOOL_ID } .gul-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 6px;
}
#${ TOOL_ID } button {
padding: 6px 14px;
border: 1px solid #a2a9b1;
border-radius: 2px;
cursor: pointer;
font-size: 0.92em;
font-family: inherit;
background: #fff;
}
#${ TOOL_ID } button.gul-primary {
background: #36c;
color: #fff;
border-color: #2a4b8d;
font-weight: bold;
}
#${ TOOL_ID } button.gul-primary:hover { background: #2a4b8d; }
#${ TOOL_ID } button.gul-danger {
background: #d73333;
color: #fff;
border-color: #b32424;
font-weight: bold;
}
#${ TOOL_ID } button.gul-danger:hover { background: #b32424; }
#${ TOOL_ID } button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#${ TOOL_ID } .gul-status {
margin: 10px 0 0;
padding: 8px 12px;
border-radius: 2px;
font-size: 0.91em;
display: none;
}
#${ TOOL_ID } .gul-status.info    { background: #eaf3fb; border: 1px solid #72a0d3; display: block; }
#${ TOOL_ID } .gul-status.success { background: #d5fdf4; border: 1px solid #14866d; display: block; }
#${ TOOL_ID } .gul-status.error  { background: #fee7e6; border: 1px solid #d73333; display: block; }
#${ TOOL_ID } .gul-status.warning { background: #fef6e7; border: 1px solid #fc3; display: block; }
#${ TOOL_ID } .gul-results-wrap {
display: none;
margin-top: 14px;
}
#${ TOOL_ID } .gul-results-wrap.visible { display: block; }
#${ TOOL_ID } .gul-results-table {
width: 100%;
border-collapse: collapse;
font-size: 0.89em;
background: #fff;
}
#${ TOOL_ID } .gul-results-table th,
#${ TOOL_ID } .gul-results-table td {
border: 1px solid #c8ccd1;
padding: 5px 8px;
text-align: left;
vertical-align: middle;
}
#${ TOOL_ID } .gul-results-table th {
background: #eaecf0;
font-weight: bold;
}
#${ TOOL_ID } .gul-results-table tr:nth-child(even) td { background: #f8f9fa; }
#${ TOOL_ID } .gul-results-table .gul-status-cell {
text-align: center;
white-space: nowrap;
}
#${ TOOL_ID } .badge {
display: inline-block;
padding: 1px 7px;
border-radius: 10px;
font-size: 0.85em;
font-weight: bold;
}
#${ TOOL_ID } .badge-pending  { background: #eaecf0; color: #555; }
#${ TOOL_ID } .badge-locked  { background: #d5fdf4; color: #14866d; }
#${ TOOL_ID } .badge-failed  { background: #fee7e6; color: #d73333; }
#${ TOOL_ID } .badge-skipped  { background: #fef6e7; color: #b45a00; }
#${ TOOL_ID } .gul-progress {
height: 8px;
background: #eaecf0;
border-radius: 4px;
overflow: hidden;
margin-top: 8px;
display: none;
}
#${ TOOL_ID } .gul-progress.visible { display: block; }
#${ TOOL_ID } .gul-progress-bar {
height: 100%;
background: #36c;
width: 0%;
transition: width 0.3s ease;
}
#${ TOOL_ID } .gul-select-all-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
font-size: 0.91em;
}
#${ TOOL_ID } .gul-count {
color: #555;
font-size: 0.88em;
margin-left: auto;
}
#${ TOOL_ID } .gul-summary {
font-size: 0.9em;
margin-top: 10px;
padding: 8px 12px;
background: #fff;
border: 1px solid #c8ccd1;
border-radius: 2px;
}
` );


// ── Build UI ───────────────────────────────────────────────────────────────
let cssInjected = false;


function buildUI() {
function injectCSS() {
const $container = $( '<div>' ).attr( 'id', TOOL_ID );
if ( cssInjected ) { return; }
cssInjected = true;


// Header
mw.util.addCSS( `
$container.append( `
.gul-body {
<div class="gul-header">
padding: 14px 16px;
<svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor">
font-family: inherit;
<path d="M10 2a4 4 0 0 1 4 4v1h1a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1V6a4 4 0 0 1 4-4zm0 9a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm0-7a2 2 0 0 0-2 2v1h4V6a2 2 0 0 0-2-2z"/>
}
</svg>
.gul-body fieldset {
${ TOOL_TITLE }
border: 1px solid #c8ccd1;
</div>
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
// 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>
Start from username (optional, for pagination):
<input type="text" id="gul-from" placeholder="Leave blank to start from the beginning" autocomplete="off" />
</label>
<input type="text" id="gul-from" placeholder="Leave blank to start from beginning" autocomplete="off" />
` );
` );
$body.append( $searchFs );
$body.append( $searchFs );


// ── Lock options fieldset
// 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 (sets <code>hidden</code> flag — requires suppressor right)</label>
<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 (sets <code>suppressed</code> flag — requires suppressor right)</label>
<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 );


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


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


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


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


$container.append( $body );
// ── Bind events (delegated so they survive dialog re-opens) ───────────────


return $container;
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 );
} );
}
}


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


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


function clearStatus() {
function clearStatus() {
$( '#gul-status' ).removeClass( 'info success error warning' ).hide().html( '' );
$( '#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 ) {
const $cell = $( `#gul-row-${ CSS.escape( username ) } .gul-status-cell` );
$( '#' + rowId( username ) + ' .gul-status-cell' )
$cell.html( `<span class="badge badge-${ type }">${ text }</span>` );
.html( '<span class="gul-badge gul-badge-' + type + '">' + text + '</span>' );
}
}


function setProgress( done, total ) {
function setProgress( done, total ) {
const pct = total > 0 ? Math.round( ( done / total ) * 100 ) : 0;
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 ────────────────────────────────────────────────────────────


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


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


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


function fetchPage() {
function fetchPage() {
if ( done || results.length >= MAX_ACCOUNTS ) {
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:   'query',
action: 'query',
list:     'globalallusers',
list:   'globalallusers',
agulimit: '500',
agulimit: '500',
aguprop:   'lockinfo|registration',
aguprop: 'lockinfo|registration',
format:   'json'
format: 'json'
};
};
if ( continueToken ) {
if ( continueFrom ) {
params.agufrom = continueToken;
params.agufrom = continueFrom;
}
}


Line 394: Line 441:
const users = ( data.query || {} ).globalallusers || [];
const users = ( data.query || {} ).globalallusers || [];
page++;
page++;
if ( users.length === 0 ) {
done = true;
deferred.resolve( results.slice( 0, MAX_ACCOUNTS ) );
return;
}


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…`,
'Searched ~' + ( page * 500 ) + ' usernames, found ' +
results.length + ' match(es) so far…',
'info'
'info'
);
);


// Continue token
if ( !users.length || !( data.continue && data.continue.agufrom ) ) {
if ( data.continue && data.continue.agufrom ) {
exhausted = true;
continueToken = data.continue.agufrom;
deferred.resolve( results.slice( 0, MAX_ACCOUNTS ) );
} else {
continueFrom = data.continue.agufrom;
fetchPage();
fetchPage();
} else {
done = true;
deferred.resolve( results.slice( 0, MAX_ACCOUNTS ) );
}
}
} )
} )
Line 432: Line 473:
/**
/**
* Lock a single global account via the CentralAuth API.
* Lock a single global account via the CentralAuth API.
*
* @param {string}  username
* @param {string}  token    CSRF token
* @param {string}  reason
* @param {boolean} hide      Apply "hidden" flag
* @param {boolean} suppress  Apply "suppressed" flag
* @return {jQuery.Promise}
*/
*/
function lockAccount( username, token, reason, hide, suppress ) {
function lockAccount( username, token, reason, hide, suppress ) {
// Build the "statechange" value
// Possible states: lock, unlock, hide, suppress, unsuppress
const states = { locked: 'lock' };
const states = { locked: 'lock' };
if ( suppress ) {
if ( suppress ) {
Line 450: Line 482:
}
}


const params = {
const statechange = Object.entries( states )
action:        'centralauth',
.map( function ( pair ) { return pair[ 0 ] + '=' + pair[ 1 ]; } )
user:          username,
reason:        reason,
token:        token,
format:        'json'
};
 
// Encode state changes as pipe-separated key=value pairs
const stateStr = Object.entries( states )
.map( ( [ k, v ] ) => `${ k }=${ v }` )
.join( '|' );
.join( '|' );
params.statechange = stateStr;


return $.ajax( {
return $.ajax( {
url:    API_ENDPOINT,
url:    API_ENDPOINT,
method: 'POST',
method: 'POST',
data:   params
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' );
$wrap.empty();


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' );
$wrap.addClass( 'visible' );
return;
return;
}
}


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


// Table
// Table
const $table = $( '<table>' ).addClass( 'gul-results-table' );
$table.append( `
<thead>
<tr>
<th style="width:28px;"></th>
<th>Username</th>
<th>Registered</th>
<th>Currently locked?</th>
<th style="width:100px;">Action status</th>
</tr>
</thead>
` );
const $tbody = $( '<tbody>' );
const $tbody = $( '<tbody>' );
users.forEach( function ( u ) {
users.forEach( function ( u ) {
const isLocked = !!u.locked;
const isLocked = !!u.locked;
const rowId    = `gul-row-${ CSS.escape( u.name ) }`;
const $row = $( '<tr>' ).attr( 'id', rowId( u.name ) );
const $row     = $( '<tr>' ).attr( 'id', rowId );
 
const $cb = $( '<input type="checkbox" class="gul-user-cb">' )
.val( u.name )
.prop( 'checked', true );


$row.append(
$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 style="text-align:center;">' ).html(
$( '<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-pending">Active</span>'
: '<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( $tbody );
$wrap.append(
$wrap.append( $table );
$( '<table>' ).addClass( 'gul-results-table' ).append(
$wrap.addClass( 'visible' );
$( '<thead>' ).append(
 
$( '<tr>' ).append(
// Select-all toggle
$( '<th>' ).css( 'width', '28px' ),
$( '#gul-select-all' ).on( 'change', function () {
$( '<th>' ).text( 'Username' ),
$( '.gul-user-cb' ).prop( 'checked', this.checked );
$( '<th>' ).text( 'Registered' ),
} );
$( '<th>' ).text( 'Currently locked?' ),
$( '<th>' ).css( 'width', '110px' ).text( 'Action status' )
)
),
$tbody
)
).addClass( 'visible' );
}
}


// ── Main logic ─────────────────────────────────────────────────────────────
// ── 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-results-wrap' ).removeClass( 'visible' ).empty();
$( '#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 "<strong>${ mw.html.escape( query ) }</strong>".` +
'Found <strong>' + users.length + '</strong> account(s) matching ' +
( users.length >= MAX_ACCOUNTS ? ` (Results capped at ${ MAX_ACCOUNTS }.)` : '' ),
'"<strong>' + mw.html.escape( query ) + '</strong>".' +
( users.length >= MAX_ACCOUNTS
? ' (Results capped at ' + MAX_ACCOUNTS + '.)' : '' ),
'info'
'info'
);
);
} else {
} else {
setStatus( `No accounts found matching "${ mw.html.escape( query ) }".`, 'warning' );
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   = $( '#gul-reason' ).val().trim();
const reason     = $( '#gul-reason'     ).val().trim();
const hide     = $( '#gul-hide' ).prop( 'checked' );
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:
}
}


// Collect selected usernames
const selected = [];
const selected = [];
$( '.gul-user-cb:checked' ).each( function () {
$( '.gul-user-cb:checked' ).each( function () { selected.push( $( this ).val() ); } );
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` +
'You are about to globally lock ' + selected.length + ' account(s).\n\n' +
`Reason: ${ reason }\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; }
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( `Locking ${ selected.length } account(s)…`, 'info' );
setStatus( 'Locking ' + selected.length + ' account(s)…', 'info' );


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


// Sequential processing to avoid API rate limits
function processNext( i ) {
function processNext( index ) {
if ( i >= selected.length ) {
if ( index >= selected.length ) {
// Finished
setProgress( done, selected.length );
setProgress( done, selected.length );
setStatus(
setStatus(
`Done. <strong>${ locked }</strong> locked, ` +
'Done. <strong>' + locked + '</strong> locked, ' +
`<strong>${ skipped }</strong> skipped, ` +
'<strong>'      + skipped + '</strong> skipped, ' +
`<strong>${ failed }</strong> failed.`,
'<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();
// Add summary row
const $existing = $( '#gul-lock-summary' );
if ( $existing.length ) $existing.remove();
$( '#gul-results-wrap' ).append(
$( '#gul-results-wrap' ).append(
$( '<div>' ).attr( 'id', 'gul-lock-summary' ).addClass( 'gul-summary' ).html(
$( '<div id="gul-lock-summary">' ).addClass( 'gul-summary' ).html(
`<strong>Summary:</strong> ${ locked } locked · ${ skipped } skipped · ${ failed } failed`
'<strong>Summary:</strong> ' + locked + ' locked · ' +
skipped + ' skipped · ' + failed + ' failed'
)
)
);
);
Line 649: Line 664:
}
}


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


if ( skipLocked && isLocked ) {
if ( skipLocked && wasLocked ) {
skipped++;
skipped++; done++;
done++;
setBadge( username, 'skipped', 'Skipped' );
setBadge( username, 'skipped', 'Skipped' );
setProgress( done, selected.length );
setProgress( done, selected.length );
processNext( index + 1 );
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.centralauth && data.centralauth.status === 'ok' ) {
if ( data.error ) {
locked++;
setBadge( username, 'locked', 'Locked ✓' );
} else 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 {
// Treat an empty-but-non-error response as likely success
// (some MediaWiki versions return differently shaped responses)
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' );
`Processing ${ done } / ${ selected.length }`,
// 300 ms delay between requests to be polite to the API
'info'
setTimeout( function () { processNext( i + 1 ); }, 300 );
);
// Small delay to be polite to the API
setTimeout( function () {
processNext( index + 1 );
}, 300 );
} );
} );
}
}
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 );
}
}
// ── Initialise ─────────────────────────────────────────────────────────────
function init() {
// Only load on Special:CentralAuth or Special:GlobalUserRights
// (or remove the guard below to load everywhere)
const special = mw.config.get( 'wgCanonicalSpecialPageName' );
if ( special !== 'CentralAuth' && special !== 'GlobalUserRights' ) {
// Load on any page via mw.util.addPortletLink trigger
// Uncomment to restrict:
// return;
}
const $ui = buildUI();
// Try to insert after the heading on Special pages, else append to content
const $heading = $( '#firstHeading, h1.firstHeading' ).first();
if ( $heading.length ) {
$heading.after( $ui );
} else {
$( '#mw-content-text' ).prepend( $ui );
}
// Bind events
$( '#gul-btn-search' ).on( 'click', onSearch );
$( '#gul-btn-lock'  ).on( 'click', onLock  );
$( '#gul-btn-clear'  ).on( 'click', onClear  );
// Allow Enter key in the query field to trigger search
$( '#gul-query' ).on( 'keydown', function ( e ) {
if ( e.key === 'Enter' ) onSearch();
} );
}
// Wait for DOM + MW core
$( init );


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