diff options
Diffstat (limited to 'app/assets/javascripts/members/components')
20 files changed, 511 insertions, 237 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" |