TTO has uploaded a new change for review. ( 
https://gerrit.wikimedia.org/r/328377 )

Change subject: [WIP] User group memberships that expire
......................................................................

[WIP] User group memberships that expire

This patch adds two columns to the user_groups table: ug_id, a primary key,
and ug_expiry, a timestamp giving a date when the user group expires. A new
UserGroupMembership class, based on the Block class, manages entries in
this table.

When the expiry date passes, the row in user_groups is ignored, and will
eventually be purged from the DB when UserGroupMembership::insert is next
called. Old, expired user group memberships are not kept; instead, the log
entries are available to find the history of these memberships, similar
to the way it has always worked for blocks and protections.

Anyone getting user group info through the User object will get correct
information. However, code that reads the user_groups table directly will
now need to skip over rows with ug_expiry < wfTimestampNow(). See
UsersPager for an example of how to do this.

NULL is used to represent infinite (no) expiry, rather than a string
'infinity' or similar (except in the API). This allows existing user group
assignments and log entries, which are all infinite in duration, to be
treated the same as new, infinite-length memberships, without special
casing everything.

There are a few very minor breaking changes: some protected functions in
UsersPager are altered or removed, and the UsersPagerDoBatchLookups hook
(unused in any Wikimedia Git-hosted extension) has a change of parameter.

Bits still to do:
* ApiUserrights - need to discuss with Anomie or someone else about how
  the input parameters should be structured
* What's going on in ContribsPager and NewFilesPager? Can't work it out
* Postgres DB stuff

Bug: T12493
Change-Id: I93c955dc7a970f78e32aa503c01c67da30971d1a
---
M autoload.php
M docs/hooks.txt
M includes/Preferences.php
M includes/api/ApiQueryUserInfo.php
M includes/api/ApiQueryUsers.php
M includes/api/ApiUserrights.php
M includes/api/i18n/en.json
M includes/api/i18n/qqq.json
M includes/installer/MysqlUpdater.php
M includes/installer/SqliteUpdater.php
M includes/logging/RightsLogFormatter.php
M includes/specials/SpecialUserrights.php
M includes/specials/pagers/ActiveUsersPager.php
M includes/specials/pagers/UsersPager.php
M includes/user/User.php
A includes/user/UserGroupMembership.php
M includes/user/UserRightsProxy.php
M languages/i18n/en.json
M languages/i18n/qqq.json
A maintenance/archives/patch-user_groups-id-expiry.sql
A maintenance/sqlite/archives/patch-user_groups-id-expiry.sql
M maintenance/tables.sql
M resources/Resources.php
A resources/src/mediawiki.special/mediawiki.special.userrights.css
M resources/src/mediawiki.special/mediawiki.special.userrights.js
25 files changed, 1,018 insertions(+), 185 deletions(-)


  git pull ssh://gerrit.wikimedia.org:29418/mediawiki/core 
refs/changes/77/328377/1

diff --git a/autoload.php b/autoload.php
index 941b335..0b9d353 100644
--- a/autoload.php
+++ b/autoload.php
@@ -1514,6 +1514,7 @@
        'UserBlockedError' => __DIR__ . 
'/includes/exception/UserBlockedError.php',
        'UserCache' => __DIR__ . '/includes/cache/UserCache.php',
        'UserDupes' => __DIR__ . '/maintenance/userDupes.inc',
+       'UserGroupMembership' => __DIR__ . 
'/includes/user/UserGroupMembership.php',
        'UserMailer' => __DIR__ . '/includes/mail/UserMailer.php',
        'UserNamePrefixSearch' => __DIR__ . 
'/includes/user/UserNamePrefixSearch.php',
        'UserNotLoggedIn' => __DIR__ . 
'/includes/exception/UserNotLoggedIn.php',
diff --git a/docs/hooks.txt b/docs/hooks.txt
index 1ecc1f8..17da819 100644
--- a/docs/hooks.txt
+++ b/docs/hooks.txt
@@ -3745,7 +3745,8 @@
 displayed correctly in Special:ListUsers.
 $dbr: Read-only database handle
 $userIds: Array of user IDs whose groups we should look up
-&$cache: Array of user ID -> internal user group name (e.g. 'sysop') mappings
+&$cache: Array of user ID -> (array of internal group name (e.g. 'sysop') ->
+UserGroupMembership object)
 &$groups: Array of group name -> bool true mappings for members of a given user
 group
 
diff --git a/includes/Preferences.php b/includes/Preferences.php
index cf8e7b8..8ec1b39 100644
--- a/includes/Preferences.php
+++ b/includes/Preferences.php
@@ -224,24 +224,48 @@
                        'section' => 'personal/info',
                ];
 
+               $lang = $context->getLanguage();
+
                # Get groups to which the user belongs
                $userEffectiveGroups = $user->getEffectiveGroups();
-               $userGroups = $userMembers = [];
+               $userGroupMemberships = $user->getGroupMemberships();
+               $userGroups = $userMembers = $userTempGroups = $userTempMembers 
= [];
                foreach ( $userEffectiveGroups as $ueg ) {
                        if ( $ueg == '*' ) {
                                // Skip the default * group, seems useless here
                                continue;
                        }
-                       $groupName = User::getGroupName( $ueg );
-                       $userGroups[] = User::makeGroupLinkHTML( $ueg, 
$groupName );
 
-                       $memberName = User::getGroupMember( $ueg, $userName );
-                       $userMembers[] = User::makeGroupLinkHTML( $ueg, 
$memberName );
+                       if ( isset( $userGroupMemberships[$ueg] ) ) {
+                               $groupStringOrObject = 
$userGroupMemberships[$ueg];
+                       } else {
+                               $groupStringOrObject = $ueg;
+                       }
+
+                       $userG = UserGroupMembership::getLinkHTML( 
$groupStringOrObject, $context );
+                       $userM = UserGroupMembership::getLinkHTML( 
$groupStringOrObject, $context,
+                               true, $userName );
+
+                       // Store expiring groups separately, so we can place 
them before non-expiring
+                       // groups in the list. This is to avoid the ambiguity 
of something like
+                       // "administrator, bureaucrat (until X date)" -- users 
might wonder whether the
+                       // expiry date applies to both groups, or just the last 
one
+                       if ( $groupStringOrObject instanceof 
UserGroupMembership &&
+                               $groupStringOrObject->getExpiry()
+                       ) {
+                               $userTempGroups[] = $userG;
+                               $userTempMembers[] = $userM;
+                       } else {
+                               $userGroups[] = $userG;
+                               $userMembers[] = $userM;
+                       }
                }
-               asort( $userGroups );
-               asort( $userMembers );
-
-               $lang = $context->getLanguage();
+               sort( $userGroups );
+               sort( $userMembers );
+               sort( $userTempGroups );
+               sort( $userTempMembers );
+               $userGroups = array_merge( $userTempGroups, $userGroups );
+               $userMembers = array_merge( $userTempMembers, $userMembers );
 
                $defaultPreferences['usergroups'] = [
                        'type' => 'info',
diff --git a/includes/api/ApiQueryUserInfo.php 
b/includes/api/ApiQueryUserInfo.php
index 60e122c..5aa08d4 100644
--- a/includes/api/ApiQueryUserInfo.php
+++ b/includes/api/ApiQueryUserInfo.php
@@ -146,6 +146,19 @@
                        ApiResult::setIndexedTagName( $vals['groups'], 'g' ); 
// even if empty
                }
 
+               if ( isset( $this->prop['groupmemberships'] ) ) {
+                       $ugms = $user->getGroupMemberships();
+                       $vals['groupmemberships'] = [];
+                       foreach ( $ugms as $group => $ugm ) {
+                               $vals['groupmemberships'][] = [
+                                       'group' => $group,
+                                       'expiry' => $ugm->getExpiry() ?: 
'infinity',
+                               ];
+                       }
+                       ApiResult::setArrayType( $vals['groupmemberships'], 
'array' ); // even if empty
+                       ApiResult::setIndexedTagName( 
$vals['groupmemberships'], 'groupmembership' ); // even if empty
+               }
+
                if ( isset( $this->prop['implicitgroups'] ) ) {
                        $vals['implicitgroups'] = $user->getAutomaticGroups();
                        ApiResult::setArrayType( $vals['implicitgroups'], 
'array' ); // even if empty
@@ -305,6 +318,7 @@
                                        'blockinfo',
                                        'hasmsg',
                                        'groups',
+                                       'groupmemberships',
                                        'implicitgroups',
                                        'rights',
                                        'changeablegroups',
diff --git a/includes/api/ApiQueryUsers.php b/includes/api/ApiQueryUsers.php
index 2d620a4..d689eac 100644
--- a/includes/api/ApiQueryUsers.php
+++ b/includes/api/ApiQueryUsers.php
@@ -42,6 +42,7 @@
                // everything except 'blockinfo' which might show hidden 
records if the user
                // making the request has the appropriate permissions
                'groups',
+               'groupmemberships',
                'implicitgroups',
                'rights',
                'editcount',
@@ -167,11 +168,15 @@
 
                                $this->addTables( 'user_groups' );
                                $this->addJoinConds( [ 'user_groups' => [ 
'INNER JOIN', 'ug_user=user_id' ] ] );
-                               $this->addFields( [ 'user_name', 'ug_group' ] );
+                               $this->addFields( array_merge( [ 'user_name' ], 
UserGroupMembership::selectFields() ) );
                                $userGroupsRes = $this->select( __METHOD__ );
 
+                               $now = wfTimestampNow();
                                foreach ( $userGroupsRes as $row ) {
-                                       $userGroups[$row->user_name][] = 
$row->ug_group;
+                                       // don't keep expired user groups
+                                       if ( !$row->ug_expiry || 
$row->ug_expiry >= $now ) {
+                                               $userGroups[$row->user_name][] 
= $row;
+                                       }
                                }
                        }
 
@@ -205,6 +210,15 @@
 
                                if ( isset( $this->prop['groups'] ) ) {
                                        $data[$key]['groups'] = 
$user->getEffectiveGroups();
+                               }
+
+                               if ( isset( $this->prop['groupmemberships'] ) ) 
{
+                                       $data[$key]['groupmemberships'] = 
array_map( function( $ugm ) {
+                                               return [
+                                                       'group' => 
$ugm->getGroup(),
+                                                       'expiry' => 
$ugm->getExpiry() ?: 'infinity',
+                                               ];
+                                       }, $user->getGroupMemberships() );
                                }
 
                                if ( isset( $this->prop['implicitgroups'] ) ) {
@@ -303,6 +317,10 @@
                                        ApiResult::setArrayType( 
$data[$u]['groups'], 'array' );
                                        ApiResult::setIndexedTagName( 
$data[$u]['groups'], 'g' );
                                }
