Skip to content

Create guest overage confirmation component

What does this MR do and why?

GitLab subscriptions come with a certain number of user seats. For user roles, some roles will take up a seat, and some roles don't (for example a Guest role).

On the Manage -> Members page, a list of members is shown for the project/group. In the Max Role column, a user's role can be changed. When the role is changed from one that doesn't take up a seat to one that does, we check to see if this will cause a seat usage overage, and show a warning modal if it will:

Peek_2024-05-24_17-22

Currently, the warning modal is constructed purely using JS in these 2 files:

This is awkward to maintain because rather than using a Vue component, it creates one using pure Javascript. This MR refactors it into an actual Vue component, and also fixes several bugs (see comments for details). Note that this component is currently unused, this is some prerequisite work for a follow-up MR. Also note that the feature itself is also unused, the feature flag exists but defaults to false and is not enabled on production.

How to set up and validate locally

The component is currently unused and will be used in a future MR, so we will modify the current role dropdown to use it. Apply this patch first:

Patch
Index: ee/app/assets/javascripts/members/components/table/guest_overage_confirmation.vue
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/ee/app/assets/javascripts/members/components/table/guest_overage_confirmation.vue b/ee/app/assets/javascripts/members/components/table/guest_overage_confirmation.vue
--- a/ee/app/assets/javascripts/members/components/table/guest_overage_confirmation.vue	(revision 2147f5bdd20f93c8bef58a3e4df056c692dd5bea)
+++ b/ee/app/assets/javascripts/members/components/table/guest_overage_confirmation.vue	(date 1716628685698)
@@ -73,36 +73,42 @@
     // Check to see if changing the role would increase the seat usage and cause an overage, and if so, show a warning
     // modal. Otherwise, act as if the overage warning was accepted and emit the confirm event.
     async confirmOverage() {
-      if (this.shouldSkipConfirmationCheck) {
-        return this.emitConfirm();
-      }
+      try {
+        if (this.shouldSkipConfirmationCheck) {
+          this.emitConfirm();
+          return;
+        }
 
-      const response = await this.$apollo.query({
-        query: getBillableUserCountChanges,
-        fetchPolicy: fetchPolicies.NO_CACHE,
-        variables: {
-          fullPath: this.groupPath,
-          addGroupId: this.isGroup ? this.member.id : null,
-          addUserIds: this.isGroup ? null : [this.member.id],
-          addUserEmails: [],
-          role: ACCESS_LEVEL_LABELS[this.role.accessLevel].toUpperCase(),
-          memberRoleId: this.role.memberRoleId,
-        },
-      });
+        const response = await this.$apollo.query({
+          query: getBillableUserCountChanges,
+          fetchPolicy: fetchPolicies.NO_CACHE,
+          variables: {
+            fullPath: this.groupPath,
+            addGroupId: this.isGroup ? this.member.id : null,
+            addUserIds: this.isGroup ? null : [this.member.id],
+            addUserEmails: [],
+            role: ACCESS_LEVEL_LABELS[this.role.accessLevel].toUpperCase(),
+            memberRoleId: this.role.memberRoleId,
+          },
+        });
 
-      const { willIncreaseOverage, seatsInSubscription, newBillableUserCount } =
-        response?.data?.group?.gitlabSubscriptionsPreviewBillableUserChange || {};
-      // If the overage won't increase or if there's no subscription data, don't show the modal.
-      if (!willIncreaseOverage || isNil(seatsInSubscription) || isNil(newBillableUserCount)) {
-        return this.emitConfirm();
-      }
+        const { willIncreaseOverage, seatsInSubscription, newBillableUserCount } =
+          response?.data?.group?.gitlabSubscriptionsPreviewBillableUserChange || {};
+        // If the overage won't increase or if there's no subscription data, don't show the modal.
+        if (!willIncreaseOverage || isNil(seatsInSubscription) || isNil(newBillableUserCount)) {
+          // this.emitConfirm();
+          // return;
+        }
 
-      // Overage check is valid, set a bunch of values and show the modal.
-      this.groupName = response.data.group.name;
-      this.seatsInSubscription = seatsInSubscription;
-      this.newBillableUserCount = newBillableUserCount;
+        // Overage check is valid, set a bunch of values and show the modal.
+        this.groupName = response.data.group.name;
+        this.seatsInSubscription = seatsInSubscription;
+        this.newBillableUserCount = newBillableUserCount;
 
-      return this.$refs.modal.show();
+        this.$refs.modal.show();
+      } catch (error) {
+        this.$emit('error', error);
+      }
     },
     emitConfirm() {
       this.$emit('confirm');
Index: app/assets/javascripts/members/components/table/max_role.vue
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/app/assets/javascripts/members/components/table/max_role.vue b/app/assets/javascripts/members/components/table/max_role.vue
--- a/app/assets/javascripts/members/components/table/max_role.vue	(revision 2147f5bdd20f93c8bef58a3e4df056c692dd5bea)
+++ b/app/assets/javascripts/members/components/table/max_role.vue	(date 1716628646514)
@@ -10,9 +10,10 @@
   initialSelectedRole,
   handleMemberRoleUpdate,
 } from 'ee_else_ce/members/utils';
