summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/members
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-01-18 19:00:14 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2023-01-18 19:00:14 +0000
commit05f0ebba3a2c8ddf39e436f412dc2ab5bf1353b2 (patch)
tree11d0f2a6ec31c7793c184106cedc2ded3d9a2cc5 /app/assets/javascripts/members
parentec73467c23693d0db63a797d10194da9e72a74af (diff)
downloadgitlab-ce-05f0ebba3a2c8ddf39e436f412dc2ab5bf1353b2.tar.gz
Add latest changes from gitlab-org/gitlab@15-8-stable-eev15.8.0-rc42
Diffstat (limited to 'app/assets/javascripts/members')
-rw-r--r--app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue2
-rw-r--r--app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue1
-rw-r--r--app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue2
-rw-r--r--app/assets/javascripts/members/components/action_buttons/leave_button.vue40
-rw-r--r--app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue1
-rw-r--r--app/assets/javascripts/members/components/action_buttons/remove_member_button.vue31
-rw-r--r--app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue95
-rw-r--r--app/assets/javascripts/members/components/action_dropdowns/constants.js22
-rw-r--r--app/assets/javascripts/members/components/action_dropdowns/leave_group_dropdown_item.vue36
-rw-r--r--app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue86
-rw-r--r--app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue134
-rw-r--r--app/assets/javascripts/members/components/modals/leave_modal.vue63
-rw-r--r--app/assets/javascripts/members/components/modals/remove_member_modal.vue48
-rw-r--r--app/assets/javascripts/members/components/table/created_at.vue8
-rw-r--r--app/assets/javascripts/members/components/table/member_action_buttons.vue11
-rw-r--r--app/assets/javascripts/members/components/table/member_activity.vue38
-rw-r--r--app/assets/javascripts/members/components/table/member_source.vue41
-rw-r--r--app/assets/javascripts/members/components/table/members_table.vue42
-rw-r--r--app/assets/javascripts/members/components/table/members_table_cell.vue7
-rw-r--r--app/assets/javascripts/members/components/table/role_dropdown.vue40
-rw-r--r--app/assets/javascripts/members/constants.js17
-rw-r--r--app/assets/javascripts/members/guest_overage_confirm_action.js3
-rw-r--r--app/assets/javascripts/members/index.js6
-rw-r--r--app/assets/javascripts/members/utils.js6
24 files changed, 540 insertions, 240 deletions
diff --git a/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue
index f4893721b9e..164fed308ff 100644
--- a/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue
+++ b/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue
@@ -49,8 +49,6 @@ export default {
:message="message"
:title="s__('Member|Deny access')"
:is-access-request="true"
- icon="close"
- button-category="primary"
/>
</div>
</action-button-group>
diff --git a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue
index 112f722c632..90034f46e7c 100644
--- a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue
@@ -40,7 +40,6 @@ export default {
:title="$options.title"
:aria-label="$options.title"
icon="check"
- variant="confirm"
type="submit"
/>
</gl-form>
diff --git a/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue
index ab9abfd38c6..91062c222f4 100644
--- a/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue
+++ b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue
@@ -41,8 +41,6 @@ export default {
<remove-member-button
:member-id="member.id"
:message="message"
- icon="remove"
- button-category="primary"
:title="s__('Member|Revoke invite')"
is-invite
/>
diff --git a/app/assets/javascripts/members/components/action_buttons/leave_button.vue b/app/assets/javascripts/members/components/action_buttons/leave_button.vue
deleted file mode 100644
index f600a207b8d..00000000000
--- a/app/assets/javascripts/members/components/action_buttons/leave_button.vue
+++ /dev/null
@@ -1,40 +0,0 @@
-<script>
-import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
-import { LEAVE_MODAL_ID } from '../../constants';
-import LeaveModal from '../modals/leave_modal.vue';
-
-export default {
- name: 'LeaveButton',
- title: __('Leave'),
- modalId: LEAVE_MODAL_ID,
- components: {
- GlButton,
- LeaveModal,
- },
- directives: {
- GlModal: GlModalDirective,
- GlTooltip: GlTooltipDirective,
- },
- props: {
- member: {
- type: Object,
- required: true,
- },
- },
-};
-</script>
-
-<template>
- <div>
- <gl-button
- v-gl-tooltip.hover
- v-gl-modal="$options.modalId"
- :title="$options.title"
- :aria-label="$options.title"
- icon="leave"
- variant="danger"
- />
- <leave-modal :member="member" />
- </div>
-</template>
diff --git a/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue
index fef7940eaa2..24500fbe44d 100644
--- a/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue
@@ -32,7 +32,6 @@ export default {
<template>
<gl-button
v-gl-tooltip.hover
- variant="danger"
:title="$options.i18n.buttonTitle"
:aria-label="$options.i18n.buttonTitle"
icon="remove"
diff --git a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
index 27c67e84675..4b3bb89da55 100644
--- a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
@@ -14,34 +14,13 @@ export default {
type: Number,
required: true,
},
- memberType: {
- type: String,
- required: false,
- default: null,
- },
message: {
type: String,
required: true,
},
title: {
type: String,
- required: false,
- default: null,
- },
- icon: {
- type: String,
- required: false,
- default: undefined,
- },
- buttonText: {
- type: String,
- required: false,
- default: '',
- },
- buttonCategory: {
- type: String,
- required: false,
- default: 'secondary',
+ required: true,
},
isAccessRequest: {
type: Boolean,
@@ -70,7 +49,6 @@ export default {
isAccessRequest: this.isAccessRequest,
isInvite: this.isInvite,
memberPath: this.memberPath.replace(':id', this.memberId),
- memberType: this.memberType,
message: this.message,
userDeletionObstacles: this.userDeletionObstacles,
};
@@ -89,13 +67,10 @@ export default {
<template>
<gl-button
v-gl-tooltip
- variant="danger"
- :category="buttonCategory"
:title="title"
:aria-label="title"
- :icon="icon"
+ icon="remove"
data-qa-selector="delete_member_button"
@click="showRemoveMemberModal(modalData)"
- ><template v-if="buttonText">{{ buttonText }}</template></gl-button
- >
+ />
</template>
diff --git a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
deleted file mode 100644
index 122e0a142a9..00000000000
--- a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
+++ /dev/null
@@ -1,95 +0,0 @@
-<script>
-import { __, s__, sprintf } from '~/locale';
-import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils';
-import ActionButtonGroup from './action_button_group.vue';
-import LeaveButton from './leave_button.vue';
-import RemoveMemberButton from './remove_member_button.vue';
-
-export default {
- name: 'UserActionButtons',
- components: {
- ActionButtonGroup,
- RemoveMemberButton,
- LeaveButton,
- LdapOverrideButton: () =>
- import('ee_component/members/components/ldap/ldap_override_button.vue'),
- },
- props: {
- member: {
- type: Object,
- required: true,
- },
- isCurrentUser: {
- type: Boolean,
- required: true,
- },
- isInvitedUser: {
- type: Boolean,
- required: true,
- },
- permissions: {
- type: Object,
- required: true,
- },
- },
- computed: {
- message() {
- const { user, source } = this.member;
-
- if (user) {
- return sprintf(
- s__('Members|Are you sure you want to remove %{usersName} from "%{source}"?'),
- {
- usersName: user.name,
- source: source.fullName,
- },
- false,
- );
- }
-
- return sprintf(
- s__('Members|Are you sure you want to remove this orphaned member from "%{source}"?'),
- {
- source: source.fullName,
- },
- );
- },
- userDeletionObstaclesUserData() {
- return {
- name: this.member.user?.name,
- obstacles: parseUserDeletionObstacles(this.member.user),
- };
- },
- removeMemberButtonText() {
- return this.isInvitedUser ? null : __('Remove member');
- },
- removeMemberButtonIcon() {
- return this.isInvitedUser ? 'remove' : '';
- },
- removeMemberButtonCategory() {
- return this.isInvitedUser ? 'primary' : 'secondary';
- },
- },
-};
-</script>
-
-<template>
- <action-button-group>
- <div v-if="permissions.canRemove" class="gl-px-1">
- <leave-button v-if="isCurrentUser" :member="member" />
- <remove-member-button
- v-else
- :member-id="member.id"
- :member-type="member.type"
- :user-deletion-obstacles="userDeletionObstaclesUserData"
- :message="message"
- :icon="removeMemberButtonIcon"
- :button-text="removeMemberButtonText"
- :button-category="removeMemberButtonCategory"
- />
- </div>
- <div v-else-if="permissions.canOverride && !member.isOverridden" class="gl-px-1">
- <ldap-override-button :member="member" />
- </div>
- </action-button-group>
-</template>
diff --git a/app/assets/javascripts/members/components/action_dropdowns/constants.js b/app/assets/javascripts/members/components/action_dropdowns/constants.js
new file mode 100644
index 00000000000..8ccfc57dc28
--- /dev/null
+++ b/app/assets/javascripts/members/components/action_dropdowns/constants.js
@@ -0,0 +1,22 @@
+import { __, s__ } from '~/locale';
+
+export const I18N = {
+ actions: __('More actions'),
+ disableTwoFactor: s__('Members|Disable two-factor authentication'),
+ editPermissions: s__('Members|Edit permissions'),
+ leaveGroup: __('Leave group'),
+ removeMember: __('Remove member'),
+ confirmDisableTwoFactor: s__(
+ 'Members|Are you sure you want to disable the two-factor authentication for %{userName}?',
+ ),
+ confirmNormalUserRemoval: s__(
+ 'Members|Are you sure you want to remove %{userName} from "%{group}"?',
+ ),
+ confirmOrphanedUserRemoval: s__(
+ 'Members|Are you sure you want to remove this orphaned member from "%{group}"?',
+ ),
+ personalProjectOwnerCannotBeRemoved: s__("Members|A personal project's owner cannot be removed."),
+ lastGroupOwnerCannotBeRemoved: s__(
+ 'Members|A group must have at least one owner. To remove the member, assign a new owner.',
+ ),
+};
diff --git a/app/assets/javascripts/members/components/action_dropdowns/leave_group_dropdown_item.vue b/app/assets/javascripts/members/components/action_dropdowns/leave_group_dropdown_item.vue
new file mode 100644
index 00000000000..15606ad567c
--- /dev/null
+++ b/app/assets/javascripts/members/components/action_dropdowns/leave_group_dropdown_item.vue
@@ -0,0 +1,36 @@
+<script>
+import { GlDropdownItem, GlModalDirective } from '@gitlab/ui';
+import { LEAVE_MODAL_ID } from '../../constants';
+import LeaveModal from '../modals/leave_modal.vue';
+
+export default {
+ name: 'LeaveGroupDropdownItem',
+ modalId: LEAVE_MODAL_ID,
+ components: {
+ GlDropdownItem,
+ LeaveModal,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ permissions: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown-item v-gl-modal="$options.modalId">
+ <span class="gl-text-red-500">
+ <slot></slot>
+ </span>
+ <leave-modal :member="member" :permissions="permissions" />
+ </gl-dropdown-item>
+</template>
diff --git a/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue b/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue
new file mode 100644
index 00000000000..f224aaa31f7
--- /dev/null
+++ b/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue
@@ -0,0 +1,86 @@
+<script>
+import { GlDropdownItem } from '@gitlab/ui';
+import { mapActions, mapState } from 'vuex';
+
+export default {
+ name: 'RemoveMemberDropdownItem',
+ components: { GlDropdownItem },
+ inject: ['namespace'],
+ props: {
+ memberId: {
+ type: Number,
+ required: true,
+ },
+ /**
+ * `GroupMember` (`app/models/members/group_member.rb`)
+ * or
+ * `ProjectMember` (`app/models/members/project_member.rb`).
+ */
+ memberModelType: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ modalMessage: {
+ type: String,
+ required: true,
+ },
+ isAccessRequest: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isInvite: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ userDeletionObstacles: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ preventRemoval: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ ...mapState({
+ memberPath(state) {
+ return state[this.namespace].memberPath;
+ },
+ }),
+ modalData() {
+ return {
+ isAccessRequest: this.isAccessRequest,
+ isInvite: this.isInvite,
+ memberPath: this.memberPath.replace(':id', this.memberId),
+ memberModelType: this.memberModelType,
+ message: this.modalMessage,
+ userDeletionObstacles: this.userDeletionObstacles,
+ preventRemoval: this.preventRemoval,
+ };
+ },
+ },
+ methods: {
+ ...mapActions({
+ showRemoveMemberModal(dispatch, payload) {
+ return dispatch(`${this.namespace}/showRemoveMemberModal`, payload);
+ },
+ }),
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown-item
+ data-qa-selector="delete_member_dropdown_item"
+ @click="showRemoveMemberModal(modalData)"
+ >
+ <span class="gl-text-red-500">
+ <slot></slot>
+ </span>
+ </gl-dropdown-item>
+</template>
diff --git a/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue b/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue
new file mode 100644
index 00000000000..8f5c32956a2
--- /dev/null
+++ b/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue
@@ -0,0 +1,134 @@
+<script>
+import { GlDropdown, GlTooltipDirective } from '@gitlab/ui';
+import { sprintf } from '~/locale';
+import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils';
+import {
+ MEMBER_MODEL_TYPE_GROUP_MEMBER,
+ MEMBER_MODEL_TYPE_PROJECT_MEMBER,
+} from '~/members/constants';
+import { I18N } from './constants';
+import LeaveGroupDropdownItem from './leave_group_dropdown_item.vue';
+import RemoveMemberDropdownItem from './remove_member_dropdown_item.vue';
+
+export default {
+ name: 'UserActionDropdown',
+ i18n: I18N,
+ components: {
+ GlDropdown,
+ DisableTwoFactorDropdownItem: () =>
+ import(
+ 'ee_component/members/components/action_dropdowns/disable_two_factor_dropdown_item.vue'
+ ),
+ LdapOverrideDropdownItem: () =>
+ import('ee_component/members/components/ldap/ldap_override_dropdown_item.vue'),
+ LeaveGroupDropdownItem,
+ RemoveMemberDropdownItem,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ isCurrentUser: {
+ type: Boolean,
+ required: true,
+ },
+ permissions: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ modalDisableTwoFactor() {
+ const userName = this.member.user.username;
+ return sprintf(this.$options.i18n.confirmDisableTwoFactor, { userName }, false);
+ },
+ modalRemoveUser() {
+ const { user, source } = this.member;
+
+ if (this.permissions.canRemoveBlockedByLastOwner) {
+ if (this.member.type === MEMBER_MODEL_TYPE_PROJECT_MEMBER) {
+ return I18N.personalProjectOwnerCannotBeRemoved;
+ }
+
+ if (this.member.type === MEMBER_MODEL_TYPE_GROUP_MEMBER) {
+ return I18N.lastGroupOwnerCannotBeRemoved;
+ }
+ }
+
+ if (user) {
+ return sprintf(
+ this.$options.i18n.confirmNormalUserRemoval,
+ { userName: user.name, group: source.fullName },
+ false,
+ );
+ }
+
+ return sprintf(this.$options.i18n.confirmOrphanedUserRemoval, { group: source.fullName });
+ },
+ userDeletionObstaclesUserData() {
+ return {
+ name: this.member.user?.name,
+ obstacles: parseUserDeletionObstacles(this.member.user),
+ };
+ },
+ showDropdown() {
+ return (
+ this.permissions.canDisableTwoFactor || this.showLeaveOrRemove || this.showLdapOverride
+ );
+ },
+ showLeaveOrRemove() {
+ return this.permissions.canRemove || this.permissions.canRemoveBlockedByLastOwner;
+ },
+ showLdapOverride() {
+ return this.permissions.canOverride && !this.member.isOverridden;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ v-if="showDropdown"
+ v-gl-tooltip="$options.i18n.actions"
+ :text="$options.i18n.actions"
+ text-sr-only
+ icon="ellipsis_v"
+ category="tertiary"
+ no-caret
+ right
+ data-testid="user-action-dropdown"
+ data-qa-selector="user_action_dropdown"
+ >
+ <disable-two-factor-dropdown-item
+ v-if="permissions.canDisableTwoFactor"
+ :modal-message="modalDisableTwoFactor"
+ :user-id="member.user.id"
+ >
+ {{ $options.i18n.disableTwoFactor }}
+ </disable-two-factor-dropdown-item>
+
+ <template v-if="showLeaveOrRemove">
+ <leave-group-dropdown-item v-if="isCurrentUser" :member="member" :permissions="permissions">{{
+ $options.i18n.leaveGroup
+ }}</leave-group-dropdown-item>
+
+ <remove-member-dropdown-item
+ v-else
+ :member-id="member.id"
+ :member-model-type="member.type"
+ :user-deletion-obstacles="userDeletionObstaclesUserData"
+ :modal-message="modalRemoveUser"
+ :prevent-removal="permissions.canRemoveBlockedByLastOwner"
+ >{{ $options.i18n.removeMember }}</remove-member-dropdown-item
+ >
+ </template>
+
+ <ldap-override-dropdown-item v-else-if="showLdapOverride" :member="member">{{
+ $options.i18n.editPermissions
+ }}</ldap-override-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/members/components/modals/leave_modal.vue b/app/assets/javascripts/members/components/modals/leave_modal.vue
index e39669e17dd..8bc6aca9cc1 100644
--- a/app/assets/javascripts/members/components/modals/leave_modal.vue
+++ b/app/assets/javascripts/members/components/modals/leave_modal.vue
@@ -5,22 +5,30 @@ import csrf from '~/lib/utils/csrf';
import { __, s__, sprintf } from '~/locale';
import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils';
-import { LEAVE_MODAL_ID } from '../../constants';
+import {
+ LEAVE_MODAL_ID,
+ MEMBER_MODEL_TYPE_GROUP_MEMBER,
+ MEMBER_MODEL_TYPE_PROJECT_MEMBER,
+} from '../../constants';
export default {
name: 'LeaveModal',
actionCancel: {
text: __('Cancel'),
},
- actionPrimary: {
- text: __('Leave'),
- attributes: {
- variant: 'danger',
- },
- },
csrf,
modalId: LEAVE_MODAL_ID,
- modalContent: s__('Members|Are you sure you want to leave "%{source}"?'),
+ i18n: {
+ title: s__('Members|Leave "%{source}"'),
+ body: s__('Members|Are you sure you want to leave "%{source}"?'),
+ preventedTitle: s__('Members|Cannot leave "%{source}"'),
+ preventedBodyProjectMemberModelType: s__(
+ 'Members|You cannot remove yourself from a personal project.',
+ ),
+ preventedBodyGroupMemberModelType: s__(
+ 'Members|A group must have at least one owner. To leave this group, assign a new owner.',
+ ),
+ },
components: { GlModal, GlForm, GlSprintf, UserDeletionObstaclesList },
directives: {
GlTooltip: GlTooltipDirective,
@@ -31,6 +39,10 @@ export default {
type: Object,
required: true,
},
+ permissions: {
+ type: Object,
+ required: true,
+ },
},
computed: {
...mapState({
@@ -42,7 +54,35 @@ export default {
return this.memberPath.replace(/:id$/, 'leave');
},
modalTitle() {
- return sprintf(s__('Members|Leave "%{source}"'), { source: this.member.source.fullName });
+ return sprintf(
+ this.permissions.canRemoveBlockedByLastOwner
+ ? this.$options.i18n.preventedTitle
+ : this.$options.i18n.title,
+ { source: this.member.source.fullName },
+ );
+ },
+ preventedModalBody() {
+ if (this.member.type === MEMBER_MODEL_TYPE_PROJECT_MEMBER) {
+ return this.$options.i18n.preventedBodyProjectMemberModelType;
+ }
+
+ if (this.member.type === MEMBER_MODEL_TYPE_GROUP_MEMBER) {
+ return this.$options.i18n.preventedBodyGroupMemberModelType;
+ }
+
+ return null;
+ },
+ actionPrimary() {
+ if (this.permissions.canRemoveBlockedByLastOwner) {
+ return null;
+ }
+
+ return {
+ text: __('Leave'),
+ attributes: {
+ variant: 'danger',
+ },
+ };
},
obstacles() {
return parseUserDeletionObstacles(this.member.user);
@@ -64,13 +104,14 @@ export default {
v-bind="$attrs"
:modal-id="$options.modalId"
:title="modalTitle"
- :action-primary="$options.actionPrimary"
+ :action-primary="actionPrimary"
:action-cancel="$options.actionCancel"
@primary="handlePrimary"
>
<gl-form ref="form" :action="leavePath" method="post">
<p>
- <gl-sprintf :message="$options.modalContent">
+ <template v-if="permissions.canRemoveBlockedByLastOwner">{{ preventedModalBody }}</template>
+ <gl-sprintf v-else :message="$options.i18n.body">
<template #source>{{ member.source.fullName }}</template>
</gl-sprintf>
</p>
diff --git a/app/assets/javascripts/members/components/modals/remove_member_modal.vue b/app/assets/javascripts/members/components/modals/remove_member_modal.vue
index 1bb1f90302c..337379d8b4e 100644
--- a/app/assets/javascripts/members/components/modals/remove_member_modal.vue
+++ b/app/assets/javascripts/members/components/modals/remove_member_modal.vue
@@ -4,6 +4,7 @@ import { mapActions, mapState } from 'vuex';
import csrf from '~/lib/utils/csrf';
import { s__, __ } from '~/locale';
import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
+import { MEMBER_MODEL_TYPE_GROUP_MEMBER } from '../../constants';
export default {
actionCancel: {
@@ -27,8 +28,13 @@ export default {
memberPath(state) {
return state[this.namespace].removeMemberModalData.memberPath;
},
- memberType(state) {
- return state[this.namespace].removeMemberModalData.memberType;
+ /**
+ * `GroupMember` (`app/models/members/group_member.rb`)
+ * or
+ * `ProjectMember` (`app/models/members/project_member.rb`).
+ */
+ memberModelType(state) {
+ return state[this.namespace].removeMemberModalData.memberModelType;
},
message(state) {
return state[this.namespace].removeMemberModalData.message;
@@ -36,12 +42,15 @@ export default {
userDeletionObstacles(state) {
return state[this.namespace].removeMemberModalData.userDeletionObstacles ?? {};
},
+ preventRemoval(state) {
+ return state[this.namespace].removeMemberModalData.preventRemoval;
+ },
removeMemberModalVisible(state) {
return state[this.namespace].removeMemberModalVisible;
},
}),
isGroupMember() {
- return this.memberType === 'GroupMember';
+ return this.memberModelType === MEMBER_MODEL_TYPE_GROUP_MEMBER;
},
actionText() {
if (this.isAccessRequest) {
@@ -53,6 +62,10 @@ export default {
return __('Remove member');
},
actionPrimary() {
+ if (this.preventRemoval) {
+ return null;
+ }
+
return {
text: this.actionText,
attributes: {
@@ -95,21 +108,22 @@ export default {
>
<form ref="form" :action="memberPath" method="post">
<p>{{ message }}</p>
+ <template v-if="!preventRemoval">
+ <user-deletion-obstacles-list
+ v-if="hasObstaclesToUserDeletion"
+ :obstacles="userDeletionObstacles.obstacles"
+ :user-name="userDeletionObstacles.name"
+ />
- <user-deletion-obstacles-list
- v-if="hasObstaclesToUserDeletion"
- :obstacles="userDeletionObstacles.obstacles"
- :user-name="userDeletionObstacles.name"
- />
-
- <input ref="method" type="hidden" name="_method" value="delete" />
- <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
- <gl-form-checkbox v-if="isGroupMember" name="remove_sub_memberships">
- {{ __('Also remove direct user membership from subgroups and projects') }}
- </gl-form-checkbox>
- <gl-form-checkbox v-if="hasWorkspaceAccess" name="unassign_issuables">
- {{ __('Also unassign this user from related issues and merge requests') }}
- </gl-form-checkbox>
+ <input ref="method" type="hidden" name="_method" value="delete" />
+ <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
+ <gl-form-checkbox v-if="isGroupMember" name="remove_sub_memberships">
+ {{ __('Also remove direct user membership from subgroups and projects') }}
+ </gl-form-checkbox>
+ <gl-form-checkbox v-if="hasWorkspaceAccess" name="unassign_issuables">
+ {{ __('Also unassign this user from related issues and merge requests') }}
+ </gl-form-checkbox>
+ </template>
</form>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/members/components/table/created_at.vue b/app/assets/javascripts/members/components/table/created_at.vue
index 0bad70894f9..44d124ad0db 100644
--- a/app/assets/javascripts/members/components/table/created_at.vue
+++ b/app/assets/javascripts/members/components/table/created_at.vue
@@ -1,10 +1,10 @@
<script>
import { GlSprintf } from '@gitlab/ui';
-import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import UserDate from '~/vue_shared/components/user_date.vue';
export default {
name: 'CreatedAt',
- components: { GlSprintf, TimeAgoTooltip },
+ components: { GlSprintf, UserDate },
props: {
date: {
type: String,
@@ -29,12 +29,12 @@ export default {
<span>
<gl-sprintf v-if="showCreatedBy" :message="s__('Members|%{time} by %{user}')">
<template #time>
- <time-ago-tooltip :time="date" />
+ <user-date :date="date" />
</template>
<template #user>
<a :href="createdBy.webUrl">{{ createdBy.name }}</a>
</template>
</gl-sprintf>
- <time-ago-tooltip v-else :time="date" />
+ <user-date v-else :date="date" />
</span>
</template>
diff --git a/app/assets/javascripts/members/components/table/member_action_buttons.vue b/app/assets/javascripts/members/components/table/member_action_buttons.vue
index ecc2ed82ad0..6ec7be608ba 100644
--- a/app/assets/javascripts/members/components/table/member_action_buttons.vue
+++ b/app/assets/javascripts/members/components/table/member_action_buttons.vue
@@ -3,12 +3,12 @@ import { MEMBER_TYPES, EE_ACTION_BUTTONS } from 'ee_else_ce/members/constants';
import AccessRequestActionButtons from '../action_buttons/access_request_action_buttons.vue';
import GroupActionButtons from '../action_buttons/group_action_buttons.vue';
import InviteActionButtons from '../action_buttons/invite_action_buttons.vue';
-import UserActionButtons from '../action_buttons/user_action_buttons.vue';
+import UserActionDropdown from '../action_dropdowns/user_action_dropdown.vue';
export default {
name: 'MemberActionButtons',
components: {
- UserActionButtons,
+ UserActionDropdown,
GroupActionButtons,
InviteActionButtons,
AccessRequestActionButtons,
@@ -32,15 +32,11 @@ export default {
type: Boolean,
required: true,
},
- isInvitedUser: {
- type: Boolean,
- required: true,
- },
},
computed: {
actionButtonComponent() {
const dictionary = {
- [MEMBER_TYPES.user]: 'user-action-buttons',
+ [MEMBER_TYPES.user]: 'user-action-dropdown',
[MEMBER_TYPES.group]: 'group-action-buttons',
[MEMBER_TYPES.invite]: 'invite-action-buttons',
[MEMBER_TYPES.accessRequest]: 'access-request-action-buttons',
@@ -60,6 +56,5 @@ export default {
:member="member"
:permissions="permissions"
:is-current-user="isCurrentUser"
- :is-invited-user="isInvitedUser"
/>
</template>
diff --git a/app/assets/javascripts/members/components/table/member_activity.vue b/app/assets/javascripts/members/components/table/member_activity.vue
new file mode 100644
index 00000000000..3b223cb1afa
--- /dev/null
+++ b/app/assets/javascripts/members/components/table/member_activity.vue
@@ -0,0 +1,38 @@
+<script>
+import UserDate from '~/vue_shared/components/user_date.vue';
+
+export default {
+ components: { UserDate },
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ userCreated() {
+ return this.member.user?.createdAt;
+ },
+ lastActivity() {
+ return this.member.user?.lastActivityOn;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div v-if="userCreated">
+ <strong>{{ s__('Members|User created') }}:</strong>
+ <user-date :date="userCreated" />
+ </div>
+ <div v-if="member.createdAt">
+ <strong>{{ s__('Members|Access granted') }}:</strong>
+ <user-date :date="member.createdAt" />
+ </div>
+ <div v-if="lastActivity">
+ <strong>{{ s__('Members|Last activity') }}:</strong>
+ <user-date :date="lastActivity" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/members/components/table/member_source.vue b/app/assets/javascripts/members/components/table/member_source.vue
index 30fcbfcd3f8..ed1971d020b 100644
--- a/app/assets/javascripts/members/components/table/member_source.vue
+++ b/app/assets/javascripts/members/components/table/member_source.vue
@@ -1,11 +1,19 @@
<script>
-import { GlTooltipDirective } from '@gitlab/ui';
+import { GlSprintf, GlTooltipDirective } from '@gitlab/ui';
+import { s__, __ } from '~/locale';
export default {
name: 'MemberSource',
+ i18n: {
+ inherited: __('Inherited'),
+ directMember: __('Direct member'),
+ directMemberWithCreatedBy: s__('Members|Direct member by %{createdBy}'),
+ inheritedMemberWithCreatedBy: s__('Members|%{group} by %{createdBy}'),
+ },
directives: {
GlTooltip: GlTooltipDirective,
},
+ components: { GlSprintf },
props: {
memberSource: {
type: Object,
@@ -15,13 +23,40 @@ export default {
type: Boolean,
required: true,
},
+ createdBy: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ showCreatedBy() {
+ return this.createdBy?.name && this.createdBy?.webUrl;
+ },
+ messageWithCreatedBy() {
+ return this.isDirectMember
+ ? this.$options.i18n.directMemberWithCreatedBy
+ : this.$options.i18n.inheritedMemberWithCreatedBy;
+ },
},
};
</script>
<template>
- <span v-if="isDirectMember">{{ __('Direct member') }}</span>
- <a v-else v-gl-tooltip.hover :title="__('Inherited')" :href="memberSource.webUrl">{{
+ <span v-if="showCreatedBy">
+ <gl-sprintf :message="messageWithCreatedBy">
+ <template #group>
+ <a v-gl-tooltip.hover="$options.i18n.inherited" :href="memberSource.webUrl">{{
+ memberSource.fullName
+ }}</a>
+ </template>
+ <template #createdBy>
+ <a :href="createdBy.webUrl">{{ createdBy.name }}</a>
+ </template>
+ </gl-sprintf>
+ </span>
+ <span v-else-if="isDirectMember">{{ $options.i18n.directMember }}</span>
+ <a v-else v-gl-tooltip.hover="$options.i18n.inherited" :href="memberSource.webUrl">{{
memberSource.fullName
}}</a>
</template>
diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue
index 0512bc04085..8f03a298e63 100644
--- a/app/assets/javascripts/members/components/table/members_table.vue
+++ b/app/assets/javascripts/members/components/table/members_table.vue
@@ -2,14 +2,20 @@
import { GlTable, GlBadge, GlPagination } from '@gitlab/ui';
import { mapState } from 'vuex';
import MembersTableCell from 'ee_else_ce/members/components/table/members_table_cell.vue';
-import { canUnban, canOverride, canRemove, canResend, canUpdate } from 'ee_else_ce/members/utils';
+import {
+ canDisableTwoFactor,
+ canUnban,
+ canOverride,
+ canRemove,
+ canRemoveBlockedByLastOwner,
+ canResend,
+ canUpdate,
+} from 'ee_else_ce/members/utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
-import UserDate from '~/vue_shared/components/user_date.vue';
import {
FIELD_KEY_ACTIONS,
FIELDS,
ACTIVE_TAB_QUERY_PARAM_NAME,
- TAB_QUERY_PARAM_VALUES,
MEMBER_STATE_AWAITING,
MEMBER_STATE_ACTIVE,
USER_STATE_BLOCKED,
@@ -23,6 +29,7 @@ import ExpirationDatepicker from './expiration_datepicker.vue';
import MemberActionButtons from './member_action_buttons.vue';
import MemberAvatar from './member_avatar.vue';
import MemberSource from './member_source.vue';
+import MemberActivity from './member_activity.vue';
import RoleDropdown from './role_dropdown.vue';
export default {
@@ -40,11 +47,13 @@ export default {
RemoveGroupLinkModal,
RemoveMemberModal,
ExpirationDatepicker,
- UserDate,
+ MemberActivity,
+ DisableTwoFactorModal: () =>
+ import('ee_component/members/components/modals/disable_two_factor_modal.vue'),
LdapOverrideConfirmationModal: () =>
import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'),
},
- inject: ['namespace', 'currentUserId'],
+ inject: ['namespace', 'currentUserId', 'canManageMembers'],
props: {
tabQueryParamValue: {
type: String,
@@ -80,18 +89,17 @@ export default {
return paramName && currentPage && perPage && totalItems;
},
- isInvitedUser() {
- return this.tabQueryParamValue === TAB_QUERY_PARAM_VALUES.invite;
- },
},
methods: {
hasActionButtons(member) {
return (
canRemove(member) ||
+ canRemoveBlockedByLastOwner(member, this.canManageMembers) ||
canResend(member) ||
canUpdate(member, this.currentUserId) ||
canOverride(member) ||
- canUnban(member)
+ canUnban(member) ||
+ canDisableTwoFactor(member)
);
},
showField(field) {
@@ -249,7 +257,11 @@ export default {
<template #cell(source)="{ item: member }">
<members-table-cell #default="{ isDirectMember }" :member="member">
- <member-source :is-direct-member="isDirectMember" :member-source="member.source" />
+ <member-source
+ :is-direct-member="isDirectMember"
+ :member-source="member.source"
+ :created-by="member.createdBy"
+ />
</members-table-cell>
</template>
@@ -281,12 +293,8 @@ export default {
</members-table-cell>
</template>
- <template #cell(userCreatedAt)="{ item: member }">
- <user-date :date="member.user.createdAt" />
- </template>
-
- <template #cell(lastActivityOn)="{ item: member }">
- <user-date :date="member.user.lastActivityOn" />
+ <template #cell(activity)="{ item: member }">
+ <member-activity :member="member" />
</template>
<template #cell(actions)="{ item: member }">
@@ -294,7 +302,6 @@ export default {
<member-action-buttons
:member-type="memberType"
:is-current-user="isCurrentUser"
- :is-invited-user="isInvitedUser"
:permissions="permissions"
:member="member"
/>
@@ -317,6 +324,7 @@ export default {
:label-prev-page="__('Go to previous page')"
align="center"
/>
+ <disable-two-factor-modal />
<remove-group-link-modal />
<remove-member-modal />
<ldap-override-confirmation-modal />
diff --git a/app/assets/javascripts/members/components/table/members_table_cell.vue b/app/assets/javascripts/members/components/table/members_table_cell.vue
index 51eff428d63..407cbc55dd3 100644
--- a/app/assets/javascripts/members/components/table/members_table_cell.vue
+++ b/app/assets/javascripts/members/components/table/members_table_cell.vue
@@ -5,13 +5,14 @@ import {
isDirectMember,
isCurrentUser,
canRemove,
+ canRemoveBlockedByLastOwner,
canResend,
canUpdate,
} from '../../utils';
export default {
name: 'MembersTableCell',
- inject: ['currentUserId'],
+ inject: ['currentUserId', 'canManageMembers'],
props: {
member: {
type: Object,
@@ -45,6 +46,9 @@ export default {
isCurrentUser() {
return isCurrentUser(this.member, this.currentUserId);
},
+ canRemoveBlockedByLastOwner() {
+ return canRemoveBlockedByLastOwner(this.member, this.canManageMembers);
+ },
canRemove() {
return canRemove(this.member);
},
@@ -62,6 +66,7 @@ export default {
isCurrentUser: this.isCurrentUser,
permissions: {
canRemove: this.canRemove,
+ canRemoveBlockedByLastOwner: this.canRemoveBlockedByLastOwner,
canResend: this.canResend,
canUpdate: this.canUpdate,
},
diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue
index 6cd8bf57313..70808587d56 100644
--- a/app/assets/javascripts/members/components/table/role_dropdown.vue
+++ b/app/assets/javascripts/members/components/table/role_dropdown.vue
@@ -2,7 +2,9 @@
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { mapActions } from 'vuex';
+import * as Sentry from '@sentry/browser';
import { s__ } from '~/locale';
+import { guestOverageConfirmAction } from 'ee_else_ce/members/guest_overage_confirm_action';
export default {
name: 'RoleDropdown',
@@ -11,7 +13,7 @@ export default {
GlDropdownItem,
LdapDropdownItem: () => import('ee_component/members/components/ldap/ldap_dropdown_item.vue'),
},
- inject: ['namespace'],
+ inject: ['namespace', 'group'],
props: {
member: {
type: Object,
@@ -30,7 +32,7 @@ export default {
},
computed: {
disabled() {
- return this.busy || (this.permissions.canOverride && !this.member.isOverridden);
+ return this.permissions.canOverride && !this.member.isOverridden;
},
},
mounted() {
@@ -50,22 +52,45 @@ export default {
return dispatch(`${this.namespace}/updateMemberRole`, payload);
},
}),
- handleSelect(value, name) {
- if (value === this.member.accessLevel.integerValue) {
+ async handleOverageConfirm(currentRoleValue, newRoleValue, newRoleName) {
+ return guestOverageConfirmAction({
+ currentRoleValue,
+ newRoleValue,
+ newRoleName,
+ group: this.group,
+ memberId: this.member.id,
+ memberType: this.namespace,
+ });
+ },
+ async handleSelect(newRoleValue, newRoleName) {
+ const currentRoleValue = this.member.accessLevel.integerValue;
+ if (newRoleValue === currentRoleValue) {
return;
}
this.busy = true;
+ const confirmed = await this.handleOverageConfirm(
+ currentRoleValue,
+ newRoleValue,
+ newRoleName,
+ );
+ if (!confirmed) {
+ this.busy = false;
+ return;
+ }
+
this.updateMemberRole({
memberId: this.member.id,
- accessLevel: { integerValue: value, stringValue: name },
+ accessLevel: { integerValue: newRoleValue, stringValue: newRoleName },
})
.then(() => {
this.$toast.show(s__('Members|Role updated successfully.'));
- this.busy = false;
})
- .catch(() => {
+ .catch((error) => {
+ Sentry.captureException(error);
+ })
+ .finally(() => {
this.busy = false;
});
},
@@ -80,6 +105,7 @@ export default {
:text="member.accessLevel.stringValue"
:header-text="__('Change role')"
:disabled="disabled"
+ :loading="busy"
>
<gl-dropdown-item
v-for="(value, name) in member.validRoles"
diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js
index dab544c7cbc..68c5831db62 100644
--- a/app/assets/javascripts/members/constants.js
+++ b/app/assets/javascripts/members/constants.js
@@ -20,6 +20,7 @@ export const FIELD_KEY_MAX_ROLE = 'maxRole';
export const FIELD_KEY_USER_CREATED_AT = 'userCreatedAt';
export const FIELD_KEY_LAST_ACTIVITY_ON = 'lastActivityOn';
export const FIELD_KEY_EXPIRATION = 'expiration';
+export const FIELD_KEY_ACTIVITY = 'activity';
export const FIELD_KEY_LAST_SIGN_IN = 'lastSignIn';
export const FIELD_KEY_ACTIONS = 'actions';
@@ -41,8 +42,6 @@ export const FIELDS = [
{
key: FIELD_KEY_GRANTED,
label: __('Access granted'),
- thClass: 'col-meta',
- tdClass: 'col-meta',
sort: {
asc: 'last_joined',
desc: 'oldest_joined',
@@ -77,8 +76,14 @@ export const FIELDS = [
tdClass: 'col-expiration',
},
{
+ key: FIELD_KEY_ACTIVITY,
+ label: s__('Members|Activity'),
+ thClass: 'col-activity',
+ tdClass: 'col-activity',
+ },
+ {
key: FIELD_KEY_USER_CREATED_AT,
- label: __('Created on'),
+ label: s__('Members|User created'),
sort: {
asc: 'oldest_created_user',
desc: 'recent_created_user',
@@ -158,6 +163,12 @@ export const MEMBER_TYPES = {
accessRequest: 'accessRequest',
};
+// `app/models/members/group_member.rb`
+export const MEMBER_MODEL_TYPE_GROUP_MEMBER = 'GroupMember';
+
+// `app/models/members/project_member.rb`
+export const MEMBER_MODEL_TYPE_PROJECT_MEMBER = 'ProjectMember';
+
export const TAB_QUERY_PARAM_VALUES = {
group: 'groups',
invite: 'invited',
diff --git a/app/assets/javascripts/members/guest_overage_confirm_action.js b/app/assets/javascripts/members/guest_overage_confirm_action.js
new file mode 100644
index 00000000000..2205c3ad792
--- /dev/null
+++ b/app/assets/javascripts/members/guest_overage_confirm_action.js
@@ -0,0 +1,3 @@
+export const guestOverageConfirmAction = () => {
+ return true;
+};
diff --git a/app/assets/javascripts/members/index.js b/app/assets/javascripts/members/index.js
index 359239c5c0c..c7398127727 100644
--- a/app/assets/javascripts/members/index.js
+++ b/app/assets/javascripts/members/index.js
@@ -21,6 +21,8 @@ export const initMembersApp = (el, options) => {
canExportMembers,
canFilterByEnterprise,
exportCsvPath,
+ groupName,
+ groupPath,
...vuexStoreAttributes
} = parseDataAttributes(el);
@@ -66,6 +68,10 @@ export const initMembersApp = (el, options) => {
canFilterByEnterprise,
canExportMembers,
exportCsvPath,
+ group: {
+ name: groupName,
+ path: groupPath,
+ },
},
render: (createElement) => createElement('members-tabs'),
});
diff --git a/app/assets/javascripts/members/utils.js b/app/assets/javascripts/members/utils.js
index bf87ab53d36..09e4b5e8a6f 100644
--- a/app/assets/javascripts/members/utils.js
+++ b/app/assets/javascripts/members/utils.js
@@ -51,6 +51,9 @@ export const canRemove = (member) => {
return isDirectMember(member) && member.canRemove;
};
+export const canRemoveBlockedByLastOwner = (member, canManageMembers) =>
+ isDirectMember(member) && canManageMembers && member.isLastOwner;
+
export const canResend = (member) => {
return Boolean(member.invite?.canResend);
};
@@ -106,6 +109,9 @@ export const buildSortHref = ({
};
// Defined in `ee/app/assets/javascripts/members/utils.js`
+export const canDisableTwoFactor = () => false;
+
+// Defined in `ee/app/assets/javascripts/members/utils.js`
export const canOverride = () => false;
// Defined in `ee/app/assets/javascripts/members/utils.js`