MediaWiki master
SpecialUserRights.php
Go to the documentation of this file.
1<?php
21namespace MediaWiki\Specials;
22
25use LogPage;
50
63 protected $mTarget;
67 protected $mFetchedUser = null;
68 protected $isself = false;
69
70 private UserGroupManagerFactory $userGroupManagerFactory;
71
73 private $userGroupManager = null;
74
75 private UserNameUtils $userNameUtils;
76 private UserNamePrefixSearch $userNamePrefixSearch;
77 private UserFactory $userFactory;
78 private ActorStoreFactory $actorStoreFactory;
79 private WatchlistManager $watchlistManager;
80
89 public function __construct(
90 UserGroupManagerFactory $userGroupManagerFactory = null,
91 UserNameUtils $userNameUtils = null,
92 UserNamePrefixSearch $userNamePrefixSearch = null,
93 UserFactory $userFactory = null,
94 ActorStoreFactory $actorStoreFactory = null,
95 WatchlistManager $watchlistManager = null
96 ) {
97 parent::__construct( 'Userrights' );
99 // This class is extended and therefore falls back to global state - T263207
100 $this->userNameUtils = $userNameUtils ?? $services->getUserNameUtils();
101 $this->userNamePrefixSearch = $userNamePrefixSearch ?? $services->getUserNamePrefixSearch();
102 $this->userFactory = $userFactory ?? $services->getUserFactory();
103 $this->userGroupManagerFactory = $userGroupManagerFactory ?? $services->getUserGroupManagerFactory();
104 $this->actorStoreFactory = $actorStoreFactory ?? $services->getActorStoreFactory();
105 $this->watchlistManager = $watchlistManager ?? $services->getWatchlistManager();
106 }
107
108 public function doesWrites() {
109 return true;
110 }
111
123 public function userCanChangeRights( UserIdentity $targetUser, $checkIfSelf = true ) {
124 $isself = $this->getUser()->equals( $targetUser );
125
126 $userGroupManager = $this->userGroupManagerFactory
127 ->getUserGroupManager( $targetUser->getWikiId() );
128 $available = $userGroupManager->getGroupsChangeableBy( $this->getAuthority() );
129 if ( !$targetUser->isRegistered() ) {
130 return false;
131 }
132
133 if ( $available['add'] || $available['remove'] ) {
134 // can change some rights for any user
135 return true;
136 }
137
138 if ( ( $available['add-self'] || $available['remove-self'] )
139 && ( $isself || !$checkIfSelf )
140 ) {
141 // can change some rights for self
142 return true;
143 }
144
145 return false;
146 }
147
155 public function execute( $par ) {
156 $user = $this->getUser();
157 $request = $this->getRequest();
158 $session = $request->getSession();
159 $out = $this->getOutput();
160
161 $out->addModules( [ 'mediawiki.special.userrights' ] );
162
163 $this->mTarget = $par ?? $request->getVal( 'user' );
164 if ( $this->mTarget === null ) {
165 $fetchedStatus = Status::newFatal( 'nouserspecified' );
166
167 } else {
168 $this->mTarget = trim( $this->mTarget );
169
170 if ( $this->userNameUtils->getCanonical( $this->mTarget ) === $user->getName() ) {
171 $this->isself = true;
172 }
173
174 $fetchedStatus = $this->fetchUser( $this->mTarget, true );
175 }
176
177 if ( $fetchedStatus->isOK() ) {
178 $this->mFetchedUser = $fetchedUser = $fetchedStatus->value;
179 // Phan false positive on Status object - T323205
180 '@phan-var UserIdentity $fetchedUser';
181 $wikiId = $fetchedUser->getWikiId();
182 if ( $wikiId === UserIdentity::LOCAL ) {
183 // Set the 'relevant user' in the skin, so it displays links like Contributions,
184 // User logs, UserRights, etc.
185 $this->getSkin()->setRelevantUser( $this->mFetchedUser );
186 }
187 $this->userGroupManager = $this->userGroupManagerFactory
188 ->getUserGroupManager( $wikiId );
189 }
190
191 // show a successbox, if the user rights was saved successfully
192 if (
193 $session->get( 'specialUserrightsSaveSuccess' ) &&
194 $this->mFetchedUser !== null
195 ) {
196 // Remove session data for the success message
197 $session->remove( 'specialUserrightsSaveSuccess' );
198
199 $out->addModuleStyles( 'mediawiki.notification.convertmessagebox.styles' );
200 $out->addHTML(
201 Html::successBox(
203 'p',
204 [],
205 $this->msg( 'savedrights', $this->getDisplayUsername( $this->mFetchedUser ) )->text()
206 ),
207 'mw-notify-success'
208 )
209 );
210 }
211
212 $this->setHeaders();
213 $this->outputHeader();
214
215 $out->addModuleStyles( 'mediawiki.special' );
216 $this->addHelpLink( 'Help:Assigning permissions' );
217
218 $this->switchForm();
219
220 if (
221 $request->wasPosted() &&
222 $request->getCheck( 'saveusergroups' ) &&
223 $this->mTarget !== null &&
224 $user->matchEditToken( $request->getVal( 'wpEditToken' ), $this->mTarget )
225 ) {
226 /*
227 * If the user is blocked and they only have "partial" access
228 * (e.g. they don't have the userrights permission), then don't
229 * allow them to change any user rights.
230 */
231 if ( !$this->getAuthority()->isAllowed( 'userrights' ) ) {
232 $block = $user->getBlock();
233 if ( $block && $block->isSitewide() ) {
234 throw new UserBlockedError(
235 $block,
236 $user,
237 $this->getLanguage(),
238 $request->getIP()
239 );
240 }
241 }
242
243 $this->checkReadOnly();
244
245 // save settings
246 if ( !$fetchedStatus->isOK() ) {
247 $this->getOutput()->addWikiTextAsInterface(
248 $fetchedStatus->getWikiText( false, false, $this->getLanguage() )
249 );
250
251 return;
252 }
253
254 $targetUser = $this->mFetchedUser;
255 $conflictCheck = $request->getVal( 'conflictcheck-originalgroups' );
256 $conflictCheck = ( $conflictCheck === '' ) ? [] : explode( ',', $conflictCheck );
257 $userGroups = $this->userGroupManager->getUserGroups( $targetUser, IDBAccessObject::READ_LATEST );
258
259 if ( $userGroups !== $conflictCheck ) {
260 $out->addHTML( Html::errorBox(
261 $this->msg( 'userrights-conflict' )->parse()
262 ) );
263 } else {
264 $status = $this->saveUserGroups(
265 $request->getText( 'user-reason' ),
266 $targetUser
267 );
268
269 if ( $status->isOK() ) {
270 // Set session data for the success message
271 $session->set( 'specialUserrightsSaveSuccess', 1 );
272
273 $out->redirect( $this->getSuccessURL() );
274 return;
275 } else {
276 // Print an error message and redisplay the form
277 $out->wrapWikiTextAsInterface(
278 'error', $status->getWikiText( false, false, $this->getLanguage() )
279 );
280 }
281 }
282 }
283
284 // show some more forms
285 if ( $this->mTarget !== null ) {
286 $this->editUserGroupsForm( $this->mTarget );
287 }
288 }
289
290 private function getSuccessURL() {
291 return $this->getPageTitle( $this->mTarget )->getFullURL();
292 }
293
300 public function canProcessExpiries() {
301 return true;
302 }
303
313 public static function expiryToTimestamp( $expiry ) {
314 if ( wfIsInfinity( $expiry ) ) {
315 return null;
316 }
317
318 $unix = strtotime( $expiry );
319
320 if ( !$unix || $unix === -1 ) {
321 return false;
322 }
323
324 // @todo FIXME: Non-qualified absolute times are not in users specified timezone
325 // and there isn't notice about it in the ui (see ProtectionForm::getExpiry)
326 return wfTimestamp( TS_MW, $unix );
327 }
328
337 protected function saveUserGroups( string $reason, UserIdentity $user ) {
338 if ( $this->userNameUtils->isTemp( $user->getName() ) ) {
339 return Status::newFatal( 'userrights-no-tempuser' );
340 }
341 $allgroups = $this->userGroupManager->listAllGroups();
342 $addgroup = [];
343 $groupExpiries = []; // associative array of (group name => expiry)
344 $removegroup = [];
345 $existingUGMs = $this->userGroupManager->getUserGroupMemberships( $user );
346
347 // This could possibly create a highly unlikely race condition if permissions are changed between
348 // when the form is loaded and when the form is saved. Ignoring it for the moment.
349 foreach ( $allgroups as $group ) {
350 // We'll tell it to remove all unchecked groups, and add all checked groups.
351 // Later on, this gets filtered for what can actually be removed
352 if ( $this->getRequest()->getCheck( "wpGroup-$group" ) ) {
353 $addgroup[] = $group;
354
355 if ( $this->canProcessExpiries() ) {
356 // read the expiry information from the request
357 $expiryDropdown = $this->getRequest()->getVal( "wpExpiry-$group" );
358 if ( $expiryDropdown === 'existing' ) {
359 continue;
360 }
361
362 if ( $expiryDropdown === 'other' ) {
363 $expiryValue = $this->getRequest()->getVal( "wpExpiry-$group-other" );
364 } else {
365 $expiryValue = $expiryDropdown;
366 }
367
368 // validate the expiry
369 $groupExpiries[$group] = self::expiryToTimestamp( $expiryValue );
370
371 if ( $groupExpiries[$group] === false ) {
372 return Status::newFatal( 'userrights-invalid-expiry', $group );
373 }
374
375 // not allowed to have things expiring in the past
376 if ( $groupExpiries[$group] && $groupExpiries[$group] < wfTimestampNow() ) {
377 return Status::newFatal( 'userrights-expiry-in-past', $group );
378 }
379
380 // if the user can only add this group (not remove it), the expiry time
381 // cannot be brought forward (T156784)
382 if ( !$this->canRemove( $group ) &&
383 isset( $existingUGMs[$group] ) &&
384 ( $existingUGMs[$group]->getExpiry() ?: 'infinity' ) >
385 ( $groupExpiries[$group] ?: 'infinity' )
386 ) {
387 return Status::newFatal( 'userrights-cannot-shorten-expiry', $group );
388 }
389 }
390 } else {
391 $removegroup[] = $group;
392 }
393 }
394
395 $this->doSaveUserGroups( $user, $addgroup, $removegroup, $reason, [], $groupExpiries );
396
397 if ( $user->getWikiId() === UserIdentity::LOCAL && $this->getRequest()->getCheck( 'wpWatch' ) ) {
398 $this->watchlistManager->addWatchIgnoringRights(
399 $this->getUser(),
400 Title::makeTitle( NS_USER, $user->getName() )
401 );
402 }
403
404 return Status::newGood();
405 }
406
422 public function doSaveUserGroups( $user, array $add, array $remove, string $reason = '',
423 array $tags = [], array $groupExpiries = []
424 ) {
425 // Validate input set...
426 $isself = $user->getName() == $this->getUser()->getName();
427 if ( $this->userGroupManager !== null ) {
428 // Used after form submit
429 $userGroupManager = $this->userGroupManager;
430 } else {
431 // Used as backend-function
432 $userGroupManager = $this->userGroupManagerFactory
433 ->getUserGroupManager( $user->getWikiId() );
434 }
435 $groups = $userGroupManager->getUserGroups( $user );
436 $ugms = $userGroupManager->getUserGroupMemberships( $user );
437 $changeable = $userGroupManager->getGroupsChangeableBy( $this->getAuthority() );
438 $addable = array_merge( $changeable['add'], $isself ? $changeable['add-self'] : [] );
439 $removable = array_merge( $changeable['remove'], $isself ? $changeable['remove-self'] : [] );
440
441 $remove = array_unique( array_intersect( $remove, $removable, $groups ) );
442 $add = array_intersect( $add, $addable );
443
444 // add only groups that are not already present or that need their expiry updated,
445 // UNLESS the user can only add this group (not remove it) and the expiry time
446 // is being brought forward (T156784)
447 $add = array_filter( $add,
448 static function ( $group ) use ( $groups, $groupExpiries, $removable, $ugms ) {
449 if ( isset( $groupExpiries[$group] ) &&
450 !in_array( $group, $removable ) &&
451 isset( $ugms[$group] ) &&
452 ( $ugms[$group]->getExpiry() ?: 'infinity' ) >
453 ( $groupExpiries[$group] ?: 'infinity' )
454 ) {
455 return false;
456 }
457 return !in_array( $group, $groups ) || array_key_exists( $group, $groupExpiries );
458 } );
459
460 if ( $user->getWikiId() === UserIdentity::LOCAL ) {
461 // For compatibility local changes are provided as User object to the hook
462 $hookUser = $this->userFactory->newFromUserIdentity( $user );
463 } else {
464 // Interwiki changes are provided as UserIdentity since 1.41, was UserRightsProxy before
465 $hookUser = $user;
466 }
467 $this->getHookRunner()->onChangeUserGroups( $this->getUser(), $hookUser, $add, $remove );
468
469 $oldGroups = $groups;
470 $oldUGMs = $userGroupManager->getUserGroupMemberships( $user );
471 $newGroups = $oldGroups;
472
473 // Remove groups, then add new ones/update expiries of existing ones
474 if ( $remove ) {
475 foreach ( $remove as $index => $group ) {
476 if ( !$userGroupManager->removeUserFromGroup( $user, $group ) ) {
477 unset( $remove[$index] );
478 }
479 }
480 $newGroups = array_diff( $newGroups, $remove );
481 }
482 if ( $add ) {
483 foreach ( $add as $index => $group ) {
484 $expiry = $groupExpiries[$group] ?? null;
485 if ( !$userGroupManager->addUserToGroup( $user, $group, $expiry, true ) ) {
486 unset( $add[$index] );
487 }
488 }
489 $newGroups = array_merge( $newGroups, $add );
490 }
491 $newGroups = array_unique( $newGroups );
492 $newUGMs = $userGroupManager->getUserGroupMemberships( $user );
493
494 // Ensure that caches are cleared
495 $this->userFactory->invalidateCache( $user );
496
497 // update groups in external authentication database
498 $this->getHookRunner()->onUserGroupsChanged( $hookUser, $add, $remove,
499 $this->getUser(), $reason, $oldUGMs, $newUGMs );
500
501 wfDebug( 'oldGroups: ' . print_r( $oldGroups, true ) );
502 wfDebug( 'newGroups: ' . print_r( $newGroups, true ) );
503 wfDebug( 'oldUGMs: ' . print_r( $oldUGMs, true ) );
504 wfDebug( 'newUGMs: ' . print_r( $newUGMs, true ) );
505
506 // Only add a log entry if something actually changed
507 if ( $newGroups != $oldGroups || $newUGMs != $oldUGMs ) {
508 $this->addLogEntry( $user, $oldGroups, $newGroups, $reason, $tags, $oldUGMs, $newUGMs );
509 }
510
511 return [ $add, $remove ];
512 }
513
521 protected static function serialiseUgmForLog( $ugm ) {
522 if ( !$ugm instanceof UserGroupMembership ) {
523 return null;
524 }
525 return [ 'expiry' => $ugm->getExpiry() ];
526 }
527
538 protected function addLogEntry( $user, array $oldGroups, array $newGroups, string $reason,
539 array $tags, array $oldUGMs, array $newUGMs
540 ) {
541 // make sure $oldUGMs and $newUGMs are in the same order, and serialise
542 // each UGM object to a simplified array
543 $oldUGMs = array_map( static function ( $group ) use ( $oldUGMs ) {
544 return isset( $oldUGMs[$group] ) ?
545 self::serialiseUgmForLog( $oldUGMs[$group] ) :
546 null;
547 }, $oldGroups );
548 $newUGMs = array_map( static function ( $group ) use ( $newUGMs ) {
549 return isset( $newUGMs[$group] ) ?
550 self::serialiseUgmForLog( $newUGMs[$group] ) :
551 null;
552 }, $newGroups );
553
554 $logEntry = new ManualLogEntry( 'rights', 'rights' );
555 $logEntry->setPerformer( $this->getUser() );
556 $logEntry->setTarget( Title::makeTitle( NS_USER, $this->getDisplayUsername( $user ) ) );
557 $logEntry->setComment( $reason );
558 $logEntry->setParameters( [
559 '4::oldgroups' => $oldGroups,
560 '5::newgroups' => $newGroups,
561 'oldmetadata' => $oldUGMs,
562 'newmetadata' => $newUGMs,
563 ] );
564 $logid = $logEntry->insert();
565 if ( count( $tags ) ) {
566 $logEntry->addTags( $tags );
567 }
568 $logEntry->publish( $logid );
569 }
570
575 private function editUserGroupsForm( $username ) {
576 $status = $this->fetchUser( $username, true );
577 if ( !$status->isOK() ) {
578 $this->getOutput()->addWikiTextAsInterface(
579 $status->getWikiText( false, false, $this->getLanguage() )
580 );
581
582 return;
583 }
584
586 $user = $status->value;
587 '@phan-var UserIdentity $user';
588
589 $groups = $this->userGroupManager->getUserGroups( $user );
590 $groupMemberships = $this->userGroupManager->getUserGroupMemberships( $user );
591 $this->showEditUserGroupsForm( $user, $groups, $groupMemberships );
592
593 // This isn't really ideal logging behavior, but let's not hide the
594 // interwiki logs if we're using them as is.
595 $this->showLogFragment( $user, $this->getOutput() );
596 }
597
607 public function fetchUser( $username, $writing = true ) {
608 $parts = explode( $this->getConfig()->get( MainConfigNames::UserrightsInterwikiDelimiter ),
609 $username );
610 if ( count( $parts ) < 2 ) {
611 $name = trim( $username );
612 $wikiId = UserIdentity::LOCAL;
613 } else {
614 [ $name, $wikiId ] = array_map( 'trim', $parts );
615
616 if ( WikiMap::isCurrentWikiId( $wikiId ) ) {
617 $wikiId = UserIdentity::LOCAL;
618 } else {
619 if ( $writing &&
620 !$this->getAuthority()->isAllowed( 'userrights-interwiki' )
621 ) {
622 return Status::newFatal( 'userrights-no-interwiki' );
623 }
624 $localDatabases = $this->getConfig()->get( MainConfigNames::LocalDatabases );
625 if ( !in_array( $wikiId, $localDatabases ) ) {
626 return Status::newFatal( 'userrights-nodatabase', $wikiId );
627 }
628 }
629 }
630
631 if ( $name === '' ) {
632 return Status::newFatal( 'nouserspecified' );
633 }
634
635 $userIdentityLookup = $this->actorStoreFactory->getUserIdentityLookup( $wikiId );
636 if ( $name[0] == '#' ) {
637 // Numeric ID can be specified...
638 $id = intval( substr( $name, 1 ) );
639
640 $user = $userIdentityLookup->getUserIdentityByUserId( $id );
641 if ( !$user ) {
642 // Different error message for compatibility
643 return Status::newFatal( 'noname' );
644 }
645 $name = $user->getName();
646 } else {
647 $name = $this->userNameUtils->getCanonical( $name );
648 if ( $name === false ) {
649 // invalid name
650 return Status::newFatal( 'nosuchusershort', $username );
651 }
652 $user = $userIdentityLookup->getUserIdentityByName( $name );
653 }
654
655 if ( $this->userNameUtils->isTemp( $name ) ) {
656 return Status::newFatal( 'userrights-no-group' );
657 }
658
659 if ( !$user || !$user->isRegistered() ) {
660 return Status::newFatal( 'nosuchusershort', $username );
661 }
662
663 if ( $user->getWikiId() === UserIdentity::LOCAL &&
664 $this->userFactory->newFromUserIdentity( $user )->isHidden() &&
665 !$this->getAuthority()->isAllowed( 'hideuser' )
666 ) {
667 // Cannot see hidden users, pretend they don't exist
668 return Status::newFatal( 'nosuchusershort', $username );
669 }
670
671 return Status::newGood( $user );
672 }
673
681 public function makeGroupNameList( $ids ) {
682 if ( !$ids ) {
683 return $this->msg( 'rightsnone' )->inContentLanguage()->text();
684 } else {
685 return implode( ', ', $ids );
686 }
687 }
688
692 protected function switchForm() {
693 $this->getOutput()->addModules( 'mediawiki.userSuggest' );
694
695 $this->getOutput()->addHTML(
696 Html::openElement(
697 'form',
698 [
699 'method' => 'get',
700 'action' => wfScript(),
701 'name' => 'uluser',
702 'id' => 'mw-userrights-form1'
703 ]
704 ) .
705 Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) .
706 Xml::fieldset( $this->msg( 'userrights-lookup-user' )->text() ) .
707 Xml::inputLabel(
708 $this->msg( 'userrights-user-editname' )->text(),
709 'user',
710 'username',
711 30,
712 $this->mTarget !== null ? str_replace( '_', ' ', $this->mTarget ) : '',
713 [
714 'class' => 'mw-autocomplete-user', // used by mediawiki.userSuggest
715 ] + (
716 // Set autofocus on blank input and error input
717 $this->mFetchedUser === null ? [ 'autofocus' => '' ] : []
718 )
719 ) . ' ' .
721 $this->msg( 'editusergroup' )->text()
722 ) .
723 Html::closeElement( 'fieldset' ) .
724 Html::closeElement( 'form' ) . "\n"
725 );
726 }
727
737 protected function showEditUserGroupsForm( $user, $groups, $groupMemberships ) {
738 $list = $membersList = $tempList = $tempMembersList = [];
739 foreach ( $groupMemberships as $ugm ) {
740 $linkG = UserGroupMembership::getLinkHTML( $ugm, $this->getContext() );
741 $linkM = UserGroupMembership::getLinkHTML( $ugm, $this->getContext(), $user->getName() );
742 if ( $ugm->getExpiry() ) {
743 $tempList[] = $linkG;
744 $tempMembersList[] = $linkM;
745 } else {
746 $list[] = $linkG;
747 $membersList[] = $linkM;
748
749 }
750 }
751
752 $autoList = [];
753 $autoMembersList = [];
754
755 if ( $user->getWikiId() === UserIdentity::LOCAL ) {
756 // Listing autopromote groups works only on the local wiki
757 foreach ( $this->userGroupManager->getUserAutopromoteGroups( $user ) as $group ) {
758 $autoList[] = UserGroupMembership::getLinkHTML( $group, $this->getContext() );
759 $autoMembersList[] = UserGroupMembership::getLinkHTML( $group, $this->getContext(), $user->getName() );
760 }
761 }
762
763 $language = $this->getLanguage();
764 $displayedList = $this->msg( 'userrights-groupsmember-type' )
765 ->rawParams(
766 $language->commaList( array_merge( $tempList, $list ) ),
767 $language->commaList( array_merge( $tempMembersList, $membersList ) )
768 )->escaped();
769 $displayedAutolist = $this->msg( 'userrights-groupsmember-type' )
770 ->rawParams(
771 $language->commaList( $autoList ),
772 $language->commaList( $autoMembersList )
773 )->escaped();
774
775 $grouplist = '';
776 $count = count( $list ) + count( $tempList );
777 if ( $count > 0 ) {
778 $grouplist = $this->msg( 'userrights-groupsmember' )
779 ->numParams( $count )
780 ->params( $user->getName() )
781 ->parse();
782 $grouplist = '<p>' . $grouplist . ' ' . $displayedList . "</p>\n";
783 }
784
785 $count = count( $autoList );
786 if ( $count > 0 ) {
787 $autogrouplistintro = $this->msg( 'userrights-groupsmember-auto' )
788 ->numParams( $count )
789 ->params( $user->getName() )
790 ->parse();
791 $grouplist .= '<p>' . $autogrouplistintro . ' ' . $displayedAutolist . "</p>\n";
792 }
793
794 $systemUser = $user->getWikiId() === UserIdentity::LOCAL
795 && $this->userFactory->newFromUserIdentity( $user )->isSystemUser();
796 if ( $systemUser ) {
797 $systemusernote = $this->msg( 'userrights-systemuser' )
798 ->params( $user->getName() )
799 ->parse();
800 $grouplist .= '<p>' . $systemusernote . "</p>\n";
801 }
802
803 // Only add an email link if the user is not a system user
804 $flags = $systemUser ? 0 : Linker::TOOL_LINKS_EMAIL;
805 $userToolLinks = Linker::userToolLinks(
806 $user->getId( $user->getWikiId() ),
807 $this->getDisplayUsername( $user ),
808 false, /* default for redContribsWhenNoEdits */
809 $flags
810 );
811
812 [ $groupCheckboxes, $canChangeAny ] =
813 $this->groupCheckboxes( $groupMemberships, $user );
814 $this->getOutput()->addHTML(
815 Html::openElement(
816 'form',
817 [
818 'method' => 'post',
819 'action' => $this->getPageTitle()->getLocalURL(),
820 'name' => 'editGroup',
821 'id' => 'mw-userrights-form2'
822 ]
823 ) .
824 Html::hidden( 'user', $this->mTarget ) .
825 Html::hidden( 'wpEditToken', $this->getUser()->getEditToken( $this->mTarget ) ) .
826 Html::hidden(
827 'conflictcheck-originalgroups',
828 implode( ',', $this->userGroupManager->getUserGroups( $user ) )
829 ) . // Conflict detection
830 Html::openElement( 'fieldset' ) .
832 'legend',
833 [],
834 $this->msg(
835 $canChangeAny ? 'userrights-editusergroup' : 'userrights-viewusergroup',
836 $user->getName()
837 )->text()
838 ) .
839 $this->msg(
840 $canChangeAny ? 'editinguser' : 'viewinguserrights'
841 )->params( wfEscapeWikiText( $this->getDisplayUsername( $user ) ) )
842 ->rawParams( $userToolLinks )->parse()
843 );
844 if ( $canChangeAny ) {
845 $this->getOutput()->addHTML(
846 $this->msg( 'userrights-groups-help', $user->getName() )->parse() .
847 $grouplist .
848 $groupCheckboxes .
849 Html::openElement( 'table', [ 'id' => 'mw-userrights-table-outer' ] ) .
850 "<tr>
851 <td class='mw-label'>" .
852 Html::label( $this->msg( 'userrights-reason' )->text(), 'wpReason' ) .
853 "</td>
854 <td class='mw-input'>" .
855 Xml::input( 'user-reason', 60, $this->getRequest()->getVal( 'user-reason' ) ?? false, [
856 'id' => 'wpReason',
857 // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
858 // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
859 // Unicode codepoints.
860 'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
861 ] ) .
862 "</td>
863 </tr>
864 <tr>
865 <td></td>
866 <td class='mw-submit'>" .
867 Html::submitButton( $this->msg( 'saveusergroups', $user->getName() )->text(),
868 [ 'name' => 'saveusergroups' ] +
869 Linker::tooltipAndAccesskeyAttribs( 'userrights-set' )
870 ) .
871 "</td>
872 </tr>
873 <tr>
874 <td></td>
875 <td class='mw-input'>" .
876 Html::check( 'wpWatch', false, [ 'id' => 'wpWatch' ] ) .
877 '&nbsp;' . Html::label( $this->msg( 'userrights-watchuser' )->text(), 'wpWatch' ) .
878 "</td>
879 </tr>" .
880 Xml::closeElement( 'table' ) . "\n"
881 );
882 } else {
883 $this->getOutput()->addHTML( $grouplist );
884 }
885 $this->getOutput()->addHTML(
886 Xml::closeElement( 'fieldset' ) .
887 Xml::closeElement( 'form' ) . "\n"
888 );
889 }
890
900 private function groupCheckboxes( $usergroups, UserIdentity $user ) {
901 $allgroups = $this->userGroupManager->listAllGroups();
902 $ret = '';
903
904 // Get the list of preset expiry times from the system message
905 $expiryOptionsMsg = $this->msg( 'userrights-expiry-options' )->inContentLanguage();
906 $expiryOptions = $expiryOptionsMsg->isDisabled()
907 ? []
908 : XmlSelect::parseOptionsMessage( $expiryOptionsMsg->text() );
909
910 // Put all column info into an associative array so that extensions can
911 // more easily manage it.
912 $columns = [ 'unchangeable' => [], 'changeable' => [] ];
913
914 foreach ( $allgroups as $group ) {
915 $set = isset( $usergroups[$group] );
916 // Users who can add the group, but not remove it, can only lengthen
917 // expiries, not shorten them. So they should only see the expiry
918 // dropdown if the group currently has a finite expiry
919 $canOnlyLengthenExpiry = ( $set && $this->canAdd( $group ) &&
920 !$this->canRemove( $group ) && $usergroups[$group]->getExpiry() );
921 // Should the checkbox be disabled?
922 $disabledCheckbox = !(
923 ( $set && $this->canRemove( $group ) ) ||
924 ( !$set && $this->canAdd( $group ) ) );
925 // Should the expiry elements be disabled?
926 $disabledExpiry = $disabledCheckbox && !$canOnlyLengthenExpiry;
927 // Do we need to point out that this action is irreversible?
928 $irreversible = !$disabledCheckbox && (
929 ( $set && !$this->canAdd( $group ) ) ||
930 ( !$set && !$this->canRemove( $group ) ) );
931
932 $checkbox = [
933 'set' => $set,
934 'disabled' => $disabledCheckbox,
935 'disabled-expiry' => $disabledExpiry,
936 'irreversible' => $irreversible
937 ];
938
939 if ( $disabledCheckbox && $disabledExpiry ) {
940 $columns['unchangeable'][$group] = $checkbox;
941 } else {
942 $columns['changeable'][$group] = $checkbox;
943 }
944 }
945
946 // Build the HTML table
947 $ret .= Xml::openElement( 'table', [ 'class' => 'mw-userrights-groups' ] ) .
948 "<tr>\n";
949 foreach ( $columns as $name => $column ) {
950 if ( $column === [] ) {
951 continue;
952 }
953 // Messages: userrights-changeable-col, userrights-unchangeable-col
954 $ret .= Xml::element(
955 'th',
956 null,
957 $this->msg( 'userrights-' . $name . '-col', count( $column ) )->text()
958 );
959 }
960
961 $ret .= "</tr>\n<tr>\n";
962 $uiLanguage = $this->getLanguage();
963 $userName = $user->getName();
964 foreach ( $columns as $column ) {
965 if ( $column === [] ) {
966 continue;
967 }
968 $ret .= "\t<td style='vertical-align:top;'>\n";
969 foreach ( $column as $group => $checkbox ) {
970 $member = $uiLanguage->getGroupMemberName( $group, $userName );
971 if ( $checkbox['irreversible'] ) {
972 $text = $this->msg( 'userrights-irreversible-marker', $member )->text();
973 } elseif ( $checkbox['disabled'] && !$checkbox['disabled-expiry'] ) {
974 $text = $this->msg( 'userrights-no-shorten-expiry-marker', $member )->text();
975 } else {
976 $text = $member;
977 }
978 $checkboxHtml = Html::element( 'input', [
979 'type' => 'checkbox', 'name' => "wpGroup-$group", 'value' => '1',
980 'id' => "wpGroup-$group", 'checked' => $checkbox['set'],
981 'class' => 'mw-userrights-groupcheckbox',
982 'disabled' => $checkbox['disabled'],
983 ] ) . '&nbsp;' . Html::label( $text, "wpGroup-$group" );
984
985 if ( $this->canProcessExpiries() ) {
986 $uiUser = $this->getUser();
987
988 $currentExpiry = isset( $usergroups[$group] ) ?
989 $usergroups[$group]->getExpiry() :
990 null;
991
992 // If the user can't modify the expiry, print the current expiry below
993 // it in plain text. Otherwise provide UI to set/change the expiry
994 if ( $checkbox['set'] &&
995 ( $checkbox['irreversible'] || $checkbox['disabled-expiry'] )
996 ) {
997 if ( $currentExpiry ) {
998 $expiryFormatted = $uiLanguage->userTimeAndDate( $currentExpiry, $uiUser );
999 $expiryFormattedD = $uiLanguage->userDate( $currentExpiry, $uiUser );
1000 $expiryFormattedT = $uiLanguage->userTime( $currentExpiry, $uiUser );
1001 $expiryHtml = Xml::element( 'span', null,
1002 $this->msg( 'userrights-expiry-current' )->params(
1003 $expiryFormatted, $expiryFormattedD, $expiryFormattedT )->text() );
1004 } else {
1005 $expiryHtml = Xml::element( 'span', null,
1006 $this->msg( 'userrights-expiry-none' )->text() );
1007 }
1008 // T171345: Add a hidden form element so that other groups can still be manipulated,
1009 // otherwise saving errors out with an invalid expiry time for this group.
1010 $expiryHtml .= Html::hidden( "wpExpiry-$group",
1011 $currentExpiry ? 'existing' : 'infinite' );
1012 $expiryHtml .= "<br />\n";
1013 } else {
1014 $expiryHtml = Html::element( 'span', [],
1015 $this->msg( 'userrights-expiry' )->text() );
1016 $expiryHtml .= Html::openElement( 'span' );
1017
1018 // add a form element to set the expiry date
1019 $expiryFormOptions = new XmlSelect(
1020 "wpExpiry-$group",
1021 "mw-input-wpExpiry-$group", // forward compatibility with HTMLForm
1022 $currentExpiry ? 'existing' : 'infinite'
1023 );
1024 if ( $checkbox['disabled-expiry'] ) {
1025 $expiryFormOptions->setAttribute( 'disabled', 'disabled' );
1026 }
1027
1028 if ( $currentExpiry ) {
1029 $timestamp = $uiLanguage->userTimeAndDate( $currentExpiry, $uiUser );
1030 $d = $uiLanguage->userDate( $currentExpiry, $uiUser );
1031 $t = $uiLanguage->userTime( $currentExpiry, $uiUser );
1032 $existingExpiryMessage = $this->msg( 'userrights-expiry-existing',
1033 $timestamp, $d, $t );
1034 $expiryFormOptions->addOption( $existingExpiryMessage->text(), 'existing' );
1035 }
1036
1037 $expiryFormOptions->addOption(
1038 $this->msg( 'userrights-expiry-none' )->text(),
1039 'infinite'
1040 );
1041 $expiryFormOptions->addOption(
1042 $this->msg( 'userrights-expiry-othertime' )->text(),
1043 'other'
1044 );
1045
1046 $expiryFormOptions->addOptions( $expiryOptions );
1047
1048 // Add expiry dropdown
1049 $expiryHtml .= $expiryFormOptions->getHTML() . '<br />';
1050
1051 // Add custom expiry field
1052 $expiryHtml .= Html::element( 'input', [
1053 'name' => "wpExpiry-$group-other", 'size' => 30, 'value' => '',
1054 'id' => "mw-input-wpExpiry-$group-other",
1055 'class' => 'mw-userrights-expiryfield',
1056 'disabled' => $checkbox['disabled-expiry'],
1057 ] );
1058
1059 // If the user group is set but the checkbox is disabled, mimic a
1060 // checked checkbox in the form submission
1061 if ( $checkbox['set'] && $checkbox['disabled'] ) {
1062 $expiryHtml .= Html::hidden( "wpGroup-$group", 1 );
1063 }
1064
1065 $expiryHtml .= Html::closeElement( 'span' );
1066 }
1067
1068 $divAttribs = [
1069 'id' => "mw-userrights-nested-wpGroup-$group",
1070 'class' => 'mw-userrights-nested',
1071 ];
1072 $checkboxHtml .= "\t\t\t" . Html::rawElement( 'div', $divAttribs, $expiryHtml ) . "\n";
1073 }
1074 $ret .= "\t\t" . ( ( $checkbox['disabled'] && $checkbox['disabled-expiry'] )
1075 ? Html::rawElement( 'div', [ 'class' => 'mw-userrights-disabled' ], $checkboxHtml )
1076 : Html::rawElement( 'div', [], $checkboxHtml )
1077 ) . "\n";
1078 }
1079 $ret .= "\t</td>\n";
1080 }
1081 $ret .= Html::closeElement( 'tr' ) . Html::closeElement( 'table' );
1082
1083 return [ $ret, (bool)$columns['changeable'] ];
1084 }
1085
1090 private function canRemove( $group ) {
1091 $groups = $this->changeableGroups();
1092
1093 return in_array(
1094 $group,
1095 $groups['remove'] ) || ( $this->isself && in_array( $group, $groups['remove-self'] )
1096 );
1097 }
1098
1103 private function canAdd( $group ) {
1104 $groups = $this->changeableGroups();
1105
1106 return in_array(
1107 $group,
1108 $groups['add'] ) || ( $this->isself && in_array( $group, $groups['add-self'] )
1109 );
1110 }
1111
1121 protected function changeableGroups() {
1122 return $this->userGroupManager->getGroupsChangeableBy( $this->getContext()->getAuthority() );
1123 }
1124
1133 private function getDisplayUsername( UserIdentity $user ) {
1134 $userName = $user->getName();
1135 if ( $user->getWikiId() !== UserIdentity::LOCAL ) {
1136 $userName .= $this->getConfig()->get( MainConfigNames::UserrightsInterwikiDelimiter )
1137 . $user->getWikiId();
1138 }
1139 return $userName;
1140 }
1141
1148 protected function showLogFragment( $user, $output ) {
1149 $rightsLogPage = new LogPage( 'rights' );
1150 $output->addHTML( Html::element( 'h2', [], $rightsLogPage->getName()->text() ) );
1151 LogEventsList::showLogExtract( $output, 'rights',
1152 Title::makeTitle( NS_USER, $this->getDisplayUsername( $user ) ) );
1153 }
1154
1163 public function prefixSearchSubpages( $search, $limit, $offset ) {
1164 $search = $this->userNameUtils->getCanonical( $search );
1165 if ( !$search ) {
1166 // No prefix suggestion for invalid user
1167 return [];
1168 }
1169 // Autocomplete subpage as user list - public to allow caching
1170 return $this->userNamePrefixSearch
1171 ->search( UserNamePrefixSearch::AUDIENCE_PUBLIC, $search, $limit, $offset );
1172 }
1173
1174 protected function getGroupName() {
1175 return 'users';
1176 }
1177}
1178
1183class_alias( SpecialUserRights::class, 'UserrightsPage' );
const NS_USER
Definition Defines.php:67
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
wfTimestampNow()
Convenience function; returns MediaWiki timestamp for the present time.
wfEscapeWikiText( $input)
Escapes the given text so that it may be output using addWikiText() without any linking,...
wfScript( $script='index')
Get the URL path to a MediaWiki entry point.
wfIsInfinity( $str)
Determine input string is represents as infinity.
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
Class to simplify the use of log pages.
Definition LogPage.php:45
Class for creating new log entries and inserting them into the database.
Handle database storage of comments such as edit summaries and log reasons.
This class is a collection of static functions that serve two purposes:
Definition Html.php:56
static openElement( $element, $attribs=[])
Identical to rawElement(), but has no third parameter and omits the end tag (and the self-closing '/'...
Definition Html.php:240
static input( $name, $value='', $type='text', array $attribs=[])
Convenience function to produce an <input> element.
Definition Html.php:657
static closeElement( $element)
Returns "</$element>".
Definition Html.php:304
static submitButton( $contents, array $attrs=[], array $modifiers=[])
Returns an HTML input element in a string.
Definition Html.php:163
static element( $element, $attribs=[], $contents='')
Identical to rawElement(), but HTML-escapes $contents (like Xml::element()).
Definition Html.php:216
Some internal bits split of from Skin.php.
Definition Linker.php:63
A class containing constants representing the names of configuration variables.
const LocalDatabases
Name constant for the LocalDatabases setting, for use with Config::get()
const UserrightsInterwikiDelimiter
Name constant for the UserrightsInterwikiDelimiter setting, for use with Config::get()
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
This is one of the Core classes and should be read at least once by any new developers.
Parent class for all special pages.
setHeaders()
Sets headers - this should be called from the execute() method of all derived classes!
getSkin()
Shortcut to get the skin being used for this instance.
getUser()
Shortcut to get the User executing this instance.
getPageTitle( $subpage=false)
Get a self-referential title object.
checkReadOnly()
If the wiki is currently in readonly mode, throws a ReadOnlyError.
getConfig()
Shortcut to get main config object.
getContext()
Gets the context this SpecialPage is executed in.
getRequest()
Get the WebRequest being used for this instance.
msg( $key,... $params)
Wrapper around wfMessage that sets the current context.
getOutput()
Get the OutputPage being used for this instance.
getAuthority()
Shortcut to get the Authority executing this instance.
getLanguage()
Shortcut to get user's language.
outputHeader( $summaryMessageKey='')
Outputs a summary message on top of special pages By default the message key is the canonical name of...
addHelpLink( $to, $overrideBaseUrl=false)
Adds help link with an icon via page indicators.
Special page to allow managing user group membership.
execute( $par)
Manage forms to be shown according to posted data.
doesWrites()
Indicates whether this special page may perform database writes.
doSaveUserGroups( $user, array $add, array $remove, string $reason='', array $tags=[], array $groupExpiries=[])
Save user groups changes in the database.
null string $mTarget
The target of the local right-adjuster's interest.
getGroupName()
Under which header this special page is listed in Special:SpecialPages See messages 'specialpages-gro...
showEditUserGroupsForm( $user, $groups, $groupMemberships)
Show the form to edit group memberships.
addLogEntry( $user, array $oldGroups, array $newGroups, string $reason, array $tags, array $oldUGMs, array $newUGMs)
Add a rights log entry for an action.
userCanChangeRights(UserIdentity $targetUser, $checkIfSelf=true)
Check whether the current user (from context) can change the target user's rights.
prefixSearchSubpages( $search, $limit, $offset)
Return an array of subpages beginning with $search that this special page will accept.
switchForm()
Output a form to allow searching for a user.
static expiryToTimestamp( $expiry)
Converts a user group membership expiry string into a timestamp.
__construct(UserGroupManagerFactory $userGroupManagerFactory=null, UserNameUtils $userNameUtils=null, UserNamePrefixSearch $userNamePrefixSearch=null, UserFactory $userFactory=null, ActorStoreFactory $actorStoreFactory=null, WatchlistManager $watchlistManager=null)
null UserIdentity $mFetchedUser
The user object of the target username or null.
fetchUser( $username, $writing=true)
Normalize the input username, which may be local or remote, and return a user identity object,...
canProcessExpiries()
Returns true if this user rights form can set and change user group expiries.
saveUserGroups(string $reason, UserIdentity $user)
Save user groups changes in the database.
static serialiseUgmForLog( $ugm)
Serialise a UserGroupMembership object for storage in the log_params section of the logging table.
showLogFragment( $user, $output)
Show a rights log fragment for the specified user.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:54
Represents a title within MediaWiki.
Definition Title.php:79
Creates User objects.
Factory service for UserGroupManager instances.
getUserGroups(UserIdentity $user, int $queryFlags=IDBAccessObject::READ_NORMAL)
Get the list of explicit group memberships this user has.
addUserToGroup(UserIdentity $user, string $group, string $expiry=null, bool $allowUpdate=false)
Add the user to the given group.
removeUserFromGroup(UserIdentity $user, string $group)
Remove the user from the given group.
getUserGroupMemberships(UserIdentity $user, int $queryFlags=IDBAccessObject::READ_NORMAL)
Loads and returns UserGroupMembership objects for all the groups a user currently belongs to.
getGroupsChangeableBy(Authority $authority)
Returns an array of groups that this $actor can add and remove.
Represents a "user group membership" – a specific instance of a user belonging to a group.
Handles searching prefixes of user names.
UserNameUtils service.
Tools for dealing with other locally-hosted wikis.
Definition WikiMap.php:31
Class for generating HTML <select> or <datalist> elements.
Definition XmlSelect.php:30
Module of static functions for generating XML.
Definition Xml.php:37
Show an error when a user tries to do something they do not have the necessary permissions for.
Show an error when the user tries to do something whilst blocked.
Interface for database access objects.
getWikiId()
Get the ID of the wiki this page belongs to.
Interface for objects representing user identity.
isRegistered()
This must be equivalent to getId() != 0 and is provided for code readability.
getId( $wikiId=self::LOCAL)
element(SerializerNode $parent, SerializerNode $node, $contents)