-import { ACCESS_LEVEL_LABELS } from '~/access_level/constants';
+import { ACCESS_LEVEL_LABELS, ACCESS_LEVEL_GUEST_INTEGER } from '~/access_level/constants';
 import { s__ } from '~/locale';
 import { logError } from '~/lib/logger';
+import { createAlert } from '~/alert';
 
 export default {
   components: {
@@ -22,6 +23,8 @@
       import('ee_component/members/components/action_dropdowns/ldap_dropdown_footer.vue'),
     ManageRolesDropdownFooter: () =>
       import('ee_component/members/components/action_dropdowns/manage_roles_dropdown_footer.vue'),
+    GuestOverageConfirmation: () =>
+      import('ee_else_ce/members/components/table/guest_overage_confirmation.vue'),
   },
   directives: {
     GlTooltip: GlTooltipDirective,
@@ -46,12 +49,23 @@
       isDesktop: false,
       memberRoleId: this.member.accessLevel.memberRoleId ?? null,
       selectedRole: initialSelectedRole(accessLevelOptions.flatten, this.member),
+      previousRole: null,
+      previousMemberRoleId: null,
     };
   },
   computed: {
     disabled() {
       return this.permissions.canOverride && !this.member.isOverridden;
     },
+    currentRole() {
+      const role = this.accessLevelOptions.flatten.find((item) => item.value === this.selectedRole);
+
+      if (!role.memberRoleId && !role.occupiesSeat) {
+        role.occupiesSeat = role.accessLevel > ACCESS_LEVEL_GUEST_INTEGER;
+      }
+
+      return role;
+    },
   },
   mounted() {
     this.isDesktop = bp.isDesktop();
@@ -68,49 +82,46 @@
     }),
     async handleSelect(value) {
       this.busy = true;
-
-      const newRole = this.accessLevelOptions.flatten.find((item) => item.value === value);
-      const previousRole = this.selectedRole;
-      const previousMemberRoleId = this.memberRoleId;
-
-      try {
-        const confirmed = await guestOverageConfirmAction({
-          oldAccessLevel: this.member.accessLevel.integerValue,
-          newRoleName: ACCESS_LEVEL_LABELS[newRole.accessLevel],
-          newMemberRoleId: newRole.memberRoleId,
-          group: this.group,
-          memberId: this.member.id,
-          memberType: this.namespace,
-        });
-        if (!confirmed) {
-          return;
-        }
-
-        this.selectedRole = value;
-        this.memberRoleId = newRole.memberRoleId;
-
+      this.previousRole = this.selectedRole;
+      this.previousMemberRoleId = this.currentRole.memberRoleId;
+      this.selectedRole = value;
+      await this.$nextTick();
+      this.$refs.overage.confirmOverage();
+    },
+    async updateRole() {
+      try {
         const response = await this.updateMemberRole({
           memberId: this.member.id,
-          accessLevel: newRole.accessLevel,
-          memberRoleId: newRole.memberRoleId,
+          accessLevel: this.currentRole.accessLevel,
+          memberRoleId: this.currentRole.memberRoleId,
         });
 
         // EE has a flow where role change is not immediate but goes through an approval process.
         // In that case we should restore previously selected user role
         this.selectedRole = handleMemberRoleUpdate({
-          currentRole: previousRole,
-          requestedRole: newRole.value,
+          currentRole: this.previousRole,
+          requestedRole: this.selectedRole,
           response,
         });
+
+        this.member.usingLicense = this.currentRole.occupiesSeat;
       } catch (error) {
-        this.selectedRole = previousRole;
-        this.memberRoleId = previousMemberRoleId;
+        this.cancelUpdate();
         logError(error);
         Sentry.captureException(error);
       } finally {
         this.busy = false;
       }
     },
+    cancelUpdate() {
+      this.busy = false;
+      this.selectedRole = this.previousRole;
+      this.previousMemberRoleId = this.memberRoleId;
+    },
+    handleError({ message }) {
+      createAlert({ message });
+      this.cancelUpdate();
+    },
   },
   i18n: {
     customRole: s__('MemberRole|Custom role'),
@@ -120,6 +131,15 @@
 
 <template>
   <div>
+    <guest-overage-confirmation
+      ref="overage"
+      :group-path="group.path"
+      :member="member"
+      :role="currentRole"
+      @confirm="updateRole"
+      @cancel="cancelUpdate"
+      @error="handleError"
+    />
     <gl-collapsible-listbox
       v-if="permissions.canUpdate"
       :placement="isDesktop ? 'left' : 'right'"
Index: ee/app/assets/javascripts/members/utils.js
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/ee/app/assets/javascripts/members/utils.js b/ee/app/assets/javascripts/members/utils.js
--- a/ee/app/assets/javascripts/members/utils.js	(revision 2147f5bdd20f93c8bef58a3e4df056c692dd5bea)
+++ b/ee/app/assets/javascripts/members/utils.js	(date 1716623394666)
@@ -62,12 +62,13 @@
   const { flatten: staticRoleDropdownItems } = CERoleDropdownItems({ validRoles });
 
   const customRoleDropdownItems = customRoles.map(
-    ({ baseAccessLevel, name, memberRoleId, description }) => ({
+    ({ baseAccessLevel, name, memberRoleId, description, occupiesSeat }) => ({
       accessLevel: baseAccessLevel,
       memberRoleId,
       text: name,
       value: uniqueId('role-custom-'),
       description,
+      occupiesSeat,
     }),
   );

Then follow this video walkthrough (with audio commentary):

2024-05-24_23-22-24

These steps below are for reference:

  1. Enable the :show_overage_on_role_promotion feature flag:
echo "Feature.enable(:show_overage_on_role_promotion)" | rails c
  1. Enable SAAS mode, then restart GDK:
export GITLAB_SIMULATE_SAAS=1
gdk restart
  1. Go to Admin Area -> Settings -> General -> expand Account and limits -> enable Allow use of licensed EE features -> Save changes.

  2. Go to Admin Area -> Overview -> Groups. Click on Edit for a group of your choice in the list. On the edit page, change the Plan to Ultimate, then click on Save changes at the bottom.

  3. Go to the group's Settings -> Roles and Permissions page. Click on New role and use it to create 2 new roles: one with a base role of Guest with only Read code permission (doesn't take up a seat), and another role with a base role of your choice and any other permission (takes up a seat).

  4. Go to the group's Manage -> Members page. You should see that several users have the Is using seat badge.

  5. For one of the users that has the Is using seat badge, change their role to Guest. Verify that you do not get the warning modal.

  6. Change the role now to a standard role higher than Guest. Verify that you do get the warning modal.

  7. Click Cancel in the modal. Verify that the role is still Guest and not changed. Try it again, but this time click Continue with overages. Verify that the role is changed, and the Is using seat badge is shown again.

  8. Repeat the above steps, but with the Guest custom role and the other custom role that you created.

  9. Open ee/app/assets/javascripts/members/components/table/guest_overage_confirmation.vue and throw an error just inside the try block:

async confirmCoverage() {
  try {
    throw new Error('some error');
    ...
  1. Try changing the user's role and verify that an error is shown.

Related to #456282

Edited by Daniel Tian

Merge request reports