diff options
Diffstat (limited to 'app/assets/javascripts/members')
35 files changed, 1939 insertions, 0 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 new file mode 100644 index 00000000000..10078d5cd64 --- /dev/null +++ b/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue @@ -0,0 +1,59 @@ +<script> +import ActionButtonGroup from './action_button_group.vue'; +import RemoveMemberButton from './remove_member_button.vue'; +import ApproveAccessRequestButton from './approve_access_request_button.vue'; +import { s__, sprintf } from '~/locale'; + +export default { + name: 'AccessRequestActionButtons', + components: { ActionButtonGroup, RemoveMemberButton, ApproveAccessRequestButton }, + props: { + member: { + type: Object, + required: true, + }, + permissions: { + type: Object, + required: true, + }, + isCurrentUser: { + type: Boolean, + required: true, + }, + }, + computed: { + message() { + const { user, source } = this.member; + + if (this.isCurrentUser) { + return sprintf( + s__('Members|Are you sure you want to withdraw your access request for "%{source}"'), + { source: source.name }, + ); + } + + return sprintf( + s__('Members|Are you sure you want to deny %{usersName}\'s request to join "%{source}"'), + { usersName: user.name, source: source.name }, + ); + }, + }, +}; +</script> + +<template> + <action-button-group> + <div v-if="permissions.canUpdate" class="gl-px-1"> + <approve-access-request-button :member-id="member.id" /> + </div> + <div v-if="permissions.canRemove" class="gl-px-1"> + <remove-member-button + :member-id="member.id" + :message="message" + :title="s__('Member|Deny access')" + :is-access-request="true" + icon="close" + /> + </div> + </action-button-group> +</template> diff --git a/app/assets/javascripts/members/components/action_buttons/action_button_group.vue b/app/assets/javascripts/members/components/action_buttons/action_button_group.vue new file mode 100644 index 00000000000..8356fdb60b1 --- /dev/null +++ b/app/assets/javascripts/members/components/action_buttons/action_button_group.vue @@ -0,0 +1,11 @@ +<script> +export default { + name: 'ActionButtonGroup', +}; +</script> + +<template> + <div class="gl-display-flex gl-flex-align-items-center gl-justify-content-end gl-mx-n1"> + <slot></slot> + </div> +</template> 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 new file mode 100644 index 00000000000..e8a53ff173d --- /dev/null +++ b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue @@ -0,0 +1,42 @@ +<script> +import { mapState } from 'vuex'; +import { GlButton, GlForm, GlTooltipDirective } from '@gitlab/ui'; +import csrf from '~/lib/utils/csrf'; +import { __ } from '~/locale'; + +export default { + name: 'ApproveAccessRequestButton', + csrf, + title: __('Grant access'), + components: { GlButton, GlForm }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + memberId: { + type: Number, + required: true, + }, + }, + computed: { + ...mapState(['memberPath']), + approvePath() { + return this.memberPath.replace(/:id$/, `${this.memberId}/approve_access_request`); + }, + }, +}; +</script> + +<template> + <gl-form :action="approvePath" method="post"> + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + <gl-button + v-gl-tooltip.hover + :title="$options.title" + :aria-label="$options.title" + icon="check" + variant="success" + type="submit" + /> + </gl-form> +</template> diff --git a/app/assets/javascripts/members/components/action_buttons/group_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/group_action_buttons.vue new file mode 100644 index 00000000000..2aebfe80db5 --- /dev/null +++ b/app/assets/javascripts/members/components/action_buttons/group_action_buttons.vue @@ -0,0 +1,27 @@ +<script> +import ActionButtonGroup from './action_button_group.vue'; +import RemoveGroupLinkButton from './remove_group_link_button.vue'; + +export default { + name: 'GroupActionButtons', + components: { ActionButtonGroup, RemoveGroupLinkButton }, + props: { + member: { + type: Object, + required: true, + }, + permissions: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <action-button-group> + <div v-if="permissions.canRemove" class="gl-px-1"> + <remove-group-link-button :group-link="member" /> + </div> + </action-button-group> +</template> 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 new file mode 100644 index 00000000000..2b0a75640e2 --- /dev/null +++ b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue @@ -0,0 +1,48 @@ +<script> +import ActionButtonGroup from './action_button_group.vue'; +import RemoveMemberButton from './remove_member_button.vue'; +import ResendInviteButton from './resend_invite_button.vue'; +import { s__, sprintf } from '~/locale'; + +export default { + name: 'InviteActionButtons', + components: { ActionButtonGroup, RemoveMemberButton, ResendInviteButton }, + props: { + member: { + type: Object, + required: true, + }, + permissions: { + type: Object, + required: true, + }, + }, + computed: { + message() { + const { invite, source } = this.member; + + return sprintf( + s__( + 'Members|Are you sure you want to revoke the invitation for %{inviteEmail} to join "%{source}"', + ), + { inviteEmail: invite.email, source: source.name }, + ); + }, + }, +}; +</script> + +<template> + <action-button-group> + <div v-if="permissions.canResend" class="gl-px-1"> + <resend-invite-button :member-id="member.id" /> + </div> + <div v-if="permissions.canRemove" class="gl-px-1"> + <remove-member-button + :member-id="member.id" + :message="message" + :title="s__('Member|Revoke invite')" + /> + </div> + </action-button-group> +</template> diff --git a/app/assets/javascripts/members/components/action_buttons/leave_button.vue b/app/assets/javascripts/members/components/action_buttons/leave_button.vue new file mode 100644 index 00000000000..443a962e0cf --- /dev/null +++ b/app/assets/javascripts/members/components/action_buttons/leave_button.vue @@ -0,0 +1,40 @@ +<script> +import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import LeaveModal from '../modals/leave_modal.vue'; +import { LEAVE_MODAL_ID } from '../../constants'; + +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 new file mode 100644 index 00000000000..9d89cb40676 --- /dev/null +++ b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue @@ -0,0 +1,36 @@ +<script> +import { mapActions } from 'vuex'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + name: 'RemoveGroupLinkButton', + i18n: { + buttonTitle: s__('Members|Remove group'), + }, + components: { GlButton }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + groupLink: { + type: Object, + required: true, + }, + }, + methods: { + ...mapActions(['showRemoveGroupLinkModal']), + }, +}; +</script> + +<template> + <gl-button + v-gl-tooltip.hover + variant="danger" + :title="$options.i18n.buttonTitle" + :aria-label="$options.i18n.buttonTitle" + icon="remove" + @click="showRemoveGroupLinkModal(groupLink)" + /> +</template> 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 new file mode 100644 index 00000000000..b0b7ff4ce9a --- /dev/null +++ b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue @@ -0,0 +1,57 @@ +<script> +import { mapState } from 'vuex'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; + +export default { + name: 'RemoveMemberButton', + components: { GlButton }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + memberId: { + type: Number, + required: true, + }, + message: { + type: String, + required: true, + }, + title: { + type: String, + required: true, + }, + icon: { + type: String, + required: false, + default: 'remove', + }, + isAccessRequest: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + ...mapState(['memberPath']), + computedMemberPath() { + return this.memberPath.replace(':id', this.memberId); + }, + }, +}; +</script> + +<template> + <gl-button + v-gl-tooltip.hover + class="js-remove-member-button" + variant="danger" + :title="title" + :aria-label="title" + :icon="icon" + :data-member-path="computedMemberPath" + :data-is-access-request="isAccessRequest" + :data-message="message" + data-qa-selector="delete_member_button" + /> +</template> diff --git a/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue b/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue new file mode 100644 index 00000000000..1cc3fd17e98 --- /dev/null +++ b/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue @@ -0,0 +1,41 @@ +<script> +import { mapState } from 'vuex'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import csrf from '~/lib/utils/csrf'; +import { __ } from '~/locale'; + +export default { + name: 'ResendInviteButton', + csrf, + title: __('Resend invite'), + components: { GlButton }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + memberId: { + type: Number, + required: true, + }, + }, + computed: { + ...mapState(['memberPath']), + resendPath() { + return this.memberPath.replace(/:id$/, `${this.memberId}/resend_invite`); + }, + }, +}; +</script> + +<template> + <form :action="resendPath" method="post"> + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + <gl-button + v-gl-tooltip.hover + :title="$options.title" + :aria-label="$options.title" + icon="paper-airplane" + type="submit" + /> + </form> +</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 new file mode 100644 index 00000000000..f2bc9c7e876 --- /dev/null +++ b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue @@ -0,0 +1,70 @@ +<script> +import ActionButtonGroup from './action_button_group.vue'; +import RemoveMemberButton from './remove_member_button.vue'; +import LeaveButton from './leave_button.vue'; +import { s__, sprintf } from '~/locale'; + +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, + }, + 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.name, + }, + ); + } + + return sprintf( + s__('Members|Are you sure you want to remove this orphaned member from "%{source}"'), + { + source: source.name, + }, + ); + }, + }, +}; +</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" + :message="message" + :title="s__('Member|Remove member')" + /> + </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/avatars/group_avatar.vue b/app/assets/javascripts/members/components/avatars/group_avatar.vue new file mode 100644 index 00000000000..3b176bf2b43 --- /dev/null +++ b/app/assets/javascripts/members/components/avatars/group_avatar.vue @@ -0,0 +1,34 @@ +<script> +import { GlAvatarLink, GlAvatarLabeled } from '@gitlab/ui'; +import { AVATAR_SIZE } from '../../constants'; + +export default { + name: 'GroupAvatar', + avatarSize: AVATAR_SIZE, + components: { GlAvatarLink, GlAvatarLabeled }, + props: { + member: { + type: Object, + required: true, + }, + }, + computed: { + group() { + return this.member.sharedWithGroup; + }, + }, +}; +</script> + +<template> + <gl-avatar-link :href="group.webUrl"> + <gl-avatar-labeled + :label="group.fullName" + :src="group.avatarUrl" + :alt="group.fullName" + :size="$options.avatarSize" + :entity-name="group.name" + :entity-id="group.id" + /> + </gl-avatar-link> +</template> diff --git a/app/assets/javascripts/members/components/avatars/invite_avatar.vue b/app/assets/javascripts/members/components/avatars/invite_avatar.vue new file mode 100644 index 00000000000..08e702007bb --- /dev/null +++ b/app/assets/javascripts/members/components/avatars/invite_avatar.vue @@ -0,0 +1,32 @@ +<script> +import { GlAvatarLabeled } from '@gitlab/ui'; +import { AVATAR_SIZE } from '../../constants'; + +export default { + name: 'InviteAvatar', + avatarSize: AVATAR_SIZE, + components: { GlAvatarLabeled }, + props: { + member: { + type: Object, + required: true, + }, + }, + computed: { + invite() { + return this.member.invite; + }, + }, +}; +</script> + +<template> + <gl-avatar-labeled + :label="invite.email" + :src="invite.avatarUrl" + :alt="invite.email" + :size="$options.avatarSize" + :entity-name="invite.email" + :entity-id="member.id" + /> +</template> diff --git a/app/assets/javascripts/members/components/avatars/user_avatar.vue b/app/assets/javascripts/members/components/avatars/user_avatar.vue new file mode 100644 index 00000000000..fe45ca769af --- /dev/null +++ b/app/assets/javascripts/members/components/avatars/user_avatar.vue @@ -0,0 +1,91 @@ +<script> +import { + GlAvatarLink, + GlAvatarLabeled, + GlBadge, + GlSafeHtmlDirective as SafeHtml, +} from '@gitlab/ui'; +import { generateBadges } from 'ee_else_ce/members/utils'; +import { __ } from '~/locale'; +import { AVATAR_SIZE } from '../../constants'; +import { glEmojiTag } from '~/emoji'; + +export default { + name: 'UserAvatar', + avatarSize: AVATAR_SIZE, + orphanedUserLabel: __('Orphaned member'), + safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, + components: { + GlAvatarLink, + GlAvatarLabeled, + GlBadge, + }, + directives: { + SafeHtml, + }, + props: { + member: { + type: Object, + required: true, + }, + isCurrentUser: { + type: Boolean, + required: true, + }, + }, + computed: { + user() { + return this.member.user; + }, + badges() { + return generateBadges(this.member, this.isCurrentUser).filter(badge => badge.show); + }, + statusEmoji() { + return this.user?.status?.emoji; + }, + }, + methods: { + glEmojiTag, + }, +}; +</script> + +<template> + <gl-avatar-link + v-if="user" + class="js-user-link" + :href="user.webUrl" + :data-user-id="user.id" + :data-username="user.username" + > + <gl-avatar-labeled + :label="user.name" + :sub-label="`@${user.username}`" + :src="user.avatarUrl" + :alt="user.name" + :size="$options.avatarSize" + :entity-name="user.name" + :entity-id="user.id" + > + <template #meta> + <div v-if="statusEmoji" class="gl-p-1"> + <span v-safe-html:[$options.safeHtmlConfig]="glEmojiTag(statusEmoji)"></span> + </div> + <div v-for="badge in badges" :key="badge.text" class="gl-p-1"> + <gl-badge size="sm" :variant="badge.variant"> + {{ badge.text }} + </gl-badge> + </div> + </template> + </gl-avatar-labeled> + </gl-avatar-link> + + <gl-avatar-labeled + v-else + :label="$options.orphanedUserLabel" + :alt="$options.orphanedUserLabel" + :size="$options.avatarSize" + :entity-name="$options.orphanedUserLabel" + :entity-id="member.id" + /> +</template> diff --git a/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue b/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue new file mode 100644 index 00000000000..f869ecd392f --- /dev/null +++ b/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue @@ -0,0 +1,26 @@ +<script> +import { mapState } from 'vuex'; +import MembersFilteredSearchBar from './members_filtered_search_bar.vue'; +import SortDropdown from './sort_dropdown.vue'; + +export default { + name: 'FilterSortContainer', + components: { MembersFilteredSearchBar, SortDropdown }, + computed: { + ...mapState(['filteredSearchBar', 'tableSortableFields']), + showContainer() { + return this.filteredSearchBar.show || this.showSortDropdown; + }, + showSortDropdown() { + return this.tableSortableFields.length; + }, + }, +}; +</script> + +<template> + <div v-if="showContainer" class="gl-bg-gray-10 gl-p-3 gl-display-md-flex"> + <members-filtered-search-bar v-if="filteredSearchBar.show" class="gl-p-3 gl-flex-grow-1" /> + <sort-dropdown v-if="showSortDropdown" class="gl-p-3 gl-flex-shrink-0" /> + </div> +</template> diff --git a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue new file mode 100644 index 00000000000..c1df0b94234 --- /dev/null +++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue @@ -0,0 +1,132 @@ +<script> +import { mapState } from 'vuex'; +import { GlFilteredSearchToken } from '@gitlab/ui'; +import { setUrlParams, queryToObject } from '~/lib/utils/url_utility'; +import { getParameterByName } from '~/lib/utils/common_utils'; +import { s__ } from '~/locale'; +import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import { SEARCH_TOKEN_TYPE, SORT_PARAM } from '~/members/constants'; + +export default { + name: 'MembersFilteredSearchBar', + components: { FilteredSearchBar }, + availableTokens: [ + { + type: 'two_factor', + icon: 'lock', + title: s__('Members|2FA'), + token: GlFilteredSearchToken, + unique: true, + operators: [{ value: '=', description: 'is' }], + options: [ + { value: 'enabled', title: s__('Members|Enabled') }, + { value: 'disabled', title: s__('Members|Disabled') }, + ], + requiredPermissions: 'canManageMembers', + }, + { + type: 'with_inherited_permissions', + icon: 'group', + title: s__('Members|Membership'), + token: GlFilteredSearchToken, + unique: true, + operators: [{ value: '=', description: 'is' }], + options: [ + { value: 'exclude', title: s__('Members|Direct') }, + { value: 'only', title: s__('Members|Inherited') }, + ], + }, + ], + data() { + return { + initialFilterValue: [], + }; + }, + computed: { + ...mapState(['sourceId', 'filteredSearchBar', 'canManageMembers']), + tokens() { + return this.$options.availableTokens.filter(token => { + if ( + Object.prototype.hasOwnProperty.call(token, 'requiredPermissions') && + !this[token.requiredPermissions] + ) { + return false; + } + + return this.filteredSearchBar.tokens?.includes(token.type); + }); + }, + }, + created() { + const query = queryToObject(window.location.search); + + const tokens = this.tokens + .filter(token => query[token.type]) + .map(token => ({ + type: token.type, + value: { + data: query[token.type], + operator: '=', + }, + })); + + if (query[this.filteredSearchBar.searchParam]) { + tokens.push({ + type: SEARCH_TOKEN_TYPE, + value: { + data: query[this.filteredSearchBar.searchParam], + }, + }); + } + + this.initialFilterValue = tokens; + }, + methods: { + handleFilter(tokens) { + const params = tokens.reduce((accumulator, token) => { + const { type, value } = token; + + if (!type || !value) { + return accumulator; + } + + if (type === SEARCH_TOKEN_TYPE) { + if (value.data !== '') { + return { + ...accumulator, + [this.filteredSearchBar.searchParam]: value.data, + }; + } + } else { + return { + ...accumulator, + [type]: value.data, + }; + } + + return accumulator; + }, {}); + + const sortParam = getParameterByName(SORT_PARAM); + + window.location.href = setUrlParams( + { ...params, ...(sortParam && { sort: sortParam }) }, + window.location.href, + true, + ); + }, + }, +}; +</script> + +<template> + <filtered-search-bar + :namespace="sourceId.toString()" + :tokens="tokens" + :recent-searches-storage-key="filteredSearchBar.recentSearchesStorageKey" + :search-input-placeholder="filteredSearchBar.placeholder" + :initial-filter-value="initialFilterValue" + data-testid="members-filtered-search-bar" + @onFilter="handleFilter" + /> +</template> diff --git a/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue b/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue new file mode 100644 index 00000000000..de7fbc4241c --- /dev/null +++ b/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue @@ -0,0 +1,77 @@ +<script> +import { mapState } from 'vuex'; +import { GlSorting, GlSortingItem } from '@gitlab/ui'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { parseSortParam, buildSortHref } from '~/members/utils'; +import { FIELDS } from '~/members/constants'; + +export default { + name: 'SortDropdown', + components: { GlSorting, GlSortingItem }, + computed: { + ...mapState(['tableSortableFields', 'filteredSearchBar']), + sort() { + return parseSortParam(this.tableSortableFields); + }, + activeOption() { + return FIELDS.find(field => field.key === this.sort.sortByKey); + }, + activeOptionLabel() { + return this.activeOption?.label; + }, + isAscending() { + return !this.sort.sortDesc; + }, + filteredOptions() { + return FIELDS.filter(field => this.tableSortableFields.includes(field.key) && field.sort).map( + field => ({ + key: field.key, + label: field.label, + href: buildSortHref({ + sortBy: field.key, + sortDesc: false, + filteredSearchBarTokens: this.filteredSearchBar.tokens, + filteredSearchBarSearchParam: this.filteredSearchBar.searchParam, + }), + }), + ); + }, + }, + methods: { + isActive(key) { + return this.activeOption.key === key; + }, + handleSortDirectionChange() { + visitUrl( + buildSortHref({ + sortBy: this.activeOption.key, + sortDesc: !this.sort.sortDesc, + filteredSearchBarTokens: this.filteredSearchBar.tokens, + filteredSearchBarSearchParam: this.filteredSearchBar.searchParam, + }), + ); + }, + }, +}; +</script> + +<template> + <gl-sorting + class="gl-display-flex" + dropdown-class="gl-w-full" + data-testid="members-sort-dropdown" + :text="activeOptionLabel" + :is-ascending="isAscending" + :sort-direction-tool-tip="__('Sort direction')" + @sortDirectionChange="handleSortDirectionChange" + > + <gl-sorting-item + v-for="option in filteredOptions" + :key="option.key" + :href="option.href" + :active="isActive(option.key)" + > + {{ option.label }} + </gl-sorting-item> + </gl-sorting> +</template> diff --git a/app/assets/javascripts/members/components/modals/leave_modal.vue b/app/assets/javascripts/members/components/modals/leave_modal.vue new file mode 100644 index 00000000000..57a5da774e3 --- /dev/null +++ b/app/assets/javascripts/members/components/modals/leave_modal.vue @@ -0,0 +1,70 @@ +<script> +import { mapState } from 'vuex'; +import { GlModal, GlForm, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +import csrf from '~/lib/utils/csrf'; +import { __, s__, sprintf } from '~/locale'; +import { LEAVE_MODAL_ID } 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}"?'), + components: { GlModal, GlForm, GlSprintf }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + member: { + type: Object, + required: true, + }, + }, + computed: { + ...mapState(['memberPath']), + leavePath() { + return this.memberPath.replace(/:id$/, 'leave'); + }, + modalTitle() { + return sprintf(s__('Members|Leave "%{source}"'), { source: this.member.source.name }); + }, + }, + methods: { + handlePrimary() { + this.$refs.form.$el.submit(); + }, + }, +}; +</script> + +<template> + <gl-modal + v-bind="$attrs" + :modal-id="$options.modalId" + :title="modalTitle" + :action-primary="$options.actionPrimary" + :action-cancel="$options.actionCancel" + size="sm" + @primary="handlePrimary" + > + <gl-form ref="form" :action="leavePath" method="post"> + <p> + <gl-sprintf :message="$options.modalContent"> + <template #source>{{ member.source.name }}</template> + </gl-sprintf> + </p> + + <input type="hidden" name="_method" value="delete" /> + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + </gl-form> + </gl-modal> +</template> diff --git a/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue new file mode 100644 index 00000000000..231d014a4ec --- /dev/null +++ b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue @@ -0,0 +1,69 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { GlModal, GlSprintf, GlForm } from '@gitlab/ui'; +import csrf from '~/lib/utils/csrf'; +import { __, s__, sprintf } from '~/locale'; +import { REMOVE_GROUP_LINK_MODAL_ID } from '../../constants'; + +export default { + name: 'RemoveGroupLinkModal', + actionCancel: { + text: __('Cancel'), + }, + actionPrimary: { + text: s__('Members|Remove group'), + attributes: { + variant: 'danger', + }, + }, + csrf, + i18n: { + modalBody: s__('Members|Are you sure you want to remove "%{groupName}"?'), + }, + modalId: REMOVE_GROUP_LINK_MODAL_ID, + components: { GlModal, GlSprintf, GlForm }, + computed: { + ...mapState(['memberPath', 'groupLinkToRemove', 'removeGroupLinkModalVisible']), + groupLinkPath() { + return this.memberPath.replace(/:id$/, this.groupLinkToRemove?.id); + }, + groupName() { + return this.groupLinkToRemove?.sharedWithGroup.fullName; + }, + modalTitle() { + return sprintf(s__('Members|Remove "%{groupName}"'), { groupName: this.groupName }); + }, + }, + methods: { + ...mapActions(['hideRemoveGroupLinkModal']), + handlePrimary() { + this.$refs.form.$el.submit(); + }, + }, +}; +</script> + +<template> + <gl-modal + v-bind="$attrs" + :modal-id="$options.modalId" + :visible="removeGroupLinkModalVisible" + :title="modalTitle" + :action-primary="$options.actionPrimary" + :action-cancel="$options.actionCancel" + size="sm" + @primary="handlePrimary" + @hide="hideRemoveGroupLinkModal" + > + <gl-form ref="form" :action="groupLinkPath" method="post"> + <p> + <gl-sprintf :message="$options.i18n.modalBody"> + <template #groupName>{{ groupName }}</template> + </gl-sprintf> + </p> + + <input type="hidden" name="_method" value="delete" /> + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + </gl-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 new file mode 100644 index 00000000000..0bad70894f9 --- /dev/null +++ b/app/assets/javascripts/members/components/table/created_at.vue @@ -0,0 +1,40 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + name: 'CreatedAt', + components: { GlSprintf, TimeAgoTooltip }, + props: { + date: { + type: String, + required: false, + default: null, + }, + createdBy: { + type: Object, + required: false, + default: null, + }, + }, + computed: { + showCreatedBy() { + return this.createdBy?.name && this.createdBy?.webUrl; + }, + }, +}; +</script> + +<template> + <span> + <gl-sprintf v-if="showCreatedBy" :message="s__('Members|%{time} by %{user}')"> + <template #time> + <time-ago-tooltip :time="date" /> + </template> + <template #user> + <a :href="createdBy.webUrl">{{ createdBy.name }}</a> + </template> + </gl-sprintf> + <time-ago-tooltip v-else :time="date" /> + </span> +</template> diff --git a/app/assets/javascripts/members/components/table/expiration_datepicker.vue b/app/assets/javascripts/members/components/table/expiration_datepicker.vue new file mode 100644 index 00000000000..0a8af81c1d1 --- /dev/null +++ b/app/assets/javascripts/members/components/table/expiration_datepicker.vue @@ -0,0 +1,99 @@ +<script> +import { GlDatepicker } from '@gitlab/ui'; +import { mapActions } from 'vuex'; +import { getDateInFuture } from '~/lib/utils/datetime_utility'; +import { s__ } from '~/locale'; + +export default { + name: 'ExpirationDatepicker', + components: { GlDatepicker }, + props: { + member: { + type: Object, + required: true, + }, + permissions: { + type: Object, + required: true, + }, + }, + data() { + return { + selectedDate: null, + busy: false, + }; + }, + computed: { + minDate() { + // Members expire at the beginning of the day. + // The first selectable day should be tomorrow. + const today = new Date(); + const beginningOfToday = new Date(today.setHours(0, 0, 0, 0)); + + return getDateInFuture(beginningOfToday, 1); + }, + disabled() { + return ( + this.busy || + !this.permissions.canUpdate || + (this.permissions.canOverride && !this.member.isOverridden) + ); + }, + }, + mounted() { + if (this.member.expiresAt) { + this.selectedDate = new Date(this.member.expiresAt); + } + }, + methods: { + ...mapActions(['updateMemberExpiration']), + handleInput(date) { + this.busy = true; + this.updateMemberExpiration({ + memberId: this.member.id, + expiresAt: date, + }) + .then(() => { + this.$toast.show(s__('Members|Expiration date updated successfully.')); + this.busy = false; + }) + .catch(() => { + this.busy = false; + }); + }, + handleClear() { + this.busy = true; + + this.updateMemberExpiration({ + memberId: this.member.id, + expiresAt: null, + }) + .then(() => { + this.$toast.show(s__('Members|Expiration date removed successfully.')); + this.selectedDate = null; + this.busy = false; + }) + .catch(() => { + this.busy = false; + }); + }, + }, +}; +</script> + +<template> + <!-- `:target="null"` allows the datepicker to be opened on focus --> + <!-- `:container="null"` renders the datepicker in the body to prevent conflicting CSS table styles --> + <gl-datepicker + v-model="selectedDate" + class="gl-max-w-full" + show-clear-button + :target="null" + :container="null" + :min-date="minDate" + :placeholder="__('Expiration date')" + :disabled="disabled" + @input="handleInput" + @clear="handleClear" + /> +</template> diff --git a/app/assets/javascripts/members/components/table/expires_at.vue b/app/assets/javascripts/members/components/table/expires_at.vue new file mode 100644 index 00000000000..c91de061b50 --- /dev/null +++ b/app/assets/javascripts/members/components/table/expires_at.vue @@ -0,0 +1,66 @@ +<script> +import { GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +import { + approximateDuration, + differenceInSeconds, + formatDate, + getDayDifference, +} from '~/lib/utils/datetime_utility'; +import { DAYS_TO_EXPIRE_SOON } from '../../constants'; + +export default { + name: 'ExpiresAt', + components: { GlSprintf }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + date: { + type: String, + required: false, + default: null, + }, + }, + computed: { + noExpirationSet() { + return this.date === null; + }, + parsed() { + return new Date(this.date); + }, + differenceInSeconds() { + return differenceInSeconds(new Date(), this.parsed); + }, + isExpired() { + return this.differenceInSeconds <= 0; + }, + inWords() { + return approximateDuration(this.differenceInSeconds); + }, + formatted() { + return formatDate(this.parsed); + }, + expiresSoon() { + return getDayDifference(new Date(), this.parsed) < DAYS_TO_EXPIRE_SOON; + }, + cssClass() { + return { + 'gl-text-red-500': this.isExpired, + 'gl-text-orange-500': this.expiresSoon, + }; + }, + }, +}; +</script> + +<template> + <span v-if="noExpirationSet">{{ s__('Members|No expiration set') }}</span> + <span v-else v-gl-tooltip.hover :title="formatted" :class="cssClass"> + <template v-if="isExpired">{{ s__('Members|Expired') }}</template> + <gl-sprintf v-else :message="s__('Members|in %{time}')"> + <template #time> + {{ inWords }} + </template> + </gl-sprintf> + </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 new file mode 100644 index 00000000000..c61ebec33bd --- /dev/null +++ b/app/assets/javascripts/members/components/table/member_action_buttons.vue @@ -0,0 +1,57 @@ +<script> +import UserActionButtons from '../action_buttons/user_action_buttons.vue'; +import GroupActionButtons from '../action_buttons/group_action_buttons.vue'; +import InviteActionButtons from '../action_buttons/invite_action_buttons.vue'; +import AccessRequestActionButtons from '../action_buttons/access_request_action_buttons.vue'; +import { MEMBER_TYPES } from '../../constants'; + +export default { + name: 'MemberActionButtons', + components: { + UserActionButtons, + GroupActionButtons, + InviteActionButtons, + AccessRequestActionButtons, + }, + props: { + member: { + type: Object, + required: true, + }, + memberType: { + type: String, + required: true, + }, + permissions: { + type: Object, + required: true, + }, + isCurrentUser: { + type: Boolean, + required: true, + }, + }, + computed: { + actionButtonComponent() { + const dictionary = { + [MEMBER_TYPES.user]: 'user-action-buttons', + [MEMBER_TYPES.group]: 'group-action-buttons', + [MEMBER_TYPES.invite]: 'invite-action-buttons', + [MEMBER_TYPES.accessRequest]: 'access-request-action-buttons', + }; + + return dictionary[this.memberType]; + }, + }, +}; +</script> + +<template> + <component + :is="actionButtonComponent" + v-if="actionButtonComponent" + :member="member" + :permissions="permissions" + :is-current-user="isCurrentUser" + /> +</template> diff --git a/app/assets/javascripts/members/components/table/member_avatar.vue b/app/assets/javascripts/members/components/table/member_avatar.vue new file mode 100644 index 00000000000..a1f98d4008a --- /dev/null +++ b/app/assets/javascripts/members/components/table/member_avatar.vue @@ -0,0 +1,35 @@ +<script> +import { kebabCase } from 'lodash'; +import UserAvatar from '../avatars/user_avatar.vue'; +import InviteAvatar from '../avatars/invite_avatar.vue'; +import GroupAvatar from '../avatars/group_avatar.vue'; + +export default { + name: 'MemberAvatar', + components: { UserAvatar, InviteAvatar, GroupAvatar, AccessRequestAvatar: UserAvatar }, + props: { + memberType: { + type: String, + required: true, + }, + isCurrentUser: { + type: Boolean, + required: true, + }, + member: { + type: Object, + required: true, + }, + }, + computed: { + avatarComponent() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `${kebabCase(this.memberType)}-avatar`; + }, + }, +}; +</script> + +<template> + <component :is="avatarComponent" :member="member" :is-current-user="isCurrentUser" /> +</template> diff --git a/app/assets/javascripts/members/components/table/member_source.vue b/app/assets/javascripts/members/components/table/member_source.vue new file mode 100644 index 00000000000..030d72c3420 --- /dev/null +++ b/app/assets/javascripts/members/components/table/member_source.vue @@ -0,0 +1,27 @@ +<script> +import { GlTooltipDirective } from '@gitlab/ui'; + +export default { + name: 'MemberSource', + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + memberSource: { + type: Object, + required: true, + }, + isDirectMember: { + type: Boolean, + required: true, + }, + }, +}; +</script> + +<template> + <span v-if="isDirectMember">{{ __('Direct member') }}</span> + <a v-else v-gl-tooltip.hover :title="__('Inherited')" :href="memberSource.webUrl">{{ + memberSource.name + }}</a> +</template> diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue new file mode 100644 index 00000000000..da77e5caad2 --- /dev/null +++ b/app/assets/javascripts/members/components/table/members_table.vue @@ -0,0 +1,151 @@ +<script> +import { mapState } from 'vuex'; +import { GlTable, GlBadge } from '@gitlab/ui'; +import MembersTableCell from 'ee_else_ce/members/components/table/members_table_cell.vue'; +import { canOverride, canRemove, canResend, canUpdate } from 'ee_else_ce/members/utils'; +import { FIELDS } from '../../constants'; +import initUserPopovers from '~/user_popovers'; +import MemberAvatar from './member_avatar.vue'; +import MemberSource from './member_source.vue'; +import CreatedAt from './created_at.vue'; +import ExpiresAt from './expires_at.vue'; +import MemberActionButtons from './member_action_buttons.vue'; +import RoleDropdown from './role_dropdown.vue'; +import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue'; +import ExpirationDatepicker from './expiration_datepicker.vue'; + +export default { + name: 'MembersTable', + components: { + GlTable, + GlBadge, + MemberAvatar, + CreatedAt, + ExpiresAt, + MembersTableCell, + MemberSource, + MemberActionButtons, + RoleDropdown, + RemoveGroupLinkModal, + ExpirationDatepicker, + LdapOverrideConfirmationModal: () => + import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'), + }, + computed: { + ...mapState(['members', 'tableFields', 'tableAttrs', 'currentUserId', 'sourceId']), + filteredFields() { + return FIELDS.filter(field => this.tableFields.includes(field.key) && this.showField(field)); + }, + userIsLoggedIn() { + return this.currentUserId !== null; + }, + }, + mounted() { + initUserPopovers(this.$el.querySelectorAll('.js-user-link')); + }, + methods: { + showField(field) { + if (!Object.prototype.hasOwnProperty.call(field, 'showFunction')) { + return true; + } + + return this[field.showFunction](); + }, + showActionsField() { + if (!this.userIsLoggedIn) { + return false; + } + + return this.members.some(member => { + return ( + canRemove(member, this.sourceId) || + canResend(member) || + canUpdate(member, this.currentUserId, this.sourceId) || + canOverride(member) + ); + }); + }, + }, +}; +</script> + +<template> + <div> + <gl-table + v-bind="tableAttrs.table" + class="members-table" + data-testid="members-table" + head-variant="white" + stacked="lg" + :fields="filteredFields" + :items="members" + primary-key="id" + thead-class="border-bottom" + :empty-text="__('No members found')" + show-empty + :tbody-tr-attr="tableAttrs.tr" + > + <template #cell(account)="{ item: member }"> + <members-table-cell #default="{ memberType, isCurrentUser }" :member="member"> + <member-avatar + :member-type="memberType" + :is-current-user="isCurrentUser" + :member="member" + /> + </members-table-cell> + </template> + + <template #cell(source)="{ item: member }"> + <members-table-cell #default="{ isDirectMember }" :member="member"> + <member-source :is-direct-member="isDirectMember" :member-source="member.source" /> + </members-table-cell> + </template> + + <template #cell(granted)="{ item: { createdAt, createdBy } }"> + <created-at :date="createdAt" :created-by="createdBy" /> + </template> + + <template #cell(invited)="{ item: { createdAt, createdBy } }"> + <created-at :date="createdAt" :created-by="createdBy" /> + </template> + + <template #cell(requested)="{ item: { createdAt } }"> + <created-at :date="createdAt" /> + </template> + + <template #cell(expires)="{ item: { expiresAt } }"> + <expires-at :date="expiresAt" /> + </template> + + <template #cell(maxRole)="{ item: member }"> + <members-table-cell #default="{ permissions }" :member="member"> + <role-dropdown v-if="permissions.canUpdate" :permissions="permissions" :member="member" /> + <gl-badge v-else>{{ member.accessLevel.stringValue }}</gl-badge> + </members-table-cell> + </template> + + <template #cell(expiration)="{ item: member }"> + <members-table-cell #default="{ permissions }" :member="member"> + <expiration-datepicker :permissions="permissions" :member="member" /> + </members-table-cell> + </template> + + <template #cell(actions)="{ item: member }"> + <members-table-cell #default="{ memberType, isCurrentUser, permissions }" :member="member"> + <member-action-buttons + :member-type="memberType" + :is-current-user="isCurrentUser" + :permissions="permissions" + :member="member" + /> + </members-table-cell> + </template> + + <template #head(actions)="{ label }"> + <span data-testid="col-actions" class="gl-sr-only">{{ label }}</span> + </template> + </gl-table> + <remove-group-link-modal /> + <ldap-override-confirmation-modal /> + </div> +</template> diff --git a/app/assets/javascripts/members/components/table/members_table_cell.vue b/app/assets/javascripts/members/components/table/members_table_cell.vue new file mode 100644 index 00000000000..20aa01b96bc --- /dev/null +++ b/app/assets/javascripts/members/components/table/members_table_cell.vue @@ -0,0 +1,72 @@ +<script> +import { mapState } from 'vuex'; +import { MEMBER_TYPES } from '../../constants'; +import { + isGroup, + isDirectMember, + isCurrentUser, + canRemove, + canResend, + canUpdate, +} from '../../utils'; + +export default { + name: 'MembersTableCell', + props: { + member: { + type: Object, + required: true, + }, + }, + computed: { + ...mapState(['sourceId', 'currentUserId']), + isGroup() { + return isGroup(this.member); + }, + isInvite() { + return Boolean(this.member.invite); + }, + isAccessRequest() { + return Boolean(this.member.requestedAt); + }, + memberType() { + if (this.isGroup) { + return MEMBER_TYPES.group; + } else if (this.isInvite) { + return MEMBER_TYPES.invite; + } else if (this.isAccessRequest) { + return MEMBER_TYPES.accessRequest; + } + + return MEMBER_TYPES.user; + }, + isDirectMember() { + return isDirectMember(this.member, this.sourceId); + }, + isCurrentUser() { + return isCurrentUser(this.member, this.currentUserId); + }, + canRemove() { + return canRemove(this.member, this.sourceId); + }, + canResend() { + return canResend(this.member); + }, + canUpdate() { + return canUpdate(this.member, this.currentUserId, this.sourceId); + }, + }, + render() { + return this.$scopedSlots.default({ + memberType: this.memberType, + isDirectMember: this.isDirectMember, + isCurrentUser: this.isCurrentUser, + permissions: { + canRemove: this.canRemove, + canResend: this.canResend, + canUpdate: this.canUpdate, + }, + }); + }, +}; +</script> diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue new file mode 100644 index 00000000000..8ad45ab6920 --- /dev/null +++ b/app/assets/javascripts/members/components/table/role_dropdown.vue @@ -0,0 +1,94 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; +import { mapActions } from 'vuex'; +import { s__ } from '~/locale'; + +export default { + name: 'RoleDropdown', + components: { + GlDropdown, + GlDropdownItem, + LdapDropdownItem: () => import('ee_component/members/components/ldap/ldap_dropdown_item.vue'), + }, + props: { + member: { + type: Object, + required: true, + }, + permissions: { + type: Object, + required: true, + }, + }, + data() { + return { + isDesktop: false, + busy: false, + }; + }, + computed: { + disabled() { + return this.busy || (this.permissions.canOverride && !this.member.isOverridden); + }, + }, + mounted() { + this.isDesktop = bp.isDesktop(); + + // Bootstrap Vue and GlDropdown to not support adding attributes to the dropdown toggle + // This can be changed once https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1060 is implemented + const dropdownToggle = this.$refs.glDropdown.$el.querySelector('.dropdown-toggle'); + + if (dropdownToggle) { + dropdownToggle.setAttribute('data-qa-selector', 'access_level_dropdown'); + } + }, + methods: { + ...mapActions(['updateMemberRole']), + handleSelect(value, name) { + if (value === this.member.accessLevel.integerValue) { + return; + } + + this.busy = true; + + this.updateMemberRole({ + memberId: this.member.id, + accessLevel: { integerValue: value, stringValue: name }, + }) + .then(() => { + this.$toast.show(s__('Members|Role updated successfully.')); + this.busy = false; + }) + .catch(() => { + this.busy = false; + }); + }, + }, +}; +</script> + +<template> + <gl-dropdown + ref="glDropdown" + :right="!isDesktop" + :text="member.accessLevel.stringValue" + :header-text="__('Change permissions')" + :disabled="disabled" + > + <gl-dropdown-item + v-for="(value, name) in member.validRoles" + :key="value" + is-check-item + :is-checked="value === member.accessLevel.integerValue" + data-qa-selector="access_level_link" + @click="handleSelect(value, name)" + > + {{ name }} + </gl-dropdown-item> + <ldap-dropdown-item + v-if="permissions.canOverride && member.isOverridden" + :member-id="member.id" + /> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js new file mode 100644 index 00000000000..21af825f795 --- /dev/null +++ b/app/assets/javascripts/members/constants.js @@ -0,0 +1,100 @@ +import { __ } from '~/locale'; + +export const FIELDS = [ + { + key: 'account', + label: __('Account'), + sort: { + asc: 'name_asc', + desc: 'name_desc', + }, + }, + { + key: 'source', + label: __('Source'), + thClass: 'col-meta', + tdClass: 'col-meta', + }, + { + key: 'granted', + label: __('Access granted'), + thClass: 'col-meta', + tdClass: 'col-meta', + sort: { + asc: 'last_joined', + desc: 'oldest_joined', + }, + }, + { + key: 'invited', + label: __('Invited'), + thClass: 'col-meta', + tdClass: 'col-meta', + }, + { + key: 'requested', + label: __('Requested'), + thClass: 'col-meta', + tdClass: 'col-meta', + }, + { + key: 'expires', + label: __('Access expires'), + thClass: 'col-meta', + tdClass: 'col-meta', + }, + { + key: 'maxRole', + label: __('Max role'), + thClass: 'col-max-role', + tdClass: 'col-max-role', + sort: { + asc: 'access_level_asc', + desc: 'access_level_desc', + }, + }, + { + key: 'expiration', + label: __('Expiration'), + thClass: 'col-expiration', + tdClass: 'col-expiration', + }, + { + key: 'lastSignIn', + label: __('Last sign-in'), + sort: { + asc: 'recent_sign_in', + desc: 'oldest_sign_in', + }, + }, + { + key: 'actions', + thClass: 'col-actions', + tdClass: 'col-actions', + showFunction: 'showActionsField', + }, +]; + +export const DEFAULT_SORT = { + sortByKey: 'account', + sortDesc: false, +}; + +export const AVATAR_SIZE = 48; + +export const MEMBER_TYPES = { + user: 'user', + group: 'group', + invite: 'invite', + accessRequest: 'accessRequest', +}; + +export const DAYS_TO_EXPIRE_SOON = 7; + +export const LEAVE_MODAL_ID = 'member-leave-modal'; + +export const REMOVE_GROUP_LINK_MODAL_ID = 'remove-group-link-modal-id'; + +export const SEARCH_TOKEN_TYPE = 'filtered-search-term'; + +export const SORT_PARAM = 'sort'; diff --git a/app/assets/javascripts/members/store/actions.js b/app/assets/javascripts/members/store/actions.js new file mode 100644 index 00000000000..4c31b3c9744 --- /dev/null +++ b/app/assets/javascripts/members/store/actions.js @@ -0,0 +1,44 @@ +import * as types from './mutation_types'; +import axios from '~/lib/utils/axios_utils'; +import { formatDate } from '~/lib/utils/datetime_utility'; + +export const updateMemberRole = async ({ state, commit }, { memberId, accessLevel }) => { + try { + await axios.put( + state.memberPath.replace(/:id$/, memberId), + state.requestFormatter({ accessLevel: accessLevel.integerValue }), + ); + + commit(types.RECEIVE_MEMBER_ROLE_SUCCESS, { memberId, accessLevel }); + } catch (error) { + commit(types.RECEIVE_MEMBER_ROLE_ERROR); + + throw error; + } +}; + +export const showRemoveGroupLinkModal = ({ commit }, groupLink) => { + commit(types.SHOW_REMOVE_GROUP_LINK_MODAL, groupLink); +}; + +export const hideRemoveGroupLinkModal = ({ commit }) => { + commit(types.HIDE_REMOVE_GROUP_LINK_MODAL); +}; + +export const updateMemberExpiration = async ({ state, commit }, { memberId, expiresAt }) => { + try { + await axios.put( + state.memberPath.replace(':id', memberId), + state.requestFormatter({ expires_at: expiresAt ? formatDate(expiresAt, 'isoDate') : '' }), + ); + + commit(types.RECEIVE_MEMBER_EXPIRATION_SUCCESS, { + memberId, + expiresAt: expiresAt ? formatDate(expiresAt, 'isoUtcDateTime') : null, + }); + } catch (error) { + commit(types.RECEIVE_MEMBER_EXPIRATION_ERROR); + + throw error; + } +}; diff --git a/app/assets/javascripts/members/store/index.js b/app/assets/javascripts/members/store/index.js new file mode 100644 index 00000000000..f219f8931b0 --- /dev/null +++ b/app/assets/javascripts/members/store/index.js @@ -0,0 +1,9 @@ +import createState from 'ee_else_ce/members/store/state'; +import mutations from 'ee_else_ce/members/store/mutations'; +import * as actions from 'ee_else_ce/members/store/actions'; + +export default initialState => ({ + state: createState(initialState), + actions, + mutations, +}); diff --git a/app/assets/javascripts/members/store/mutation_types.js b/app/assets/javascripts/members/store/mutation_types.js new file mode 100644 index 00000000000..77307aa745b --- /dev/null +++ b/app/assets/javascripts/members/store/mutation_types.js @@ -0,0 +1,10 @@ +export const RECEIVE_MEMBER_ROLE_SUCCESS = 'RECEIVE_MEMBER_ROLE_SUCCESS'; +export const RECEIVE_MEMBER_ROLE_ERROR = 'RECEIVE_MEMBER_ROLE_ERROR'; + +export const RECEIVE_MEMBER_EXPIRATION_SUCCESS = 'RECEIVE_MEMBER_EXPIRATION_SUCCESS'; +export const RECEIVE_MEMBER_EXPIRATION_ERROR = 'RECEIVE_MEMBER_EXPIRATION_ERROR'; + +export const HIDE_ERROR = 'HIDE_ERROR'; + +export const SHOW_REMOVE_GROUP_LINK_MODAL = 'SHOW_REMOVE_GROUP_LINK_MODAL'; +export const HIDE_REMOVE_GROUP_LINK_MODAL = 'HIDE_REMOVE_GROUP_LINK_MODAL'; diff --git a/app/assets/javascripts/members/store/mutations.js b/app/assets/javascripts/members/store/mutations.js new file mode 100644 index 00000000000..2415e744290 --- /dev/null +++ b/app/assets/javascripts/members/store/mutations.js @@ -0,0 +1,48 @@ +import Vue from 'vue'; +import { s__ } from '~/locale'; +import * as types from './mutation_types'; +import { findMember } from './utils'; + +export default { + [types.RECEIVE_MEMBER_ROLE_SUCCESS](state, { memberId, accessLevel }) { + const member = findMember(state, memberId); + + if (!member) { + return; + } + + Vue.set(member, 'accessLevel', accessLevel); + }, + [types.RECEIVE_MEMBER_ROLE_ERROR](state) { + state.errorMessage = s__( + "Members|An error occurred while updating the member's role, please try again.", + ); + state.showError = true; + }, + [types.RECEIVE_MEMBER_EXPIRATION_SUCCESS](state, { memberId, expiresAt }) { + const member = findMember(state, memberId); + + if (!member) { + return; + } + + Vue.set(member, 'expiresAt', expiresAt); + }, + [types.RECEIVE_MEMBER_EXPIRATION_ERROR](state) { + state.errorMessage = s__( + "Members|An error occurred while updating the member's expiration date, please try again.", + ); + state.showError = true; + }, + [types.HIDE_ERROR](state) { + state.showError = false; + state.errorMessage = ''; + }, + [types.SHOW_REMOVE_GROUP_LINK_MODAL](state, groupLink) { + state.removeGroupLinkModalVisible = true; + state.groupLinkToRemove = groupLink; + }, + [types.HIDE_REMOVE_GROUP_LINK_MODAL](state) { + state.removeGroupLinkModalVisible = false; + }, +}; diff --git a/app/assets/javascripts/members/store/state.js b/app/assets/javascripts/members/store/state.js new file mode 100644 index 00000000000..23a7983adcc --- /dev/null +++ b/app/assets/javascripts/members/store/state.js @@ -0,0 +1,27 @@ +export default ({ + members, + sourceId, + currentUserId, + canManageMembers, + tableFields, + tableAttrs, + tableSortableFields, + memberPath, + requestFormatter, + filteredSearchBar, +}) => ({ + members, + sourceId, + currentUserId, + canManageMembers, + tableFields, + tableAttrs, + tableSortableFields, + memberPath, + requestFormatter, + filteredSearchBar, + showError: false, + errorMessage: '', + removeGroupLinkModalVisible: false, + groupLinkToRemove: null, +}); diff --git a/app/assets/javascripts/members/store/utils.js b/app/assets/javascripts/members/store/utils.js new file mode 100644 index 00000000000..7dcd33111e8 --- /dev/null +++ b/app/assets/javascripts/members/store/utils.js @@ -0,0 +1 @@ +export const findMember = (state, memberId) => state.members.find(member => member.id === memberId); diff --git a/app/assets/javascripts/members/utils.js b/app/assets/javascripts/members/utils.js new file mode 100644 index 00000000000..bf1fc2d7515 --- /dev/null +++ b/app/assets/javascripts/members/utils.js @@ -0,0 +1,97 @@ +import { __ } from '~/locale'; +import { getParameterByName } from '~/lib/utils/common_utils'; +import { setUrlParams } from '~/lib/utils/url_utility'; +import { FIELDS, DEFAULT_SORT } from './constants'; + +export const generateBadges = (member, isCurrentUser) => [ + { + show: isCurrentUser, + text: __("It's you"), + variant: 'success', + }, + { + show: member.user?.blocked, + text: __('Blocked'), + variant: 'danger', + }, + { + show: member.user?.twoFactorEnabled, + text: __('2FA'), + variant: 'info', + }, +]; + +export const isGroup = member => { + return Boolean(member.sharedWithGroup); +}; + +export const isDirectMember = (member, sourceId) => { + return isGroup(member) || member.source?.id === sourceId; +}; + +export const isCurrentUser = (member, currentUserId) => { + return member.user?.id === currentUserId; +}; + +export const canRemove = (member, sourceId) => { + return isDirectMember(member, sourceId) && member.canRemove; +}; + +export const canResend = member => { + return Boolean(member.invite?.canResend); +}; + +export const canUpdate = (member, currentUserId, sourceId) => { + return ( + !isCurrentUser(member, currentUserId) && isDirectMember(member, sourceId) && member.canUpdate + ); +}; + +export const parseSortParam = sortableFields => { + const sortParam = getParameterByName('sort'); + + const sortedField = FIELDS.filter(field => sortableFields.includes(field.key)).find( + field => field.sort?.asc === sortParam || field.sort?.desc === sortParam, + ); + + if (!sortedField) { + return DEFAULT_SORT; + } + + return { + sortByKey: sortedField.key, + sortDesc: sortedField?.sort?.desc === sortParam, + }; +}; + +export const buildSortHref = ({ + sortBy, + sortDesc, + filteredSearchBarTokens, + filteredSearchBarSearchParam, +}) => { + const sortDefinition = FIELDS.find(field => field.key === sortBy)?.sort; + + if (!sortDefinition) { + return ''; + } + + const sortParam = sortDesc ? sortDefinition.desc : sortDefinition.asc; + + const filterParams = + filteredSearchBarTokens?.reduce((accumulator, token) => { + return { + ...accumulator, + [token]: getParameterByName(token), + }; + }, {}) || {}; + + if (filteredSearchBarSearchParam) { + filterParams[filteredSearchBarSearchParam] = getParameterByName(filteredSearchBarSearchParam); + } + + return setUrlParams({ ...filterParams, sort: sortParam }, window.location.href, true); +}; + +// Defined in `ee/app/assets/javascripts/vue_shared/components/members/utils.js` +export const canOverride = () => false; |