Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.48% covered (warning)
88.48%
338 / 382
76.92% covered (warning)
76.92%
20 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserGroupsSpecialPage
88.48% covered (warning)
88.48%
338 / 382
76.92% covered (warning)
76.92%
20 / 26
107.50
0.00% covered (danger)
0.00%
0 / 1
 setTargetName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setChangeableGroups
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
7.23
 addModules
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 showMessageOnSuccess
13.33% covered (danger)
13.33%
2 / 15
0.00% covered (danger)
0.00%
0 / 1
4.60
 setSuccessFlag
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 buildGroupsForm
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 buildFormHeader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildFormDescription
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 buildFormGroupsLists
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 buildFormExtraInfo
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 buildViewGroupsFormContent
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
1
 buildEditGroupsFormContent
100.00% covered (success)
100.00%
91 / 91
100.00% covered (success)
100.00%
1 / 1
5
 prepareAvailableGroups
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 makeGroupFields
77.17% covered (warning)
77.17%
71 / 92
0.00% covered (danger)
0.00%
0 / 1
44.18
 readGroupsForm
85.71% covered (warning)
85.71%
18 / 21
0.00% covered (danger)
0.00%
0 / 1
10.29
 splitGroupsIntoAddRemove
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
9
 getCurrentUserGroupsFields
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
3
 showLogFragment
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 categorizeUserGroupsForDisplay
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 makeConflictCheckKey
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 conflictOccured
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getTargetUserToolLinks
n/a
0 / 0
n/a
0 / 0
0
 canAdd
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canRemove
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getGroupAnnotations
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addGroupAnnotation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sortGroupMemberships
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 doesWrites
n/a
0 / 0
n/a
0 / 0
1
 getGroupName
