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 ""