+                               if ( isset( $this->prop['groupmemberships'] ) 
&& isset( $data[$u]['groupmemberships'] ) ) {
+                                       ApiResult::setArrayType( 
$data[$u]['groupmemberships'], 'array' );
+                                       ApiResult::setIndexedTagName( 
$data[$u]['groupmemberships'], 'groupmembership' );
+                               }
                                if ( isset( $this->prop['implicitgroups'] ) && 
isset( $data[$u]['implicitgroups'] ) ) {
                                        ApiResult::setArrayType( 
$data[$u]['implicitgroups'], 'array' );
                                        ApiResult::setIndexedTagName( 
$data[$u]['implicitgroups'], 'g' );
@@ -347,6 +365,7 @@
                                ApiBase::PARAM_TYPE => [
                                        'blockinfo',
                                        'groups',
+                                       'groupmemberships',
                                        'implicitgroups',
                                        'rights',
                                        'editcount',
diff --git a/includes/api/ApiUserrights.php b/includes/api/ApiUserrights.php
index 79c6866..aecfa5a 100644
--- a/includes/api/ApiUserrights.php
+++ b/includes/api/ApiUserrights.php
@@ -1,9 +1,7 @@
 <?php
 
 /**
- *
- *
- * Created on Mar 24, 2009
+ * API userrights module
  *
  * Copyright © 2009 Roan Kattouw "<Firstname>.<Lastname>@gmail.com"
  *
diff --git a/includes/api/i18n/en.json b/includes/api/i18n/en.json
index d748894..0c25e0e 100644
--- a/includes/api/i18n/en.json
+++ b/includes/api/i18n/en.json
@@ -583,6 +583,7 @@
        "apihelp-query+allusers-param-prop": "Which pieces of information to 
include:",
        "apihelp-query+allusers-paramvalue-prop-blockinfo": "Adds the 
information about a current block on the user.",
        "apihelp-query+allusers-paramvalue-prop-groups": "Lists groups that the 
user is in. This uses more server resources and may return fewer results than 
the limit.",
+       "apihelp-query+allusers-paramvalue-prop-groupmemberships": "Lists 
groups that the user has been explicitly assigned to, including the expiry date 
of each group membership.",
        "apihelp-query+allusers-paramvalue-prop-implicitgroups": "Lists all the 
groups the user is automatically in.",
        "apihelp-query+allusers-paramvalue-prop-rights": "Lists rights that the 
user has.",
        "apihelp-query+allusers-paramvalue-prop-editcount": "Adds the edit 
count of the user.",
@@ -1222,6 +1223,7 @@
        "apihelp-query+userinfo-paramvalue-prop-blockinfo": "Tags if the 
current user is blocked, by whom, and for what reason.",
        "apihelp-query+userinfo-paramvalue-prop-hasmsg": "Adds a tag 
<samp>messages</samp> if the current user has pending messages.",
        "apihelp-query+userinfo-paramvalue-prop-groups": "Lists all the groups 
the current user belongs to.",
+       "apihelp-query+userinfo-paramvalue-prop-groupmemberships": "Lists 
groups that the current user has been explicitly assigned to, including the 
expiry date of each group membership.",
        "apihelp-query+userinfo-paramvalue-prop-implicitgroups": "Lists all the 
groups the current user is automatically a member of.",
        "apihelp-query+userinfo-paramvalue-prop-rights": "Lists all the rights 
the current user has.",
        "apihelp-query+userinfo-paramvalue-prop-changeablegroups": "Lists the 
groups the current user can add to and remove from.",
diff --git a/includes/api/i18n/qqq.json b/includes/api/i18n/qqq.json
index 2bdc64a..2ee58e7 100644
--- a/includes/api/i18n/qqq.json
+++ b/includes/api/i18n/qqq.json
@@ -549,6 +549,7 @@
        "apihelp-query+allusers-param-prop": 
"{{doc-apihelp-param|query+allusers|prop|paramvalues=1}}",
        "apihelp-query+allusers-paramvalue-prop-blockinfo": 
"{{doc-apihelp-paramvalue|query+allusers|prop|blockinfo}}",
        "apihelp-query+allusers-paramvalue-prop-groups": 
"{{doc-apihelp-paramvalue|query+allusers|prop|groups}}",
+       "apihelp-query+allusers-paramvalue-prop-groupmemberships": 
"{{doc-apihelp-paramvalue|query+allusers|prop|groupmemberships}}",
        "apihelp-query+allusers-paramvalue-prop-implicitgroups": 
"{{doc-apihelp-paramvalue|query+allusers|prop|implicitgroups}}",
        "apihelp-query+allusers-paramvalue-prop-rights": 
"{{doc-apihelp-paramvalue|query+allusers|prop|rights}}",
        "apihelp-query+allusers-paramvalue-prop-editcount": 
"{{doc-apihelp-paramvalue|query+allusers|prop|editcount}}",
@@ -1140,6 +1141,7 @@
        "apihelp-query+userinfo-paramvalue-prop-blockinfo": 
"{{doc-apihelp-paramvalue|query+userinfo|prop|blockinfo}}",
        "apihelp-query+userinfo-paramvalue-prop-hasmsg": 
"{{doc-apihelp-paramvalue|query+userinfo|prop|hasmsg}}",
        "apihelp-query+userinfo-paramvalue-prop-groups": 
"{{doc-apihelp-paramvalue|query+userinfo|prop|groups}}",
+       "apihelp-query+userinfo-paramvalue-prop-groupmemberships": 
"{{doc-apihelp-paramvalue|query+userinfo|prop|groupmemberships}}",
        "apihelp-query+userinfo-paramvalue-prop-implicitgroups": 
"{{doc-apihelp-paramvalue|query+userinfo|prop|implicitgroups}}",
        "apihelp-query+userinfo-paramvalue-prop-rights": 
"{{doc-apihelp-paramvalue|query+userinfo|prop|rights}}",
        "apihelp-query+userinfo-paramvalue-prop-changeablegroups": 
"{{doc-apihelp-paramvalue|query+userinfo|prop|changeablegroups}}",
diff --git a/includes/installer/MysqlUpdater.php 
b/includes/installer/MysqlUpdater.php
index d95222c..535a06c 100644
--- a/includes/installer/MysqlUpdater.php
+++ b/includes/installer/MysqlUpdater.php
@@ -294,6 +294,8 @@
 
                        // 1.29
                        [ 'addField', 'externallinks', 'el_index_60', 
'patch-externallinks-el_index_60.sql' ],
+                       // this patch adds ug_id and ug_expiry fields and an 
index
+                       [ 'addField', 'user_groups', 'ug_id', 
'patch-user_groups-id-expiry.sql' ],
                ];
        }
 
diff --git a/includes/installer/SqliteUpdater.php 
b/includes/installer/SqliteUpdater.php
index 32068e6..e66b211 100644
--- a/includes/installer/SqliteUpdater.php
+++ b/includes/installer/SqliteUpdater.php
@@ -161,6 +161,8 @@
 
                        // 1.29
                        [ 'addField', 'externallinks', 'el_index_60', 
'patch-externallinks-el_index_60.sql' ],
+                       // note this adds ug_id and ug_expiry fields and an 
index
+                       [ 'addField', 'user_groups', 'ug_id', 
'patch-user_groups-id-expiry.sql' ],
                ];
        }
 
diff --git a/includes/logging/RightsLogFormatter.php 
b/includes/logging/RightsLogFormatter.php
index be73c86..0d54180 100644
--- a/includes/logging/RightsLogFormatter.php
+++ b/includes/logging/RightsLogFormatter.php
@@ -70,7 +70,7 @@
        protected function getMessageParameters() {
                $params = parent::getMessageParameters();
 
-               // Really old entries
+               // Really old entries that lack old/new groups
                if ( !isset( $params[3] ) && !isset( $params[4] ) ) {
                        return $params;
                }
@@ -90,16 +90,20 @@
                        }
                }
 
-               $lang = $this->context->getLanguage();
+               // fetch the metadata about each group membership
+               $allParams = $this->entry->getParameters();
+
                if ( count( $oldGroups ) ) {
-                       $params[3] = $lang->listToText( $oldGroups );
+                       $params[3] = [ 'raw' => $this->formatRightsList( 
$oldGroups,
+                               isset( $allParams['oldmetadata'] ) ? 
$allParams['oldmetadata'] : [] ) ];
                } else {
                        $params[3] = $this->msg( 'rightsnone' )->text();
                }
                if ( count( $newGroups ) ) {
                        // Array_values is used here because of T44211
                        // see use of array_unique in 
UserrightsPage::doSaveUserGroups on $newGroups.
-                       $params[4] = $lang->listToText( array_values( 
$newGroups ) );
+                       $params[4] = [ 'raw' => $this->formatRightsList( 
array_values( $newGroups ),
+                               isset( $allParams['newmetadata'] ) ? 
$allParams['newmetadata'] : [] ) ];
                } else {
                        $params[4] = $this->msg( 'rightsnone' )->text();
                }
@@ -107,6 +111,39 @@
                $params[5] = $userName;
 
                return $params;
+       }
+
+       protected function formatRightsList( $groups, $serializedUGMs = [] ) {
+               $uiLanguage = $this->context->getLanguage();
+               $uiUser = $this->context->getUser();
+               $list = [];
+
+               reset( $groups );
+               reset( $serializedUGMs );
+               while ( current( $groups ) ) {
+                       $group = current( $groups );
+
+                       if ( current( $serializedUGMs ) &&
+                               isset( current( $serializedUGMs )['expiry'] ) &&
+                               current( $serializedUGMs )['expiry']
+                       ) {
+                               // there is an expiry date; format the group 
and expiry into a friendly string
+                               $expiry = current( $serializedUGMs )['expiry'];
+                               $expiryFormatted = 
$uiLanguage->userTimeAndDate( $expiry, $uiUser );
+                               $expiryFormattedD = $uiLanguage->userDate( 
$expiry, $uiUser );
+                               $expiryFormattedT = $uiLanguage->userTime( 
$expiry, $uiUser );
+                               $list[] = $this->msg( 
'rightslogentry-temporary-group' )->params( $group,
+                                       $expiryFormatted, $expiryFormattedD, 
$expiryFormattedT )->parse();
+                       } else {
+                               // the right does not expire; just insert the 
group name
+                               $list[] = $group;
+                       }
+
+                       next( $groups );
+                       next( $serializedUGMs );
+               }
+
+               return $uiLanguage->listToText( $list );
        }
 
        protected function getParametersForApi() {
@@ -126,13 +163,36 @@
                        }
                }
 
-               // Really old entries does not have log params
+               // Really old entries do not have log params
                if ( isset( $params['4:array:oldgroups'] ) ) {
                        $params['4:array:oldgroups'] = $this->makeGroupArray( 
$params['4:array:oldgroups'] );
                }
                if ( isset( $params['5:array:newgroups'] ) ) {
                        $params['5:array:newgroups'] = $this->makeGroupArray( 
$params['5:array:newgroups'] );
                }
+
+               // Walk through the parallel arrays of groups and metadata, 
combining each metadata
+               // array with the name of the group it pertains to
+               $params['oldmetadata'] = array_map( function( $index ) use ( 
$params ) {
+                       $result = [ 'group' => 
$params['4:array:oldgroups'][$index] ];
+                       if ( isset( $params['oldmetadata'][$index] ) ) {
+                               $result += $params['oldmetadata'][$index];
+                       }
+                       if ( !isset( $result['expiry'] ) ) {
+                               $result['expiry'] = 'infinity';
+                       }
+                       return $result;
+               }, array_keys( $params['4:array:oldgroups'] ) );
+               $params['newmetadata'] = array_map( function( $index ) use ( 
$params ) {
+                       $result = [ 'group' => 
$params['5:array:newgroups'][$index] ];
+                       if ( isset( $params['newmetadata'][$index] ) ) {
+                               $result += $params['newmetadata'][$index];
+                       }
+                       if ( !isset( $result['expiry'] ) ) {
+                               $result['expiry'] = 'infinity';
+                       }
+                       return $result;
+               }, array_keys( $params['5:array:newgroups'] ) );
 
                return $params;
        }
@@ -145,6 +205,14 @@
                if ( isset( $ret['newgroups'] ) ) {
                        ApiResult::setIndexedTagName( $ret['newgroups'], 'g' );
                }
+               if ( isset( $ret['oldmetadata'] ) ) {
+                       ApiResult::setArrayType( $ret['oldmetadata'], 'array' );
+                       ApiResult::setIndexedTagName( $ret['oldmetadata'], 'g' 
);
+               }
+               if ( isset( $ret['newmetadata'] ) ) {
+                       ApiResult::setArrayType( $ret['newmetadata'], 'array' );
+                       ApiResult::setIndexedTagName( $ret['newmetadata'], 'g' 
);
+               }
                return $ret;
        }
 
diff --git a/includes/specials/SpecialUserrights.php 
b/includes/specials/SpecialUserrights.php
index df98f33..679c9b5 100644
--- a/includes/specials/SpecialUserrights.php
+++ b/includes/specials/SpecialUserrights.php
@@ -79,6 +79,8 @@
                $session = $request->getSession();
                $out = $this->getOutput();
 
+               $out->addModules( [ 'mediawiki.special.userrights' ] );
+
                if ( $par !== null ) {
                        $this->mTarget = $par;
                } else {
@@ -111,7 +113,6 @@
                        // Remove session data for the success message
                        $session->remove( 'specialUserrightsSaveSuccess' );
 
-                       $out->addModules( [ 'mediawiki.special.userrights' ] );
                        $out->addModuleStyles( 
'mediawiki.notification.convertmessagebox.styles' );
                        $out->addHTML(
                                Html::rawElement(
@@ -172,18 +173,22 @@
                        ) {
                                $out->addWikiMsg( 'userrights-conflict' );
                        } else {
-                               $this->saveUserGroups(
+                               $status = $this->saveUserGroups(
                                        $this->mTarget,
                                        $request->getVal( 'user-reason' ),
                                        $targetUser
                                );
 
-                               // Set session data for the success message
-                               $session->set( 'specialUserrightsSaveSuccess', 
1 );
+                               if ( $status->isOK() ) {
+                                       // Set session data for the success 
message
+                                       $session->set( 
'specialUserrightsSaveSuccess', 1 );
 
-                               $out->redirect( $this->getSuccessURL() );
-
-                               return;
+                                       $out->redirect( $this->getSuccessURL() 
);
+                                       return;
+                               } else {
+                                       // Print an error message and redisplay 
the form
+                                       $out->addWikiText( '<div 
class="error">' . $status->getWikiText() . '</div>' );
+                               }
                        }
                }
 
@@ -198,17 +203,43 @@
        }
 
        /**
+        * Converts a user group membership expiry string into a timestamp. 
Words like
+        * 'existing' or 'other' should have been filtered out before calling 
this
+        * function.
+        *
+        * @param string $expiry
+        * @return string|null|false A string containing a valid timestamp, or 
null
+        *   if the expiry is infinite, or false if the timestamp is not valid
+        */
+       protected static function expiryToTimestamp( $expiry ) {
+               if ( wfIsInfinity( $expiry ) ) {
+                       return null;
+               }
+
+               $unix = strtotime( $expiry );
+
+               if ( !$unix || $unix === -1 ) {
+                       return false;
+               }
+
+               // @todo FIXME: Non-qualified absolute times are not in users 
specified timezone
+               // and there isn't notice about it in the ui (see 
ProtectionForm::getExpiry)
+               return wfTimestamp( TS_MW, $unix );
+       }
+
+       /**
         * Save user groups changes in the database.
         * Data comes from the editUserGroupsForm() form function
         *
         * @param string $username Username to apply changes to.
         * @param string $reason Reason for group change
         * @param User|UserRightsProxy $user Target user object.
-        * @return null
+        * @return Status
         */
-       function saveUserGroups( $username, $reason, $user ) {
+       protected function saveUserGroups( $username, $reason, $user ) {
                $allgroups = $this->getAllGroups();
                $addgroup = [];
+               $groupExpiries = []; // associative array of (group name => 
expiry)
                $removegroup = [];
 
                // This could possibly create a highly unlikely race condition 
if permissions are changed between
@@ -218,12 +249,36 @@
                        // Later on, this gets filtered for what can actually 
be removed
                        if ( $this->getRequest()->getCheck( "wpGroup-$group" ) 
) {
                                $addgroup[] = $group;
+
+                               // read the expiry information from the request
+                               $expiryDropdown = $this->getRequest()->getVal( 
"wpExpiry-$group" );
+                               if ( $expiryDropdown === 'other' ) {
+                                       $expiryValue = 
$this->getRequest()->getVal( "wpExpiry-$group-other" );
+                               } elseif ( $expiryDropdown !== 'existing' ) {
+                                       $expiryValue = $expiryDropdown;
+                               }
+
+                               // validate the expiry
+                               if ( isset( $expiryValue ) ) {
+                                       $groupExpiries[$group] = 
self::expiryToTimestamp( $expiryValue );
+
+                                       if ( $groupExpiries[$group] === false ) 
{
+                                               return Status::newFatal( 
'userrights-invalid-expiry', $group );
+                                       }
+
+                                       // not allowed to have things expiring 
in the past
+                                       if ( $groupExpiries[$group] < 
wfTimestampNow() ) {
+                                               return Status::newFatal( 
'userrights-expiry-in-past', $group );
+                                       }
+                               }
                        } else {
                                $removegroup[] = $group;
                        }
                }
 
-               $this->doSaveUserGroups( $user, $addgroup, $removegroup, 
$reason );
+               $this->doSaveUserGroups( $user, $addgroup, $removegroup, 
$reason, $groupExpiries );
+
+               return Status::newGood();
        }
 
        /**
@@ -233,9 +288,11 @@
         * @param array $add Array of groups to add
         * @param array $remove Array of groups to remove
         * @param string $reason Reason for group change
+        * @param array $groupExpiries Associative array of (group name => 
expiry),
+        *   containing only those groups that are to have new expiry values set
         * @return array Tuple of added, then removed groups
         */
