Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.04% covered (warning)
83.04%
612 / 737
56.76% covered (warning)
56.76%
21 / 37
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialCentralAuth
83.04% covered (warning)
83.04%
612 / 737
56.76% covered (warning)
56.76%
21 / 37
233.27
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
90.59% covered (success)
90.59%
77 / 85
0.00% covered (danger)
0.00%
0 / 1
25.52
 showNonexistentError
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 showRenameInProgressError
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 doSubmit
95.45% covered (success)
95.45%
42 / 44
0.00% covered (danger)
0.00%
0 / 1
16
 showStatusError
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 showError
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 showSuccess
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 showUsernameForm
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
3
 showInfo
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 getInfoFields
57.47% covered (warning)
57.47%
50 / 87
0.00% covered (danger)
0.00%
0 / 1
35.69
 showGlobalBlockingExemptWikisList
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
2
 getGlobalBlockingExemptWikiTableRows
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
5.03
 getFieldLayoutForHtmlContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 showWikiLists
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
4
 getWikiListsTable
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
3
 listAccounts
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 listWikiItem
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 getAttachedTimestampField
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 formatMergeMethod
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 formatBlockStatus
83.33% covered (warning)
83.33%
25 / 30
0.00% covered (danger)
0.00%
0 / 1
5.12
 formatBlockParams
83.87% covered (warning)
83.87%
26 / 31
0.00% covered (danger)
0.00%
0 / 1
8.27
 getRestrictionListHTML
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
20
 formatEditcount
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 formatGroups
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
4.13
 foreignLink
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
4.01
 foreignUserLink
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 adminCheck
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 showDeleteGlobalAccountForm
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
1
 showStatusForm
