diff --git a/app/assets/javascripts/access_level/constants.js b/app/assets/javascripts/access_level/constants.js index c53fa472b36b1e7e057d95c554b1affb8e1799f7..f13be142a081d62fe47b404c2b9059f324d380de 100644 --- a/app/assets/javascripts/access_level/constants.js +++ b/app/assets/javascripts/access_level/constants.js @@ -81,6 +81,10 @@ export const BASE_ROLES = [ }, ]; +export const BASE_ROLES_WITHOUT_MINIMAL_ACCESS = BASE_ROLES.filter( + ({ accessLevel }) => accessLevel !== ACCESS_LEVEL_MINIMAL_ACCESS_INTEGER, +); + export const ACCESS_LEVEL_LABELS = { [ACCESS_LEVEL_NO_ACCESS_INTEGER]: ACCESS_LEVEL_NO_ACCESS, [ACCESS_LEVEL_MINIMAL_ACCESS_INTEGER]: ACCESS_LEVEL_MINIMAL_ACCESS, diff --git a/ee/app/assets/javascripts/pages/admin/application_settings/roles_and_permissions/show/index.js b/ee/app/assets/javascripts/pages/admin/application_settings/roles_and_permissions/show/index.js new file mode 100644 index 0000000000000000000000000000000000000000..fc06901d0ea90e50d7649ad874344533b1f4d37b --- /dev/null +++ b/ee/app/assets/javascripts/pages/admin/application_settings/roles_and_permissions/show/index.js @@ -0,0 +1,3 @@ +import { initRoleDetailsApp } from 'ee/roles_and_permissions/show'; + +initRoleDetailsApp(); diff --git a/ee/app/assets/javascripts/pages/groups/settings/roles_and_permissions/show/index.js b/ee/app/assets/javascripts/pages/groups/settings/roles_and_permissions/show/index.js new file mode 100644 index 0000000000000000000000000000000000000000..fc06901d0ea90e50d7649ad874344533b1f4d37b --- /dev/null +++ b/ee/app/assets/javascripts/pages/groups/settings/roles_and_permissions/show/index.js @@ -0,0 +1,3 @@ +import { initRoleDetailsApp } from 'ee/roles_and_permissions/show'; + +initRoleDetailsApp(); diff --git a/ee/app/assets/javascripts/roles_and_permissions/components/role_details/role_details.vue b/ee/app/assets/javascripts/roles_and_permissions/components/role_details/role_details.vue new file mode 100644 index 0000000000000000000000000000000000000000..c6993a833cb54c24067a6aadf5f8f0fc05726eda --- /dev/null +++ b/ee/app/assets/javascripts/roles_and_permissions/components/role_details/role_details.vue @@ -0,0 +1,134 @@ +<script> +import { GlSprintf, GlAlert, GlButton, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { localeDateFormat } from '~/lib/utils/datetime_utility'; +import { BASE_ROLES_WITHOUT_MINIMAL_ACCESS } from '~/access_level/constants'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { TYPENAME_MEMBER_ROLE } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import DeleteRoleModal from '../delete_role_modal.vue'; +import memberRoleQuery from '../../graphql/member_role.query.graphql'; + +export default { + components: { + GlSprintf, + GlAlert, + GlButton, + GlLoadingIcon, + DeleteRoleModal, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + roleId: { + type: String, + required: true, + }, + listPagePath: { + type: String, + required: true, + }, + }, + data() { + return { + memberRole: null, + roleToDelete: null, + errorMessage: '', + }; + }, + apollo: { + memberRole: { + query: memberRoleQuery, + errorPolicy: 'none', // This is needed to stop the result() block from being called when there's an error. + variables() { + return { id: convertToGraphQLId(TYPENAME_MEMBER_ROLE, this.roleId) }; + }, + skip() { + return Boolean(this.standardRole); + }, + error() { + this.memberRole = null; + }, + }, + }, + computed: { + standardRole() { + return BASE_ROLES_WITHOUT_MINIMAL_ACCESS.find( + ({ value }) => value === this.roleId.toUpperCase(), + ); + }, + role() { + return this.memberRole || this.standardRole; + }, + headerDescription() { + return this.memberRole + ? s__('MemberRole|Custom role created on %{dateTime}') + : s__('MemberRole|This role is available by default and cannot be changed.'); + }, + createdDate() { + return localeDateFormat.asDate.format(this.role.createdAt); + }, + deleteButtonTooltip() { + // The button will be disabled if there are assigned members, so we want to show the tooltip immediately on hover + // instead of the default 0.5-second delay. + return this.hasAssignedMembers + ? { title: s__('MemberRole|To delete custom role, remove role from all users.'), delay: 0 } + : s__('MemberRole|Delete role'); + }, + hasAssignedMembers() { + return this.role.membersCount > 0; + }, + }, + methods: { + navigateToListPage() { + visitUrl(this.listPagePath); + }, + }, +}; +</script> + +<template> + <gl-loading-icon v-if="$apollo.queries.memberRole.loading" size="md" class="gl-mt-5" /> + + <gl-alert v-else-if="!role" variant="danger" class="gl-mt-5" :dismissible="false"> + {{ s__('MemberRole|Failed to fetch role.') }} + </gl-alert> + + <div v-else data-testid="role-details"> + <header class="gl-flex gl-gap-3 gl-items-center gl-mt-6 gl-mb-4 gl-flex-wrap"> + <h1 class="gl-m-0 gl-mr-auto">{{ role.name || role.text }}</h1> + + <div v-if="memberRole" class="gl-flex gl-items-center gl-gap-3" data-testid="action-buttons"> + <gl-button + v-gl-tooltip="s__('MemberRole|Edit role')" + icon="pencil" + :href="role.editPath" + class="gl-ml-2" + data-testid="edit-button" + /> + <div v-gl-tooltip="deleteButtonTooltip" data-testid="delete-button"> + <gl-button + icon="remove" + category="secondary" + variant="danger" + :disabled="hasAssignedMembers" + @click="roleToDelete = role" + /> + </div> + + <delete-role-modal + :role="roleToDelete" + @deleted="navigateToListPage" + @close="roleToDelete = null" + /> + </div> + </header> + + <p class="gl-w-full"> + <gl-sprintf :message="headerDescription"> + <template #dateTime>{{ createdDate }}</template> + </gl-sprintf> + </p> + </div> +</template> diff --git a/ee/app/assets/javascripts/roles_and_permissions/graphql/member_role.query.graphql b/ee/app/assets/javascripts/roles_and_permissions/graphql/member_role.query.graphql index 6f1ff3def41633acd726011edbc33baa80b4a2e8..986dc861382e3f45c359b3b79a827b67751e3e09 100644 --- a/ee/app/assets/javascripts/roles_and_permissions/graphql/member_role.query.graphql +++ b/ee/app/assets/javascripts/roles_and_permissions/graphql/member_role.query.graphql @@ -3,8 +3,12 @@ query memberRole($id: MemberRoleID!) { id name description + createdAt + editPath + membersCount baseAccessLevel { stringValue + humanAccess } enabledPermissions { nodes { diff --git a/ee/app/assets/javascripts/roles_and_permissions/show.js b/ee/app/assets/javascripts/roles_and_permissions/show.js new file mode 100644 index 0000000000000000000000000000000000000000..a9f35317af2f66bffe89d9c526f20bd0cf06810b --- /dev/null +++ b/ee/app/assets/javascripts/roles_and_permissions/show.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import RoleDetails from './components/role_details/role_details.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export const initRoleDetailsApp = () => { + const el = document.querySelector('#js-role-details'); + + if (!el) { + return null; + } + + return new Vue({ + el, + name: 'RoleDetailsRoot', + apolloProvider, + render(createElement) { + return createElement(RoleDetails, { + props: { + roleId: el.dataset.id, + listPagePath: el.dataset.listPagePath, + }, + }); + }, + }); +}; diff --git a/ee/app/controllers/admin/application_settings/roles_and_permissions_controller.rb b/ee/app/controllers/admin/application_settings/roles_and_permissions_controller.rb index e8370a85da0464eab252c32aecade7f1a0561549..93c482bcec6a4fec13227648fe6db7bc5a455545 100644 --- a/ee/app/controllers/admin/application_settings/roles_and_permissions_controller.rb +++ b/ee/app/controllers/admin/application_settings/roles_and_permissions_controller.rb @@ -4,6 +4,7 @@ module Admin module ApplicationSettings class RolesAndPermissionsController < Admin::ApplicationController include ::GitlabSubscriptions::SubscriptionHelper + include ::EE::RolesAndPermissions # rubocop: disable Cop/InjectEnterpriseEditionModule -- EE-only concern feature_category :user_management diff --git a/ee/app/controllers/concerns/ee/roles_and_permissions.rb b/ee/app/controllers/concerns/ee/roles_and_permissions.rb new file mode 100644 index 0000000000000000000000000000000000000000..832b3159bffdf4b5e285a421cf3ae0bfabb87a07 --- /dev/null +++ b/ee/app/controllers/concerns/ee/roles_and_permissions.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module EE + module RolesAndPermissions + extend ActiveSupport::Concern + include ::Gitlab::Utils::StrongMemoize + + included do + before_action :ensure_role_exists!, only: [:show, :edit] + end + + private + + def ensure_role_exists! + render_404 unless member_role + end + + def member_role + id = params.permit(:id)[:id] + + if /\A\d+\z/.match?(id) + MemberRoles::RolesFinder.new(current_user, id: id).execute.first + else + access_level = ::Types::MemberAccessLevelEnum.enum[id.downcase] + name = ::Gitlab::Access.options_with_owner.key(access_level) + + { name: name } if name + end + end + strong_memoize_attr :member_role + end +end diff --git a/ee/app/controllers/groups/settings/roles_and_permissions_controller.rb b/ee/app/controllers/groups/settings/roles_and_permissions_controller.rb index b0c241638f345b2e677c680946dc5fc340d6943e..609d84729922d7c09cc29fcbd294ea43c199377f 100644 --- a/ee/app/controllers/groups/settings/roles_and_permissions_controller.rb +++ b/ee/app/controllers/groups/settings/roles_and_permissions_controller.rb @@ -4,6 +4,7 @@ module Groups module Settings class RolesAndPermissionsController < Groups::ApplicationController include ::GitlabSubscriptions::SubscriptionHelper + include ::EE::RolesAndPermissions # rubocop: disable Cop/InjectEnterpriseEditionModule -- EE-only concern feature_category :user_management diff --git a/ee/app/views/admin/application_settings/roles_and_permissions/edit.html.haml b/ee/app/views/admin/application_settings/roles_and_permissions/edit.html.haml index 24a5ab3a0407ad2741d5ec8cc0707f5f5785ceb8..cbdab7f2f786d81cef56140a9c15d59feb3bb555 100644 --- a/ee/app/views/admin/application_settings/roles_and_permissions/edit.html.haml +++ b/ee/app/views/admin/application_settings/roles_and_permissions/edit.html.haml @@ -1,4 +1,3 @@ -- return unless License.feature_available?(:custom_roles) - add_to_breadcrumbs _('Roles and Permissions'), admin_application_settings_roles_and_permissions_path - page_title s_('MemberRole|Edit role') diff --git a/ee/app/views/admin/application_settings/roles_and_permissions/index.html.haml b/ee/app/views/admin/application_settings/roles_and_permissions/index.html.haml index 4d9c82570b2f4d14cc61048d7280a135dacce8da..ef863c8411922abf722b577d10731de1aea0fd18 100644 --- a/ee/app/views/admin/application_settings/roles_and_permissions/index.html.haml +++ b/ee/app/views/admin/application_settings/roles_and_permissions/index.html.haml @@ -1,4 +1,3 @@ -- return unless License.feature_available?(:custom_roles) - page_title _('Roles and Permissions') #js-roles-and-permissions{ data: member_roles_data } diff --git a/ee/app/views/admin/application_settings/roles_and_permissions/new.html.haml b/ee/app/views/admin/application_settings/roles_and_permissions/new.html.haml index 57e7f56be1637a0199889fb1d0d9597dcc9ef837..91bc53302a5a3edc87b58669d97b7246876a8e97 100644 --- a/ee/app/views/admin/application_settings/roles_and_permissions/new.html.haml +++ b/ee/app/views/admin/application_settings/roles_and_permissions/new.html.haml @@ -1,4 +1,3 @@ -- return unless License.feature_available?(:custom_roles) - add_to_breadcrumbs _('Roles and Permissions'), admin_application_settings_roles_and_permissions_path - page_title s_('MemberRole|Create role') diff --git a/ee/app/views/admin/application_settings/roles_and_permissions/show.html.haml b/ee/app/views/admin/application_settings/roles_and_permissions/show.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..b058e4d5b726a74bb37aeb5c1dba2f0e6cb63002 --- /dev/null +++ b/ee/app/views/admin/application_settings/roles_and_permissions/show.html.haml @@ -0,0 +1,6 @@ +- add_to_breadcrumbs _('Roles and Permissions'), admin_application_settings_roles_and_permissions_path +- breadcrumb_title @member_role[:name] +- page_title @member_role[:name], _('Roles and Permissions') + +#js-role-details{ data: { id: params[:id], list_page_path: admin_application_settings_roles_and_permissions_path } } + = gl_loading_icon(css_class: 'gl-mt-5', size: 'md') diff --git a/ee/app/views/groups/settings/roles_and_permissions/edit.html.haml b/ee/app/views/groups/settings/roles_and_permissions/edit.html.haml index 55c55d9ea0fd67c014ee9b90324cba907c3a5772..605b42894427b336db084e22740370ab4d6b6ca4 100644 --- a/ee/app/views/groups/settings/roles_and_permissions/edit.html.haml +++ b/ee/app/views/groups/settings/roles_and_permissions/edit.html.haml @@ -1,4 +1,3 @@ -- return unless @group.licensed_feature_available?(:custom_roles) - add_to_breadcrumbs _('Roles and Permissions'), group_settings_roles_and_permissions_path - page_title s_('MemberRole|Edit role') diff --git a/ee/app/views/groups/settings/roles_and_permissions/index.html.haml b/ee/app/views/groups/settings/roles_and_permissions/index.html.haml index 1b71d8f269dfe265b5c20abe56ac42981565aef2..19a01b9dd2ed46b07b7795e3a138ddbc5373d11a 100644 --- a/ee/app/views/groups/settings/roles_and_permissions/index.html.haml +++ b/ee/app/views/groups/settings/roles_and_permissions/index.html.haml @@ -1,4 +1,3 @@ -- return unless @group.licensed_feature_available?(:custom_roles) - page_title _('Roles and Permissions') #js-roles-and-permissions{ data: member_roles_data(@group) } diff --git a/ee/app/views/groups/settings/roles_and_permissions/new.html.haml b/ee/app/views/groups/settings/roles_and_permissions/new.html.haml index 280ce8b4019232107234791f7bd007309293d405..7adde5fa53b96c7ea505bdcf1c8da8d6992a98c4 100644 --- a/ee/app/views/groups/settings/roles_and_permissions/new.html.haml +++ b/ee/app/views/groups/settings/roles_and_permissions/new.html.haml @@ -1,4 +1,3 @@ -- return unless @group.licensed_feature_available?(:custom_roles) - add_to_breadcrumbs _('Roles and Permissions'), group_settings_roles_and_permissions_path - page_title s_('MemberRole|Create role') diff --git a/ee/app/views/groups/settings/roles_and_permissions/show.html.haml b/ee/app/views/groups/settings/roles_and_permissions/show.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..00463cc5b94c575080e2c15a4f4e1bedc77b6e78 --- /dev/null +++ b/ee/app/views/groups/settings/roles_and_permissions/show.html.haml @@ -0,0 +1,6 @@ +- add_to_breadcrumbs _('Roles and Permissions'), group_settings_roles_and_permissions_path +- breadcrumb_title @member_role[:name] +- page_title @member_role[:name], _('Roles and Permissions') + +#js-role-details{ data: { id: params[:id], list_page_path: group_settings_roles_and_permissions_path } } + = gl_loading_icon(css_class: 'gl-mt-5', size: 'md') diff --git a/ee/config/routes/admin.rb b/ee/config/routes/admin.rb index c46193fbef7ce8f0a84cf7f473b76ecad3bd8186..504317249ad2e15e5b286cd6d3f06b61daa6d510 100644 --- a/ee/config/routes/admin.rb +++ b/ee/config/routes/admin.rb @@ -65,7 +65,7 @@ resource :scim_oauth, only: [:create], controller: :scim_oauth, module: 'application_settings' - resources :roles_and_permissions, only: [:index, :new, :edit], module: 'application_settings' + resources :roles_and_permissions, only: [:index, :new, :edit, :show], module: 'application_settings' end namespace :geo do diff --git a/ee/config/routes/group.rb b/ee/config/routes/group.rb index d74e28dbe7691bd45fb47d311479ea006b6d465d..d5f514e39c4efde39699ca0a4d9e6874359901a7 100644 --- a/ee/config/routes/group.rb +++ b/ee/config/routes/group.rb @@ -19,7 +19,7 @@ end end resource :merge_requests, only: [:update] - resources :roles_and_permissions, only: [:index, :new, :edit] + resources :roles_and_permissions, only: [:index, :new, :edit, :show] resource :analytics, only: [:show, :update] resources :gitlab_duo_usage, only: [:index] diff --git a/ee/spec/frontend/roles_and_permissions/components/create_member_role_spec.js b/ee/spec/frontend/roles_and_permissions/components/create_member_role_spec.js index eccd4d9b0a01d61ed149ef394849412b22a83aa7..33df70b0ab450014caf69f68079fe631f1499412 100644 --- a/ee/spec/frontend/roles_and_permissions/components/create_member_role_spec.js +++ b/ee/spec/frontend/roles_and_permissions/components/create_member_role_spec.js @@ -13,7 +13,7 @@ import memberRoleQuery from 'ee/roles_and_permissions/graphql/member_role.query. import { visitUrl } from '~/lib/utils/url_utility'; import PermissionsSelector from 'ee/roles_and_permissions/components/permissions_selector.vue'; import { BASE_ROLES } from '~/access_level/constants'; -import { mockMemberRoleQueryResponse } from '../mock_data'; +import { getMemberRoleQueryResponse } from '../mock_data'; Vue.use(VueApollo); @@ -36,7 +36,7 @@ describe('CreateMemberRole', () => { const createMutationSuccessHandler = jest.fn().mockResolvedValue(mutationSuccessData); const updateMutationSuccessHandler = jest.fn().mockResolvedValue(mutationSuccessData); - const defaultMemberRoleHandler = jest.fn().mockResolvedValue(mockMemberRoleQueryResponse); + const defaultMemberRoleHandler = jest.fn().mockResolvedValue(getMemberRoleQueryResponse()); const createComponent = ({ stubs, diff --git a/ee/spec/frontend/roles_and_permissions/components/role_details/role_details_spec.js b/ee/spec/frontend/roles_and_permissions/components/role_details/role_details_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..92d08b7e409fe93f9d0c0d9cecce47d24efa9925 --- /dev/null +++ b/ee/spec/frontend/roles_and_permissions/components/role_details/role_details_spec.js @@ -0,0 +1,203 @@ +import { GlAlert, GlSprintf, GlButton, GlLoadingIcon } from '@gitlab/ui'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import RoleDetails from 'ee/roles_and_permissions/components/role_details/role_details.vue'; +import DeleteRoleModal from 'ee/roles_and_permissions/components/delete_role_modal.vue'; +import { BASE_ROLES_WITHOUT_MINIMAL_ACCESS } from '~/access_level/constants'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { visitUrl } from '~/lib/utils/url_utility'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import memberRoleQuery from 'ee/roles_and_permissions/graphql/member_role.query.graphql'; +import waitForPromises from 'helpers/wait_for_promises'; +import { mockMemberRole, getMemberRoleQueryResponse } from '../../mock_data'; + +Vue.use(VueApollo); + +jest.mock('~/lib/utils/url_utility'); + +const getMemberRoleHandler = (memberRole) => + jest.fn().mockResolvedValue(getMemberRoleQueryResponse(memberRole)); +const defaultMemberRoleHandler = getMemberRoleHandler(mockMemberRole); + +describe('Role details', () => { + let wrapper; + + const createWrapper = ({ + roleId = '5', + memberRoleHandler = defaultMemberRoleHandler, + listPagePath = '/list/page/path', + } = {}) => { + wrapper = shallowMountExtended(RoleDetails, { + apolloProvider: createMockApollo([[memberRoleQuery, memberRoleHandler]]), + propsData: { roleId, listPagePath }, + stubs: { GlSprintf }, + directives: { GlTooltip: createMockDirective('gl-tooltip') }, + }); + + return waitForPromises(); + }; + + const findRoleDetails = () => wrapper.findByTestId('role-details'); + const findRoleName = () => wrapper.find('h1'); + const findActionButtons = () => wrapper.findByTestId('action-buttons'); + const findHeaderDescription = () => wrapper.find('p'); + const findEditButton = () => wrapper.findByTestId('edit-button'); + const findDeleteButtonWrapper = () => wrapper.findByTestId('delete-button'); + const findDeleteButton = () => findDeleteButtonWrapper().findComponent(GlButton); + const findDeleteRoleModal = () => wrapper.findComponent(DeleteRoleModal); + const getTooltip = (findFn) => getBinding(findFn().element, 'gl-tooltip'); + + describe('when there is a query error', () => { + beforeEach(() => createWrapper({ memberRoleHandler: jest.fn().mockRejectedValue('test') })); + + it('shows error alert', () => { + const alert = wrapper.findComponent(GlAlert); + + expect(alert.text()).toBe('Failed to fetch role.'); + expect(alert.props()).toMatchObject({ variant: 'danger', dismissible: false }); + }); + + it('does not show role details', () => { + expect(findRoleDetails().exists()).toBe(false); + }); + }); + + describe('when the role is a standard role', () => { + describe.each(BASE_ROLES_WITHOUT_MINIMAL_ACCESS)('$text', (role) => { + beforeEach(() => createWrapper({ roleId: role.value })); + + it('does not call query', () => { + expect(defaultMemberRoleHandler).not.toHaveBeenCalled(); + }); + + it('shows role name', () => { + expect(findRoleName().text()).toBe(role.text); + }); + + it('does not show action buttons', () => { + expect(findActionButtons().exists()).toBe(false); + }); + + it('shows header description', () => { + expect(findHeaderDescription().text()).toBe( + 'This role is available by default and cannot be changed.', + ); + }); + }); + }); + + describe('when the role is a custom role', () => { + beforeEach(() => { + createWrapper(); + }); + + it('calls query', () => { + expect(defaultMemberRoleHandler).toHaveBeenCalledTimes(1); + expect(defaultMemberRoleHandler).toHaveBeenCalledWith({ id: 'gid://gitlab/MemberRole/5' }); + }); + + it('shows loading icon', () => { + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + + describe('after query is done', () => { + beforeEach(waitForPromises); + + it('shows role name', () => { + expect(findRoleName().text()).toBe('Custom role'); + }); + + it('shows action buttons', () => { + expect(findActionButtons().exists()).toBe(true); + }); + + it('shows header description', () => { + expect(findHeaderDescription().text()).toBe('Custom role created on Aug 4, 2024'); + }); + }); + }); + + describe('edit button', () => { + beforeEach(() => createWrapper()); + + it('shows button', () => { + expect(findEditButton().attributes('href')).toBe('role/edit/path'); + expect(findEditButton().props('icon')).toBe('pencil'); + }); + + it('shows button tooltip', () => { + expect(getTooltip(findEditButton).value).toBe('Edit role'); + }); + }); + + describe('delete button', () => { + describe.each` + membersCount | disabled | expectedTooltip + ${0} | ${false} | ${'Delete role'} + ${1} | ${true} | ${{ delay: 0, title: 'To delete custom role, remove role from all users.' }} + `( + `when the role members count is $membersCount`, + ({ membersCount, disabled, expectedTooltip }) => { + beforeEach(() => { + const memberRole = { ...mockMemberRole, membersCount }; + return createWrapper({ memberRoleHandler: getMemberRoleHandler(memberRole) }); + }); + + it('shows button', () => { + expect(findDeleteButton().props()).toMatchObject({ + icon: 'remove', + category: 'secondary', + variant: 'danger', + disabled, + }); + }); + + it('shows button tooltip on wrapper', () => { + expect(getTooltip(findDeleteButtonWrapper).value).toEqual(expectedTooltip); + }); + }, + ); + }); + + describe('delete role modal', () => { + beforeEach(() => createWrapper()); + + it('shows modal', () => { + expect(findDeleteRoleModal().props('role')).toBe(null); + }); + + describe('when delete button is clicked', () => { + beforeEach(() => { + findDeleteButton().vm.$emit('click'); + return nextTick(); + }); + + it('passes role to modal', () => { + expect(findDeleteRoleModal().props('role')).toEqual(mockMemberRole); + }); + + it('clears role to delete when modal is closed', async () => { + findDeleteRoleModal().vm.$emit('close'); + await nextTick(); + + expect(findDeleteRoleModal().props('role')).toBe(null); + }); + + describe('when role is deleted', () => { + beforeEach(() => { + findDeleteRoleModal().vm.$emit('deleted'); + return nextTick(); + }); + + it('navigates to list page', () => { + expect(visitUrl).toHaveBeenCalledWith('/list/page/path'); + }); + + it('keeps the modal open', () => { + expect(findDeleteRoleModal().props('role')).toEqual(mockMemberRole); + }); + }); + }); + }); +}); diff --git a/ee/spec/frontend/roles_and_permissions/mock_data.js b/ee/spec/frontend/roles_and_permissions/mock_data.js index 4792c66afb5966d9e1649d0668cc7da3eeb7ee95..2949e0a3521c3c1b02ede1389814b867033d23a2 100644 --- a/ee/spec/frontend/roles_and_permissions/mock_data.js +++ b/ee/spec/frontend/roles_and_permissions/mock_data.js @@ -119,16 +119,19 @@ export const mockInstanceMemberRoles = { }, }; -export const mockMemberRoleQueryResponse = { - data: { - memberRole: { - id: 1, - name: 'Custom role', - description: 'Custom role description', - baseAccessLevel: { stringValue: 'DEVELOPER' }, - enabledPermissions: { - nodes: [{ value: 'A' }, { value: 'B' }], - }, - }, +export const mockMemberRole = { + id: 1, + name: 'Custom role', + description: 'Custom role description', + createdAt: '2024-08-04T12:20:43Z', + editPath: 'role/edit/path', + membersCount: 0, + baseAccessLevel: { stringValue: 'DEVELOPER', humanAccess: 'Developer' }, + enabledPermissions: { + nodes: [{ value: 'A' }, { value: 'B' }], }, }; + +export const getMemberRoleQueryResponse = (memberRole = mockMemberRole) => ({ + data: { memberRole }, +}); diff --git a/ee/spec/requests/admin/application_settings/roles_and_permissions_controller_spec.rb b/ee/spec/requests/admin/application_settings/roles_and_permissions_controller_spec.rb index 625043d3bc0d57997f194c1f5917f3ea372c6c8c..dc528dbe296562d0632ed787a304efb6e8a29eda 100644 --- a/ee/spec/requests/admin/application_settings/roles_and_permissions_controller_spec.rb +++ b/ee/spec/requests/admin/application_settings/roles_and_permissions_controller_spec.rb @@ -3,17 +3,19 @@ require 'spec_helper' RSpec.describe Admin::ApplicationSettings::RolesAndPermissionsController, :enable_admin_mode, feature_category: :user_management do - describe 'GET #index' do - subject(:get_index) { get admin_application_settings_roles_and_permissions_path } + let_it_be(:member_role) { create(:member_role, :instance, name: 'Custom role') } + let_it_be(:role_id) { member_role.id } + let_it_be(:admin) { create(:admin) } - shared_examples 'not found' do - it 'is not found' do - get_index + shared_examples 'not found' do + it 'is not found' do + get_method - expect(response).to have_gitlab_http_status(:not_found) - end + expect(response).to have_gitlab_http_status(:not_found) end + end + shared_examples 'access control' do context 'with non-admin user' do let_it_be(:user) { create(:user) } @@ -26,15 +28,13 @@ context 'when no user is logged in' do it 'redirects to login page' do - get_index + get_method expect(response).to have_gitlab_http_status(:redirect) end end context 'with an admin user' do - let_it_be(:admin) { create(:admin) } - before do sign_in(admin) end @@ -49,7 +49,7 @@ end it 'returns a 200 status code' do - get_index + get_method expect(response).to have_gitlab_http_status(:ok) end @@ -64,4 +64,57 @@ end end end + + shared_examples 'role existence check' do + before do + sign_in(admin) + stub_licensed_features(custom_roles: true) + end + + context 'with a valid custom role' do + it 'returns a 200 status code' do + get_method + + expect(response).to have_gitlab_http_status(:ok) + end + end + + context 'when the ID is for a non-existent custom role' do + let_it_be(:role_id) { non_existing_record_id } + + it_behaves_like 'not found' + end + + context 'when the ID is for a non-existent standard role' do + let_it_be(:role_id) { 'NONEXISTENT_ROLE' } + + it_behaves_like 'not found' + end + + context 'when the ID is for the minimal access role' do + let_it_be(:role_id) { 'MINIMAL_ACCESS' } + + it_behaves_like 'not found' + end + end + + describe 'GET #index' do + subject(:get_method) { get admin_application_settings_roles_and_permissions_path } + + it_behaves_like 'access control' + end + + describe 'GET #show' do + subject(:get_method) { get admin_application_settings_roles_and_permission_path(role_id) } + + it_behaves_like 'access control' + it_behaves_like 'role existence check' + end + + describe 'GET #edit' do + subject(:get_method) { get edit_admin_application_settings_roles_and_permission_path(role_id) } + + it_behaves_like 'access control' + it_behaves_like 'role existence check' + end end diff --git a/ee/spec/requests/groups/settings/roles_and_permissions_controller_spec.rb b/ee/spec/requests/groups/settings/roles_and_permissions_controller_spec.rb index fa342838c19277b5191ac83f9dce44edba5672d5..f410ea2baef638d717f5e9642046c917c3b63006 100644 --- a/ee/spec/requests/groups/settings/roles_and_permissions_controller_spec.rb +++ b/ee/spec/requests/groups/settings/roles_and_permissions_controller_spec.rb @@ -6,38 +6,35 @@ include AdminModeHelper let_it_be(:user) { create(:user) } + let_it_be(:admin) { create(:admin) } let_it_be_with_reload(:group) { create(:group) } + let_it_be(:member_role) { create(:member_role, namespace: group, name: 'Custom role') } + let_it_be(:role_id) { member_role.id } before do stub_saas_features(gitlab_com_subscriptions: true) end - describe 'GET #index' do - subject(:get_index) { get(group_settings_roles_and_permissions_path(group)) } - - shared_examples 'page is not found' do - it 'has correct status' do - get_index + shared_examples 'page is not found' do + it 'has correct status' do + get_method - expect(response).to have_gitlab_http_status(:not_found) - end + expect(response).to have_gitlab_http_status(:not_found) end + end + shared_examples 'access control' do shared_examples 'page is found under proper conditions' do it 'returns a 200 status code' do - get_index + get_method expect(response).to have_gitlab_http_status(:ok) end context 'when accessing a subgroup' do - let_it_be(:subgroup) { create(:group, parent: group) } - - it 'is not found' do - get group_settings_roles_and_permissions_path(subgroup) + let_it_be(:group) { create(:group, parent: group) } - expect(response).to have_gitlab_http_status(:not_found) - end + it_behaves_like 'page is not found' end context 'when `custom_roles` license is disabled' do @@ -71,8 +68,6 @@ end context 'with admins' do - let_it_be(:admin) { create(:admin) } - before do sign_in(admin) enable_admin_mode!(admin) @@ -110,4 +105,58 @@ it_behaves_like 'page is found under proper conditions' end end + + shared_examples 'role existence check' do + before do + group.add_member(user, :owner) + sign_in(user) + stub_licensed_features(custom_roles: true) + end + + context 'with a valid custom role' do + it 'returns a 200 status code' do + get_method + + expect(response).to have_gitlab_http_status(:ok) + end + end + + context 'when the ID is for a non-existent custom role' do + let_it_be(:role_id) { non_existing_record_id } + + it_behaves_like 'page is not found' + end + + context 'when the ID is for a non-existent standard role' do + let_it_be(:role_id) { 'NONEXISTENT_ROLE' } + + it_behaves_like 'page is not found' + end + + context 'when the ID is for the minimal access role' do + let_it_be(:role_id) { 'MINIMAL_ACCESS' } + + it_behaves_like 'page is not found' + end + end + + describe 'GET #index' do + subject(:get_method) { get(group_settings_roles_and_permissions_path(group)) } + + it_behaves_like 'access control' + end + + describe 'GET #show' do + subject(:get_method) { get(group_settings_roles_and_permission_path(group, role_id)) } + + it_behaves_like 'access control' + it_behaves_like 'role existence check' + end + + describe 'GET #edit' do + subject(:get_method) { get(edit_group_settings_roles_and_permission_path(group, role_id)) } + + it_behaves_like 'access control' + it_behaves_like 'role existence check' + end end diff --git a/ee/spec/views/admin/application_settings/roles_and_permissions/show.html.haml_spec.rb b/ee/spec/views/admin/application_settings/roles_and_permissions/show.html.haml_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1d020e1207390f763e7afa6cf36088c30c1bba44 --- /dev/null +++ b/ee/spec/views/admin/application_settings/roles_and_permissions/show.html.haml_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'admin/application_settings/roles_and_permissions/show', feature_category: :permissions do + let_it_be(:role) { build(:member_role, id: 5, name: 'Custom role') } + + before do + @member_role = role + allow(view).to receive(:params).and_return(id: role.id) + allow(view).to receive(:add_to_breadcrumbs) + allow(view).to receive(:breadcrumb_title) + allow(view).to receive(:page_title) + + render + end + + it 'sets the breadcrumbs' do + expect(view).to have_received(:add_to_breadcrumbs).with('Roles and Permissions', + admin_application_settings_roles_and_permissions_path) + expect(view).to have_received(:breadcrumb_title).with(role.name) + end + + it 'sets the page title' do + expect(view).to have_received(:page_title).with(role.name, 'Roles and Permissions') + end + + it 'renders frontend placeholder' do + list_page_path = admin_application_settings_roles_and_permissions_path + expect(rendered).to have_selector "#js-role-details[data-id='#{role.id}']" + expect(rendered).to have_selector "#js-role-details[data-list-page-path='#{list_page_path}']" + end + + it 'renders the loading spinner' do + expect(rendered).to have_selector '#js-role-details .gl-spinner' + end +end diff --git a/ee/spec/views/groups/settings/roles_and_permissions/show.html.haml_spec.rb b/ee/spec/views/groups/settings/roles_and_permissions/show.html.haml_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..99fe7522ceffe08efefd16a378eebd916cdb4ac1 --- /dev/null +++ b/ee/spec/views/groups/settings/roles_and_permissions/show.html.haml_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'groups/settings/roles_and_permissions/show', feature_category: :permissions do + let_it_be(:role) { build(:member_role, id: 5, name: 'Custom role') } + + before do + @member_role = role + allow(view).to receive(:params).and_return(id: role.id) + allow(view).to receive(:add_to_breadcrumbs) + allow(view).to receive(:breadcrumb_title) + allow(view).to receive(:page_title) + allow(view).to receive(:group_settings_roles_and_permissions_path).and_return('list/path') + + render + end + + it 'sets the breadcrumbs' do + expect(view).to have_received(:add_to_breadcrumbs).with('Roles and Permissions', 'list/path') + expect(view).to have_received(:breadcrumb_title).with(role.name) + end + + it 'sets the page title' do + expect(view).to have_received(:page_title).with(role.name, 'Roles and Permissions') + end + + it 'renders frontend placeholder' do + expect(rendered).to have_selector "#js-role-details[data-id='#{role.id}'][data-list-page-path='list/path']" + end + + it 'renders the loading spinner' do + expect(rendered).to have_selector '#js-role-details .gl-spinner' + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 60f418b221e81460de38f4814f2da5a7aca7a9e4..5b5152b634c67ced945635315d27a4541659b9d8 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -32824,6 +32824,9 @@ msgstr "" msgid "MemberRole|Custom role" msgstr "" +msgid "MemberRole|Custom role created on %{dateTime}" +msgstr "" + msgid "MemberRole|Custom roles" msgstr "" @@ -32854,6 +32857,9 @@ msgstr "" msgid "MemberRole|Failed to delete role. %{error}" msgstr "" +msgid "MemberRole|Failed to fetch role." +msgstr "" + msgid "MemberRole|Failed to fetch roles." msgstr "" @@ -32944,9 +32950,15 @@ msgstr "" msgid "MemberRole|This role has been manually selected and will not sync to the LDAP sync role." msgstr "" +msgid "MemberRole|This role is available by default and cannot be changed." +msgstr "" + msgid "MemberRole|To delete custom role, remove role from all group members." msgstr "" +msgid "MemberRole|To delete custom role, remove role from all users." +msgstr "" + msgid "MemberRole|Update role" msgstr ""