-       function doSaveUserGroups( $user, $add, $remove, $reason = '' ) {
+       public function doSaveUserGroups( $user, $add, $remove, $reason = '', 
$groupExpiries = [] ) {
                // Validate input set...
                $isself = $user->getName() == $this->getUser()->getName();
                $groups = $user->getGroups();
@@ -245,15 +302,19 @@
 
                $remove = array_unique(
                        array_intersect( (array)$remove, $removable, $groups ) 
);
-               $add = array_unique( array_diff(
-                       array_intersect( (array)$add, $addable ),
-                       $groups )
-               );
+               $add = array_intersect( (array)$add, $addable );
 
-               $oldGroups = $user->getGroups();
+               // add only groups that are not already present or that need 
their expiry updated
+               $add = array_filter( $add,
+                       function( $group ) use ( $groups, $groupExpiries ) {
+                               return !in_array( $group, $groups ) || 
array_key_exists( $group, $groupExpiries );
+                       } );
+
+               $oldGroups = $groups;
+               $oldUGMs = $user->getGroupMemberships();
                $newGroups = $oldGroups;
 
-               // Remove then add groups
+               // Remove groups, then add new ones/update expiries of existing 
ones
                if ( $remove ) {
                        foreach ( $remove as $index => $group ) {
                                if ( !$user->removeGroup( $group ) ) {
@@ -264,13 +325,15 @@
                }
                if ( $add ) {
                        foreach ( $add as $index => $group ) {
-                               if ( !$user->addGroup( $group ) ) {
+                               $expiry = isset( $groupExpiries[$group] ) ? 
$groupExpiries[$group] : null;
+                               if ( !$user->addGroup( $group, $expiry ) ) {
                                        unset( $add[$index] );
                                }
                        }
                        $newGroups = array_merge( $newGroups, $add );
                }
                $newGroups = array_unique( $newGroups );
+               $newUGMs = $user->getGroupMemberships();
 
                // Ensure that caches are cleared
                $user->invalidateCache();
@@ -283,24 +346,58 @@
 
                wfDebug( 'oldGroups: ' . print_r( $oldGroups, true ) . "\n" );
                wfDebug( 'newGroups: ' . print_r( $newGroups, true ) . "\n" );
+               wfDebug( 'oldUGMs: ' . print_r( $oldUGMs, true ) . "\n" );
+               wfDebug( 'newUGMs: ' . print_r( $newUGMs, true ) . "\n" );
                // Deprecated in favor of UserGroupsChanged hook
                Hooks::run( 'UserRights', [ &$user, $add, $remove ], '1.26' );
 
-               if ( $newGroups != $oldGroups ) {
-                       $this->addLogEntry( $user, $oldGroups, $newGroups, 
$reason );
+               // Only add a log entry if something actually changed
+               if ( $newGroups != $oldGroups || $newUGMs != $oldUGMs ) {
+                       $this->addLogEntry( $user, $oldGroups, $newGroups, 
$reason, $oldUGMs, $newUGMs );
                }
 
                return [ $add, $remove ];
        }
 
        /**
+        * Serialise a UserGroupMembership object for storage in the log_params 
section
+        * of the logging table. Only keeps essential data, removing redundant 
fields.
+        *
+        * @param UserGroupMembership|null $ugm May be null if things get borked
+        * @return array
+        */
+       protected static function serialiseUgmForLog( $ugm ) {
+               if ( !$ugm instanceof UserGroupMembership ) {
+                       return null;
+               }
+               return [ 'expiry' => $ugm->getExpiry() ];
+       }
+
+       /**
         * Add a rights log entry for an action.
-        * @param User $user
+        * @param User|UserRightsProxy $user
         * @param array $oldGroups
         * @param array $newGroups
         * @param array $reason
+        * @param array $oldUGMs Associative array of (group name => 
UserGroupMembership)
+        * @param array $newUGMs Associative array of (group name => 
UserGroupMembership)
         */
-       function addLogEntry( $user, $oldGroups, $newGroups, $reason ) {
+       protected function addLogEntry( $user, $oldGroups, $newGroups, $reason,
+               $oldUGMs, $newUGMs ) {
+
+               // make sure $oldUGMs and $newUGMs are in the same order, and 
serialise
+               // each UGM object to a simplified array
+               $oldUGMs = array_map( function( $group ) use ( $oldUGMs ) {
+                       return isset( $oldUGMs[$group] ) ?
+                               self::serialiseUgmForLog( $oldUGMs[$group] ) :
+                               null;
+               }, $oldGroups );
+               $newUGMs = array_map( function( $group ) use ( $newUGMs ) {
+                       return isset( $newUGMs[$group] ) ?
+                               self::serialiseUgmForLog( $newUGMs[$group] ) :
+                               null;
+               }, $newGroups );
+
                $logEntry = new ManualLogEntry( 'rights', 'rights' );
                $logEntry->setPerformer( $this->getUser() );
                $logEntry->setTarget( $user->getUserPage() );
@@ -308,6 +405,8 @@
                $logEntry->setParameters( [
                        '4::oldgroups' => $oldGroups,
                        '5::newgroups' => $newGroups,
+                       'oldmetadata' => $oldUGMs,
+                       'newmetadata' => $newUGMs,
                ] );
                $logid = $logEntry->insert();
                $logEntry->publish( $logid );
@@ -328,8 +427,8 @@
                }
 
                $groups = $user->getGroups();
-
-               $this->showEditUserGroupsForm( $user, $groups );
+               $groupMemberships = $user->getGroupMemberships();
+               $this->showEditUserGroupsForm( $user, $groups, 
$groupMemberships );
 
                // This isn't really ideal logging behavior, but let's not hide 
the
                // interwiki logs if we're using them as is.
@@ -485,35 +584,47 @@
         * Show the form to edit group memberships.
         *
         * @param User|UserRightsProxy $user User or UserRightsProxy you're 
editing
-        * @param array $groups Array of groups the user is in
+        * @param array $groups Array of groups the user is in. Not used by 
this implementation
+        *   anymore, but kept for backward compatibility with subclasses
+        * @param array $groupMemberships Associative array of (group name => 
UserGroupMembership
+        *   object) containing the groups the user is in
         */
-       protected function showEditUserGroupsForm( $user, $groups ) {
-               $list = [];
-               $membersList = [];
-               foreach ( $groups as $group ) {
-                       $list[] = self::buildGroupLink( $group );
-                       $membersList[] = self::buildGroupMemberLink( $group );
+       protected function showEditUserGroupsForm( $user, $groups, 
$groupMemberships ) {
+               $list = $membersList = $tempList = $tempMembersList = [];
+               foreach ( $groupMemberships as $ugm ) {
+                       $linkG = UserGroupMembership::getLinkHTML( $ugm, 
$this->getContext() );
+                       $linkM = UserGroupMembership::getLinkHTML( $ugm, 
$this->getContext(),
+                               true, $user->getName() );
+                       if ( $ugm->getExpiry() ) {
+                               $tempList[] = $linkG;
+                               $tempMembersList[] = $linkM;
+                       } else {
+                               $list[] = $linkG;
+                               $membersList[] = $linkM;
+
+                       }
                }
 
                $autoList = [];
                $autoMembersList = [];
                if ( $user instanceof User ) {
                        foreach ( Autopromote::getAutopromoteGroups( $user ) as 
$group ) {
-                               $autoList[] = self::buildGroupLink( $group );
-                               $autoMembersList[] = 
self::buildGroupMemberLink( $group );
+                               $autoList[] = UserGroupMembership::getLinkHTML( 
$group, $this->getContext() );
+                               $autoMembersList[] = 
UserGroupMembership::getLinkHTML( $group,
+                                       $this->getContext(), true, 
$user->getName() );
                        }
                }
 
                $language = $this->getLanguage();
                $displayedList = $this->msg( 'userrights-groupsmember-type' )
                        ->rawParams(
-                               $language->listToText( $list ),
-                               $language->listToText( $membersList )
+                               $language->commaList( array_merge( $tempList, 
$list ) ),
+                               $language->commaList( array_merge( 
$tempMembersList, $membersList ) )
                        )->escaped();
                $displayedAutolist = $this->msg( 'userrights-groupsmember-type' 
)
                        ->rawParams(
-                               $language->listToText( $autoList ),
-                               $language->listToText( $autoMembersList )
+                               $language->commaList( $autoList ),
+                               $language->commaList( $autoMembersList )
                        )->escaped();
 
                $grouplist = '';
@@ -542,7 +653,8 @@
                        Linker::TOOL_LINKS_EMAIL /* Add "send e-mail" link */
                );
 
-               list( $groupCheckboxes, $canChangeAny ) = 
$this->groupCheckboxes( $groups, $user );
+               list( $groupCheckboxes, $canChangeAny ) =
+                       $this->groupCheckboxes( $groupMemberships, $user );
                $this->getOutput()->addHTML(
                        Xml::openElement(
                                'form',
@@ -609,26 +721,6 @@
        }
 
        /**
-        * Format a link to a group description page
-        *
-        * @param string $group
-        * @return string
-        */
-       private static function buildGroupLink( $group ) {
-               return User::makeGroupLinkHTML( $group, User::getGroupName( 
$group ) );
-       }
-
-       /**
-        * Format a link to a group member description page
-        *
-        * @param string $group
-        * @return string
-        */
-       private static function buildGroupMemberLink( $group ) {
-               return User::makeGroupLinkHTML( $group, User::getGroupMember( 
$group ) );
-       }
-
-       /**
         * Returns an array of all groups that may be edited
         * @return array Array of groups that may be edited.
         */
@@ -640,7 +732,8 @@
         * Adds a table with checkboxes where you can select what groups to 
add/remove
         *
         * @todo Just pass the username string?
-        * @param array $usergroups Groups the user belongs to
+        * @param array $usergroups Associative array of (group name as string 
=>
+        *   UserGroupMembership object) for groups the user belongs to
         * @param User $user
         * @return Array with 2 elements: the XHTML table element with 
checkxboes, and
         * whether any groups are changeable
@@ -649,12 +742,18 @@
                $allgroups = $this->getAllGroups();
                $ret = '';
 
+               // Get the list of preset expiry times from the system message
+               $expiryOptionsMsg = $this->msg( 'userrights-expiry-options' 
)->inContentLanguage();
+               $expiryOptions = $expiryOptionsMsg->isDisabled() ?
+                       [] :
+                       explode( ',', $expiryOptionsMsg->text() );
+
                // Put all column info into an associative array so that 
extensions can
                // more easily manage it.
                $columns = [ 'unchangeable' => [], 'changeable' => [] ];
 
                foreach ( $allgroups as $group ) {
-                       $set = in_array( $group, $usergroups );
+                       $set = isset( $usergroups[$group] );
                        // Should the checkbox be disabled?
                        $disabled = !(
                                ( $set && $this->canRemove( $group ) ) ||
@@ -710,9 +809,89 @@
                                $checkboxHtml = Xml::checkLabel( $text, 
"wpGroup-" . $group,
                                        "wpGroup-" . $group, $checkbox['set'], 
$attr );
                                $ret .= "\t\t" . ( $checkbox['disabled']
-                                       ? Xml::tags( 'span', [ 'class' => 
'mw-userrights-disabled' ], $checkboxHtml )
-                                       : $checkboxHtml
-                               ) . "<br />\n";
+                                       ? Xml::tags( 'div', [ 'class' => 
'mw-userrights-disabled' ], $checkboxHtml )
+                                       : Xml::tags( 'div', [ ], $checkboxHtml )
+                               ) . "\n";
+
+                               $uiUser = $this->getUser();
+                               $uiLanguage = $this->getLanguage();
+
+                               $currentExpiry = isset( $usergroups[$group] ) ?
+                                       $usergroups[$group]->getExpiry() :
+                                       null;
+
+                               // If the user can't uncheck this checkbox, 
print the current expiry below
+                               // it in plain text. Otherwise provide UI to 
set/change the expiry
+                               if ( $checkbox['set'] && ( 
$checkbox['irreversible'] || $checkbox['disabled'] ) ) {
+                                       if ( $currentExpiry ) {
+                                               $expiryFormatted = 
$uiLanguage->userTimeAndDate( $currentExpiry, $uiUser );
+                                               $expiryFormattedD = 
$uiLanguage->userDate( $currentExpiry, $uiUser );
+                                               $expiryFormattedT = 
$uiLanguage->userTime( $currentExpiry, $uiUser );
+                                               $expiryHtml = $this->msg( 
'userrights-expiry-current' )->params(
+                                                       $expiryFormatted, 
$expiryFormattedD, $expiryFormattedT )->text();
+                                       } else {
+                                               $expiryHtml = $this->msg( 
'userrights-expiry-none' )->text();
+                                       }
+                                       $expiryHtml .= "<br />\n";
+                               } else {
+                                       $expiryHtml = Xml::element( 'span', 
null,
+                                               $this->msg( 'userrights-expiry' 
)->text() );
+                                       $expiryHtml .= Xml::openElement( 'span' 
);
+
+                                       // add a form element to set the expiry 
date
+                                       $expiryFormOptions = new XmlSelect(
+                                               "wpExpiry-$group",
+                                               "mw-input-wpExpiry-$group", // 
forward compatibility with HTMLForm
+                                               $currentExpiry ? 'existing' : 
'infinite'
+                                       );
+                                       if ( $checkbox['disabled'] ) {
+                                               
$expiryFormOptions->setAttribute( 'disabled', 'disabled' );
+                                       }
+
+                                       if ( $currentExpiry ) {
+                                               $timestamp = 
$uiLanguage->userTimeAndDate( $currentExpiry, $uiUser );
+                                               $d = $uiLanguage->userDate( 
$currentExpiry, $uiUser );
+                                               $t = $uiLanguage->userTime( 
$currentExpiry, $uiUser );
+                                               $existingExpiryMessage = 
$this->msg( 'userrights-expiry-existing',
+                                                       $timestamp, $d, $t );
+                                               $expiryFormOptions->addOption( 
$existingExpiryMessage->text(), 'existing' );
+                                       }
+
+                                       $expiryFormOptions->addOption(
+                                               $this->msg( 
'userrights-expiry-none' )->text(),
+                                               'infinite'
+                                       );
+                                       $expiryFormOptions->addOption(
+                                               $this->msg( 
'userrights-expiry-othertime' )->text(),
+                                               'other'
+                                       );
+                                       foreach ( $expiryOptions as $option ) {
+                                               if ( strpos( $option, ":" ) === 
false ) {
+                                                       $displayText = $value = 
$option;
+                                               } else {
+                                                       list( $displayText, 
$value ) = explode( ":", $option );
+                                               }
+                                               $expiryFormOptions->addOption( 
$displayText, htmlspecialchars( $value ) );
+                                       }
+
+                                       // Add expiry dropdown
+                                       $expiryHtml .= 
$expiryFormOptions->getHTML() . '<br />';
+
+                                       // Add custom expiry field
+                                       $attribs = [ 'id' => 
"mw-input-wpExpiry-$group-other" ];
+                                       if ( $checkbox['disabled'] ) {
+                                               $attribs['disabled'] = 
'disabled';
+                                       }
+                                       $expiryHtml .= Xml::input( 
"wpExpiry-$group-other", 30, '', $attribs );
+
+                                       $expiryHtml .= Xml::closeElement( 
'span' );
+                               }
+
+                               $divAttribs = [
+                                       'id' => 
"mw-userrights-nested-wpGroup-$group",
+                                       'class' => 'mw-userrights-nested',
+                               ];
+                               $ret .= "\t\t\t" . Xml::tags( 'div', 
$divAttribs, $expiryHtml ) . "\n";
                        }
                        $ret .= "\t</td>\n";
                }
diff --git a/includes/specials/pagers/ActiveUsersPager.php 
b/includes/specials/pagers/ActiveUsersPager.php
index 645a115..e2f4d4b 100644
--- a/includes/specials/pagers/ActiveUsersPager.php
+++ b/includes/specials/pagers/ActiveUsersPager.php
@@ -101,12 +101,17 @@
                        $tables[] = 'user_groups';
                        $conds[] = 'ug_user = user_id';
                        $conds['ug_group'] = $this->groups;
+                       $conds[] = 'ug_expiry IS NULL OR ug_expiry >= ' . 
$dbr->addQuotes( $dbr->timestamp() );
                }
                if ( $this->excludegroups !== [] ) {
                        foreach ( $this->excludegroups as $group ) {
                                $conds[] = 'NOT EXISTS (' . $dbr->selectSQLText(
-                                               'user_groups', '1', [ 'ug_user 
= user_id', 'ug_group' => $group ]
-                                       ) . ')';
+                                       'user_groups', '1', [
+                                               'ug_user = user_id',
+                                               'ug_group' => $group,
+                                               'ug_expiry IS NULL OR ug_expiry 
>= ' . $dbr->addQuotes( $dbr->timestamp() )
+                                       ]
+                               ) . ')';
                        }
                }
                if ( !$this->getUser()->isAllowed( 'hideuser' ) ) {
@@ -165,9 +170,9 @@
                $list = [];
                $user = User::newFromId( $row->user_id );
 
-               $groups_list = self::getGroups( intval( $row->user_id ), 
$this->userGroupCache );
-               foreach ( $groups_list as $group ) {
-                       $list[] = self::buildGroupLink( $group, $userName );
+               $ugms = self::getGroupMemberships( intval( $row->user_id ), 
$this->userGroupCache );
+               foreach ( $ugms as $ugm ) {
+                       $list[] = $this->buildGroupLink( $ugm, $userName );
                }
 
                $groups = $lang->commaList( $list );
diff --git a/includes/specials/pagers/UsersPager.php 
b/includes/specials/pagers/UsersPager.php
index 901be38..d628432 100644
--- a/includes/specials/pagers/UsersPager.php
+++ b/includes/specials/pagers/UsersPager.php
@@ -112,6 +112,7 @@
 
                if ( $this->requestedGroup != '' ) {
                        $conds['ug_group'] = $this->requestedGroup;
+                       $conds[] = 'ug_expiry IS NULL OR ug_expiry >= ' . 
$dbr->addQuotes( $dbr->timestamp() );
                }
 
                if ( $this->requestedUser != '' ) {
@@ -177,12 +178,12 @@
                $lang = $this->getLanguage();
 
                $groups = '';
-               $groups_list = self::getGroups( intval( $row->user_id ), 
$this->userGroupCache );
+               $ugms = self::getGroupMemberships( intval( $row->user_id ), 
$this->userGroupCache );
 
-               if ( !$this->including && count( $groups_list ) > 0 ) {
+               if ( !$this->including && count( $ugms ) > 0 ) {
                        $list = [];
-                       foreach ( $groups_list as $group ) {
-                               $list[] = self::buildGroupLink( $group, 
$userName );
+                       foreach ( $ugms as $ugm ) {
+                               $list[] = $this->buildGroupLink( $ugm, 
$userName );
                        }
                        $groups = $lang->commaList( $list );
                }
@@ -231,15 +232,18 @@
                $dbr = wfGetDB( DB_REPLICA );
                $groupRes = $dbr->select(
                        'user_groups',
-                       [ 'ug_user', 'ug_group' ],
+                       UserGroupMembership::selectFields(),
                        [ 'ug_user' => $userIds ],
                        __METHOD__
                );
                $cache = [];
                $groups = [];
                foreach ( $groupRes as $row ) {
-                       $cache[intval( $row->ug_user )][] = $row->ug_group;
-                       $groups[$row->ug_group] = true;
+                       $ugm = UserGroupMembership::newFromRow( $row );
+                       if ( !$ugm->isExpired() ) {
+                               $cache[$row->ug_user][$row->ug_group] = $ugm;
+                               $groups[$row->ug_group] = true;
+                       }
                }
 
                // Give extensions a chance to add things like global user 
group data
@@ -365,36 +369,31 @@
        }
 
        /**
-        * Get a list of groups the specified user belongs to
+        * Get an associative array containing groups the specified user 
belongs to,
+        * and the relevant UserGroupMembership objects
         *
         * @param int $uid User id
         * @param array|null $cache
-        * @return array
+        * @return array (group name => UserGroupMembership object)
         */
-       protected static function getGroups( $uid, $cache = null ) {
+       protected static function getGroupMemberships( $uid, $cache = null ) {
                if ( $cache === null ) {
                        $user = User::newFromId( $uid );
-                       $effectiveGroups = $user->getEffectiveGroups();
+                       return $user->getGroupMemberships();
                } else {
-                       $effectiveGroups = isset( $cache[$uid] ) ? $cache[$uid] 
: [];
+                       return isset( $cache[$uid] ) ? $cache[$uid] : [];
                }
-               $groups = array_diff( $effectiveGroups, 
User::getImplicitGroups() );
-
-               return $groups;
        }
 
        /**
         * Format a link to a group description page
         *
-        * @param string $group Group name
+        * @param string|UserGroupMembership $group Group name or 
UserGroupMembership object
         * @param string $username Username
         * @return string
         */
-       protected static function buildGroupLink( $group, $username ) {
-               return User::makeGroupLinkHTML(
-                       $group,
-                       User::getGroupMember( $group, $username )
-               );
+       protected function buildGroupLink( $group, $username ) {
+               return UserGroupMembership::getLinkHTML( $group, 
$this->getContext(), true, $username );
        }
 
 }
diff --git a/includes/user/User.php b/includes/user/User.php
index 663c5da..25d7ce8 100644
--- a/includes/user/User.php
+++ b/includes/user/User.php
@@ -105,6 +105,7 @@
                'mEditCount',
                // user_groups table
                'mGroups',
+               'mGroupMemberships',
                // user_properties table
                'mOptionOverrides',
        ];
@@ -225,8 +226,14 @@
        protected $mRegistration;
        /** @var int */
        protected $mEditCount;
-       /** @var array */
+       /**
+        * @var array
+        * @deprecated since 1.29 Consumers use getGroups() instead; subclasses 
use
+        * $mGroupMemberships instead
+        */
        public $mGroups;
+       /** @var array Associative array of (group name => UserGroupMembership 
object) */
+       protected $mGroupMemberships;
        /** @var array */
        protected $mOptionOverrides;
        // @}
@@ -283,9 +290,7 @@
        /** @var array */
        public $mOptions;
 
-       /**
-        * @var WebRequest
-        */
+       /** @var WebRequest */
        private $mRequest;
 
        /** @var Block */
@@ -1138,6 +1143,7 @@
                $this->mEmailTokenExpires = null;
                $this->mRegistration = wfTimestamp( TS_MW );
                $this->mGroups = [];
+               $this->mGroupMemberships = [];
 
                Hooks::run( 'UserLoadDefaults', [ $this, $name ] );
        }
@@ -1249,7 +1255,7 @@
                if ( $s !== false ) {
                        // Initialise user table data
                        $this->loadFromRow( $s );
-                       $this->mGroups = null; // deferred
+                       $this->mGroupMemberships = null; // deferred
                        $this->getEditCount(); // revalidation for nulls
                        return true;
                } else {
@@ -1266,13 +1272,16 @@
         * @param stdClass $row Row from the user table to load.
         * @param array $data Further user data to load into the object
         *
-        *      user_groups             Array with groups out of the 
user_groups table
-        *      user_properties         Array with properties out of the 
user_properties table
+        *  user_groups   Array of arrays or stdClass result rows out of the 
user_groups
+        *                table. Previously you were supposed to pass an array 
of strings
+        *                here, but we also need expiry info nowadays, so an 
array of
+        *                strings is ignored.
+        *  user_properties   Array with properties out of the user_properties 
table
         */
        protected function loadFromRow( $row, $data = null ) {
                $all = true;
 
-               $this->mGroups = null; // deferred
+               $this->mGroupMemberships = null; // deferred
 
                if ( isset( $row->user_name ) ) {
                        $this->mName = $row->user_name;
@@ -1341,7 +1350,20 @@
 
                if ( is_array( $data ) ) {
                        if ( isset( $data['user_groups'] ) && is_array( 
$data['user_groups'] ) ) {
-                               $this->mGroups = $data['user_groups'];
+                               if ( !count( $data['user_groups'] ) ) {
+                                       $this->mGroupMemberships = 
$this->mGroups = [];
+                               } else {
+                                       $firstGroup = reset( 
$data['user_groups'] );
+                                       if ( is_array( $firstGroup ) || 
is_object( $firstGroup ) ) {
+                                               $this->mGroupMemberships = 
$this->mGroups = [];
+                                               foreach ( $data['user_groups'] 
as $row ) {
+                                                       $ugm = 
UserGroupMembership::newFromRow( (object)$row );
+                                                       $group = 
$ugm->getGroup();
+                                                       
$this->mGroupMemberships[$group] = $ugm;
+                                                       $this->mGroups[] = 
$group;
+                                               }
+                                       }
+                               }
                        }
                        if ( isset( $data['user_properties'] ) && is_array( 
$data['user_properties'] ) ) {
                                $this->loadOptions( $data['user_properties'] );
@@ -1365,17 +1387,17 @@
         * Load the groups from the database if they aren't already loaded.
         */
        private function loadGroups() {
-               if ( is_null( $this->mGroups ) ) {
+               if ( is_null( $this->mGroupMemberships ) ) {
                        $db = ( $this->queryFlagsUsed & self::READ_LATEST )
                                ? wfGetDB( DB_MASTER )
                                : wfGetDB( DB_REPLICA );
-                       $res = $db->select( 'user_groups',
-                               [ 'ug_group' ],
-                               [ 'ug_user' => $this->mId ],
-                               __METHOD__ );
+                       $this->mGroupMemberships = 
UserGroupMembership::getMembershipsForUser(
+                               $this->mId, $db );
+
+                       // convert to an array of plain strings for legacy 
$this->mGroups
                        $this->mGroups = [];
-                       foreach ( $res as $row ) {
-                               $this->mGroups[] = $row->ug_group;
+                       foreach ( $this->mGroupMemberships as $ugm ) {
+                               $this->mGroups[] = $ugm->getGroup();
                        }
                }
        }
@@ -1509,6 +1531,7 @@
                $this->mEffectiveGroups = null;
                $this->mImplicitGroups = null;
                $this->mGroups = null;
+               $this->mGroupMemberships = null;
                $this->mOptions = null;
                $this->mOptionsLoaded = false;
                $this->mEditCount = null;
@@ -3194,7 +3217,20 @@
        public function getGroups() {
                $this->load();
                $this->loadGroups();
-               return $this->mGroups;
+               return array_keys( $this->mGroupMemberships );
+       }
+
+       /**
+        * Get the list of explicit group memberships this user has, stored as
+        * UserGroupMembership objects. Implicit groups are not included.
+        *
+        * @return array Associative array of (group name as string => 
UserGroupMembership object)
+        * @since 1.29
+        */
+       public function getGroupMemberships() {
+               $this->load();
+               $this->loadGroups();
+               return $this->mGroupMemberships;
        }
 
        /**
@@ -3303,34 +3339,32 @@
        }
 
        /**
-        * Add the user to the given group.
-        * This takes immediate effect.
+        * Add the user to the given group. This takes immediate effect.
+        * If the user is already in the group, the expiry time will be updated 
to the new
+        * expiry time. (If $expiry is omitted or null, the membership will be 
altered to
+        * never expire.)
+        *
         * @param string $group Name of the group to add
+        * @param string $expiry Optional expiry timestamp, or null if the 
group assignment
+        *   should not expire
         * @return bool
         */
-       public function addGroup( $group ) {
+       public function addGroup( $group, $expiry = null ) {
                $this->load();
+               $this->loadGroups();
 
                if ( !Hooks::run( 'UserAddGroup', [ $this, &$group ] ) ) {
                        return false;
                }
 
-               $dbw = wfGetDB( DB_MASTER );
-               if ( $this->getId() ) {
-                       $dbw->insert( 'user_groups',
-                               [
-                                       'ug_user' => $this->getId(),
-                                       'ug_group' => $group,
-                               ],
-                               __METHOD__,
-                               [ 'IGNORE' ] );
+               // create the new UserGroupMembership and put it in the DB
+               $ugm = new UserGroupMembership( $this->mId, $group, $expiry );
+               if ( !$ugm->insert( true ) ) {
+                       return false;
                }
 
-               $this->loadGroups();
+               $this->mGroupMemberships[$group] = $ugm;
                $this->mGroups[] = $group;
-               // In case loadGroups was not called before, we now have the 
right twice.
-               // Get rid of the duplicate.
-               $this->mGroups = array_unique( $this->mGroups );
 
                // Refresh the groups caches, and clear the rights cache so it 
will be
                // refreshed on the next call to $this->getRights().
@@ -3350,28 +3384,19 @@
         */
        public function removeGroup( $group ) {
                $this->load();
+
                if ( !Hooks::run( 'UserRemoveGroup', [ $this, &$group ] ) ) {
                        return false;
                }
 
-               $dbw = wfGetDB( DB_MASTER );
-               $dbw->delete( 'user_groups',
-                       [
-                               'ug_user' => $this->getId(),
-                               'ug_group' => $group,
-                       ], __METHOD__
-               );
-               // Remember that the user was in this group
-               $dbw->insert( 'user_former_groups',
-                       [
-                               'ufg_user' => $this->getId(),
-                               'ufg_group' => $group,
-                       ],
-                       __METHOD__,
-                       [ 'IGNORE' ]
-               );
+               $ugm = UserGroupMembership::getMembership( $this->mId, $group );
+               if ( !$ugm ) {
+                       return false;
+               }
+               $ugm->delete();
 
                $this->loadGroups();
+               unset( $this->mGroupMemberships[$group] );
                $this->mGroups = array_diff( $this->mGroups, [ $group ] );
 
                // Refresh the groups caches, and clear the rights cache so it 
will be
diff --git a/includes/user/UserGroupMembership.php 
b/includes/user/UserGroupMembership.php
new file mode 100644
index 0000000..c226881
--- /dev/null
+++ b/includes/user/UserGroupMembership.php
@@ -0,0 +1,403 @@
+<?php
+/**
+ * Represents the membership of a user to a user group.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Represents a "user group membership" -- a specific instance of a user 
belonging
+ * to a group. For example, the fact that user Mary belongs to the sysop group 
is a
+ * user group membership.
+ *
+ * The class encapsulates rows in the user_groups table. The logic is 
low-level and
+ * doesn't run any hooks. Often, you will want to call User::addGroup() or
+ * User::removeGroup() instead.
+ *
+ * @since 1.29
+ */
+class UserGroupMembership {
+       /** @var int Primary key of the user_groups row */
+       protected $id;
+
+       /** @var int The ID of the user who belongs to the group */
+       protected $userId;
+
+       /** @var string */
+       protected $group;
+
+       /** @var string|null Timestamp of expiry, or null if no expiry */
+       protected $expiry;
+
+       public function __construct( $userId = 0, $group = null, $expiry = null 
) {
+               $this->userId = intval( $userId );
+               $this->group = $group; // TODO throw on invalid group?
+               $this->expiry = $expiry ?: null;
+       }
+
+       /**
+        * @return int
+        */
+       public function getId() {
+               return $this->id;
+       }
+
+       /**
+        * @return int
+        */
+       public function getUserId() {
+               return $this->userId;
+       }
+
+       /**
+        * @return string
+        */
+       public function getGroup() {
+               return $this->group;
+       }
+
+       /**
+        * @return string|null Timestamp of expiry, or null if no expiry
+        */
+       public function getExpiry() {
+               return $this->expiry;
+       }
+
+       protected function initFromRow( $row ) {
+               $this->id = intval( $row->ug_id );
+               $this->userId = intval( $row->ug_user );
+               $this->group = $row->ug_group;
+               $this->expiry = $row->ug_expiry ?: null;
+       }
+
+       public static function newFromRow( $row ) {
+               $ugm = new self;
+               $ugm->initFromRow( $row );
+               return $ugm;
+       }
+
+       /**
+        * Return the list of user_groups fields that should be selected to 
create
+        * a new user group membership.
+        * @return array
+        */
+       public static function selectFields() {
+               return [
+                       'ug_id',
+                       'ug_user',
+                       'ug_group',
+                       'ug_expiry',
+               ];
+       }
+
+       /**
+        * Delete the row from the user_groups table.
+        *
+        * @throws MWException
+        * @param IDatabase|null $dbw Optional master database connection to use
+        * @return bool
+        */
+       public function delete( IDatabase $dbw = null ) {
+               if ( wfReadOnly() ) {
+                       return false;
+               }
+
+               if ( !$this->getId() ) {
+                       throw new MWException( 'UserGroupMembership::delete() 
requires that the id member be filled' );
+               }
+
+               if ( $dbw === null ) {
+                       $dbw = wfGetDB( DB_MASTER );
+               }
+
+               $dbw->delete( 'user_groups', [ 'ug_id' => $this->id ], 
__METHOD__ );
+               // Remember that the user was in this group
+               $dbw->insert( 'user_former_groups',
+                       [
+                               'ufg_user' => $this->userId,
+                               'ufg_group' => $this->group,
+                       ],
+                       __METHOD__,
+                       [ 'IGNORE' ]
+               );
+
+               return $dbw->affectedRows() > 0;
+       }
+
+       /**
+        * Insert a user right membership into the database. The function fails 
if there
+        * is a conflicting membership entry (same user and group) already in 
the table.
+        *
+        * @throws MWException
+        * @param bool $allowUpdate Whether to perform "upsert" instead of 
INSERT
+        * @param IDatabase|null $dbw If you have one available
+        * @return bool|int False on failure, primary key (ug_id) value of newly
+        *   inserted row on success
+        */
+       public function insert( $allowUpdate = false, IDatabase $dbw = null ) {
+               if ( $dbw === null ) {
+                       $dbw = wfGetDB( DB_MASTER );
+               }
+
+               // Periodically purge old, expired memberships from the DB
+               self::purgeExpired( $dbw );
+
+               // Check that the values make sense
+               if ( $this->group === null ) {
+                       throw new MWException( 'Don\'t try inserting an 
uninitialized UserGroupMembership object' );
+               } elseif ( $this->userId <= 0 ) {
+                       throw new MWException( 'UserGroupMembership needs a 
positive user ID' );
+               }
+
+               $row = $this->getDatabaseArray();
+               $row['ug_id'] = $dbw->nextSequenceValue( 
'user_groups_ug_id_seq' );
+
+               $dbw->insert( 'user_groups', $row, __METHOD__, [ 'IGNORE' ] );
+               $affected = $dbw->affectedRows();
+               $this->id = intval( $dbw->insertId() );
+
+               // Don't collide with expired user group memberships
+               // Do this after trying to insert, in order to avoid locking
+               if ( !$affected ) {
+                       // Using SELECT + DELETE per T96428
+                       $conds = [
+                               'ug_user' => $row['ug_user'],
+                               'ug_group' => $row['ug_group'],
+                       ];
+                       $existingRow = $dbw->selectRow( 'user_groups', [ 
'ug_id', 'ug_expiry' ],
+                               $conds, __METHOD__ );
+                       if ( $existingRow ) {
+                               // if we're unconditionally updating, check 
that the expiry is not the same;
+                               // otherwise, only delete+insert if the expiry 
date is in the past
+                               if ( $allowUpdate ?
+                                       ( $existingRow->ug_expiry != 
$this->expiry ) :
+                                       ( $existingRow->ug_expiry < 
wfTimestampNow() )
+                               ) {
+                                       $dbw->delete( 'user_groups', [ 'ug_id' 
=> $existingRow->ug_id ], __METHOD__ );
+                                       $dbw->insert( 'user_groups', $row, 
__METHOD__, [ 'IGNORE' ] );
+                                       $affected = $dbw->affectedRows();
+                                       $this->id = intval( $dbw->insertId() );
+                               }
+                       }
+               }
+
+               return ( $affected ? $this->id : false );
+       }
+
+       /**
+        * Update a block in the DB with a new expiry date.
+        * The ID field needs to be loaded first.
+        *
+        * @return bool|int False on failure, primary key (ug_id) value of 
updated row
+        *   on success
+        */
+       public function update() {
+               $dbw = wfGetDB( DB_MASTER );
+
+               $dbw->update(
+                       'user_groups',
+                       $this->getDatabaseArray( $dbw ),
+                       [ 'ug_id' => $this->getId() ],
+                       __METHOD__
+               );
+
+               $affected = $dbw->affectedRows();
+               return ( $affected ? $this->id : false );
+       }
+
+       /**
+        * Get an array suitable for passing to $dbw->insert() or $dbw->update()
+        * @param IDatabase|null $db
+        * @return array
+        */
+       protected function getDatabaseArray( $db = null ) {
+               if ( !$db ) {
+                       $db = wfGetDB( DB_REPLICA );
+               }
+
+               return [
+                       'ug_user' => $this->userId,
+                       'ug_group' => $this->group,
+                       'ug_expiry' => $this->expiry ? $db->timestamp( 
$this->expiry ) : null,
+               ];
+       }
+
+       /**
+        * Check if this membership has expired. Delete it if it is.
+        * @return bool
+        */
+       public function deleteIfExpired() {
+               if ( $this->isExpired() ) {
+                       $this->delete();
+                       return true;
+               } else {
+                       return false;
+               }
+       }
+
+       /**
+        * Has the membership expired?
+        * @return bool
+        */
+       public function isExpired() {
+               if ( !$this->expiry ) {
+                       return false;
+               } else {
+                       return wfTimestampNow() > $this->expiry;
+               }
+       }
+
+       /**
+        * Purge expired memberships from the user_groups table
+        *
+        * @param IDatabase $dbw
+        */
+       public static function purgeExpired( IDatabase $dbw = null ) {
+               if ( wfReadOnly() ) {
+                       return;
+               }
+
+               if ( $dbw === null ) {
+                       $dbw = wfGetDB( DB_MASTER );
+               }
+
+               DeferredUpdates::addUpdate( new AtomicSectionUpdate(
+                       $dbw,
+                       __METHOD__,
+                       function ( IDatabase $dbw, $fname ) {
+                               $res = $dbw->selectFieldValues(
+                                       'user_groups',
+                                       'ug_id',
+                                       [ 'ug_expiry < ' . $dbw->addQuotes( 
$dbw->timestamp() ) ],
+                                       $fname
+                               );
+                               // create a light UserGroupMembership object 
just with ID
+                               $ugm = new self;
+                               foreach ( $res as $row ) {
+                                       $ugm->id = $row->ug_id;
+                                       $ugm->delete( $dbw );
+                               }
+                       }
+               ) );
+       }
+
+       /**
+        * Gets a HTML link to a user group, possibly including the expiry date 
if
+        * relevant.
+        *
+        * @param string|UserGroupMembership $ugm Either a group name as a 
string, or
+        *   a UserGroupMembership object
+        * @param IContextSource $context
+        * @param bool $isMember Whether to use the group member message 
("administrator")
+        *   instead of the group name message ("administrators")
+        * @param string $userName Name of the user who belongs to the group, 
for GENDER
+        *   of the group member message (only needed if $isMember is true)
+        * @return string
+        */
+       public static function getLinkHTML( $ugm, IContextSource $context, 
$isMember = false,
+               $userName = '' ) {
+
+               if ( $ugm instanceof UserGroupMembership ) {
+                       $expiry = $ugm->getExpiry();
+                       $group = $ugm->getGroup();
+               } else {
+                       $expiry = null;
+                       $group = $ugm;
+               }
+
+               if ( $isMember ) {
+                       $groupName = User::getGroupMember( $group, $userName );
+               } else {
+                       $groupName = User::getGroupName( $group );
+               }
+
+               if ( $expiry ) {
+                       // format the expiry to a nice string
+                       $uiLanguage = $context->getLanguage();
+                       $uiUser = $context->getUser();
+                       $expiry = $uiLanguage->userTimeAndDate( $expiry, 
$uiUser );
+                       $expiryD = $uiLanguage->userDate( $expiry, $uiUser );
+                       $expiryT = $uiLanguage->userTime( $expiry, $uiUser );
+                       return $context->msg( 
'group-membership-link-with-expiry' )->rawParams(
+                               User::makeGroupLinkHTML( $group, $groupName ) 
)->params( $expiry,
+                               $expiryD, $expiryT )->text();
+               } else {
+                       return User::makeGroupLinkHTML( $group, $groupName );
+               }
+       }
+
+       /**
+        * Returns UserGroupMembership objects for all the groups a user 
currently
+        * belongs to.
+        *
+        * @param int $userId ID of the user to search for
+        * @param IDatabase|null $db Optional database connection
+        * @return array Associative array of (group name => 
UserGroupMembership object)
+        */
+       public static function getMembershipsForUser( $userId, $db = null ) {
+               if ( !$db ) {
+                       $db = wfGetDB( DB_REPLICA );
+               }
+
+               $res = $db->select( 'user_groups',
+                       self::selectFields(),
+                       [ 'ug_user' => $userId ],
+                       __METHOD__ );
+
+               $ugms = [];
+               foreach ( $res as $row ) {
+                       $ugm = self::newFromRow( $row );
+                       if ( !$ugm->isExpired() ) {
+                               $ugms[$ugm->group] = $ugm;
+                       }
+               }
+
+               return $ugms;
+       }
+
+       /**
+        * Returns a UserGroupMembership object that pertains to the given user 
and group,
+        * or false if the user does not belong to that group (or the 
assignment has
+        * expired).
+        *
+        * @param int $userId ID of the user to search for
+        * @param string $group User group name
+        * @param IDatabase|null $db Optional database connection
+        * @return UserGroupMembership|false
+        */
+       public static function getMembership( $userId, $group, IDatabase $db = 
null ) {
+               if ( !$db ) {
+                       $db = wfGetDB( DB_REPLICA );
+               }
+
+               $row = $db->selectRow( 'user_groups',
+                       self::selectFields(),
+                       [ 'ug_user' => $userId, 'ug_group' => $group ],
+                       __METHOD__ );
+               if ( !$row ) {
+                       return false;
+               }
+
+               $ugm = self::newFromRow( $row );
+               if ( !$ugm->isExpired() ) {
+                       return $ugm;
+               } else {
+                       return false;
+               }
+       }
+}
diff --git a/includes/user/UserRightsProxy.php 
b/includes/user/UserRightsProxy.php
index 69bc503..722da08 100644
--- a/includes/user/UserRightsProxy.php
+++ b/includes/user/UserRightsProxy.php
@@ -210,38 +210,47 @@
        }
 
        /**
-        * Replaces User::addUserGroup()
-        * @param string $group
+        * Replaces User::getGroupMemberships()
         *
-        * @return bool
+        * @return array
+        * @since 1.29
         */
-       function addGroup( $group ) {
-               $this->db->insert( 'user_groups',
-                       [
-                               'ug_user' => $this->id,
-                               'ug_group' => $group,
-                       ],
-                       __METHOD__,
-                       [ 'IGNORE' ] );
-
-               return true;
+       function getGroupMemberships() {
+               $res = $this->db->select( 'user_groups',
+                       UserGroupMembership::selectFields(),
+                       [ 'ug_user' => $this->id ],
+                       __METHOD__ );
+               $ugms = [];
+               foreach ( $res as $row ) {
+                       $ugms[$row->ug_group] = 
UserGroupMembership::newFromRow( $row );
+               }
+               return $ugms;
        }
 
        /**
-        * Replaces User::removeUserGroup()
-        * @param string $group
+        * Replaces User::addGroup()
         *
+        * @param string $group
+        * @param string|null $expiry
+        * @return bool
+        */
+       function addGroup( $group, $expiry = null ) {
+               $ugm = new UserGroupMembership( $this->id, $group, $expiry );
+               return $ugm->insert( true, $this->db );
+       }
+
+       /**
+        * Replaces User::removeGroup()
+        *
+        * @param string $group
         * @return bool
         */
        function removeGroup( $group ) {
-               $this->db->delete( 'user_groups',
-                       [
-                               'ug_user' => $this->id,
-                               'ug_group' => $group,
-                       ],
-                       __METHOD__ );
-
-               return true;
+               $ugm = UserGroupMembership::getMembership( $this->id, $group, 
$this->db );
+               if ( !$ugm ) {
+                       return false;
+               }
+               return $ugm->delete( $this->db );
        }
 
        /**
diff --git a/languages/i18n/en.json b/languages/i18n/en.json
index 166140a..d9fbbce 100644
--- a/languages/i18n/en.json
+++ b/languages/i18n/en.json
@@ -1096,6 +1096,7 @@
        "username": "{{GENDER:$1|Username}}:",
        "prefs-memberingroups": "{{GENDER:$2|Member}} of 
{{PLURAL:$1|group|groups}}:",
        "prefs-memberingroups-type": "$1",
+       "group-membership-link-with-expiry": "$1 (until $2)",
        "prefs-registration": "Registration time:",
        "prefs-registration-date-time": "$1",
        "yourrealname": "Real name:",
@@ -1154,7 +1155,15 @@
        "userrights-nodatabase": "Database $1 does not exist or is not local.",
        "userrights-changeable-col": "Groups you can change",
        "userrights-unchangeable-col": "Groups you cannot change",
-       "userrights-irreversible-marker": "$1*",
+       "userrights-irreversible-marker": "*$1",
+       "userrights-expiry-current": "Expires $1",
+       "userrights-expiry-none": "Does not expire",
+       "userrights-expiry": "Expires:",
+       "userrights-expiry-existing": "Existing expiration time: $3, $2",
+       "userrights-expiry-othertime": "Other time:",
+       "userrights-expiry-options": "1 day:1 day,1 week:1 week,1 month:1 
month,3 months:3 months,6 months:6 months,1 year:1 year",
+       "userrights-invalid-expiry": "The expiry time for group \"$1\" is 
invalid.",
+       "userrights-expiry-in-past": "The expiry time for group \"$1\" is in 
the past.",
        "userrights-conflict": "Conflict of user rights changes! Please review 
and confirm your changes.",
        "group": "Group:",
        "group-user": "Users",
@@ -3921,6 +3930,7 @@
        "newuserlog-autocreate-entry": "Account created automatically",
        "rightslogentry": "changed group membership for $1 from $2 to $3",
        "rightslogentry-autopromote": "was automatically promoted from $2 to 
$3",
+       "rightslogentry-temporary-group": "$1 ([[foo|temporary]], until $2)",
        "feedback-adding": "Adding feedback to page...",
        "feedback-back": "Back",
        "feedback-bugcheck": "Great! Just check that it is not already one of 
the [$1 known bugs].",
diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json
index 15ac91a..9f8ce94 100644
--- a/languages/i18n/qqq.json
+++ b/languages/i18n/qqq.json
@@ -1280,6 +1280,7 @@
        "username": "Username field in [[Special:Preferences]]. $1 is the 
current user name for GENDER distinction (depends on sex 
setting).\n\n{{Identical|Username}}",
        "prefs-memberingroups": "This message is shown on 
[[Special:Preferences]], first tab.\n\nParameters:\n* $1 - number of user 
groups\n* $2 - the username for GENDER\nSee also:\n* 
{{msg-mw|Prefs-memberingroups-type}}",
        "prefs-memberingroups-type": "{{optional}}\nParameters:\n* $1 - list of 
group names\n* $2 - list of group member names. Label for these is 
{{msg-mw|Prefs-memberingroups}}",
+       "group-membership-link-with-expiry": "Used as part of a list of user 
groups, to show the time and date when a user's membership of a group expires. 
That is, they are a member of that group \"until\" the specified date and 
time.\n\nParameters:\n* $1 - group name\n* $2 - time and date of expiry\n* $3 - 
date of expiry\n* $4 - time of expiry",
        "prefs-registration": "Used in [[Special:Preferences]].",
        "prefs-registration-date-time": "{{optional}}\nUsed in 
[[Special:Preferences]]. Parameters are:\n* $1 date and time of registration\n* 
$2 date of registration\n* $3 time of registration",
        "yourrealname": "Used in [[Special:Preferences]], first 
tab.\n{{Identical|Real name}}",
@@ -1339,6 +1340,14 @@
        "userrights-changeable-col": "Used when editing user groups in 
[[Special:Userrights]].\n\nThe message is the head of a column of group 
assignments.\n\nParameters:\n* $1 - (Optional) for PLURAL use, the number of 
items in the column following the message. Avoid PLURAL, if your language can 
do without.",
        "userrights-unchangeable-col": "Used when editing user groups in 
[[Special:Userrights]]. The message is the head of a column of group 
assignments.\n\nParameters:\n* $1 - (Optional) for PLURAL use, the number of 
items in the column following the message. Avoid PLURAL, if your language 
allows that.",
        "userrights-irreversible-marker": "{{optional}}\nParameters:\n* $1 - 
group member",
+       "userrights-expiry-current": "Indicates when a user's membership of a 
user group expires.\n\nParameters:\n* $1 - time and date of expiry\n* $2 - date 
of expiry\n* $3 - time of expiry",
+       "userrights-expiry-none": "Indicates that a user's membership of a user 
group lasts indefinitely, and does not expire.",
+       "userrights-expiry": "Used as a label for a form element which can be 
used to select an expiry date/time.",
+       "userrights-expiry-existing": "See {{msg-mw|protect-existing-expiry}}",
+       "userrights-expiry-othertime": "{{Identical|Other time}}",
+       "userrights-expiry-options": "{{doc-important|Be careful: '''1 
translation:1 english''', so the first part is the translation and the second 
part should stay in English.}}\nOptions for the duration of the user group 
membership. Example: See e.g. [[MediaWiki:Userrights-expiry-options/nl]] if you 
still don't know how to do it.\n\nSee also {{msg-mw|protect-expiry-options}}.",
+       "userrights-invalid-expiry": "Error message on 
[[Special:UserRights]].\n\nParameters:\n* $1 - group name",
+       "userrights-expiry-in-past": "Error message on [[Special:UserRights]] 
when the user types an expiry date that has already passed.\n\nParameters:\n* 
$1 - group name",
        "userrights-conflict": "Shown on [[Special:UserRights]] if the target's 
rights have been changed since the form was loaded.",
        "group": "{{Identical|Group}}",
        "group-user": "{{doc-group|user}}\n{{Identical|User}}",
@@ -4105,6 +4114,7 @@
        "newuserlog-autocreate-entry": "This message is used in the 
[[:mw:Extension:Newuserlog|new user log]] to mark an account that was created 
by MediaWiki as part of a [[:mw:Extension:CentralAuth|CentralAuth]] global 
account.",
        "rightslogentry": "This message is displayed in the 
[[Special:Log/rights|User Rights Log]] when a bureaucrat changes the user 
groups for a user.\n\nParameters:\n* $1 - the username\n* $2 - list of user 
groups or {{msg-mw|Rightsnone}}\n* $3 - list of user groups or 
{{msg-mw|Rightsnone}}\n\nThe name of the bureaucrat who did this task appears 
before this message.\n\nSimilar to {{msg-mw|Gur-rightslog-entry}}",
        "rightslogentry-autopromote": "This message is displayed in the 
[[Special:Log/rights|User Rights Log]] when a user is automatically promoted to 
a user group.\n\nParameters:\n* $1 - (Unused)\n* $2 - a comma separated list of 
old user groups or {{msg-mw|Rightsnone}}\n* $3 - a comma separated list of new 
user groups",
+       "rightslogentry-temporary-group": "This message is displayed in the 
[[Special:Log/rights|User Rights Log]] to show that a user group has been 
allocated temporarily.\n\nParameters:\n* $1 - group name\n* $2 - date and time 
of expiry\n* $3 - date of expiry\n* $4 - time of expiry",
        "feedback-adding": "Progress notice",
        "feedback-back": "Button to go back to the previous action in the 
feedback dialog.\n{{Identical|Back}}",
        "feedback-bugcheck": "Message that appears before the user submits a 
bug, reminding them to check for known bugs.\n\nParameters:\n* $1 - bug list 
page URL",
diff --git a/maintenance/archives/patch-user_groups-id-expiry.sql 
b/maintenance/archives/patch-user_groups-id-expiry.sql
new file mode 100644
index 0000000..f69bd47
--- /dev/null
+++ b/maintenance/archives/patch-user_groups-id-expiry.sql
@@ -0,0 +1,7 @@
+-- Primary key and expiry column in user_groups table
+
+ALTER TABLE /*$wgDBprefix*/user_groups
+  ADD COLUMN ug_id int unsigned NOT NULL AUTO_INCREMENT FIRST,
+  ADD COLUMN ug_expiry varbinary(14) NULL default NULL,
+  ADD PRIMARY KEY (ug_id),
+  ADD INDEX ug_expiry (ug_expiry);
diff --git a/maintenance/sqlite/archives/patch-user_groups-id-expiry.sql 
b/maintenance/sqlite/archives/patch-user_groups-id-expiry.sql
new file mode 100644
index 0000000..3404c8a
--- /dev/null
+++ b/maintenance/sqlite/archives/patch-user_groups-id-expiry.sql
@@ -0,0 +1,22 @@
+DROP TABLE IF EXISTS /*_*/user_groups_tmp;
+
+CREATE TABLE /*$wgDBprefix*/user_groups_tmp (
+  ug_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+  ug_user int unsigned NOT NULL default 0,
+  ug_group varbinary(255) NOT NULL default '',
+  ug_expiry varbinary(14) NULL default NULL
+);
+
+INSERT OR IGNORE INTO /*_*/user_groups_tmp (
+    ug_user, ug_group )
+    SELECT
+    ug_user, ug_group
+    FROM /*_*/user_groups;
+
+DROP TABLE /*_*/user_groups;
+
+ALTER TABLE /*_*/user_groups_tmp RENAME TO /*_*/user_groups;
+
+CREATE UNIQUE INDEX /*i*/ug_user_group ON /*_*/user_groups (ug_user,ug_group);
+CREATE INDEX /*i*/ug_group ON /*_*/user_groups (ug_group);
+CREATE INDEX /*i*/ug_expiry ON /*_*/user_groups (ug_expiry);
diff --git a/maintenance/tables.sql b/maintenance/tables.sql
index 2b6ea03..efe7a3e 100644
--- a/maintenance/tables.sql
+++ b/maintenance/tables.sql
@@ -149,6 +149,9 @@
 -- comma-separated blob.
 --
 CREATE TABLE /*_*/user_groups (
+  -- Arbitrary primary key
+  ug_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+
   -- Key to user_id
   ug_user int unsigned NOT NULL default 0,
 
@@ -160,11 +163,16 @@
   -- with particular permissions. A user will have the combined
   -- permissions of any group they're explicitly in, plus
   -- the implicit '*' and 'user' groups.
-  ug_group varbinary(255) NOT NULL default ''
+  ug_group varbinary(255) NOT NULL default '',
+
+  -- Time at which the user group membership will expire. Set to
+  -- NULL for a non-expiring (infinite) membership.
+  ug_expiry varbinary(14) NULL default NULL
 ) /*$wgDBTableOptions*/;
 
 CREATE UNIQUE INDEX /*i*/ug_user_group ON /*_*/user_groups (ug_user,ug_group);
 CREATE INDEX /*i*/ug_group ON /*_*/user_groups (ug_group);
+CREATE INDEX /*i*/ug_expiry ON /*_*/user_groups (ug_expiry);
 
 -- Stores the groups the user has once belonged to.
 -- The user may still belong to these groups (check user_groups).
diff --git a/resources/Resources.php b/resources/Resources.php
index e8be528..940b92e 100644
--- a/resources/Resources.php
+++ b/resources/Resources.php
@@ -1919,6 +1919,7 @@
                ],
        ],
        'mediawiki.special.userrights' => [
+               'styles' => 
'resources/src/mediawiki.special/mediawiki.special.userrights.css',
                'scripts' => 
'resources/src/mediawiki.special/mediawiki.special.userrights.js',
                'dependencies' => [
                        'mediawiki.notification.convertmessagebox',
diff --git a/resources/src/mediawiki.special/mediawiki.special.userrights.css 
b/resources/src/mediawiki.special/mediawiki.special.userrights.css
new file mode 100644
index 0000000..a4b4087
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.userrights.css
@@ -0,0 +1,12 @@
+/*!
+ * Styling for Special:UserRights
+ */
+.mw-userrights-nested {
+       margin-left: 1.2em;
+}
+
+.mw-userrights-nested span {
+       margin-left: 0.3em;
+       display: inline-block;
+       vertical-align: middle;
+}
diff --git a/resources/src/mediawiki.special/mediawiki.special.userrights.js 
b/resources/src/mediawiki.special/mediawiki.special.userrights.js
index 0643988..3f864dd 100644
--- a/resources/src/mediawiki.special/mediawiki.special.userrights.js
+++ b/resources/src/mediawiki.special/mediawiki.special.userrights.js
@@ -1,8 +1,18 @@
 /*!
  * JavaScript for Special:UserRights
  */
-( function () {
+( function ( $ ) {
        var convertmessagebox = require( 
'mediawiki.notification.convertmessagebox' );
        // Replace successbox with notifications
        convertmessagebox();
-}() );
+
+       // Dynamically show/hide the expiry selection underneath each checkbox
+       $( '#mw-userrights-form2 input[type=checkbox]' ).on( 'change', function 
( e ) {
+               $( '#mw-userrights-nested-' + e.target.id ).toggle( 
e.target.checked );
+       } ).trigger( 'change' );
+
+       // Also dynamically show/hide the "other time" input under each dropdown
+       $( '.mw-userrights-nested select' ).on( 'change', function ( e ) {
+               $( e.target.parentNode ).find( 'input' ).toggle( $( e.target 
).val() === 'other' );
+       } ).trigger( 'change' );
+}( jQuery ) );

-- 
To view, visit https://gerrit.wikimedia.org/r/328377
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: newchange
Gerrit-Change-Id: I93c955dc7a970f78e32aa503c01c67da30971d1a
Gerrit-PatchSet: 1
Gerrit-Project: mediawiki/core
Gerrit-Branch: master
Gerrit-Owner: TTO <[email protected]>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to