100.00% covered (success)
100.00%
65 / 65
100.00% covered (success)
100.00%
1 / 1
2
 getFramedFieldsetLayout
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 showLogExtract
62.07% covered (warning)
62.07%
18 / 29
0.00% covered (danger)
0.00%
0 / 1
7.96
 evaluateTotalEditcount
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getMergeMethodDescriptions
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 prefixSearchSubpages
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\CentralAuth\Special;
4
5use DateInterval;
6use Exception;
7use InvalidArgumentException;
8use LogEventsList;
9use MediaWiki\Block\Restriction\ActionRestriction;
10use MediaWiki\Block\Restriction\NamespaceRestriction;
11use MediaWiki\Block\Restriction\PageRestriction;
12use MediaWiki\CommentFormatter\CommentFormatter;
13use MediaWiki\CommentStore\CommentStore;
14use MediaWiki\Context\DerivativeContext;
15use MediaWiki\Extension\CentralAuth\CentralAuthDatabaseManager;
16use MediaWiki\Extension\CentralAuth\CentralAuthUIService;
17use MediaWiki\Extension\CentralAuth\GlobalRename\GlobalRenameFactory;
18use MediaWiki\Extension\CentralAuth\Hooks\CentralAuthHookRunner;
19use MediaWiki\Extension\CentralAuth\User\CentralAuthGlobalRegistrationProvider;
20use MediaWiki\Extension\CentralAuth\User\CentralAuthUser;
21use MediaWiki\Extension\CentralAuth\Widget\HTMLGlobalUserTextField;
22use MediaWiki\Extension\GlobalBlocking\GlobalBlockingServices;
23use MediaWiki\Extension\GlobalBlocking\Services\GlobalBlockLookup;
24use MediaWiki\Html\Html;
25use MediaWiki\HTMLForm\HTMLForm;
26use MediaWiki\MainConfigNames;
27use MediaWiki\MediaWikiServices;
28use MediaWiki\Message\Message;
29use MediaWiki\Parser\Sanitizer;
30use MediaWiki\Registration\ExtensionRegistry;
31use MediaWiki\SpecialPage\SpecialPage;
32use MediaWiki\Title\NamespaceInfo;
33use MediaWiki\Title\Title;
34use MediaWiki\User\Registration\UserRegistrationLookup;
35use MediaWiki\User\TempUser\TempUserConfig;
36use MediaWiki\User\User;
37use MediaWiki\User\UserFactory;
38use MediaWiki\User\UserGroupMembership;
39use MediaWiki\User\UserNameUtils;
40use MediaWiki\Utils\MWTimestamp;
41use MediaWiki\WikiMap\WikiMap;
42use MediaWiki\WikiMap\WikiReference;
43use MediaWiki\Xml\Xml;
44use OOUI\FieldLayout;
45use OOUI\FieldsetLayout;
46use OOUI\HtmlSnippet;
47use OOUI\PanelLayout;
48use OOUI\Widget;
49use Wikimedia\Message\MessageSpecifier;
50use Wikimedia\Rdbms\IExpression;
51use Wikimedia\Rdbms\LikeValue;
52
53class SpecialCentralAuth extends SpecialPage {
54
55    /** @var string */
56    private $mUserName;
57    /** @var bool */
58    private $mCanUnmerge;
59    /** @var bool */
60    private $mCanLock;
61    /** @var bool */
62    private $mCanSuppress;
63    /** @var bool */
64    private $mCanEdit;
65    /** @var bool */
66    private $mCanChangeGroups;
67
68    /**
69     * @var CentralAuthUser
70     */
71    private $mGlobalUser;
72
73    /**
74     * @var array[]
75     */
76    private $mAttachedLocalAccounts;
77
78    /**
79     * @var array
80     */
81    private $mUnattachedLocalAccounts;
82
83    /** @var string */
84    private $mMethod;
85
86    /** @var bool */
87    private $mPosted;
88
89    /** @var string[] */
90    private $mWikis;
91
92    private CommentFormatter $commentFormatter;
93    private NamespaceInfo $namespaceInfo;
94    private TempUserConfig $tempUserConfig;
95    private UserFactory $userFactory;
96    private UserNameUtils $userNameUtils;
97    private UserRegistrationLookup $userRegistrationLookup;
98    private CentralAuthDatabaseManager $databaseManager;
99    private CentralAuthUIService $uiService;
100    private GlobalRenameFactory $globalRenameFactory;
101
102    public function __construct(
103        CommentFormatter $commentFormatter,
104        NamespaceInfo $namespaceInfo,
105        TempUserConfig $tempUserConfig,
106        UserFactory $userFactory,
107        UserNameUtils $userNameUtils,
108        UserRegistrationLookup $userRegistrationLookup,
109        CentralAuthDatabaseManager $databaseManager,
110        CentralAuthUIService $uiService,
111        GlobalRenameFactory $globalRenameFactory
112    ) {
113        parent::__construct( 'CentralAuth' );
114        $this->commentFormatter = $commentFormatter;
115        $this->namespaceInfo = $namespaceInfo;
116        $this->tempUserConfig = $tempUserConfig;
117        $this->userFactory = $userFactory;
118        $this->userNameUtils = $userNameUtils;
119        $this->userRegistrationLookup = $userRegistrationLookup;
120        $this->databaseManager = $databaseManager;
121        $this->uiService = $uiService;
122        $this->globalRenameFactory = $globalRenameFactory;
123    }
124
125    public function doesWrites() {
126        return true;
127    }
128
129    /** @inheritDoc */
130    public function execute( $subpage ) {
131        $this->setHeaders();
132        $this->addHelpLink( 'Extension:CentralAuth' );
133
134        $authority = $this->getContext()->getAuthority();
135        $this->mCanUnmerge = $authority->isAllowed( 'centralauth-unmerge' );
136        $this->mCanLock = $authority->isAllowed( 'centralauth-lock' );
137        $this->mCanSuppress = $authority->isAllowed( 'centralauth-suppress' );
138        $this->mCanEdit = $this->mCanUnmerge || $this->mCanLock || $this->mCanSuppress;
139        $this->mCanChangeGroups = $authority->isAllowed( 'globalgroupmembership' );
140
141        $this->getOutput()->setPageTitleMsg(
142            $this->msg( $this->mCanEdit ? 'centralauth' : 'centralauth-ro' )
143        );
144        $this->getOutput()->addModules( 'ext.centralauth' );
145        $this->getOutput()->addModuleStyles( 'ext.centralauth.misc.styles' );
146        $this->getOutput()->addJsConfigVars(
147            'wgMergeMethodDescriptions', $this->getMergeMethodDescriptions()
148        );
149
150        $this->mUserName = trim(
151            str_replace(
152                '_',
153                ' ',
154                $this->getRequest()->getText( 'target', $subpage ?? '' )
155            )
156        );
157
158        $this->mUserName = $this->getContentLanguage()->ucfirst( $this->mUserName );
159
160        $this->mPosted = $this->getRequest()->wasPosted();
161        $this->mMethod = $this->getRequest()->getVal( 'wpMethod' );
162        $this->mWikis = (array)$this->getRequest()->getArray( 'wpWikis' );
163
164        // If wpReasonList is specified then for backwards compatability with the old format of the admin status form,
165        // the value of wpReason needs to be moved to wpReason-other and the value of wpReasonList needs to be moved
166        // to wpReason.
167        if ( $this->getRequest()->getVal( 'wpReasonList' ) ) {
168            $this->getRequest()->setVal( 'wpReason-other', $this->getRequest()->getVal( 'wpReason' ) );
169            $this->getRequest()->setVal( 'wpReason', $this->getRequest()->getVal( 'wpReasonList' ) );
170            $this->getRequest()->unsetVal( 'wpReasonList' );
171        }
172
173        // Possible demo states
174
175        // success, all accounts merged
176        // successful login, some accounts merged, others left
177        // successful login, others left
178        // not account owner, others left
179
180        // is owner / is not owner
181        // did / did not merge some accounts
182        // do / don't have more accounts to merge
183
184        if ( $this->mUserName === '' ) {
185            # First time through
186            $this->getOutput()->addWikiMsg( 'centralauth-admin-intro' );
187            $this->showUsernameForm();
188            return;
189        }
190
191        $userPage = Title::makeTitleSafe( NS_USER, $this->mUserName );
192        if ( $userPage ) {
193            $localUser = User::newFromName( $userPage->getText(), false );
194            $this->getSkin()->setRelevantUser( $localUser );
195        }
196
197        // per T49991
198        $this->getOutput()->setHTMLTitle( $this->msg(
199            'pagetitle',
200            $this->msg(
201                $this->mCanEdit ? 'centralauth-admin-title' : 'centralauth-admin-title-ro',
202                $this->mUserName
203            )->plain()
204        ) );
205
206        $canonUsername = $this->userNameUtils->getCanonical( $this->mUserName );
207        if ( $canonUsername === false ) {
208            $this->showNonexistentError();
209            return;
210        }
211
212        $globalUser = $this->getRequest()->wasPosted()
213            ? CentralAuthUser::getPrimaryInstanceByName( $this->mUserName )
214            : CentralAuthUser::getInstanceByName( $this->mUserName );
215        $this->mGlobalUser = $globalUser;
216
217        if ( ( $globalUser->isSuppressed() || $globalUser->isHidden() ) &&
218            !$this->mCanSuppress
219        ) {
220            // Claim that there's nothing if the global account is hidden and the user is not
221            // allowed to see it.
222            $this->showNonexistentError();
223            return;
224        }
225
226        $continue = true;
227        if ( $this->mCanEdit && $this->mPosted ) {
228            $this->databaseManager->assertNotReadOnly();
229            $continue = $this->doSubmit();
230        }
231
232        // Show just a user friendly message when a rename is in progress
233        try {
234            $this->mAttachedLocalAccounts = $globalUser->queryAttached();
235        } catch ( Exception $e ) {
236            if ( $globalUser->renameInProgress() ) {
237                $this->showRenameInProgressError();
238                return;
239            }
240            // Rethrow
241            throw $e;
242        }
243
244        $this->mUnattachedLocalAccounts = $globalUser->queryUnattached();
245
246        if ( !$globalUser->exists() && !count( $this->mUnattachedLocalAccounts ) ) {
247            // Nothing to see here
248            $this->showNonexistentError();
249            return;
250        }
251
252        $this->showUsernameForm();
253        if ( $continue && $globalUser->exists() ) {
254            $this->showInfo();
255            $this->showGlobalBlockingExemptWikisList();
256            if ( $this->mCanLock ) {
257                $this->showStatusForm();
258            }
259            if ( $this->mCanUnmerge ) {
260                $this->showDeleteGlobalAccountForm();
261            }
262            $this->showLogExtract();
263            $this->showWikiLists();
264        } elseif ( $continue && !$globalUser->exists() ) {
265            // No global account, but we can still list the local ones
266            $this->showError( 'centralauth-admin-nonexistent', $this->mUserName );
267            $this->showWikiLists();
268        }
269    }
270
271    private function showNonexistentError() {
272        $this->showError( 'centralauth-admin-nonexistent', $this->mUserName );
273        $this->showUsernameForm();
274    }
275
276    private function showRenameInProgressError() {
277        $this->showError( 'centralauth-admin-rename-in-progress', $this->mUserName );
278        $renameStatus = $this->globalRenameFactory->newGlobalRenameUserStatus( $this->mUserName );
279        $names = $renameStatus->getNames();
280        $this->uiService->showRenameLogExtract( $this->getContext(), $names[1] );
281    }
282
283    /**
284     * @return bool Returns true if the normal form should be displayed
285     */
286    public function doSubmit() {
287        $deleted = false;
288        $globalUser = $this->mGlobalUser;
289        $request = $this->getRequest();
290
291        $givenState = $request->getVal( 'wpUserState' );
292        $stateCheck = $givenState === $globalUser->getStateHash( true );
293
294        if ( !$this->getUser()->matchEditToken( $request->getVal( 'wpEditToken' ) ) ) {
295            $this->showError( 'centralauth-token-mismatch' );
296        } elseif ( $this->mMethod == 'unmerge' && $this->mCanUnmerge ) {
297            $status = $globalUser->adminUnattach( $this->mWikis );
298            if ( !$status->isGood() ) {
299                $this->showStatusError( $status->getWikiText() );
300            } else {
301                $this->showSuccess( 'centralauth-admin-unmerge-success',
302                    $this->getLanguage()->formatNum( $status->successCount ),
303                    /* deprecated */ $status->successCount );
304            }
305        } elseif ( $this->mMethod == 'delete' && $this->mCanUnmerge ) {
306            $status = $globalUser->adminDelete( $request->getVal( 'reason' ), $this->getUser() );
307            if ( !$status->isGood() ) {
308                $this->showStatusError( $status->getWikiText() );
309            } else {
310                $this->showSuccess( 'centralauth-admin-delete-success', $this->mUserName );
311                $deleted = true;
312            }
313        } elseif ( $this->mMethod == 'set-status' && !$stateCheck ) {
314            $this->showError( 'centralauth-state-mismatch' );
315        } elseif ( $this->mMethod == 'set-status' && $this->mCanLock ) {
316            $setLocked = $request->getBool( 'wpStatusLocked' );
317            $setHidden = $request->getInt( 'wpStatusHidden', -1 );
318            $reason = $request->getText( 'wpReason' );
319            $reasonDetail = $request->getText( 'wpReason-other' );
320
321            if ( $reason == 'other' ) {
322                $reason = $reasonDetail;
323            } elseif ( $reasonDetail ) {
324                $reason .= $this->msg( 'colon-separator' )->inContentLanguage()->text() .
325                    $reasonDetail;
326            }
327
328            $status = $globalUser->adminLockHide(
329                $setLocked,
330                $setHidden,
331                $reason,
332                $this->getContext()
333            );
334
335            // Tell the user what happened
336            if ( !$status->isGood() ) {
337                $this->showStatusError( $status->getWikiText() );
338            } elseif ( $status->successCount > 0 ) {
339                $this->showSuccess( 'centralauth-admin-setstatus-success', $this->mUserName );
340            }
341        } else {
342            $this->showError( 'centralauth-admin-bad-input' );
343        }
344        return !$deleted;
345    }
346
347    /**
348     * @param string $wikitext
349     */
350    private function showStatusError( $wikitext ) {
351        $out = $this->getOutput();
352        $out->addHTML(
353            Html::errorBox(
354                $out->parseAsInterface( $wikitext )
355            )
356        );
357    }
358
359    /**
360     * @param string $key
361     * @param mixed ...$params
362     */
363    private function showError( $key, ...$params ) {
364        $this->getOutput()->addHTML( Html::errorBox( $this->msg( $key, ...$params )->parse() ) );
365    }
366
367    /**
368     * @param string $key
369     * @param mixed ...$params
370     */
371    private function showSuccess( $key, ...$params ) {
372        $this->getOutput()->addHTML( Html::successBox( $this->msg( $key, ...$params )->parse() ) );
373    }
374
375    private function showUsernameForm() {
376        $lookup = $this->msg(
377            $this->mCanEdit ? 'centralauth-admin-lookup-rw' : 'centralauth-admin-lookup-ro'
378        )->text();
379
380        $formDescriptor = [
381            'user' => [
382                'class' => HTMLGlobalUserTextField::class,
383                'name' => 'target',
384                'label-message' => 'centralauth-admin-username',
385                'size' => 25,
386                'id' => 'target',
387                'default' => $this->mUserName,
388                'required' => true
389            ]
390        ];
391
392        $legend = $this->msg( $this->mCanEdit ? 'centralauth-admin-manage' : 'centralauth-admin-view' )->text();
393
394        $context = new DerivativeContext( $this->getContext() );
395        // Remove subpage
396        $context->setTitle( $this->getPageTitle() );
397        $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $context );
398        $htmlForm
399            ->setMethod( 'get' )
400            ->setSubmitText( $lookup )
401            ->setSubmitID( 'centralauth-submit-find' )
402            ->setWrapperLegend( $legend )
403            ->prepareForm()
404            ->displayForm( false );
405    }
406
407    private function showInfo() {
408        $attribs = $this->getInfoFields();
409
410        // Give grep a chance to find the usages:
411        // centralauth-admin-info-username, centralauth-admin-info-registered,
412        // centralauth-admin-info-editcount, centralauth-admin-info-locked,
413        // centralauth-admin-info-hidden, centralauth-admin-info-groups
414        $content = Xml::openElement( "ul" );
415        foreach ( $attribs as $key => [ 'label' => $msg, 'data' => $data ] ) {
416            $content .= Html::rawElement(
417                'li',
418                [ 'id' => "mw-centralauth-admin-info-$key" ],
419                $this->msg( 'centralauth-admin-info-line' )
420                    ->params( $this->msg( $msg )->escaped() )
421                    ->rawParams( $data )
422                    ->parse()
423            );
424        }
425        $content .= Xml::closeElement( "ul" );
426
427        $this->getOutput()->addHTML( $this->getFramedFieldsetLayout(
428            $content, 'centralauth-admin-info-header', 'mw-centralauth-info'
429        ) );
430    }
431
432    /**
433     * @return array Array of arrays where each array has two keys: 'label' containing the message
434     *   key or Message object to be used as the label, and 'data' which is the already HTML escaped
435     *   value that is associated with the label. The first dimension keys are field names, but are currently
436     *   not used in the UI or for generating messages.
437     * @phan-return array<string,array{label:string|Message,data:string}>
438     */
439    private function getInfoFields() {
440        $globalUser = $this->mGlobalUser;
441
442        $reg = $globalUser->getRegistration();
443        $age = $this->uiService->prettyTimespan(
444            $this->getContext(),
445            (int)wfTimestamp( TS_UNIX ) - (int)wfTimestamp( TS_UNIX, $reg )
446        );
447        $attribs = [
448            'username' => htmlspecialchars( $globalUser->getName() ),
449            'registered' => htmlspecialchars(
450                $this->getLanguage()->timeanddate( $reg, true ) . " ($age)" ),
451            'editcount' => htmlspecialchars(
452                $this->getLanguage()->formatNum( $this->evaluateTotalEditcount() ) ),
453            'attached' => htmlspecialchars(
454                $this->getLanguage()->formatNum( count( $this->mAttachedLocalAccounts ) ) ),
455        ];
456
457        if (
458            // Renaming self is not allowed.
459            $globalUser->getName() !== $this->getContext()->getUser()->getName()
460            && $this->getContext()->getAuthority()->isAllowed( 'centralauth-rename' )
461        ) {
462            $renameLink = $this->getLinkRenderer()->makeKnownLink(
463                SpecialPage::getTitleFor( 'GlobalRenameUser', $globalUser->getName() ),
464                $this->msg( 'centralauth-admin-info-username-rename' )->text()
465            );
466
467            $attribs['username'] .= $this->msg( 'word-separator' )->escaped();
468            $attribs['username'] .= $this->msg( 'parentheses' )->rawParams( $renameLink )->escaped();
469        }
470
471        if ( count( $this->mUnattachedLocalAccounts ) ) {
472            $attribs['unattached'] = htmlspecialchars(
473                $this->getLanguage()->formatNum( count( $this->mUnattachedLocalAccounts ) ) );
474        }
475
476        if ( $globalUser->isLocked() ) {
477            $attribs['locked'] = $this->msg( 'centralauth-admin-yes' )->escaped();
478        }
479
480        if ( $this->mCanSuppress ) {
481            $attribs['hidden'] = $this->uiService->formatHiddenLevel(
482                $this->getContext(),
483                $globalUser->getHiddenLevelInt()
484            );
485        }
486
487        if ( $this->tempUserConfig->isTempName( $globalUser->getName() ) ) {
488            $localUser = $this->userFactory->newFromName( $globalUser->getName() );
489            // if the central user is valid, the local username is too, but Phan doesn't know that
490            '@phan-var User $localUser';
491            $registrationDate = $this->userRegistrationLookup
492                ->getRegistration( $localUser, CentralAuthGlobalRegistrationProvider::TYPE );
493            $expirationDays = $this->tempUserConfig->getExpireAfterDays();
494            if ( $registrationDate && $expirationDays ) {
495                // Add one day to account for the expiration script running daily
496                $expirationDate = MWTimestamp::getInstance( $registrationDate )
497                    ->add( new DateInterval( 'P' . ( $expirationDays + 1 ) . 'D' ) );
498                if ( $expirationDate->getTimestamp() < MWTimestamp::time() ) {
499                    $attribs['expired'] = htmlspecialchars( $this->getLanguage()
500                        ->userTimeAndDate( $expirationDate->timestamp, $localUser ) );
501                }
502            }
503        }
504
505        // Convert the values of the existing $attribs array into arrays, where the value is placed in the 'data'
506        // key and the 'label' is the message key generated from the associated key.
507        $attribsWithMessageKeys = [];
508        foreach ( $attribs as $key => $value ) {
509            $attribsWithMessageKeys[$key] = [
510                'label' => "centralauth-admin-info-$key",
511                'data' => $value,
512            ];
513        }
514
515        $groups = $globalUser->getGlobalGroupsWithExpiration();
516        if ( $groups ) {
517            $groupLinks = [];
518            // Ensure temporary groups are displayed first, to avoid ambiguity like
519            // "first, second (expires at some point)" (unclear if only second expires or if both expire)
520            uasort( $groups, static fn ( $first, $second ) => (bool)$second <=> (bool)$first );
521
522            $uiLanguage = $this->getLanguage();
523            $uiUser = $this->getUser();
524
525            foreach ( $groups as $group => $expiry ) {
526                $link = $this->getLinkRenderer()->makeLink(
527                    SpecialPage::getTitleFor( 'GlobalGroupPermissions', $group ),
528                    $uiLanguage->getGroupName( $group )
529                );
530
531                if ( $expiry ) {
532                    $link = $this->msg( 'group-membership-link-with-expiry' )
533                        ->rawParams( $link )
534                        ->params( $uiLanguage->userTimeAndDate( $expiry, $uiUser ) )
535                        ->escaped();
536                }
537
538                $groupLinks[] = $link;
539            }
540
541            $attribsWithMessageKeys['groups'] = [
542                'label' => $this->msg( 'centralauth-admin-info-groups' )->numParams( count( $groups ) ),
543                'data' => $uiLanguage->commaList( $groupLinks ),
544            ];
545        }
546
547        if ( $this->mCanChangeGroups ) {
548            if ( !isset( $attribsWithMessageKeys['groups'] ) ) {
549                $attribsWithMessageKeys['groups'] = [
550                    'label' => $this->msg( 'centralauth-admin-info-groups' )->numParams( 0 ),
551                    'data' => $this->msg( 'rightsnone' )->escaped(),
552                ];
553            }
554
555            $manageGroupsLink = $this->getLinkRenderer()->makeKnownLink(
556                SpecialPage::getTitleFor( 'GlobalGroupMembership', $globalUser->getName() ),
557                $this->msg( 'centralauth-admin-info-groups-manage' )->text()
558            );
559
560            $attribsWithMessageKeys['groups']['data'] .= $this->msg( 'word-separator' )->escaped();
561            $attribsWithMessageKeys['groups']['data'] .= $this->msg( 'parentheses' )
562                ->rawParams( $manageGroupsLink )->escaped();
563        }
564
565        $caHookRunner = new CentralAuthHookRunner( $this->getHookContainer() );
566        $caHookRunner->onCentralAuthInfoFields( $globalUser, $this->getContext(), $attribsWithMessageKeys );
567
568        return $attribsWithMessageKeys;
569    }
570
571    private function showGlobalBlockingExemptWikisList() {
572        $tableRows = $this->getGlobalBlockingExemptWikiTableRows();
573        if ( !$tableRows ) {
574            return;
575        }
576
577        $header = Xml::openElement( 'thead' ) . Xml::openElement( 'tr' );
578        $header .= Html::element(
579            'th',
580            [],
581            $this->msg( 'centralauth-admin-globalblock-exempt-list-wiki-heading' )->text()
582        );
583        $header .= Html::element(
584            'th',
585            [ 'class' => 'unsortable' ],
586            $this->msg( 'centralauth-admin-globalblock-exempt-list-reason-heading' )->text()
587        );
588        $header .= Xml::closeElement( 'tr' ) . Xml::closeElement( 'thead' );
589
590        $body = Html::rawElement( 'tbody', [], $tableRows );
591
592        $tableHtml = Html::rawElement(
593            'table',
594            [ 'class' => 'wikitable sortable mw-centralauth-globalblock-exempt-list-table' ],
595            $header . $body
596        );
597        $this->getOutput()->addHTML( $this->getFramedFieldsetLayout(
598            $tableHtml, 'centralauth-admin-globalblock-exempt-list',
599            'mw-centralauth-globalblock-exempt-list'
600        ) );
601    }
602
603    private function getGlobalBlockingExemptWikiTableRows(): string {
604        // There can be no global blocks if the GlobalBlocking extension is not loaded.
605        if ( !ExtensionRegistry::getInstance()->isLoaded( 'GlobalBlocking' ) ) {
606            return '';
607        }
608
609        $globalBlockingServices = GlobalBlockingServices::wrap( MediaWikiServices::getInstance() );
610        $globalBlock = $globalBlockingServices->getGlobalBlockLookup()
611            ->getGlobalBlockingBlock( null, $this->mGlobalUser->getId(), GlobalBlockLookup::SKIP_LOCAL_DISABLE_CHECK );
612        // If the user is not globally blocked, then their global block cannot be locally disabled (so there will be
613        // no rows to display).
614        if ( $globalBlock === null ) {
615            return '';
616        }
617
618        $html = '';
619        $queryWikis = array_merge( $this->mAttachedLocalAccounts, $this->mUnattachedLocalAccounts );
620        foreach ( $queryWikis as $wiki ) {
621            // Check if the global block is disabled on the given wiki, and if it is then add it to the table HTML.
622            $localBlockStatus = $globalBlockingServices->getGlobalBlockLocalStatusLookup()
623                ->getLocalWhitelistInfo( $globalBlock->gb_id, null, $wiki['wiki'] );
624            if ( $localBlockStatus === false ) {
625                continue;
626            }
627
628            $row = Html::rawElement( 'td', [], $this->foreignUserLink( $wiki['wiki'] ) ) .
629                Html::element( 'td', [], $localBlockStatus['reason'] );
630            $html .= Html::rawElement( 'tr', [], $row );
631        }
632        return $html;
633    }
634
635    /**
636     * Generates a {@link FieldLayout} that can be used in a HTMLFormInfo field instance when 'rawrow' is true.
637     * This is useful in the case that the HTML contains elements which cannot appear inside a label element.
638     *
639     * @param string $html The HTML that we want to use in the 'info' field
640     * @return FieldLayout An instance suitable for use in an 'info' field with 'rawrow' set to true
641     */
642    private function getFieldLayoutForHtmlContent( string $html ): FieldLayout {
643        return new FieldLayout( new Widget( [ 'content' => new HtmlSnippet( $html ) ] ) );
644    }
645
646    private function showWikiLists() {
647        $showUnmergeCheckboxes = $this->mCanUnmerge && $this->mGlobalUser->exists();
648
649        $formDescriptor = [
650            'wikilist' => [
651                'type' => 'info',
652                'raw' => true,
653                // We need to use a "rawrow" here to prevent the table element being wrapped by a label element.
654                'rawrow' => true,
655                // When using "rawrow" we need to provide the HTML content via a FieldLayout.
656                'default' => $this->getFieldLayoutForHtmlContent( $this->getWikiListsTable( $showUnmergeCheckboxes ) ),
657            ],
658            'Method' => [
659                'type' => 'hidden',
660                'default' => 'unmerge',
661            ],
662        ];
663
664        if ( $showUnmergeCheckboxes ) {
665            $formDescriptor['submit'] = [
666                'type' => 'submit',
667                'buttonlabel-message' => 'centralauth-admin-unmerge',
668                'flags' => [ 'progressive' ],
669            ];
670        }
671
672        $context = new DerivativeContext( $this->getContext() );
673        $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $context );
674        $htmlForm
675            ->setAction( $this->getPageTitle()->getFullURL( [ 'target' => $this->mUserName ] ) )
676            ->suppressDefaultSubmit()
677            ->setWrapperLegendMsg(
678                $showUnmergeCheckboxes ? 'centralauth-admin-list-legend-rw' : 'centralauth-admin-list-legend-ro'
679            )
680            ->setId( 'mw-centralauth-merged' )
681            ->prepareForm()
682            ->displayForm( false );
683    }
684
685    /**
686     * @param bool $showUnmergeCheckboxes Whether the checkboxes to allow the user to unmerge a local account should
687     *   be shown in the table.
688     * @return string The HTML for the table of local accounts
689     */
690    private function getWikiListsTable( bool $showUnmergeCheckboxes ): string {
691        $columns = [
692            // centralauth-admin-list-localwiki
693            "localwiki",
694            // centralauth-admin-list-attached-on
695            "attached-on",
696            // centralauth-admin-list-method
697            "method",
698            // centralauth-admin-list-blocked
699            "blocked",
700            // centralauth-admin-list-editcount
701            "editcount",
702            // centralauth-admin-list-groups
703            "groups",
704        ];
705
706        $header = Xml::openElement( 'thead' ) . Xml::openElement( 'tr' );
707        if ( $showUnmergeCheckboxes ) {
708            $header .= Html::element( 'th', [ 'class' => 'unsortable' ] );
709        }
710        foreach ( $columns as $c ) {
711            $header .= Html::element( 'th', [], $this->msg( "centralauth-admin-list-$c" )->text() );
712        }
713        $header .= Xml::closeElement( 'tr' ) . Xml::closeElement( 'thead' );
714
715        $body = Html::rawElement(
716            'tbody', [],
717            $this->listAccounts( $this->mAttachedLocalAccounts, $showUnmergeCheckboxes ) .
718            $this->listAccounts( $this->mUnattachedLocalAccounts, $showUnmergeCheckboxes )
719        );
720
721        return Html::rawElement(
722            'table',
723            [ 'class' => 'wikitable sortable mw-centralauth-wikislist' ],
724            $header . $body
725        );
726    }
727
728    /**
729     * @param array[] $list The result of {@link CentralAuthUser::queryAttached} or
730     *   {@link CentralAuthUser::queryUnattached()}
731     * @return string The HTML body table rows
732     */
733    private function listAccounts( array $list, bool $showUnmergeCheckboxes ): string {
734        ksort( $list );
735        return implode( "\n", array_map( function ( $row ) use ( $showUnmergeCheckboxes ) {
736            return $this->listWikiItem( $row, $showUnmergeCheckboxes );
737        }, $list ) );
738    }
739
740    /**
741     * @param array $row The an item from an array returned by either {@link CentralAuthUser::queryAttached} or
742     *    {@link CentralAuthUser::queryUnattached()}
743     * @return string The HTML row that represents the provided array
744     */
745    private function listWikiItem( array $row, bool $showUnmergeCheckboxes ): string {
746        $html = Xml::openElement( 'tr' );
747
748        if ( $showUnmergeCheckboxes ) {
749            if ( !empty( $row['attachedMethod'] ) ) {
750                $html .= Html::rawElement( 'td', [], $this->adminCheck( $row['wiki'] ) );
751            } else {
752                // Account is unattached, don't show checkbox to detach
753                $html .= Xml::element( 'td' );
754            }
755        }
756
757        $html .= Html::rawElement( 'td', [], $this->foreignUserLink( $row['wiki'] ) );
758
759        $attachedTimestamp = $row['attachedTimestamp'] ?? '';
760
761        $html .= $this->getAttachedTimestampField( $attachedTimestamp );
762
763        if ( empty( $row['attachedMethod'] ) ) {
764            $attachedMethod = $this->msg( 'centralauth-admin-unattached' )->parse();
765        } else {
766            $attachedMethod = $this->formatMergeMethod( $row['attachedMethod'] );
767        }
768        $html .= Html::rawElement( 'td', [ 'class' => 'mw-centralauth-wikislist-method' ], $attachedMethod );
769
770        $html .= Html::rawElement( 'td', [], $this->formatBlockStatus( $row ) ) .
771            Html::rawElement(
772                'td', [ 'class' => 'mw-centralauth-wikislist-editcount' ], $this->formatEditcount( $row )
773            ) .
774            Html::rawElement( 'td', [], $this->formatGroups( $row ) ) .
775            Xml::closeElement( 'tr' );
776
777        return $html;
778    }
779
780    /**
781     * @param string|null $attachedTimestamp
782     *
783     * @return string
784     */
785    private function getAttachedTimestampField( $attachedTimestamp ) {
786        if ( !$attachedTimestamp ) {
787            $html = Xml::openElement( 'td', [ 'data-sort-value' => '0' ] ) .
788                $this->msg( 'centralauth-admin-unattached' )->parse();
789        } else {
790            $html = Xml::openElement( 'td',
791                [ 'data-sort-value' => $attachedTimestamp ] ) .
792                // visible date and time in users preference
793                htmlspecialchars( $this->getLanguage()->timeanddate( $attachedTimestamp, true ) );
794        }
795
796        $html .= Xml::closeElement( 'td' );
797        return $html;
798    }
799
800    /**
801     * @param string $method
802     * @return string
803     * @see CentralAuthUser::attach()
804     */
805    private function formatMergeMethod( $method ) {
806        // Give grep a chance to find the usages:
807        // centralauth-merge-method-primary, centralauth-merge-method-empty,
808        // centralauth-merge-method-mail, centralauth-merge-method-password,
809        // centralauth-merge-method-admin, centralauth-merge-method-new,
810        // centralauth-merge-method-login
811        $brief = $this->msg( "centralauth-merge-method-{$method}" )->text();
812        $html =
813            Html::element(
814                'img', [
815                    'src' => $this->getConfig()->get( MainConfigNames::ExtensionAssetsPath )
816                        . "/CentralAuth/images/icons/merged-{$method}.png",
817                    'alt' => $brief,
818                    'title' => $brief,
819                ]
820            )
821            . Html::element(
822                'span', [
823                    'class' => 'merge-method-help',
824                    'title' => $brief,
825                    'data-centralauth-mergemethod' => $method
826                ],
827                $this->msg( 'centralauth-merge-method-questionmark' )->text()
828            );
829
830        return $html;
831    }
832
833    /**
834     * @param array $row
835     * @return string
836     */
837    private function formatBlockStatus( $row ) {
838        $additionalHtml = '';
839        if ( isset( $row['blocked'] ) && $row['blocked'] ) {
840            $optionMessage = $this->formatBlockParams( $row );
841            if ( $row['block-expiry'] == 'infinity' ) {
842                $text = $this->msg( 'centralauth-admin-blocked2-indef' )->text();
843            } else {
844                $expiry = $this->getLanguage()->timeanddate( $row['block-expiry'], true );
845                $expiryd = $this->getLanguage()->date( $row['block-expiry'], true );
846                $expiryt = $this->getLanguage()->time( $row['block-expiry'], true );
847
848                $text = $this->msg( 'centralauth-admin-blocked2', $expiry, $expiryd, $expiryt )
849                    ->text();
850            }
851
852            if ( $row['block-reason'] ) {
853                $reason = Sanitizer::escapeHtmlAllowEntities( $row['block-reason'] );
854                $reason = $this->commentFormatter->formatLinks(
855                    $reason,
856                    null,
857                    false,
858                    $row['wiki']
859                );
860
861                $msg = $this->msg( 'centralauth-admin-blocked-reason' );
862                $msg->rawParams( '<span class="plainlinks">' . $reason . '</span>' );
863
864                $additionalHtml .= Html::rawElement( 'br' ) . $msg->parse();
865            }
866
867            $additionalHtml .= ' ' . $optionMessage;
868
869        } else {
870            $text = $this->msg( 'centralauth-admin-notblocked' )->text();
871        }
872
873        return self::foreignLink(
874            $row['wiki'],
875            'Special:Log/block',
876            $text,
877            $this->msg( 'centralauth-admin-blocklog' )->text(),
878            'page=User:' . urlencode( $this->mUserName )
879        ) . $additionalHtml;
880    }
881
882    /**
883     * Format a block's parameters.
884     *
885     * @see BlockListPager::formatValue()
886     *
887     * @param array $row
888     * @return string
889     */
890    private function formatBlockParams( $row ) {
891        global $wgConf;
892
893        // Ensure all the data is loaded before trying to use.
894        $wgConf->loadFullData();
895
896        $properties = [];
897
898        if ( $row['block-sitewide'] ) {
899            $properties[] = $this->msg( 'blocklist-editing-sitewide' )->escaped();
900        }
901
902        if ( !$row['block-sitewide'] && $row['block-restrictions'] ) {
903            $list = $this->getRestrictionListHTML( $row );
904            if ( $list ) {
905                $properties[] = $this->msg( 'blocklist-editing' )->escaped() . $list;
906            }
907        }
908
909        $options = [
910            'anononly' => 'anononlyblock',
911            'nocreate' => 'createaccountblock',
912            'noautoblock' => 'noautoblockblock',
913            'noemail' => 'emailblock',
914            'nousertalk' => 'blocklist-nousertalk',
915        ];
916        foreach ( $options as $option => $msg ) {
917            if ( $row['block-' . $option] ) {
918                $properties[] = $this->msg( $msg )->escaped();
919            }
920        }
921
922        if ( !$properties ) {
923            return '';
924        }
925
926        return Html::rawElement(
927            'ul',
928            [],
929            implode( '', array_map( static function ( $prop ) {
930                return Html::rawElement(
931                    'li',
932                    [],
933                    $prop
934                );
935            }, $properties ) )
936        );
937    }
938
939    /**
940     * @see BlockListPager::getRestrictionListHTML()
941     *
942     * @param array $row
943     *
944     * @return string
945     */
946    private function getRestrictionListHTML( array $row ) {
947        $count = array_reduce( $row['block-restrictions'], static function ( $carry, $restriction ) {
948            $carry[$restriction->getType()] += 1;
949            return $carry;
950        }, [
951            PageRestriction::TYPE => 0,
952            NamespaceRestriction::TYPE => 0,
953            ActionRestriction::TYPE => 0,
954        ] );
955
956        $restrictions = [];
957        foreach ( $count as $type => $value ) {
958            if ( $value === 0 ) {
959                continue;
960            }
961
962            $restrictions[] = Html::rawElement(
963                'li',
964                [],
965                self::foreignLink(
966                    $row['wiki'],
967                    'Special:BlockList/' . $row['name'],
968                    $this->msg( 'centralauth-block-editing-' . $type, $value )->text()
969                )
970            );
971        }
972
973        if ( count( $restrictions ) === 0 ) {
974            return '';
975        }
976
977        return Html::rawElement(
978            'ul',
979            [],
980            implode( '', $restrictions )
981        );
982    }
983
984    /**
985     * @param array $row
986     * @return string
987     * @throws Exception
988     */
989    private function formatEditcount( $row ) {
990        $wiki = WikiMap::getWiki( $row['wiki'] );
991        if ( !$wiki ) {
992            throw new InvalidArgumentException( "Invalid wiki: {$row['wiki']}" );
993        }
994        $wikiname = $wiki->getDisplayName();
995        $editCount = $this->getLanguage()->formatNum( intval( $row['editCount'] ) );
996
997        return self::foreignLink(
998            $row['wiki'],
999            'Special:Contributions/' . $this->mUserName,
1000            $editCount,
1001            $this->msg( 'centralauth-foreign-contributions' )
1002                ->params( $editCount, $wikiname )->text()
1003        );
1004    }
1005
1006    /**
1007     * @param array $row
1008     * @return string
1009     */
1010    private function formatGroups( $row ) {
1011        if ( !count( $row['groupMemberships'] ) ) {
1012            return '';
1013        }
1014
1015        // We place temporary groups before non-expiring groups in the list.
1016        // This is to avoid the ambiguity of something like
1017        // "sysop, bureaucrat (temporary)" -- users might wonder whether the
1018        // "temporary" indication applies to both groups, or just the last one
1019        $listTemporary = [];
1020        $list = [];
1021        /** @var UserGroupMembership $ugm */
1022        foreach ( $row['groupMemberships'] as $group => $ugm ) {
1023            if ( $ugm->getExpiry() ) {
1024                $listTemporary[] = $this->msg( 'centralauth-admin-group-temporary',
1025                    wfEscapeWikitext( $group ) )->parse();
1026            } else {
1027                $list[] = htmlspecialchars( $group );
1028            }
1029        }
1030        return $this->getLanguage()->commaList( array_merge( $listTemporary, $list ) );
1031    }
1032
1033    /**
1034     * @param string|WikiReference $wikiID
1035     * @param string $title
1036     * @param string $text not HTML escaped
1037     * @param string $hint
1038     * @param string $params
1039     * @return string
1040     * @throws Exception
1041     */
1042    public static function foreignLink( $wikiID, $title, $text, $hint = '', $params = '' ) {
1043        if ( $wikiID instanceof WikiReference ) {
1044            $wiki = $wikiID;
1045        } else {
1046            $wiki = WikiMap::getWiki( $wikiID );
1047            if ( !$wiki ) {
1048                throw new InvalidArgumentException( "Invalid wiki: $wikiID" );
1049            }
1050        }
1051
1052        $url = $wiki->getFullUrl( $title );
1053        if ( $params ) {
1054            $url .= '?' . $params;
1055        }
1056        return Xml::element( 'a',
1057            [
1058                'href' => $url,
1059                'title' => $hint,
1060            ],
1061            $text );
1062    }
1063
1064    /**
1065     * @param string $wikiID
1066     * @return string
1067     * @throws Exception
1068     */
1069    private function foreignUserLink( $wikiID ) {
1070        $wiki = WikiMap::getWiki( $wikiID );
1071        if ( !$wiki ) {
1072            throw new InvalidArgumentException( "Invalid wiki: $wikiID" );
1073        }
1074
1075        $wikiname = $wiki->getDisplayName();
1076        return self::foreignLink(
1077            $wiki,
1078            $this->namespaceInfo->getCanonicalName( NS_USER ) . ':' . $this->mUserName,
1079            $wikiname,
1080            $this->msg( 'centralauth-foreign-link', $this->mUserName, $wikiname )->text()
1081        );
1082    }
1083
1084    /**
1085     * @param string $wikiID
1086     * @return string
1087     */
1088    private function adminCheck( $wikiID ) {
1089        return Xml::check( 'wpWikis[]', false, [ 'value' => $wikiID ] );
1090    }
1091
1092    /**
1093     * Generates a form for managing deleting a global account which contains a description, a reason field
1094     * and destructive submit button.
1095     */
1096    private function showDeleteGlobalAccountForm() {
1097        $formDescriptor = [
1098            'info' => [
1099                'type' => 'info',
1100                'raw' => true,
1101                'default' => $this->msg( 'centralauth-admin-delete-description' )->parseAsBlock(),
1102                'cssclass' => 'mw-centralauth-admin-delete-intro',
1103            ],
1104            'reason' => [
1105                'type' => 'text',
1106                'label-message' => 'centralauth-admin-reason',
1107                'id' => "delete-reason",
1108                'name' => 'reason',
1109            ],
1110            'Method' => [
1111                'type' => 'hidden',
1112                'default' => 'delete',
1113            ],
1114            'submit' => [
1115                'type' => 'submit',
1116                'buttonlabel-message' => 'centralauth-admin-delete-button',
1117                'flags' => [ 'progressive', 'destructive' ]
1118            ],
1119        ];
1120
1121        $context = new DerivativeContext( $this->getContext() );
1122        $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $context );
1123        $htmlForm
1124            ->setAction( $this->getPageTitle()->getFullURL( [ 'target' => $this->mUserName ] ) )
1125            ->suppressDefaultSubmit()
1126            ->setWrapperLegendMsg( 'centralauth-admin-delete-title' )
1127            ->setId( 'mw-centralauth-delete' )
1128            ->prepareForm()
1129            ->displayForm( false );
1130    }
1131
1132    private function showStatusForm() {
1133        // Allows locking, hiding, locking and hiding.
1134        $formDescriptor = [
1135            'intro' => [
1136                'type' => 'info',
1137                'raw' => true,
1138                'default' => $this->msg( 'centralauth-admin-status-intro' )->parseAsBlock(),
1139                'cssclass' => 'mw-centralauth-admin-status-intro',
1140                'section' => 'intro',
1141            ],
1142            'StatusLocked' => [
1143                'type' => 'radio',
1144                'label-message' => 'centralauth-admin-status-locked',
1145                'options-messages' => [
1146                    'centralauth-admin-status-locked-no' => 0,
1147                    'centralauth-admin-status-locked-yes' => 1,
1148                ],
1149                'default' => (int)$this->mGlobalUser->isLocked(),
1150                'id' => 'mw-centralauth-admin-status-locked',
1151                'section' => 'lockedhidden',
1152            ],
1153            'StatusHidden' => [
1154                'type' => 'radio',
1155                'label-message' => 'centralauth-admin-status-hidden',
1156                'options-messages' => [
1157                    'centralauth-admin-status-hidden-no' => CentralAuthUser::HIDDEN_LEVEL_NONE,
1158                ],
1159                'default' => $this->mGlobalUser->getHiddenLevelInt(),
1160                'id' => 'mw-centralauth-admin-status-hidden',
1161                'section' => 'lockedhidden',
1162            ],
1163            'Reason' => [
1164                'type' => 'selectandother',
1165                'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
1166                'label-message' => 'centralauth-admin-reason',
1167                'options-message' => 'centralauth-admin-status-reasons',
1168                'other-message' => 'centralauth-admin-reason-other-select',
1169                'id' => 'mw-centralauth-admin-reason',
1170            ],
1171            'Method' => [
1172                'type' => 'hidden',
1173                'default' => 'set-status',
1174            ],
1175            'UserState' => [
1176                'type' => 'hidden',
1177                'default' => $this->mGlobalUser->getStateHash( false ),
1178            ],
1179            'submit' => [
1180                'type' => 'submit',
1181                'buttonlabel-message' => 'centralauth-admin-status-submit',
1182                'flags' => [ 'progressive' ],
1183            ],
1184        ];
1185
1186        if ( $this->mCanSuppress ) {
1187            $formDescriptor['StatusHidden']['options-messages'] += [
1188                'centralauth-admin-status-hidden-list' => CentralAuthUser::HIDDEN_LEVEL_LISTS,
1189                'centralauth-admin-status-hidden-oversight' => CentralAuthUser::HIDDEN_LEVEL_SUPPRESSED,
1190            ];
1191        }
1192
1193        $context = new DerivativeContext( $this->getContext() );
1194        $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $context );
1195        $htmlForm
1196            ->setAction( $this->getPageTitle()->getFullURL( [ 'target' => $this->mUserName ] ) )
1197            ->suppressDefaultSubmit()
1198            ->setId( 'mw-centralauth-admin-status' )
1199            ->setWrapperLegendMsg( 'centralauth-admin-status' )
1200            ->prepareForm()
1201            ->displayForm( false );
1202    }
1203
1204    /**
1205     * Gets a framed and padded fieldset that contains the given HTML.
1206     *
1207     * Used over HTMLForm as we need to avoid adding hidden fields like "wpEditToken" and form elements to parts of
1208     * the page that are not forms.
1209     *
1210     * @param string $html The HTML to be wrapped with the fieldset
1211     * @param string|MessageSpecifier $fieldsetLegendMsg The message to use as the fieldset legend
1212     * @param string|null $fieldsetId The ID for the fieldset, or null for no ID
1213     * @return string HTML
1214     */
1215    private function getFramedFieldsetLayout( string $html, $fieldsetLegendMsg, ?string $fieldsetId = null ): string {
1216        $fieldset = new FieldsetLayout( [
1217            'label' => $this->msg( $fieldsetLegendMsg )->text(),
1218            'items' => [
1219                new Widget( [
1220                    'content' => new HtmlSnippet( $html ),
1221                ] ),
1222            ],
1223            'id' => $fieldsetId,
1224        ] );
1225        return new PanelLayout( [
1226            'classes' => [ 'mw-htmlform-ooui-wrapper' ],
1227            'expanded' => false,
1228            'padded' => true,
1229            'framed' => true,
1230            'content' => $fieldset,
1231        ] );
1232    }
1233
1234    private function showLogExtract() {
1235        $user = $this->mGlobalUser->getName();
1236        $title = Title::newFromText( $this->namespaceInfo->getCanonicalName( NS_USER ) . ":{$user}@global" );
1237        if ( !$title ) {
1238            // Don't fatal even if a Title couldn't be generated
1239            // because we've invalid usernames too :/
1240            return;
1241        }
1242        $logTypes = [ 'globalauth' ];
1243        if ( $this->mCanSuppress ) {
1244            $logTypes[] = 'suppress';
1245        }
1246        $html = '';
1247        $numRows = LogEventsList::showLogExtract(
1248            $html,
1249            $logTypes,
1250            $title->getPrefixedText(),
1251            '',
1252            [ 'showIfEmpty' => true ]
1253        );
1254
1255        if ( $numRows ) {
1256            $this->getOutput()->addHTML( $this->getFramedFieldsetLayout( $html, 'centralauth-admin-logsnippet' ) );
1257
1258            return;
1259        }
1260
1261        if ( $this->mGlobalUser->isLocked() ) {
1262            $logOtherWikiMsg = $this
1263                ->msg( 'centralauth-admin-log-otherwiki' )
1264                ->params( $this->mGlobalUser->getName() );
1265
1266            if ( !$logOtherWikiMsg->isDisabled() ) {
1267                $this->getOutput()->addHTML(
1268                    Html::warningBox(
1269                        $logOtherWikiMsg->parse(),
1270                        'centralauth-admin-log-otherwiki'
1271                    )
1272                );
1273            }
1274        }
1275    }
1276
1277    /**
1278     * @return int
1279     */
1280    private function evaluateTotalEditcount() {
1281        $total = 0;
1282        foreach ( $this->mAttachedLocalAccounts as $acc ) {
1283            $total += $acc['editCount'];
1284        }
1285        return $total;
1286    }
1287
1288    /**
1289     * @return array[]
1290     */
1291    private function getMergeMethodDescriptions() {
1292        // Give grep a chance to find the usages:
1293        // centralauth-merge-method-primary, centralauth-merge-method-new,
1294        // centralauth-merge-method-empty, centralauth-merge-method-password,
1295        // centralauth-merge-method-mail, centralauth-merge-method-admin,
1296        // centralauth-merge-method-login
1297        // Give grep a chance to find the usages:
1298        // centralauth-merge-method-primary-desc, centralauth-merge-method-new-desc,
1299        // centralauth-merge-method-empty-desc, centralauth-merge-method-password-desc,
1300        // centralauth-merge-method-mail-desc, centralauth-merge-method-admin-desc,
1301        // centralauth-merge-method-login-desc
1302        $mergeMethodDescriptions = [];
1303        foreach ( [ 'primary', 'new', 'empty', 'password', 'mail', 'admin', 'login' ] as $method ) {
1304            $mergeMethodDescriptions[$method] = [
1305                'short' => $this->getLanguage()->ucfirst(
1306                    $this->msg( "centralauth-merge-method-{$method}" )->escaped()
1307                ),
1308                'desc' => $this->msg( "centralauth-merge-method-{$method}-desc" )->escaped()
1309            ];
1310        }
1311        return $mergeMethodDescriptions;
1312    }
1313
1314    /**
1315     * Return an array of subpages beginning with $search that this special page will accept.
1316     *
1317     * @param string $search Prefix to search for
1318     * @param int $limit Maximum number of results to return (usually 10)
1319     * @param int $offset Number of results to skip (usually 0)
1320     * @return string[] Matching subpages
1321     */
1322    public function prefixSearchSubpages( $search, $limit, $offset ) {
1323        $search = $this->userNameUtils->getCanonical( $search );
1324        if ( !$search ) {
1325            // No prefix suggestion for invalid user
1326            return [];
1327        }
1328
1329        $dbr = $this->databaseManager->getCentralReplicaDB();
1330
1331        // Autocomplete subpage as user list - non-hidden users to allow caching
1332        return $dbr->newSelectQueryBuilder()
1333            ->select( 'gu_name' )
1334            ->from( 'globaluser' )
1335            ->where( [
1336                $dbr->expr( 'gu_name', IExpression::LIKE, new LikeValue( $search, $dbr->anyString() ) ),
1337                'gu_hidden_level' => CentralAuthUser::HIDDEN_LEVEL_NONE,
1338            ] )
1339            ->orderBy( 'gu_name' )
1340            ->limit( $limit )
1341            ->offset( $offset )
1342            ->caller( __METHOD__ )
1343            ->fetchFieldValues();
1344    }
1345
1346    /** @inheritDoc */
1347    protected function getGroupName() {
1348        return 'users';
1349    }
1350}