n/a
0 / 0
n/a
0 / 0
1
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\SpecialPage;
8
9use MediaWiki\CommentStore\CommentStore;
10use MediaWiki\Html\Html;
11use MediaWiki\HTMLForm\HTMLForm;
12use MediaWiki\Logging\LogEventsList;
13use MediaWiki\Logging\LogPage;
14use MediaWiki\Message\Message;
15use MediaWiki\Title\Title;
16use MediaWiki\User\UserGroupAssignmentService;
17use MediaWiki\User\UserGroupMembership;
18use MediaWiki\Xml\XmlSelect;
19use OOUI\FieldLayout;
20use OOUI\FieldsetLayout;
21use OOUI\HtmlSnippet;
22use OOUI\LabelWidget;
23use OOUI\PanelLayout;
24use Status;
25
26/**
27 * A base class for special pages that allow to view and edit user groups.
28 *
29 * @stable to extend
30 * @ingroup SpecialPage
31 */
32abstract class UserGroupsSpecialPage extends SpecialPage {
33
34    /** @var string The bare name of the target user, e.g. "Foo" in a form suitable for {{GENDER:}} */
35    protected string $targetBareName = '';
36
37    /**
38     * @var string The display name of the target user, e.g. "Foo", "Foo@wiki". It will also be used as a value
39     *   for the hidden target field in the edit groups form.
40     */
41    protected string $targetDisplayName = '';
42
43    /** @var list<string> An array of all explicit groups in the system */
44    protected array $explicitGroups = [];
45
46    /**
47     * @var array<string,UserGroupMembership> An array of group name => UserGroupMembership objects that the target
48     *   user belongs to
49     */
50    protected array $groupMemberships = [];
51
52    /** @var array<string> An array of group names that can be added by the current user to the current target */
53    protected array $addableGroups = [];
54
55    /** @var array<string> An array of group names that can be removed by the current user to the current target */
56    protected array $removableGroups = [];
57
58    /** @var array<string,list<Message|string>> An array of group name => list of annotations to show below the group */
59    protected array $groupAnnotations = [];
60
61    /** @var bool Whether the "Watch the user page" checkbox should be available on the page */
62    protected bool $enableWatchUser = true;
63
64    /** @var string Name of session flag that's saved when the user groups are successfully saved */
65    private const SAVE_SUCCESS_FLAG = 'specialUserrightsSaveSuccess';
66
67    /** @var string Name of the form field, which stores the conflict check key */
68    private const CONFLICT_CHECK_FIELD = 'conflictcheck-originalgroups';
69
70    /**
71     * Sets the name of the target user. If this page uses a special notation for the username (e.g. "Foo@wiki"),
72     * which is different from actual bare username, this additional form should be passed as the second parameter.
73     * The second form will be used in the interface messages and in the hidden target field in the groups form.
74     * @param string $bareName A form of the name that can be used with {{GENDER:}}
75     * @param string|null $displayName A form of the name that will be used as a value of the target field
76     *   in the edit groups form. If null, $targetName is used.
77     */
78    protected function setTargetName( string $bareName, ?string $displayName = null ): void {
79        $this->targetBareName = $bareName;
80        $this->targetDisplayName = $displayName ?? $bareName;
81    }
82
83    /**
84     * Sets the groups that can be added and removed by the current user to/from the target user.
85     * If there are any restricted groups, adds appropriate annotations for them. This method accepts
86     * the same input structure as returned by {@see UserGroupAssignmentService::getChangeableGroups()}.
87     * @param array{add:list<string>,remove:list<string>,restricted:array<string,array>} $changeableGroups
88     */
89    protected function setChangeableGroups( array $changeableGroups ): void {
90        $this->addableGroups = $changeableGroups['add'];
91        $this->removableGroups = $changeableGroups['remove'];
92        foreach ( $changeableGroups['restricted'] as $group => $details ) {
93            $isConditionMet = $details['condition-met'];
94            if ( $isConditionMet === false ) {
95                if ( isset( $details['message'] ) ) {
96                    $messageKey = $details['message'];
97                } else {
98                    $customMessageKey = 'userrights-restricted-group-' . $group;
99                    $messageKey = $this->msg( $customMessageKey )->exists() ?
100                        $customMessageKey :
101                        'userrights-restricted-group-warning';
102                }
103                $this->addGroupAnnotation( $group, $messageKey );
104            } elseif ( $isConditionMet === null ) {
105                $customMessageKey = 'userrights-restricted-group-' . $group . '-private-conditions';
106                $messageKey = $this->msg( $customMessageKey )->exists() ?
107                    $customMessageKey :
108                    'userrights-restricted-group-warning-private-conditions';
109                $this->addGroupAnnotation( $group, $messageKey );
110            }
111        }
112    }
113
114    /**
115     * Adds ResourceLoader modules that are used by this page.
116     */
117    protected function addModules(): void {
118        $out = $this->getOutput();
119        $out->addModules( [ 'mediawiki.special.userrights' ] );
120        $out->addModuleStyles( [ 'mediawiki.special', 'mediawiki.codex.messagebox.styles' ] );
121    }
122
123    /**
124     * If the session contains a flag that the user rights were successfully saved,
125     * shows a success message and removes the flag from the session.
126     */
127    protected function showMessageOnSuccess(): void {
128        $session = $this->getRequest()->getSession();
129        if ( $session->get( self::SAVE_SUCCESS_FLAG ) ) {
130            // Remove session data for the success message
131            $session->remove( self::SAVE_SUCCESS_FLAG );
132
133            $out = $this->getOutput();
134            $out->addModuleStyles( 'mediawiki.notification.convertmessagebox.styles' );
135            $out->addHTML(
136                Html::successBox(
137                    Html::element(
138                        'p',
139                        [],
140                        $this->msg( 'savedrights', $this->targetDisplayName )->text()
141                    ),
142                    'mw-notify-success'
143                )
144            );
145        }
146    }
147
148    /**
149     * Sets a flag in the session that the user rights were successfully saved.
150     * Next requests can call {@see showMessageOnSuccess()} to show a success message.
151     */
152    protected function setSuccessFlag(): void {
153        $session = $this->getRequest()->getSession();
154        $session->set( self::SAVE_SUCCESS_FLAG, 1 );
155    }
156
157    /**
158     * Builds the user groups form, either in view or edit mode.
159     * @return string The HTML of the form
160     */
161    protected function buildGroupsForm(): string {
162        $this->getOutput()->addBodyClasses( 'mw-special-UserGroupsSpecialPage' );
163
164        $groups = $this->prepareAvailableGroups();
165
166        $canChangeAny = array_any(
167            $groups,
168            static fn ( $group ) => $group['canAdd'] || $group['canRemove']
169        );
170
171        $panel = $canChangeAny ?
172            $this->buildEditGroupsFormContent( $groups ) :
173            $this->buildViewGroupsFormContent();
174        return $panel->toString();
175    }
176
177    private function buildFormHeader( string $messageKey ): string {
178        return $this->msg( $messageKey, $this->targetBareName )->text();
179    }
180
181    private function buildFormDescription( string $messageKey ): string {
182        return $this->msg( $messageKey )
183            ->params( wfEscapeWikiText( $this->targetDisplayName ) )
184            ->rawParams( $this->getTargetUserToolLinks() )->parse();
185    }
186
187    private function buildFormGroupsLists(): array {
188        return array_map( static function ( $field ) {
189            return $field['label'] . ' ' . $field['list'];
190        }, $this->getCurrentUserGroupsFields() );
191    }
192
193    /**
194     * Allow subclasses to add extra information. This is displayed on the edit and
195     * view panels, after the lists of the target user's groups.
196     *
197     * @return ?string Parsed HTML
198     */
199    protected function buildFormExtraInfo(): ?string {
200        return null;
201    }
202
203    /**
204     * Builds the user groups form in view-only mode.
205     */
206    private function buildViewGroupsFormContent(): PanelLayout {
207        $panelLabel = $this->buildFormHeader( 'userrights-viewusergroup' );
208
209        $panelItems = array_filter( [
210            $this->buildFormDescription( 'viewinguserrights' ),
211            ...$this->buildFormGroupsLists(),
212            $this->buildFormExtraInfo(),
213        ] );
214        $panelItems = array_map( static function ( $label ) {
215            return new FieldLayout(
216                new LabelWidget( [
217                    'label' => new HtmlSnippet( $label )
218                ] )
219            );
220        }, $panelItems );
221
222        return new PanelLayout( [
223            'expanded' => false,
224            'padded' => true,
225            'framed' => true,
226            'content' => new FieldsetLayout( [
227                'label' => $panelLabel,
228                'items' => $panelItems,
229            ] )
230        ] );
231    }
232
233    /**
234     * Builds the user groups form in edit mode.
235     * @param array $groups Prepared list of groups to show, {@see prepareAvailableGroups()}
236     */
237    private function buildEditGroupsFormContent( array $groups ): PanelLayout {
238        $panelLabel = $this->buildFormHeader( 'userrights-editusergroup' );
239
240        $panelItems = array_filter( [
241            $this->buildFormDescription( 'editinguser' ),
242            $this->msg( 'userrights-groups-help', $this->targetBareName )->parse(),
243            ...$this->buildFormGroupsLists(),
244            $this->buildFormExtraInfo(),
245        ] );
246        $panelItems = array_map( static function ( $label ) {
247            return new FieldLayout(
248                new LabelWidget( [
249                    'label' => new HtmlSnippet( $label )
250                ] )
251            );
252        }, $panelItems );
253
254        $formDescriptor = [
255            'user' => [
256                'type' => 'hidden',
257                'name' => 'user',
258                'default' => $this->targetDisplayName,
259            ],
260            'EditToken' => [
261                'type' => 'hidden',
262                'default' => $this->getUser()->getEditToken( $this->targetDisplayName ),
263            ],
264            self::CONFLICT_CHECK_FIELD => [
265                'type' => 'hidden',
266                'name' => self::CONFLICT_CHECK_FIELD,
267                'default' => $this->makeConflictCheckKey(),
268            ],
269        ];
270
271        $memberships = $this->groupMemberships;
272        $unchangeableGroupFields = [];
273        $changeableGroupFields = [];
274        foreach ( $groups as $group => $groupData ) {
275            $isMember = array_key_exists( $group, $memberships );
276            $expiry = null;
277            if ( $isMember ) {
278                $expiry = $memberships[$group]->getExpiry();
279            }
280
281            [ $groupFields, $isChangeable ] = $this->makeGroupFields(
282                $groupData,
283                $isMember,
284                $expiry,
285                $this->targetBareName
286            );
287
288            if ( $isChangeable ) {
289                $changeableGroupFields += $groupFields;
290            } else {
291                $unchangeableGroupFields += $groupFields;
292            }
293        }
294
295        // Ensure that the unchangeable fields section is before the changeable fields section,
296        // so that it displays on the correct side, if present.
297        $formDescriptor += $unchangeableGroupFields;
298        $formDescriptor += $changeableGroupFields;
299
300        $formDescriptor['user-reason'] = [
301            'type' => 'text',
302            'name' => 'user-reason',
303            'id' => 'wpReason',
304            'label' => $this->msg( 'userrights-reason' )->text(),
305            // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
306            // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
307            // Unicode codepoints.
308            'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
309            'maxlength-unit' => 'codepoints',
310            'size' => 60,
311            'default' => $this->getRequest()->getVal( 'user-reason' ) ?? false,
312        ];
313
314        if ( $this->enableWatchUser ) {
315            $formDescriptor['Watch'] = [
316                'type' => 'check',
317                'default' => false,
318                'id' => 'wpWatch',
319                'label' => $this->msg( 'userrights-watchuser' )->text(),
320            ];
321        }
322
323        $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext(), 'userrights' );
324        $htmlForm
325            ->setMethod( 'POST' )
326            ->setName( 'editGroup' )
327            ->setTitle( $this->getPageTitle() )
328            ->setId( 'mw-userrights-form2' )
329            ->setSubmitTextMsg( $this->msg( 'saveusergroups', $this->targetBareName ) )
330            ->setSubmitName( 'saveusergroups' )
331            ->prepareForm();
332        $form = $htmlForm->getHtml( true );
333
334        return new PanelLayout( [
335            'expanded' => false,
336            'padded' => true,
337            'framed' => true,
338            'content' => [
339                new FieldsetLayout( [
340                    'label' => $panelLabel,
341                    'items' => $panelItems,
342                ] ),
343                new PanelLayout( [
344                    'expanded' => false,
345                    'content' => new HtmlSnippet( $form ),
346                ] )
347            ],
348        ] );
349    }
350
351    /**
352     * Returns an array of all user groups that should be presented in the form, along with
353     * information whether the current user can add/remove them and any annotations.
354     * @return array<string,array{group:string,canAdd:bool,canRemove:bool,annotations:list<Message|string>}>
355     */
356    private function prepareAvailableGroups(): array {
357        $allGroups = $this->explicitGroups;
358
359        // We store user groups with information whether the current user can add/remove them
360        // and possibly other data that will be then used for rendering the form
361        $result = [];
362
363        foreach ( $allGroups as $group ) {
364            $result[$group] = [
365                'group' => $group,
366                'canAdd' => $this->canAdd( $group ),
367                'canRemove' => $this->canRemove( $group ),
368                'annotations' => $this->getGroupAnnotations( $group ),
369            ];
370        }
371
372        return $result;
373    }
374
375    /**
376     * Creates an HTML code for a single item in the user groups form: a checkbox along with the expiry field
377     * (if applicable) and any annotations.
378     * @param array $groupData The group data as returned by {@see prepareAvailableGroups()}
379     * @param bool $isMember Whether the target user is currently a member of this group
380     * @param string|null $expiry The expiry time of this group for the target user, or null if it has no expiry.
381     *   Ignored if the user is not a member of this group.
382     * @param string $userName The username of the target user, used for {{GENDER:}}
383     * @return array{0:array<string, array<string, mixed>>, 1:bool} Array of form fields, and whether any are
384     *   changeable (i.e. any of the checkbox or expiry field are not disabled)
385     */
386    private function makeGroupFields( array $groupData, bool $isMember, ?string $expiry, string $userName ): array {
387        $group = $groupData['group'];
388        $uiLanguage = $this->getLanguage();
389        $member = $uiLanguage->getGroupMemberName( $group, $userName );
390
391        // Users who can add the group, but not remove it, can only lengthen
392        // expiries, not shorten them. So they should only see the expiry
393        // dropdown if the group currently has a finite expiry
394        $canOnlyLengthenExpiry = (
395            $isMember && $expiry &&
396            $groupData['canAdd'] && !$groupData['canRemove']
397        );
398
399        // Should the checkbox be disabled?
400        $disabledCheckbox = !(
401            ( $isMember && $groupData['canRemove'] ) ||
402            ( !$isMember && $groupData['canAdd'] )
403        );
404
405        // Should the expiry elements be disabled?
406        $disabledExpiry = $disabledCheckbox && !$canOnlyLengthenExpiry;
407
408        // Do we need to point out that this action is irreversible?
409        $irreversible = !$disabledCheckbox && (
410            ( $isMember && !$groupData['canAdd'] ) ||
411            ( !$isMember && !$groupData['canRemove'] )
412        );
413
414        if ( $irreversible ) {
415            $text = $this->msg( 'userrights-irreversible-marker', $member )->text();
416        } elseif ( $disabledCheckbox && !$disabledExpiry ) {
417            $text = $this->msg( 'userrights-no-shorten-expiry-marker', $member )->text();
418        } else {
419            $text = $member;
420        }
421
422        $checkboxField = [
423            'type' => 'check',
424            'name' => "wpGroup-$group",
425            'id' => "wpGroup-$group",
426            'default' => $isMember,
427            'cssclass' => 'mw-userrights-groupcheckbox',
428            'disabled' => $disabledCheckbox,
429            'label' => $text,
430            'help-messages' => [],
431        ];
432
433        foreach ( $groupData['annotations'] as $annotation ) {
434            if ( !$annotation instanceof Message ) {
435                $message = $this->msg( $annotation );
436            } else {
437                $message = $annotation;
438            }
439
440            $checkboxField['help-messages'][] = $message;
441            $checkboxField['help-messages'][] = $this->msg( 'userrights-checkbox-help-message-separator' );
442        }
443
444        $uiUser = $this->getUser();
445
446        // If the user can't modify the expiry, print the current expiry below
447        // it in plain text. Otherwise, provide UI to set/change the expiry
448        if ( $isMember && ( $irreversible || $disabledExpiry ) ) {
449            if ( $expiry ) {
450                $checkboxField['help-messages'][] = $this->msg( 'userrights-expiry-current' )->params(
451                    $uiLanguage->userTimeAndDate( $expiry, $uiUser ),
452                    $uiLanguage->userDate( $expiry, $uiUser ),
453                    $uiLanguage->userTime( $expiry, $uiUser )
454                );
455            } else {
456                $checkboxField['help-messages'][] = $this->msg( 'userrights-expiry-none' );
457            }
458            // T171345: Add a hidden form element so that other groups can still be manipulated,
459            // otherwise saving errors out with an invalid expiry time for this group.
460            $expiryField = [
461                'type' => 'hidden',
462                'name' => "wpExpiry-$group",
463                'default' => $expiry ? 'existing' : 'infinite',
464            ];
465        } else {
466            $expiryField = [
467                'type' => 'selectorother',
468                'label' => $this->msg( 'userrights-expiry-for', $member )->text(),
469                'other-message' => 'userrights-expiry-othertime',
470                'name' => "wpExpiry-$group",
471                'id' => "mw-input-wpExpiry-$group",
472                'hide-if' => [ '!==', "wpGroup-$group", '1' ],
473                'disabled' => $disabledExpiry,
474            ];
475
476            // Create expiry field options. If there is an existing expiry, set it to the default.
477            // Otherwise, default to infinite.
478            $expiries = [];
479
480            $expiries[$this->msg( 'userrights-expiry-none' )->text()] = 'infinite';
481            $expiryOptionsMsg = $this->msg( 'userrights-expiry-options' )->inContentLanguage();
482            $expiryOptions = $expiryOptionsMsg->isDisabled()
483                ? []
484                : XmlSelect::parseOptionsMessage( $expiryOptionsMsg->text() );
485            $expiries = array_merge( $expiries, $expiryOptions );
486
487            if ( $isMember && $expiry ) {
488                $existingExpiryText = $this->msg(
489                    'userrights-expiry-existing',
490                    $uiLanguage->userTimeAndDate( $expiry, $uiUser ),
491                    $uiLanguage->userDate( $expiry, $uiUser ),
492                    $uiLanguage->userTime( $expiry, $uiUser )
493                )->text();
494                $expiries[$existingExpiryText] = 'existing';
495                $expiryField['default'] = 'existing';
496            } else {
497                $expiryField['default'] = 'infinite';
498            }
499
500            $expiryField['options'] = $expiries;
501        }
502
503        $fullyDisabled = $disabledCheckbox && $disabledExpiry;
504        $checkboxField['section'] = $fullyDisabled ? 'unchangeable-col' : 'changeable-col';
505        $expiryField['section'] = $fullyDisabled ? 'unchangeable-col' : 'changeable-col';
506
507        $groupFields = [
508            "wpGroup-$group" => $checkboxField,
509            "wpExpiry-$group" => $expiryField
510        ];
511
512        if ( $isMember && $disabledCheckbox && !( $irreversible || $disabledExpiry ) ) {
513            // If the user group is set but the checkbox is disabled, mimic a
514            // checked checkbox in the form submission so that the expiry is read
515            $groupFields["wpHidden-$group"] = [
516                'type' => 'hidden',
517                'name' => "wpGroup-$group",
518                'default' => 1,
519            ];
520        }
521
522        return [ $groupFields, !$fullyDisabled ];
523    }
524
525    /**
526     * Reads the user groups set in the form. Returns them wrapped in a Status object.
527     * On success, the value is an array of group name => expiry pairs. The expiry
528     * is either a timestamp, null or 'existing' (meaning no change).
529     * On failure, the status is fatal and contains an appropriate error message.
530     *
531     * NOTE: This method doesn't check whether the current user is actually allowed
532     * to add/remove the groups. Normally, the result doesn't contain groups that
533     * the user is not supposed to change.
534     */
535    protected function readGroupsForm(): Status {
536        $allGroups = $this->explicitGroups;
537        // New state of the user groups, read from the form (group name => expiry)
538        // The expiry is either timestamp, null or 'existing' (meaning no change)
539        $newGroups = [];
540
541        foreach ( $allGroups as $group ) {
542            // We'll tell it to remove all unchecked groups, and add all checked groups.
543            // For disabled checkboxes, the state is propagated from the current memberships.
544            if ( $this->getRequest()->getCheck( "wpGroup-$group" ) ) {
545                // Default expiry is infinity, may be changed below
546                $newGroups[$group] = null;
547
548                // read the expiry information from the request
549                $expiryDropdown = $this->getRequest()->getVal( "wpExpiry-$group" );
550                if ( $expiryDropdown === 'existing' ) {
551                    $newGroups[$group] = 'existing';
552                    continue;
553                }
554
555                if ( $expiryDropdown === 'other' ) {
556                    $expiryValue = $this->getRequest()->getVal( "wpExpiry-$group-other" );
557                } else {
558                    $expiryValue = $expiryDropdown;
559                }
560
561                // validate the expiry
562                $expiry = UserGroupAssignmentService::expiryToTimestamp( $expiryValue );
563
564                if ( $expiry === false ) {
565                    return Status::newFatal( 'userrights-invalid-expiry', $group );
566                }
567
568                // not allowed to have things expiring in the past
569                if ( $expiry && $expiry < wfTimestampNow() ) {
570                    return Status::newFatal( 'userrights-expiry-in-past', $group );
571                }
572
573                $newGroups[$group] = $expiry;
574            } elseif ( !$this->canRemove( $group ) && isset( $this->groupMemberships[$group] ) ) {
575                // If the checkbox is absent from the request, it's either unchecked or disabled.
576                // If it's the latter, pretend that its state hasn't changed from the current group membership.
577                $newGroups[$group] = 'existing';
578            }
579        }
580
581        return Status::newGood( $newGroups );
582    }
583
584    /**
585     * Compares the current and new groups and splits them into groups to add, to remove, and prepares
586     * the new expiries of the groups in 'add'. If a group has its expiry changed, but the user is already
587     * a member of it, this group will be included in 'add' (to update the expiry).
588     * @param array<string, ?string> $newGroups An array of group name => expiry pairs, as returned
589     *   by {@see readGroupsForm()}. The expiry is either a timestamp, null (meaning infinity) or
590     *   'existing' (meaning no change).
591     * @param array<string, UserGroupMembership> $existingUGMs The current group memberships of
592     *   the target user, in the same format as in {@see $groupMemberships}.
593     * @return array{0:list<string>,1:list<string>,2:array<string,?string>} Respectively: the groups
594     *   to add, to remove, and the expiries to set on the groups to add.
595     */
596    protected function splitGroupsIntoAddRemove( array $newGroups, array $existingUGMs ): array {
597        $involvedGroups = array_unique( array_merge( array_keys( $existingUGMs ), array_keys( $newGroups ) ) );
598
599        $addGroups = [];
600        $removeGroups = [];
601        $groupExpiries = [];
602        foreach ( $involvedGroups as $group ) {
603            // By definition of $involvedGroups, at least one of $hasGroup and $wantsGroup is true
604            $hasGroup = array_key_exists( $group, $existingUGMs );
605            $wantsGroup = array_key_exists( $group, $newGroups );
606
607            if ( $wantsGroup && $newGroups[$group] === 'existing' ) {
608                // No change requested for this group
609                continue;
610            }
611
612            if ( $hasGroup && !$wantsGroup ) {
613                $removeGroups[] = $group;
614                continue;
615            }
616            if ( !$hasGroup && $wantsGroup ) {
617                $addGroups[] = $group;
618                $groupExpiries[$group] = $newGroups[$group];
619                continue;
620            }
621
622            $currentExpiry = $existingUGMs[$group]->getExpiry();
623            $wantedExpiry = $newGroups[$group];
624            if ( $currentExpiry !== $wantedExpiry ) {
625                $addGroups[] = $group;
626                $groupExpiries[$group] = $wantedExpiry;
627            }
628        }
629
630        return [ $addGroups, $removeGroups, $groupExpiries ];
631    }
632
633    /**
634     * Get the message translations for displaying the types of groups memberships the user has, and the
635     * list of groups for each type.
636     *
637     * @return array<array{label:string,list:string}>
638     */
639    private function getCurrentUserGroupsFields(): array {
640        $userGroups = $this->sortGroupMemberships( $this->groupMemberships );
641        $groupParagraphs = $this->categorizeUserGroupsForDisplay( $userGroups );
642
643        $context = $this->getContext();
644        $userName = $this->targetBareName;
645        $language = $this->getLanguage();
646
647        $fields = [];
648        foreach ( $groupParagraphs as $paragraphKey => $groups ) {
649            if ( count( $groups ) === 0 ) {
650                continue;
651            }
652
653            $groupLinks = array_map(
654                static fn ( $group ) => UserGroupMembership::getLinkHTML( $group, $context ),
655                $groups
656            );
657            $memberLinks = array_map(
658                static fn ( $group ) => UserGroupMembership::getLinkHTML( $group, $context, $userName ),
659                $groups
660            );
661
662            // Some languages prefer to have group names listed and some others prefer the member names,
663            // i.e. "Administrators" or "Administrator", respectively. This message acts as a switch between these.
664            $displayedList = $this->msg( 'userrights-groupsmember-type' )
665                ->rawParams(
666                    $language->commaList( $groupLinks ),
667                    $language->commaList( $memberLinks )
668                )->escaped();
669
670            $paragraphHeader = $this->msg( $paragraphKey )
671                ->numParams( count( $groups ) )
672                ->params( $userName )
673                ->parse();
674
675            $fields[] = [
676                'label' => $paragraphHeader,
677                'list' => $displayedList
678            ];
679        }
680        return $fields;
681    }
682
683    /**
684     * Shows a log fragment for the current target user, i.e. page "User:{$this->targetDisplayName}".
685     *
686     * @param string $logType The type of the log to show
687     * @param string $logSubType The subtype of the log to show
688     */
689    protected function showLogFragment( string $logType, string $logSubType ): void {
690        $logPage = new LogPage( $logType );
691
692        $logTitle = $logPage->getName()
693            // setContext allows us to test it - otherwise, English text would be used in tests
694            ->setContext( $this->getContext() )
695            ->text();
696
697        $output = $this->getOutput();
698        $output->addHTML( Html::element( 'h2', [], $logTitle ) );
699        LogEventsList::showLogExtract(
700            $output,
701            $logSubType,
702            Title::makeTitle( NS_USER, $this->targetDisplayName )
703        );
704    }
705
706    /**
707     * This function is invoked when constructing the "current user groups" part of the form. It can be
708     * overridden by the implementations to split the user groups into several paragraphs or add more
709     * groups to the list, which are not expected to be editable through the form.
710     *
711     * @param array<string,UserGroupMembership> $userGroups The user groups the target belongs to, in
712     *   the same format as {@see $groupMemberships}. The groups are sorted in such a way that permanent
713     *   memberships are after temporary ones.
714     * @return array<string,list<UserGroupMembership|string>> List of groups to show, keyed by the message key to
715     *   include at the beginning of the respective paragraph. The default implementation returns a single
716     *   paragraph with all the groups, keyed by 'userrights-groupsmember'.
717     */
718    protected function categorizeUserGroupsForDisplay( array $userGroups ): array {
719        return [
720            'userrights-groupsmember' => array_values( $userGroups ),
721        ];
722    }
723
724    /**
725     * Returns a string that represents the current state of the target's groups. It is used to
726     * detect attempts of concurrent modifications to the user groups.
727     * @param ?array<string,UserGroupMembership> $groupMemberships The group memberships to use
728     *   in the conflict key generation. If null, defaults to the value of {@see $groupMemberships}.
729     *   It's advised to use set this parameter to memberships fetched from the primary database when
730     *   trying to detect conflicts on handling a request to save changes to user groups.
731     */
732    protected function makeConflictCheckKey( ?array $groupMemberships = null ): string {
733        $groupMemberships ??= $this->groupMemberships;
734        $groups = array_keys( $groupMemberships );
735        // Sort, so that the keys are safe to compare
736        sort( $groups );
737        return implode( ',', $groups );
738    }
739
740    /**
741     * Tests if a conflict occurred when trying to save changes to user groups, by comparing
742     * the conflict check key received from the form with the expected one.
743     * @param ?array<string,UserGroupMembership> $groupMembershipsPrimary The group memberships
744     *   to use when generating the expected conflict check key. If null, defaults to the value
745     *   of {@see $groupMemberships}. It's recommended to pass memberships fetched from the primary
746     *   database, so that concurrent changes made by other requests are detected.
747     */
748    protected function conflictOccured( ?array $groupMembershipsPrimary = null ): bool {
749        $request = $this->getRequest();
750        $receivedConflictCheck = $request->getVal( self::CONFLICT_CHECK_FIELD );
751        $expectedConflictCheck = $this->makeConflictCheckKey( $groupMembershipsPrimary );
752
753        return $receivedConflictCheck !== $expectedConflictCheck;
754    }
755
756    /**
757     * Returns an HTML snippet with links to pages like user talk, contributions etc. for the
758     * target user. It will be used in the "Changing user groups of" header.
759     */
760    abstract protected function getTargetUserToolLinks(): string;
761
762    /**
763     * Whether the current user can add the target user to the given group.
764     */
765    protected function canAdd( string $group ): bool {
766        return in_array( $group, $this->addableGroups );
767    }
768
769    /**
770     * Whether the current user can remove the target user from the given group.
771     */
772    protected function canRemove( string $group ): bool {
773        return in_array( $group, $this->removableGroups );
774    }
775
776    /**
777     * Returns an array of annotations (messages or message keys) that should be displayed
778     * below the checkbox for the given group. The default implementation returns an empty array.
779     *
780     * Annotations can signify special properties of the group, e.g. conditions required to grant this
781     * group or consequences of adding the user etc.
782     * @return list<Message|string>
783     */
784    protected function getGroupAnnotations( string $group ): array {
785        return $this->groupAnnotations[$group] ?? [];
786    }
787
788    /**
789     * Adds an annotation (message or message key) that should be displayed below the checkbox
790     * for the given group. The annotation will be appended to any existing annotations
791     * for this group.
792     */
793    protected function addGroupAnnotation( string $group, Message|string $annotation ): void {
794        $this->groupAnnotations[$group][] = $annotation;
795    }
796
797    /**
798     * Sorts the given group memberships so that the temporary memberships come first, followed
799     * by the permanent ones; within each category, sorts alphabetically by group name.
800     * @param array<string,UserGroupMembership> $memberships
801     * @return array<string,UserGroupMembership>
802     */
803    private function sortGroupMemberships( array $memberships ): array {
804        uasort( $memberships, static function ( $a, $b ) {
805            $aPermanent = $a->getExpiry() === null;
806            $bPermanent = $b->getExpiry() === null;
807
808            if ( $aPermanent === $bPermanent ) {
809                return $a->getGroup() <=> $b->getGroup();
810            } else {
811                return $aPermanent ? 1 : -1;
812            }
813        } );
814        return $memberships;
815    }
816
817    /**
818     * @inheritDoc
819     * @codeCoverageIgnore Merely declarative
820     */
821    public function doesWrites() {
822        return true;
823    }
824
825    /**
826     * @inheritDoc
827     * @codeCoverageIgnore Merely declarative
828     */
829    protected function getGroupName() {
830        return 'users';
831    }
832}