Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.28% covered (warning)
78.28%
292 / 373
25.00% covered (danger)
25.00%
4 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialGlobalGroupMembership
78.28% covered (warning)
78.28%
292 / 373
25.00% covered (danger)
25.00%
4 / 16
122.62
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
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
56.52% covered (warning)
56.52%
39 / 69
0.00% covered (danger)
0.00%
0 / 1
40.75
 getSuccessURL
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 saveUserGroups
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
8.16
 doSaveUserGroups
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
7
 addLogEntry
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
3
 editUserGroupsForm
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
2.35
 fetchUser
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
12.02
 switchForm
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
2
 showEditUserGroupsForm
100.00% covered (success)
100.00%
91 / 91
100.00% covered (success)
100.00%
1 / 1
7
 groupCheckboxes
98.55% covered (success)
98.55%
68 / 69
0.00% covered (danger)
0.00%
0 / 1
5
 changeableGroups
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 showLogFragment
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 prefixSearchSubpages
0.00% covered (danger)
0.00%
0 / 5
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/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21namespace MediaWiki\Extension\CentralAuth\Special;
22
23use HTMLForm;
24use LogEventsList;
25use LogPage;
26use ManualLogEntry;
27use MediaWiki\CommentStore\CommentStore;
28use MediaWiki\Extension\CentralAuth\GlobalGroup\GlobalGroupLookup;
29use MediaWiki\Extension\CentralAuth\User\CentralAuthUser;
30use MediaWiki\Extension\CentralAuth\Widget\HTMLGlobalUserTextField;
31use MediaWiki\Html\Html;
32use MediaWiki\Linker\Linker;
33use MediaWiki\Output\OutputPage;
34use MediaWiki\SpecialPage\SpecialPage;
35use MediaWiki\Specials\SpecialUserRights;
36use MediaWiki\Status\Status;
37use MediaWiki\Title\TitleFactory;
38use MediaWiki\User\UserGroupMembership;
39use MediaWiki\User\UserNamePrefixSearch;
40use MediaWiki\User\UserNameUtils;
41use PermissionsError;
42use UserBlockedError;
43use Xml;
44use XmlSelect;
45
46/**
47 * Equivalent of Special:Userrights for global groups.
48 *
49 * @ingroup Extensions
50 */
51class SpecialGlobalGroupMembership extends SpecialPage {
52    /**
53     * The target of the local right-adjuster's interest.  Can be gotten from
54     * either a GET parameter or a subpage-style parameter, so have a member
55     * variable for it.
56     * @var null|string
57     */
58    protected $mTarget;
59
60    /**
61     * @var null|CentralAuthUser The user object of the target username or null.
62     */
63    protected $mFetchedUser = null;
64
65    private TitleFactory $titleFactory;
66
67    /** @var UserNamePrefixSearch */
68    private $userNamePrefixSearch;
69
70    /** @var UserNameUtils */
71    private $userNameUtils;
72
73    /** @var GlobalGroupLookup */
74    private $globalGroupLookup;
75
76    /**
77     * @param TitleFactory $titleFactory
78     * @param UserNamePrefixSearch $userNamePrefixSearch
79     * @param UserNameUtils $userNameUtils
80     * @param GlobalGroupLookup $globalGroupLookup
81     */
82    public function __construct(
83        TitleFactory $titleFactory,
84        UserNamePrefixSearch $userNamePrefixSearch,
85        UserNameUtils $userNameUtils,
86        GlobalGroupLookup $globalGroupLookup
87    ) {
88        parent::__construct( 'GlobalGroupMembership' );
89        $this->titleFactory = $titleFactory;
90        $this->userNamePrefixSearch = $userNamePrefixSearch;
91        $this->userNameUtils = $userNameUtils;
92        $this->globalGroupLookup = $globalGroupLookup;
93    }
94
95    /**
96     * @inheritDoc
97     */
98    public function doesWrites() {
99        return true;
100    }
101
102    /**
103     * Manage forms to be shown according to posted data.
104     * Depending on the submit button used, call a form or a save function.
105     *
106     * @param string|null $par String if any subpage provided, else null
107     * @throws UserBlockedError|PermissionsError
108     */
109    public function execute( $par ) {
110        $user = $this->getUser();
111        $request = $this->getRequest();
112        $session = $request->getSession();
113        $out = $this->getOutput();
114
115        $out->addModules( [ 'mediawiki.special.userrights' ] );
116
117        $this->mTarget = $par ?? $request->getVal( 'user' );
118
119        $fetchedStatus = $this->mTarget === null ? Status::newFatal( 'nouserspecified' ) :
120            $this->fetchUser( $this->mTarget );
121        if ( $fetchedStatus->isOK() ) {
122            $this->mFetchedUser = $fetchedStatus->value;
123        }
124
125        // show a successbox, if the user rights was saved successfully
126        if (
127            $session->get( 'specialUserrightsSaveSuccess' ) &&
128            $this->mFetchedUser !== null
129        ) {
130            // Remove session data for the success message
131            $session->remove( 'specialUserrightsSaveSuccess' );
132
133            $out->addModuleStyles( 'mediawiki.notification.convertmessagebox.styles' );
134            $out->addHTML(
135                Html::successBox(
136                    Html::element(
137                        'p',
138                        [],
139                        $this->msg( 'savedrights', $this->mFetchedUser->getName() )->text()
140                    ),
141                    'mw-notify-success'
142                )
143            );
144        }
145
146        $this->setHeaders();
147        $this->outputHeader();
148
149        $out->addModuleStyles( 'mediawiki.special' );
150        $this->addHelpLink( 'Help:Assigning permissions' );
151
152        $this->switchForm();
153
154        if (
155            $request->wasPosted() &&
156            $request->getCheck( 'saveusergroups' ) &&
157            $this->mTarget !== null &&
158            $user->matchEditToken( $request->getVal( 'wpEditToken' ), $this->mTarget )
159        ) {
160            /*
161             * If the user is blocked and they only have "partial" access
162             * (e.g. they don't have the userrights permission), then don't
163             * allow them to change any user rights.
164             */
165            if ( !$this->getAuthority()->isAllowed( 'userrights' ) ) {
166                $block = $user->getBlock();
167                if ( $block && $block->isSitewide() ) {
168                    throw new UserBlockedError(
169                        $block,
170                        $user,
171                        $this->getLanguage(),
172                        $request->getIP()
173                    );
174                }
175            }
176
177            $this->checkReadOnly();
178
179            // save settings
180            if ( !$fetchedStatus->isOK() ) {
181                $this->getOutput()->addWikiTextAsInterface(
182                    $fetchedStatus->getWikiText( false, false, $this->getLanguage() )
183                );
184
185                return;
186            }
187
188            $targetUser = $this->mFetchedUser;
189
190            $conflictCheck = $request->getVal( 'conflictcheck-originalgroups' );
191            $conflictCheck = ( $conflictCheck === '' ) ? [] : explode( ',', $conflictCheck );
192            $userGroups = $targetUser->getGlobalGroups();
193
194            if ( $userGroups !== $conflictCheck ) {
195                $out->addHTML( Html::errorBox(
196                    $this->msg( 'userrights-conflict' )->parse()
197                ) );
198            } else {
199                $status = $this->saveUserGroups(
200                    $targetUser,
201                    $request->getVal( 'user-reason' )
202                );
203
204                if ( $status->isOK() ) {
205                    // Set session data for the success message
206                    $session->set( 'specialUserrightsSaveSuccess', 1 );
207
208                    $out->redirect( $this->getSuccessURL() );
209                    return;
210                } else {
211                    // Print an error message and redisplay the form
212                    $out->wrapWikiTextAsInterface(
213                        'error', $status->getWikiText( false, false, $this->getLanguage() )
214                    );
215                }
216            }
217        }
218
219        // show some more forms
220        if ( $this->mTarget !== null ) {
221            $this->editUserGroupsForm( $this->mTarget );
222        }
223    }
224
225    /**
226     * @return string
227     */
228    private function getSuccessURL() {
229        return $this->getPageTitle( $this->mTarget )->getFullURL();
230    }
231
232    /**
233     * Save user groups changes in the database.
234     * Data comes from the editUserGroupsForm() form function
235     *
236     * @param CentralAuthUser $user Target user object.
237     * @param string $reason Reason for group change
238     * @return Status
239     */
240    private function saveUserGroups( CentralAuthUser $user, string $reason ): Status {
241        $allgroups = $this->globalGroupLookup->getDefinedGroups();
242        $addgroup = [];
243        // associative array of (group name => expiry)
244        $groupExpiries = [];
245        $removegroup = [];
246        $existingGroups = $user->getGlobalGroupsWithExpiration();
247
248        // This could possibly create a highly unlikely race condition if permissions are changed between
249        //  when the form is loaded and when the form is saved. Ignoring it for the moment.
250        foreach ( $allgroups as $group ) {
251            // We'll tell it to remove all unchecked groups, and add all checked groups.
252            // Later on, this gets filtered for what can actually be removed
253            if ( $this->getRequest()->getCheck( "wpGroup-$group" ) ) {
254                $addgroup[] = $group;
255
256                // read the expiry information from the request
257                $expiryDropdown = $this->getRequest()->getVal( "wpExpiry-$group" );
258                if ( $expiryDropdown === 'existing' ) {
259                    continue;
260                }
261
262                if ( $expiryDropdown === 'other' ) {
263                    $expiryValue = $this->getRequest()->getVal( "wpExpiry-$group-other" );
264                } else {
265                    $expiryValue = $expiryDropdown;
266                }
267
268                // validate the expiry
269                $groupExpiries[$group] = SpecialUserRights::expiryToTimestamp( $expiryValue );
270
271                if ( $groupExpiries[$group] === false ) {
272                    return Status::newFatal( 'userrights-invalid-expiry', $group );
273                }
274
275                // not allowed to have things expiring in the past
276                if ( $groupExpiries[$group] && $groupExpiries[$group] < wfTimestampNow() ) {
277                    return Status::newFatal( 'userrights-expiry-in-past', $group );
278                }
279            } else {
280                $removegroup[] = $group;
281            }
282        }
283
284        $this->doSaveUserGroups( $user, $addgroup, $removegroup, $reason, [], $groupExpiries );
285
286        return Status::newGood();
287    }
288
289    /**
290     * Save user groups changes in the database. This function does not throw errors;
291     * instead, it ignores groups that the performer does not have permission to set.
292     *
293     * @param CentralAuthUser $user
294     * @param string[] $add Array of groups to add
295     * @param string[] $remove Array of groups to remove
296     * @param string $reason Reason for group change
297     * @param string[] $tags Array of change tags to add to the log entry
298     * @param array<string,?string> $groupExpiries Associative array of (group name => expiry),
299     *   containing only those groups that are to have new expiry values set
300     * @return array Tuple of added, then removed groups
301     */
302    public function doSaveUserGroups(
303        CentralAuthUser $user,
304        array $add,
305        array $remove,
306        string $reason = '',
307        array $tags = [],
308        array $groupExpiries = []
309    ) {
310        // Validate input set...
311        $groups = $user->getGlobalGroupsWithExpiration();
312        $changeable = $this->changeableGroups();
313
314        $remove = array_unique( array_intersect( $remove, $changeable, array_keys( $groups ) ) );
315        $add = array_intersect( $add, $changeable );
316
317        // add only groups that are not already present or that need their expiry updated
318        $add = array_filter( $add,
319            static function ( $group ) use ( $groups, $groupExpiries ) {
320                return !array_key_exists( $group, $groups ) || array_key_exists( $group, $groupExpiries );
321            } );
322
323        // Remove groups, then add new ones/update expiries of existing ones
324        if ( $remove ) {
325            foreach ( $remove as $group ) {
326                $user->removeFromGlobalGroups( $group );
327            }
328        }
329        if ( $add ) {
330            foreach ( $add as $group ) {
331                $expiry = $groupExpiries[$group] ?? null;
332                $user->addToGlobalGroup( $group, $expiry );
333            }
334        }
335
336        $newGroups = $user->getGlobalGroupsWithExpiration();
337
338        // Ensure that caches are cleared
339        $user->invalidateCache();
340
341        // Only add a log entry if something actually changed
342        if ( $groups !== $newGroups ) {
343            $this->addLogEntry(
344                $user,
345                $groups,
346                $newGroups,
347                $reason,
348                $tags
349            );
350        }
351
352        return [ $add, $remove ];
353    }
354
355    /**
356     * @param CentralAuthUser $user
357     * @param array $oldGroups
358     * @param array $newGroups
359     * @param string $reason
360     * @param array $tags Not currently used
361     */
362    private function addLogEntry(
363        CentralAuthUser $user,
364        array $oldGroups,
365        array $newGroups,
366        string $reason,
367        array $tags
368    ) {
369        $oldGroupNames = [];
370        $newGroupNames = [];
371        $oldGroupMetadata = [];
372        $newGroupMetadata = [];
373
374        foreach ( $oldGroups as $key => &$value ) {
375            $oldGroupNames[] = $key;
376            $oldGroupMetadata[] = [ 'expiry' => $value ];
377        }
378
379        foreach ( $newGroups as $key => &$value ) {
380            $newGroupNames[] = $key;
381            $newGroupMetadata[] = [ 'expiry' => $value ];
382        }
383
384        $entry = new ManualLogEntry( 'gblrights', 'usergroups' );
385        $entry->setTarget( $this->titleFactory->makeTitle( NS_USER, $user->getName() ) );
386        $entry->setPerformer( $this->getUser() );
387        $entry->setComment( $reason );
388        $entry->setParameters( [
389            'oldGroups' => $oldGroupNames,
390            'newGroups' => $newGroupNames,
391            'oldMetadata' => $oldGroupMetadata,
392            'newMetadata' => $newGroupMetadata,
393        ] );
394        $logid = $entry->insert();
395        $entry->publish( $logid );
396    }
397
398    /**
399     * Edit user groups membership
400     * @param string $username Name of the user.
401     */
402    private function editUserGroupsForm( $username ) {
403        $status = $this->fetchUser( $username );
404        if ( !$status->isOK() ) {
405            $this->getOutput()->addWikiTextAsInterface(
406                $status->getWikiText( false, false, $this->getLanguage() )
407            );
408
409            return;
410        }
411
412        /** @var CentralAuthUser $user */
413        $user = $status->value;
414        '@phan-var CentralAuthUser $user';
415
416        $this->showEditUserGroupsForm( $user );
417
418        // This isn't really ideal logging behavior, but let's not hide the
419        // interwiki logs if we're using them as is.
420        $this->showLogFragment( $user, $this->getOutput() );
421    }
422
423    /**
424     * @param string $username
425     * @return Status
426     */
427    public function fetchUser( $username ) {
428        if ( $username === '' ) {
429            return Status::newFatal( 'nouserspecified' );
430        }
431
432        if ( $username[0] == '#' ) {
433            $id = intval( substr( $username, 1 ) );
434            $globalUser = CentralAuthUser::newPrimaryInstanceFromId( $id );
435            // If the user exists, but is hidden from the viewer, pretend that it does
436            // not exist. - T285190/T260863
437            if (
438                !$globalUser
439                || (
440                    ( $globalUser->isSuppressed() || $globalUser->isHidden() )
441                    && !$this->getContext()->getAuthority()->isAllowed( 'centralauth-suppress' )
442                )
443            ) {
444                return Status::newFatal( 'noname', $id );
445            }
446        } else {
447            // fetchUser() is public; normalize in case the caller forgot to. See T343963 and
448            // T344495.
449            $username = $this->userNameUtils->getCanonical( $username );
450            if ( !is_string( $username ) ) {
451                // $username was invalid, return nosuchuser.
452                return Status::newFatal( 'nosuchusershort', $username );
453            }
454
455            // If the user exists, but is hidden from the viewer, pretend that it does
456            // not exist. - T285190
457            $globalUser = CentralAuthUser::getPrimaryInstanceByName( $username );
458            if (
459                !$globalUser->exists()
460                || (
461                    ( $globalUser->isSuppressed() || $globalUser->isHidden() )
462                    && !$this->getContext()->getAuthority()->isAllowed( 'centralauth-suppress' )
463                )
464            ) {
465                return Status::newFatal( 'nosuchusershort', $username );
466            }
467        }
468
469        return Status::newGood( $globalUser );
470    }
471
472    /**
473     * Output a form to allow searching for a user
474     */
475    private function switchForm() {
476        $this->addHelpLink( 'Extension:CentralAuth' );
477        $this->getOutput()->addModuleStyles( 'mediawiki.special' );
478        $formDescriptor = [
479            'user' => [
480                'class' => HTMLGlobalUserTextField::class,
481                'name' => 'user',
482                'id' => 'username',
483                'label-message' => 'userrights-user-editname',
484                'size' => 30,
485                'default' => $this->mTarget,
486            ]
487        ];
488
489        $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
490        $htmlForm
491            ->setMethod( 'get' )
492            // Strip subpage
493            ->setTitle( $this->getPageTitle() )
494            ->setAction( $this->getConfig()->get( 'Script' ) )
495            ->setId( 'mw-userrights-form1' )
496            ->setName( 'uluser' )
497            ->setSubmitTextMsg( 'editusergroup' )
498            ->setWrapperLegendMsg( 'userrights-lookup-user' )
499            ->prepareForm()
500            ->displayForm( false );
501    }
502
503    /**
504     * Show the form to edit group memberships.
505     * @param CentralAuthUser $user user you're editing
506     */
507    private function showEditUserGroupsForm( CentralAuthUser $user ) {
508        $list = $membersList = $tempList = $tempMembersList = [];
509        foreach ( $user->getGlobalGroupsWithExpiration() as $group => $expiration ) {
510            $ugm = new UserGroupMembership( $user->getId(), $group, $expiration );
511            $linkG = UserGroupMembership::getLinkHTML( $ugm, $this->getContext() );
512            $linkM = UserGroupMembership::getLinkHTML( $ugm, $this->getContext(), $user->getName() );
513            if ( $ugm->getExpiry() ) {
514                $tempList[] = $linkG;
515                $tempMembersList[] = $linkM;
516            } else {
517                $list[] = $linkG;
518                $membersList[] = $linkM;
519            }
520        }
521
522        $language = $this->getLanguage();
523        $displayedList = $this->msg( 'userrights-groupsmember-type' )
524            ->rawParams(
525                $language->commaList( array_merge( $tempList, $list ) ),
526                $language->commaList( array_merge( $tempMembersList, $membersList ) )
527            )->escaped();
528
529        $grouplist = '';
530        $count = count( $list ) + count( $tempList );
531        if ( $count > 0 ) {
532            $grouplist = $this->msg( 'userrights-groupsmember' )
533                ->numParams( $count )
534                ->params( $user->getName() )
535                ->parse();
536            $grouplist = '<p>' . $grouplist . ' ' . $displayedList . "</p>\n";
537        }
538
539        $userToolLinks = Linker::userToolLinks(
540            $user->getId(),
541            $user->getName(),
542            // default for redContribsWhenNoEdits
543            false,
544            Linker::TOOL_LINKS_EMAIL
545        );
546
547        $canChangeAny = $this->changeableGroups() !== [];
548        $this->getOutput()->addHTML(
549            Xml::openElement(
550                'form',
551                [
552                    'method' => 'post',
553                    'action' => $this->getPageTitle()->getLocalURL(),
554                    'name' => 'editGroup',
555                    'id' => 'mw-userrights-form2'
556                ]
557            ) .
558            Html::hidden( 'user', $this->mTarget ) .
559            Html::hidden( 'wpEditToken', $this->getUser()->getEditToken( $this->mTarget ) ) .
560            // Conflict detection
561            Html::hidden(
562                'conflictcheck-originalgroups',
563                implode( ',', $user->getGlobalGroups() )
564            ) .
565            Xml::openElement( 'fieldset' ) .
566            Xml::element(
567                'legend',
568                [],
569                $this->msg(
570                    $canChangeAny ? 'userrights-editusergroup' : 'userrights-viewusergroup',
571                    $user->getName()
572                )->text()
573            ) .
574            $this->msg(
575                $canChangeAny ? 'editinguser' : 'viewinguserrights'
576            )->params( wfEscapeWikiText( $user->getName() ) )
577                ->rawParams( $userToolLinks )->parse()
578        );
579        if ( $canChangeAny ) {
580            $this->getOutput()->addHTML(
581                $this->msg( 'userrights-groups-help', $user->getName() )->parse() .
582                $grouplist .
583                $this->groupCheckboxes( $user ) .
584                Xml::openElement( 'table', [ 'id' => 'mw-userrights-table-outer' ] ) .
585                    "<tr>
586                        <td class='mw-label'>" .
587                            Xml::label( $this->msg( 'userrights-reason' )->text(), 'wpReason' ) .
588                        "</td>
589                        <td class='mw-input'>" .
590                            Xml::input( 'user-reason', 60, $this->getRequest()->getVal( 'user-reason' ) ?? false, [
591                                'id' => 'wpReason',
592                                // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
593                                // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
594                                // Unicode codepoints.
595                                'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
596                            ] ) .
597                        "</td>
598                    </tr>
599                    <tr>
600                        <td></td>
601                        <td class='mw-submit'>" .
602                            Xml::submitButton( $this->msg( 'saveusergroups', $user->getName() )->text(),
603                                [ 'name' => 'saveusergroups' ] +
604                                    Linker::tooltipAndAccesskeyAttribs( 'userrights-set' )
605                            ) .
606                        "</td>
607                    </tr>" .
608                Xml::closeElement( 'table' ) . "\n"
609            );
610        } else {
611            $this->getOutput()->addHTML( $grouplist );
612        }
613        $this->getOutput()->addHTML(
614            Xml::closeElement( 'fieldset' ) .
615            Xml::closeElement( 'form' ) . "\n"
616        );
617    }
618
619    /**
620     * Adds a table with checkboxes where you can select what groups to add/remove.
621     *
622     * This is only called when the user can change any of the groups.
623     *
624     * @param CentralAuthUser $user
625     * @return string The HTML table element with checkboxes and expiry dropdowns
626     */
627    private function groupCheckboxes( CentralAuthUser $user ) {
628        $allgroups = $this->globalGroupLookup->getDefinedGroups();
629        $currentGroups = $user->getGlobalGroupsWithExpiration();
630        $ret = '';
631
632        // Get the list of preset expiry times from the system message
633        $expiryOptionsMsg = $this->msg( 'userrights-expiry-options' )->inContentLanguage();
634        $expiryOptions = $expiryOptionsMsg->isDisabled()
635            ? []
636            : XmlSelect::parseOptionsMessage( $expiryOptionsMsg->text() );
637
638        // Build the HTML table
639        $ret .= Xml::openElement( 'table', [ 'class' => 'mw-userrights-groups' ] ) .
640            "<tr>\n";
641        $ret .= Xml::element(
642            'th',
643            null,
644            $this->msg( 'userrights-changeable-col', count( $allgroups ) )->text()
645        );
646
647        $ret .= "</tr>\n<tr>\n";
648        $uiLanguage = $this->getLanguage();
649
650        $ret .= "\t<td style='vertical-align:top;'>\n";
651        foreach ( $allgroups as $group ) {
652            $set = array_key_exists( $group, $currentGroups );
653
654            $member = $uiLanguage->getGroupMemberName( $group, $user->getName() );
655            $id = "wpGroup-$group";
656            $checkboxHtml = Html::element( 'input', [
657                'class' => 'mw-userrights-groupcheckbox',
658                'type' => 'checkbox', 'value' => '1', 'checked' => $set,
659                'id' => $id, 'name' => $id,
660            ] ) . '&nbsp;' . Html::label( $member, $id );
661
662            $uiUser = $this->getUser();
663
664            $currentExpiry = $currentGroups[$group] ?? null;
665
666            $expiryHtml = Xml::element( 'span', null,
667                $this->msg( 'userrights-expiry' )->text() );
668            $expiryHtml .= Xml::openElement( 'span' );
669
670            // add a form element to set the expiry date
671            $expiryFormOptions = new XmlSelect(
672                "wpExpiry-$group",
673                // forward compatibility with HTMLForm
674                "mw-input-wpExpiry-$group",
675                $currentExpiry ? 'existing' : 'infinite'
676            );
677
678            if ( $currentExpiry ) {
679                $timestamp = $uiLanguage->userTimeAndDate( $currentExpiry, $uiUser );
680                $d = $uiLanguage->userDate( $currentExpiry, $uiUser );
681                $t = $uiLanguage->userTime( $currentExpiry, $uiUser );
682                $existingExpiryMessage = $this->msg( 'userrights-expiry-existing',
683                    $timestamp, $d, $t );
684                $expiryFormOptions->addOption( $existingExpiryMessage->text(), 'existing' );
685            }
686
687            $expiryFormOptions->addOption(
688                $this->msg( 'userrights-expiry-none' )->text(),
689                'infinite'
690            );
691            $expiryFormOptions->addOption(
692                $this->msg( 'userrights-expiry-othertime' )->text(),
693                'other'
694            );
695
696            $expiryFormOptions->addOptions( $expiryOptions );
697
698            // Add expiry dropdown
699            $expiryHtml .= $expiryFormOptions->getHTML() . '<br />';
700
701            // Add custom expiry field
702            $attribs = [
703                'id' => "mw-input-wpExpiry-$group-other",
704                'class' => 'mw-userrights-expiryfield',
705            ];
706            $expiryHtml .= Xml::input( "wpExpiry-$group-other", 30, '', $attribs );
707
708            $expiryHtml .= Xml::closeElement( 'span' );
709
710            $divAttribs = [
711                'id' => "mw-userrights-nested-wpGroup-$group",
712                'class' => 'mw-userrights-nested',
713            ];
714            $checkboxHtml .= "\t\t\t" . Xml::tags( 'div', $divAttribs, $expiryHtml ) . "\n";
715
716            $ret .= "\t\t" . Xml::tags( 'div', [], $checkboxHtml
717            ) . "\n";
718        }
719        $ret .= "\t</td>\n";
720
721        $ret .= Xml::closeElement( 'tr' ) . Xml::closeElement( 'table' );
722
723        return $ret;
724    }
725
726    /**
727     * @return string[]
728     */
729    private function changeableGroups() {
730        if ( $this->getContext()->getAuthority()->isAllowed( 'globalgroupmembership' ) ) {
731            return $this->globalGroupLookup->getDefinedGroups();
732        }
733        return [];
734    }
735
736    /**
737     * @param CentralAuthUser $user
738     * @param OutputPage $output
739     */
740    private function showLogFragment( $user, $output ) {
741        $logPage = new LogPage( 'gblrights' );
742        $output->addHTML( Xml::element( 'h2', null, $logPage->getName()->text() . "\n" ) );
743        LogEventsList::showLogExtract(
744            $output,
745            'gblrights',
746            $this->titleFactory->makeTitle( NS_USER, $user->getName() )
747        );
748    }
749
750    /**
751     * Return an array of subpages beginning with $search that this special page will accept.
752     *
753     * @param string $search Prefix to search for
754     * @param int $limit Maximum number of results to return (usually 10)
755     * @param int $offset Number of results to skip (usually 0)
756     * @return string[] Matching subpages
757     */
758    public function prefixSearchSubpages( $search, $limit, $offset ) {
759        $search = $this->userNameUtils->getCanonical( $search );
760        if ( !$search ) {
761            // No prefix suggestion for invalid user
762            return [];
763        }
764        // Autocomplete subpage as user list - public to allow caching
765        return $this->userNamePrefixSearch
766            ->search( UserNamePrefixSearch::AUDIENCE_PUBLIC, $search, $limit, $offset );
767    }
768
769    /**
770     * @inheritDoc
771     */
772    protected function getGroupName() {
773        return 'users';
774    }
775}