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