]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-22807 Allow customization of mapping used for Gitlab
authorSarath Nair <91882341+sarath-nair-sonarsource@users.noreply.github.com>
Thu, 22 Aug 2024 15:13:58 +0000 (17:13 +0200)
committersonartech <sonartech@sonarsource.com>
Fri, 23 Aug 2024 20:02:33 +0000 (20:02 +0000)
12 files changed:
server/sonar-web/src/main/js/api/gitlab-provisioning.ts
server/sonar-web/src/main/js/api/mocks/GitlabProvisioningServiceMock.ts
server/sonar-web/src/main/js/apps/settings/components/authentication/DevopsRolesMappingModal.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/authentication/GitHubAuthenticationTab.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/GitHubMappingModal.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/GitLabAuthenticationTab.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/GitLabMappingModal.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-Github-it.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-Gitlab-it.tsx
server/sonar-web/src/main/js/queries/identity-provider/gitlab.ts
server/sonar-web/src/main/js/types/provisioning.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 7f0c67d928a6853efaf8083d4679677d18e760b3..8311008ec2e653607117dabc25f838d87e666cf2 100644 (file)
@@ -21,12 +21,14 @@ import axios from 'axios';
 import {
   GitLabConfigurationCreateBody,
   GitLabConfigurationUpdateBody,
+  GitLabMapping,
   GitlabConfiguration,
   ProvisioningType,
 } from '../types/provisioning';
 import { Paging } from '../types/types';
 
 const GITLAB_CONFIGURATIONS = '/api/v2/dop-translation/gitlab-configurations';
+const GITLAB_PERMISSION_MAPPINGS = '/api/v2/dop-translation/gitlab-permission-mappings';
 
 export function fetchGitLabConfigurations() {
   return axios.get<{ gitlabConfigurations: GitlabConfiguration[]; page: Paging }>(
@@ -64,3 +66,19 @@ export function deleteGitLabConfiguration(id: string): Promise<void> {
 export function syncNowGitLabProvisioning(): Promise<void> {
   return axios.post('/api/v2/dop-translation/gitlab-synchronization-runs');
 }
+
+export function fetchGitlabRolesMapping() {
+  return axios
+    .get<{ gitlabPermissionsMappings: GitLabMapping[] }>(GITLAB_PERMISSION_MAPPINGS)
+    .then((data) => data.gitlabPermissionsMappings);
+}
+
+export function updateGitlabRolesMapping(
+  role: string,
+  data: Partial<Pick<GitLabMapping, 'permissions'>>,
+) {
+  return axios.patch<GitLabMapping>(
+    `${GITLAB_PERMISSION_MAPPINGS}/${encodeURIComponent(role)}`,
+    data,
+  );
+}
index f7aa543b5420047e3ae72958b6bd3b13a07227ec..121c16a67020948054c1fe14177822d658cb26dc 100644 (file)
 import { cloneDeep, omit } from 'lodash';
 import { mockGitlabConfiguration } from '../../helpers/mocks/alm-integrations';
 import { mockPaging } from '../../helpers/testMocks';
-import { GitlabConfiguration } from '../../types/provisioning';
+import { GitLabMapping, GitlabConfiguration } from '../../types/provisioning';
 import {
   createGitLabConfiguration,
   deleteGitLabConfiguration,
   fetchGitLabConfiguration,
   fetchGitLabConfigurations,
+  fetchGitlabRolesMapping,
   updateGitLabConfiguration,
+  updateGitlabRolesMapping,
 } from '../gitlab-provisioning';
 
 jest.mock('../gitlab-provisioning');
@@ -35,16 +37,60 @@ const defaultGitlabConfiguration: GitlabConfiguration[] = [
   mockGitlabConfiguration({ id: '1', enabled: true }),
 ];
 
+const gitlabMappingMock = (id: string, permissions: (keyof GitLabMapping['permissions'])[]) => ({
+  id,
+  gitlabRole: id,
+  permissions: {
+    user: permissions.includes('user'),
+    codeViewer: permissions.includes('codeViewer'),
+    issueAdmin: permissions.includes('issueAdmin'),
+    securityHotspotAdmin: permissions.includes('securityHotspotAdmin'),
+    admin: permissions.includes('admin'),
+    scan: permissions.includes('scan'),
+  },
+});
+
+const defaultMapping: GitLabMapping[] = [
+  gitlabMappingMock('guest', ['user', 'codeViewer']),
+  gitlabMappingMock('reporter', ['user', 'codeViewer']),
+  gitlabMappingMock('developer', [
+    'user',
+    'codeViewer',
+    'issueAdmin',
+    'securityHotspotAdmin',
+    'scan',
+  ]),
+  gitlabMappingMock('maintainer', [
+    'user',
+    'codeViewer',
+    'issueAdmin',
+    'securityHotspotAdmin',
+    'scan',
+  ]),
+  gitlabMappingMock('owner', [
+    'user',
+    'codeViewer',
+    'issueAdmin',
+    'securityHotspotAdmin',
+    'admin',
+    'scan',
+  ]),
+];
+
 export default class GitlabProvisioningServiceMock {
   gitlabConfigurations: GitlabConfiguration[];
+  gitlabMapping: GitLabMapping[];
 
   constructor() {
     this.gitlabConfigurations = cloneDeep(defaultGitlabConfiguration);
+    this.gitlabMapping = cloneDeep(defaultMapping);
     jest.mocked(fetchGitLabConfigurations).mockImplementation(this.handleFetchGitLabConfigurations);
     jest.mocked(fetchGitLabConfiguration).mockImplementation(this.handleFetchGitLabConfiguration);
     jest.mocked(createGitLabConfiguration).mockImplementation(this.handleCreateGitLabConfiguration);
     jest.mocked(updateGitLabConfiguration).mockImplementation(this.handleUpdateGitLabConfiguration);
     jest.mocked(deleteGitLabConfiguration).mockImplementation(this.handleDeleteGitLabConfiguration);
+    jest.mocked(fetchGitlabRolesMapping).mockImplementation(this.handleFetchGilabRolesMapping);
+    jest.mocked(updateGitlabRolesMapping).mockImplementation(this.handleUpdateGitlabRolesMapping);
   }
 
   handleFetchGitLabConfigurations: typeof fetchGitLabConfigurations = () => {
@@ -87,6 +133,20 @@ export default class GitlabProvisioningServiceMock {
     this.gitlabConfigurations = gitlabConfigurations;
   };
 
+  handleFetchGilabRolesMapping: typeof fetchGitlabRolesMapping = () => {
+    return Promise.resolve(this.gitlabMapping);
+  };
+
+  handleUpdateGitlabRolesMapping: typeof updateGitlabRolesMapping = (id, data) => {
+    this.gitlabMapping = this.gitlabMapping.map((mapping) =>
+      mapping.id === id ? { ...mapping, ...data } : mapping,
+    );
+
+    return Promise.resolve(
+      this.gitlabMapping.find((mapping) => mapping.id === id) as GitLabMapping,
+    );
+  };
+
   reset = () => {
     this.gitlabConfigurations = cloneDeep(defaultGitlabConfiguration);
   };
diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/DevopsRolesMappingModal.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/DevopsRolesMappingModal.tsx
new file mode 100644 (file)
index 0000000..571303f
--- /dev/null
@@ -0,0 +1,278 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import { Spinner } from '@sonarsource/echoes-react';
+import {
+  ButtonSecondary,
+  Checkbox,
+  ContentCell,
+  DestructiveIcon,
+  FlagMessage,
+  FormField,
+  InputField,
+  Modal,
+  Table,
+  TableRow,
+  TableRowInteractive,
+  TrashIcon,
+} from 'design-system';
+import * as React from 'react';
+import PermissionHeader from '../../../../components/permissions/PermissionHeader';
+import { translate, translateWithParameters } from '../../../../helpers/l10n';
+import {
+  PERMISSIONS_ORDER_FOR_PROJECT_TEMPLATE,
+  convertToPermissionDefinitions,
+  isPermissionDefinitionGroup,
+} from '../../../../helpers/permissions';
+import { AlmKeys } from '../../../../types/alm-settings';
+import { GitHubMapping, GitLabMapping } from '../../../../types/provisioning';
+
+type RolesMapping = GitHubMapping[] | GitLabMapping[] | null;
+
+interface Props {
+  canAddCustomRole?: boolean;
+  isLoading: boolean;
+  mapping: RolesMapping;
+  mappingFor: AlmKeys.GitHub | AlmKeys.GitLab;
+  onClose: () => void;
+  roles?: RolesMapping;
+  setMapping: React.Dispatch<React.SetStateAction<RolesMapping>>;
+}
+
+interface PermissionCellProps extends Pick<Props, 'setMapping'> {
+  list?: GitHubMapping[] | GitLabMapping[];
+  mapping: GitHubMapping | GitLabMapping;
+}
+
+const DEFAULT_CUSTOM_ROLE_PERMISSIONS: GitHubMapping['permissions'] = {
+  user: true,
+  codeViewer: false,
+  issueAdmin: false,
+  securityHotspotAdmin: false,
+  admin: false,
+  scan: false,
+};
+
+function PermissionRow(props: Readonly<PermissionCellProps>) {
+  const { mapping } = props;
+  const isGitHubMapping = 'githubRole' in mapping;
+  const role = isGitHubMapping ? mapping.githubRole : mapping.gitlabRole;
+  const isBaseRole = isGitHubMapping ? mapping.baseRole : true;
+  const list = props.list as GitHubMapping[];
+
+  return (
+    <TableRowInteractive>
+      <ContentCell scope="row" className="sw-whitespace-nowrap">
+        <div className="sw-flex sw-max-w-[330px] sw-items-center">
+          <b className={isBaseRole ? 'sw-capitalize' : 'sw-truncate'} title={role}>
+            {role}
+          </b>
+
+          {!isBaseRole && (
+            <DestructiveIcon
+              className="sw-ml-1"
+              aria-label={translateWithParameters(
+                'settings.authentication.configuration.roles_mapping.dialog.delete_custom_role',
+                role,
+              )}
+              onClick={() => {
+                props.setMapping(list?.filter((r) => r.githubRole !== role) ?? null);
+              }}
+              Icon={TrashIcon}
+              size="small"
+            />
+          )}
+        </div>
+      </ContentCell>
+      {Object.entries(mapping.permissions).map(([key, value]) => (
+        <ContentCell key={key} className="sw-justify-center">
+          <Checkbox
+            checked={value}
+            onCheck={(newValue) =>
+              props.setMapping(
+                list?.map((item) =>
+                  item.id === mapping.id
+                    ? { ...item, permissions: { ...item.permissions, [key]: newValue } }
+                    : item,
+                ) ?? null,
+              )
+            }
+          />
+        </ContentCell>
+      ))}
+    </TableRowInteractive>
+  );
+}
+
+export function DevopsRolesMappingModal(props: Readonly<Props>) {
+  const { canAddCustomRole, isLoading, mapping, mappingFor, onClose, roles, setMapping } = props;
+  const permissions = convertToPermissionDefinitions(
+    PERMISSIONS_ORDER_FOR_PROJECT_TEMPLATE,
+    'projects_role',
+  );
+  const [customRoleInput, setCustomRoleInput] = React.useState('');
+  const [customRoleError, setCustomRoleError] = React.useState(false);
+
+  const header = translateWithParameters(
+    'settings.authentication.configuration.roles_mapping.dialog.title',
+    translate('alm', mappingFor),
+  );
+
+  const list = mapping ?? roles;
+
+  const validateAndAddCustomRole = (e: React.FormEvent) => {
+    e.preventDefault();
+    const value = customRoleInput.trim();
+    if (
+      !(list as GitHubMapping[])?.some((el) =>
+        el.baseRole ? el.githubRole.toLowerCase() === value.toLowerCase() : el.githubRole === value,
+      )
+    ) {
+      setMapping([
+        {
+          id: customRoleInput,
+          githubRole: customRoleInput,
+          permissions: { ...DEFAULT_CUSTOM_ROLE_PERMISSIONS },
+        },
+        ...((list as GitHubMapping[]) ?? []),
+      ]);
+      setCustomRoleInput('');
+    } else {
+      setCustomRoleError(true);
+    }
+  };
+
+  const haveEmptyCustomRoles =
+    mappingFor === AlmKeys.GitHub &&
+    !!mapping?.some((el) => !el.baseRole && !Object.values(el.permissions).some(Boolean));
+
+  const formBody = (
+    <div className="sw-p-0">
+      <Table
+        noHeaderTopBorder
+        columnCount={permissions.length + 1}
+        columnWidths={['auto', ...Array(permissions.length).fill('1%')]}
+        header={
+          <TableRow className="sw-sticky sw-top-0 sw-bg-white">
+            <ContentCell className="sw-whitespace-nowrap">
+              {translate('settings.authentication.configuration.roles_mapping.dialog.roles_column')}
+            </ContentCell>
+            {permissions.map((permission) => (
+              <PermissionHeader
+                key={isPermissionDefinitionGroup(permission) ? permission.category : permission.key}
+                permission={permission}
+              />
+            ))}
+          </TableRow>
+        }
+      >
+        {list
+          ?.filter((r) => ('githubRole' in r ? r.baseRole : true))
+          .map((mapping) => (
+            <PermissionRow key={mapping.id} mapping={mapping} setMapping={setMapping} list={list} />
+          ))}
+        {canAddCustomRole && (
+          <>
+            <TableRow>
+              <ContentCell colSpan={7}>
+                <div className="sw-flex sw-items-end">
+                  <form className="sw-flex sw-items-end" onSubmit={validateAndAddCustomRole}>
+                    <FormField
+                      htmlFor="custom-role-input"
+                      label={translate(
+                        'settings.authentication.configuration.roles_mapping.dialog.add_custom_role',
+                      )}
+                    >
+                      <InputField
+                        className="sw-w-[300px]"
+                        id="custom-role-input"
+                        maxLength={4000}
+                        value={customRoleInput}
+                        onChange={(event) => {
+                          setCustomRoleError(false);
+                          setCustomRoleInput(event.currentTarget.value);
+                        }}
+                        type="text"
+                      />
+                    </FormField>
+                    <ButtonSecondary
+                      type="submit"
+                      className="sw-ml-2 sw-mr-4"
+                      disabled={customRoleInput.trim() === '' || customRoleError}
+                    >
+                      {translate('add_verb')}
+                    </ButtonSecondary>
+                  </form>
+                  {customRoleError && (
+                    <FlagMessage variant="error">
+                      {translate('settings.authentication.configuration.roles_mapping.role_exists')}
+                    </FlagMessage>
+                  )}
+                </div>
+              </ContentCell>
+            </TableRow>
+
+            {list
+              ?.filter((r) => !r.baseRole)
+              .map((mapping) => (
+                <PermissionRow
+                  key={mapping.id}
+                  mapping={mapping}
+                  setMapping={setMapping}
+                  list={list}
+                />
+              ))}
+          </>
+        )}
+      </Table>
+
+      {canAddCustomRole && (
+        <FlagMessage variant="info">
+          {translate(
+            'settings.authentication.github.configuration.roles_mapping.dialog.custom_roles_description',
+          )}
+        </FlagMessage>
+      )}
+
+      <Spinner isLoading={isLoading} />
+    </div>
+  );
+
+  return (
+    <Modal onClose={onClose} isLarge>
+      <Modal.Header title={header} />
+      <Modal.Body>{formBody}</Modal.Body>
+      <Modal.Footer
+        secondaryButton={
+          <div className="sw-flex sw-items-center sw-justify-end sw-mt-2">
+            {haveEmptyCustomRoles && (
+              <FlagMessage variant="error" className="sw-inline-block sw-mb-0 sw-mr-2">
+                {translate('settings.authentication.configuration.roles_mapping.empty_custom_role')}
+              </FlagMessage>
+            )}
+            <ButtonSecondary disabled={haveEmptyCustomRoles} onClick={onClose}>
+              {translate('close')}
+            </ButtonSecondary>
+          </div>
+        }
+      />
+    </Modal>
+  );
+}
index 90b6bbcad3f42c31a54d63bcfe83165791d090ec..7c024a157d2e6c3bdcfc4360df6fd06bed339243 100644 (file)
@@ -285,13 +285,13 @@ export default function GitHubAuthenticationTab() {
                   <div className="sw-mt-6">
                     <div className="sw-flex">
                       <Highlight className="sw-mb-4 sw-mr-4 sw-flex sw-items-center sw-gap-2">
-                        <FormattedMessage id="settings.authentication.github.configuration.roles_mapping.title" />
+                        <FormattedMessage id="settings.authentication.configuration.roles_mapping.title" />
                       </Highlight>
                       <ButtonSecondary
                         className="sw--mt-2"
                         onClick={() => setIsMappingModalOpen(true)}
                       >
-                        <FormattedMessage id="settings.authentication.github.configuration.roles_mapping.button_label" />
+                        <FormattedMessage id="settings.authentication.configuration.roles_mapping.button_label" />
                       </ButtonSecondary>
                     </div>
                     <Note className="sw-mt-2">
index a13456ad010a5e3cbbf619ae499871f2e0a741ad..ed50608d1903be5328ce7b711eae28b2284fbeea 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import {
-  ButtonSecondary,
-  Checkbox,
-  ContentCell,
-  DestructiveIcon,
-  FlagMessage,
-  FormField,
-  InputField,
-  Modal,
-  Spinner,
-  Table,
-  TableRow,
-  TableRowInteractive,
-  TrashIcon,
-} from 'design-system';
 import * as React from 'react';
-import PermissionHeader from '../../../../components/permissions/PermissionHeader';
-import { translate, translateWithParameters } from '../../../../helpers/l10n';
-import {
-  PERMISSIONS_ORDER_FOR_PROJECT_TEMPLATE,
-  convertToPermissionDefinitions,
-  isPermissionDefinitionGroup,
-} from '../../../../helpers/permissions';
 import { useGithubRolesMappingQuery } from '../../../../queries/identity-provider/github';
+import { AlmKeys } from '../../../../types/alm-settings';
 import { GitHubMapping } from '../../../../types/provisioning';
+import { DevopsRolesMappingModal } from './DevopsRolesMappingModal';
 
 interface Props {
   mapping: GitHubMapping[] | null;
@@ -49,215 +29,15 @@ interface Props {
   setMapping: React.Dispatch<React.SetStateAction<GitHubMapping[] | null>>;
 }
 
-interface PermissionCellProps {
-  list?: GitHubMapping[];
-  mapping: GitHubMapping;
-  setMapping: React.Dispatch<React.SetStateAction<GitHubMapping[] | null>>;
-}
-
-const DEFAULT_CUSTOM_ROLE_PERMISSIONS: GitHubMapping['permissions'] = {
-  user: true,
-  codeViewer: false,
-  issueAdmin: false,
-  securityHotspotAdmin: false,
-  admin: false,
-  scan: false,
-};
-
-function PermissionRow(props: Readonly<PermissionCellProps>) {
-  const { mapping, list } = props;
-
-  return (
-    <TableRowInteractive>
-      <ContentCell scope="row" className="sw-whitespace-nowrap">
-        <div className="sw-flex sw-max-w-[330px] sw-items-center">
-          <b
-            className={mapping.baseRole ? 'sw-capitalize' : 'sw-truncate'}
-            title={mapping.githubRole}
-          >
-            {mapping.githubRole}
-          </b>
-          {!mapping.baseRole && (
-            <DestructiveIcon
-              className="sw-ml-1"
-              aria-label={translateWithParameters(
-                'settings.authentication.github.configuration.roles_mapping.dialog.delete_custom_role',
-                mapping.githubRole,
-              )}
-              onClick={() => {
-                props.setMapping(list?.filter((r) => r.githubRole !== mapping.githubRole) ?? null);
-              }}
-              Icon={TrashIcon}
-              size="small"
-            />
-          )}
-        </div>
-      </ContentCell>
-      {Object.entries(mapping.permissions).map(([key, value]) => (
-        <ContentCell key={key} className="sw-justify-center">
-          <Checkbox
-            checked={value}
-            onCheck={(newValue) =>
-              props.setMapping(
-                list?.map((item) =>
-                  item.id === mapping.id
-                    ? { ...item, permissions: { ...item.permissions, [key]: newValue } }
-                    : item,
-                ) ?? null,
-              )
-            }
-          />
-        </ContentCell>
-      ))}
-    </TableRowInteractive>
-  );
-}
-
-export default function GitHubMappingModal({ mapping, setMapping, onClose }: Readonly<Props>) {
+export default function GitHubMappingModal(props: Readonly<Props>) {
   const { data: roles, isPending } = useGithubRolesMappingQuery();
-  const permissions = convertToPermissionDefinitions(
-    PERMISSIONS_ORDER_FOR_PROJECT_TEMPLATE,
-    'projects_role',
-  );
-  const [customRoleInput, setCustomRoleInput] = React.useState('');
-  const [customRoleError, setCustomRoleError] = React.useState(false);
-
-  const header = translate(
-    'settings.authentication.github.configuration.roles_mapping.dialog.title',
-  );
-
-  const list = mapping ?? roles;
-
-  const validateAndAddCustomRole = (e: React.FormEvent) => {
-    e.preventDefault();
-    const value = customRoleInput.trim();
-    if (
-      !list?.some((el) =>
-        el.baseRole ? el.githubRole.toLowerCase() === value.toLowerCase() : el.githubRole === value,
-      )
-    ) {
-      setMapping([
-        {
-          id: customRoleInput,
-          githubRole: customRoleInput,
-          permissions: { ...DEFAULT_CUSTOM_ROLE_PERMISSIONS },
-        },
-        ...(list ?? []),
-      ]);
-      setCustomRoleInput('');
-    } else {
-      setCustomRoleError(true);
-    }
-  };
-
-  const haveEmptyCustomRoles = !!mapping?.some(
-    (el) => !el.baseRole && !Object.values(el.permissions).some(Boolean),
-  );
-
-  const formBody = (
-    <div className="sw-p-0">
-      <Table
-        noHeaderTopBorder
-        columnCount={permissions.length + 1}
-        columnWidths={['auto', ...Array(permissions.length).fill('1%')]}
-        header={
-          <TableRow className="sw-sticky sw-top-0 sw-bg-white">
-            <ContentCell className="sw-whitespace-nowrap">
-              {translate(
-                'settings.authentication.github.configuration.roles_mapping.dialog.roles_column',
-              )}
-            </ContentCell>
-            {permissions.map((permission) => (
-              <PermissionHeader
-                key={isPermissionDefinitionGroup(permission) ? permission.category : permission.key}
-                permission={permission}
-              />
-            ))}
-          </TableRow>
-        }
-      >
-        {list
-          ?.filter((r) => r.baseRole)
-          .map((mapping) => (
-            <PermissionRow key={mapping.id} mapping={mapping} setMapping={setMapping} list={list} />
-          ))}
-
-        <TableRow>
-          <ContentCell colSpan={7}>
-            <div className="sw-flex sw-items-end">
-              <form className="sw-flex sw-items-end" onSubmit={validateAndAddCustomRole}>
-                <FormField
-                  htmlFor="custom-role-input"
-                  label={translate(
-                    'settings.authentication.github.configuration.roles_mapping.dialog.add_custom_role',
-                  )}
-                >
-                  <InputField
-                    className="sw-w-[300px]"
-                    id="custom-role-input"
-                    maxLength={4000}
-                    value={customRoleInput}
-                    onChange={(event) => {
-                      setCustomRoleError(false);
-                      setCustomRoleInput(event.currentTarget.value);
-                    }}
-                    type="text"
-                  />
-                </FormField>
-                <ButtonSecondary
-                  type="submit"
-                  className="sw-ml-2 sw-mr-4"
-                  disabled={!customRoleInput.trim() || customRoleError}
-                >
-                  {translate('add_verb')}
-                </ButtonSecondary>
-              </form>
-              {customRoleError && (
-                <FlagMessage variant="error">
-                  {translate(
-                    'settings.authentication.github.configuration.roles_mapping.role_exists',
-                  )}
-                </FlagMessage>
-              )}
-            </div>
-          </ContentCell>
-        </TableRow>
-
-        {list
-          ?.filter((r) => !r.baseRole)
-          .map((mapping) => (
-            <PermissionRow key={mapping.id} mapping={mapping} setMapping={setMapping} list={list} />
-          ))}
-      </Table>
-      <FlagMessage variant="info">
-        {translate(
-          'settings.authentication.github.configuration.roles_mapping.dialog.custom_roles_description',
-        )}
-      </FlagMessage>
-      <Spinner loading={isPending} />
-    </div>
-  );
-
   return (
-    <Modal onClose={onClose} isLarge>
-      <Modal.Header title={header} />
-      <Modal.Body>{formBody}</Modal.Body>
-      <Modal.Footer
-        secondaryButton={
-          <div className="sw-flex sw-items-center sw-justify-end sw-mt-2">
-            {haveEmptyCustomRoles && (
-              <FlagMessage variant="error" className="sw-inline-block sw-mb-0 sw-mr-2">
-                {translate(
-                  'settings.authentication.github.configuration.roles_mapping.empty_custom_role',
-                )}
-              </FlagMessage>
-            )}
-            <ButtonSecondary disabled={haveEmptyCustomRoles} onClick={onClose}>
-              {translate('close')}
-            </ButtonSecondary>
-          </div>
-        }
-      />
-    </Modal>
+    <DevopsRolesMappingModal
+      canAddCustomRole
+      isLoading={isPending}
+      mappingFor={AlmKeys.GitHub}
+      roles={roles}
+      {...props}
+    />
   );
 }
index 59562f8facdafc56cfcf32253c31ebe31d854e20..1499e8674f4b33e0210c2dd7c3363218676d6db7 100644 (file)
@@ -18,9 +18,9 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 
-import { Spinner } from 'design-system';
+import { ButtonSecondary, Highlight, Note, Spinner } from 'design-system';
 import { isEmpty, omitBy } from 'lodash';
-import React, { FormEvent, useContext } from 'react';
+import React, { FormEvent, useContext, useState } from 'react';
 import { FormattedMessage } from 'react-intl';
 import GitLabSynchronisationWarning from '../../../../app/components/GitLabSynchronisationWarning';
 import { AvailableFeaturesContext } from '../../../../app/components/available-features/AvailableFeaturesContext';
@@ -31,11 +31,16 @@ import { useIdentityProviderQuery } from '../../../../queries/identity-provider/
 import {
   useDeleteGitLabConfigurationMutation,
   useGitLabConfigurationsQuery,
+  useGitlabRolesMappingMutation,
   useSyncWithGitLabNow,
   useUpdateGitLabConfigurationMutation,
 } from '../../../../queries/identity-provider/gitlab';
 import { Feature } from '../../../../types/features';
-import { GitLabConfigurationUpdateBody, ProvisioningType } from '../../../../types/provisioning';
+import {
+  GitLabConfigurationUpdateBody,
+  GitLabMapping,
+  ProvisioningType,
+} from '../../../../types/provisioning';
 import { DefinitionV2, SettingType } from '../../../../types/settings';
 import { Provider } from '../../../../types/types';
 import AuthenticationFormField from './AuthenticationFormField';
@@ -44,6 +49,7 @@ import ConfigurationDetails from './ConfigurationDetails';
 import ConfirmProvisioningModal from './ConfirmProvisioningModal';
 import GitLabConfigurationForm from './GitLabConfigurationForm';
 import GitLabConfigurationValidity from './GitLabConfigurationValidity';
+import GitLabMappingModal from './GitLabMappingModal';
 import ProvisioningSection from './ProvisioningSection';
 import TabHeader from './TabHeader';
 
@@ -84,10 +90,13 @@ const getDefinitions = (
 };
 
 export default function GitLabAuthenticationTab() {
-  const [openForm, setOpenForm] = React.useState(false);
-  const [changes, setChanges] = React.useState<ChangesForm | undefined>(undefined);
-  const [tokenKey, setTokenKey] = React.useState<number>(0);
-  const [showConfirmProvisioningModal, setShowConfirmProvisioningModal] = React.useState(false);
+  const [openForm, setOpenForm] = useState(false);
+  const [changes, setChanges] = useState<ChangesForm | undefined>();
+  const [tokenKey, setTokenKey] = useState<number>(0);
+  const [showConfirmProvisioningModal, setShowConfirmProvisioningModal] = useState(false);
+  const [isMappingModalOpen, setIsMappingModalOpen] = useState(false);
+  const [rolesMapping, setRolesMapping] = useState<GitLabMapping[] | null>(null);
+  const { mutateAsync: updateMapping } = useGitlabRolesMappingMutation();
 
   const hasGitlabProvisioningFeature = useContext(AvailableFeaturesContext).includes(
     Feature.GitlabProvisioning,
@@ -137,7 +146,7 @@ export default function GitLabAuthenticationTab() {
   };
 
   const updateProvisioning = () => {
-    if (!changes || !configuration) {
+    if ((!changes && !rolesMapping) || !configuration) {
       return;
     }
 
@@ -150,6 +159,14 @@ export default function GitLabAuthenticationTab() {
         },
       },
     );
+
+    if (provisioningType === ProvisioningType.auto && rolesMapping) {
+      updateMapping(rolesMapping)
+        .then(() => {
+          setRolesMapping(null);
+        })
+        .catch(() => {});
+    }
   };
 
   const setJIT = () =>
@@ -178,11 +195,14 @@ export default function GitLabAuthenticationTab() {
   const provisioningToken = changes?.provisioningToken;
 
   const canSave = () => {
-    if (!configuration || changes === undefined || isUpdating) {
+    if (!configuration || (changes === undefined && rolesMapping === null) || isUpdating) {
       return false;
     }
-    const type = changes.provisioningType ?? configuration.provisioningType;
-    if (type === ProvisioningType.auto) {
+    const type = changes?.provisioningType ?? configuration.provisioningType;
+    if (type === ProvisioningType.auto && rolesMapping !== null) {
+      return true;
+    }
+    if (changes && type === ProvisioningType.auto) {
       const areGroupsDefined =
         changes.allowedGroups?.some((val) => val !== '') ??
         configuration.allowedGroups?.some((val) => val !== '');
@@ -261,12 +281,13 @@ export default function GitLabAuthenticationTab() {
               }
               disabledConfigText={translate('settings.authentication.gitlab.enable_first')}
               enabled={configuration.enabled}
-              hasUnsavedChanges={changes !== undefined}
+              hasUnsavedChanges={changes !== undefined || rolesMapping !== null}
               canSave={canSave()}
               onSave={handleSubmit}
               onCancel={() => {
                 setChanges(undefined);
                 setTokenKey(tokenKey + 1);
+                setRolesMapping(null);
               }}
               jitTitle={translate('settings.authentication.gitlab.provisioning_at_login')}
               jitDescription={
@@ -370,6 +391,22 @@ export default function GitLabAuthenticationTab() {
                     }
                     isNotSet={configuration.provisioningType !== ProvisioningType.auto}
                   />
+                  <div className="sw-mt-6">
+                    <div className="sw-flex">
+                      <Highlight className="sw-mb-4 sw-mr-4 sw-flex sw-items-center sw-gap-2">
+                        <FormattedMessage id="settings.authentication.configuration.roles_mapping.title" />
+                      </Highlight>
+                      <ButtonSecondary
+                        className="sw--mt-2"
+                        onClick={() => setIsMappingModalOpen(true)}
+                      >
+                        <FormattedMessage id="settings.authentication.configuration.roles_mapping.button_label" />
+                      </ButtonSecondary>
+                    </div>
+                    <Note className="sw-mt-2">
+                      <FormattedMessage id="settings.authentication.gitlab.configuration.roles_mapping.description" />
+                    </Note>
+                  </div>
                 </>
               }
             />
@@ -387,6 +424,13 @@ export default function GitLabAuthenticationTab() {
           provisioningStatus={provisioningType}
         />
       )}
+      {isMappingModalOpen && (
+        <GitLabMappingModal
+          mapping={rolesMapping}
+          setMapping={setRolesMapping}
+          onClose={() => setIsMappingModalOpen(false)}
+        />
+      )}
       {openForm && (
         <GitLabConfigurationForm
           gitlabConfiguration={configuration ?? null}
diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/GitLabMappingModal.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/GitLabMappingModal.tsx
new file mode 100644 (file)
index 0000000..b5b8c2a
--- /dev/null
@@ -0,0 +1,42 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { useGitlabRolesMappingQuery } from '../../../../queries/identity-provider/gitlab';
+import { AlmKeys } from '../../../../types/alm-settings';
+import { GitLabMapping } from '../../../../types/provisioning';
+import { DevopsRolesMappingModal } from './DevopsRolesMappingModal';
+
+interface Props {
+  mapping: GitLabMapping[] | null;
+  onClose: () => void;
+  setMapping: React.Dispatch<React.SetStateAction<GitLabMapping[] | null>>;
+}
+
+export default function GitLabMappingModal(props: Readonly<Props>) {
+  const { data: roles, isPending } = useGitlabRolesMappingQuery();
+  return (
+    <DevopsRolesMappingModal
+      roles={roles}
+      isLoading={isPending}
+      mappingFor={AlmKeys.GitLab}
+      {...props}
+    />
+  );
+}
index 5e75ebb38af21939a5573337d6732bf51b5ba576..84dad7c9f3f696649dd4844eaa60539d7b5f001a 100644 (file)
@@ -120,31 +120,31 @@ const ui = {
     name: 'settings.authentication.form.edit',
   }),
   editMappingButton: ghContainer.byRole('button', {
-    name: 'settings.authentication.github.configuration.roles_mapping.button_label',
+    name: 'settings.authentication.configuration.roles_mapping.button_label',
   }),
   mappingRow: byRole('dialog', {
-    name: 'settings.authentication.github.configuration.roles_mapping.dialog.title',
+    name: 'settings.authentication.configuration.roles_mapping.dialog.title.alm.github',
   }).byRole('row'),
   customRoleInput: byRole('textbox', {
-    name: 'settings.authentication.github.configuration.roles_mapping.dialog.add_custom_role',
+    name: 'settings.authentication.configuration.roles_mapping.dialog.add_custom_role',
   }),
   customRoleAddBtn: byRole('dialog', {
-    name: 'settings.authentication.github.configuration.roles_mapping.dialog.title',
+    name: 'settings.authentication.configuration.roles_mapping.dialog.title.alm.github',
   }).byRole('button', { name: 'add_verb' }),
   roleExistsError: byRole('dialog', {
-    name: 'settings.authentication.github.configuration.roles_mapping.dialog.title',
-  }).byText('settings.authentication.github.configuration.roles_mapping.role_exists'),
+    name: 'settings.authentication.configuration.roles_mapping.dialog.title.alm.github',
+  }).byText('settings.authentication.configuration.roles_mapping.role_exists'),
   emptyRoleError: byRole('dialog', {
-    name: 'settings.authentication.github.configuration.roles_mapping.dialog.title',
-  }).byText('settings.authentication.github.configuration.roles_mapping.empty_custom_role'),
+    name: 'settings.authentication.configuration.roles_mapping.dialog.title.alm.github',
+  }).byText('settings.authentication.configuration.roles_mapping.empty_custom_role'),
   deleteCustomRoleCustom2: byRole('button', {
-    name: 'settings.authentication.github.configuration.roles_mapping.dialog.delete_custom_role.custom2',
+    name: 'settings.authentication.configuration.roles_mapping.dialog.delete_custom_role.custom2',
   }),
   getMappingRowByRole: (text: string) =>
     ui.mappingRow.getAll().find((row) => within(row).queryByText(text) !== null),
   mappingCheckbox: byRole('checkbox'),
   mappingDialogClose: byRole('dialog', {
-    name: 'settings.authentication.github.configuration.roles_mapping.dialog.title',
+    name: 'settings.authentication.configuration.roles_mapping.dialog.title.alm.github',
   }).byRole('button', {
     name: 'close',
   }),
index 186bbc3ab962593057d5799da93cff5388a75e2c..5d25656e2b39695b954f4b0e926095e9b1803b9f 100644 (file)
@@ -17,6 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+import { within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import React from 'react';
 import { byRole, byText } from '~sonar-aligned/helpers/testSelector';
@@ -63,6 +64,9 @@ const ui = {
   editConfigButton: glContainer.byRole('button', {
     name: 'settings.authentication.form.edit',
   }),
+  editMappingButton: glContainer.byRole('button', {
+    name: 'settings.authentication.configuration.roles_mapping.button_label',
+  }),
   deleteConfigButton: glContainer.byRole('button', {
     name: 'settings.authentication.form.delete',
   }),
@@ -153,6 +157,17 @@ const ui = {
   consentDialog: byRole('dialog', {
     name: 'settings.authentication.confirm_auto_provisioning.header',
   }),
+  mappingRow: byRole('dialog', {
+    name: 'settings.authentication.configuration.roles_mapping.dialog.title.alm.gitlab',
+  }).byRole('row'),
+  mappingCheckbox: byRole('checkbox'),
+  mappingDialogClose: byRole('dialog', {
+    name: 'settings.authentication.configuration.roles_mapping.dialog.title.alm.gitlab',
+  }).byRole('button', {
+    name: 'close',
+  }),
+  getMappingRowByRole: (text: string) =>
+    ui.mappingRow.getAll().find((row) => within(row).queryByText(text) !== null),
 };
 
 it('should create a Gitlab configuration and disable it with proper validation', async () => {
@@ -634,6 +649,94 @@ describe('Gitlab Provisioning', () => {
     expect(await ui.jitProvisioningRadioButton.find()).toBeChecked();
     expect(ui.consentDialog.query()).not.toBeInTheDocument();
   });
+
+  it('should sort mapping rows', async () => {
+    const user = userEvent.setup();
+    handler.setGitlabConfigurations([
+      mockGitlabConfiguration({
+        allowUsersToSignUp: false,
+        enabled: true,
+        provisioningType: ProvisioningType.auto,
+        allowedGroups: ['D12'],
+        isProvisioningTokenSet: true,
+      }),
+    ]);
+    renderAuthentication([Feature.GitlabProvisioning]);
+
+    expect(await ui.editMappingButton.find()).toBeInTheDocument();
+    await user.click(ui.editMappingButton.get());
+
+    const rows = (await ui.mappingRow.findAll()).filter(
+      (row) => within(row).queryAllByRole('checkbox').length > 0,
+    );
+
+    expect(rows).toHaveLength(5);
+
+    expect(rows[0]).toHaveTextContent('guest');
+    expect(rows[1]).toHaveTextContent('reporter');
+    expect(rows[2]).toHaveTextContent('developer');
+    expect(rows[3]).toHaveTextContent('maintainer');
+    expect(rows[4]).toHaveTextContent('owner');
+  });
+
+  it('should apply new mapping and new provisioning type at the same time', async () => {
+    const user = userEvent.setup();
+    handler.setGitlabConfigurations([
+      mockGitlabConfiguration({
+        allowUsersToSignUp: false,
+        enabled: true,
+        provisioningType: ProvisioningType.jit,
+        allowedGroups: ['D12'],
+        isProvisioningTokenSet: true,
+      }),
+    ]);
+    renderAuthentication([Feature.GitlabProvisioning]);
+
+    expect(await ui.jitProvisioningRadioButton.find()).toBeChecked();
+    expect(ui.editMappingButton.query()).not.toBeInTheDocument();
+    await user.click(ui.autoProvisioningRadioButton.get());
+    expect(await ui.editMappingButton.find()).toBeInTheDocument();
+    await user.click(ui.editMappingButton.get());
+
+    expect(await ui.mappingRow.findAll()).toHaveLength(6);
+
+    let guestCheckboxes = ui.mappingCheckbox.getAll(ui.getMappingRowByRole('guest'));
+    let ownerCheckboxes = ui.mappingCheckbox.getAll(ui.getMappingRowByRole('owner'));
+
+    expect(guestCheckboxes[0]).toBeChecked();
+    expect(guestCheckboxes[1]).toBeChecked();
+    expect(guestCheckboxes[2]).not.toBeChecked();
+    expect(guestCheckboxes[3]).not.toBeChecked();
+    expect(guestCheckboxes[4]).not.toBeChecked();
+    expect(guestCheckboxes[5]).not.toBeChecked();
+    expect(ownerCheckboxes[0]).toBeChecked();
+    expect(ownerCheckboxes[1]).toBeChecked();
+    expect(ownerCheckboxes[2]).toBeChecked();
+    expect(ownerCheckboxes[3]).toBeChecked();
+    expect(ownerCheckboxes[4]).toBeChecked();
+    expect(ownerCheckboxes[5]).toBeChecked();
+
+    await user.click(guestCheckboxes[0]);
+    await user.click(guestCheckboxes[5]);
+    await user.click(ownerCheckboxes[5]);
+    await user.click(ui.mappingDialogClose.get());
+
+    await user.click(ui.saveProvisioning.get());
+    await user.click(ui.confirmProvisioningChange.get());
+
+    // Clean local mapping state
+    await user.click(ui.jitProvisioningRadioButton.get());
+    await user.click(ui.autoProvisioningRadioButton.get());
+
+    await user.click(ui.editMappingButton.get());
+    guestCheckboxes = ui.mappingCheckbox.getAll(ui.getMappingRowByRole('guest'));
+    ownerCheckboxes = ui.mappingCheckbox.getAll(ui.getMappingRowByRole('owner'));
+
+    expect(guestCheckboxes[0]).not.toBeChecked();
+    expect(guestCheckboxes[5]).toBeChecked();
+    expect(ownerCheckboxes[5]).not.toBeChecked();
+    await user.click(ui.mappingDialogClose.get());
+  });
 });
 
 function renderAuthentication(features: Feature[] = []) {
index eec78f438f42f8c43687e293b3b7f8056c691bc5..bc109a8e03098b1c3875b4b9c38cda3f6cddc7fc 100644 (file)
  */
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import { addGlobalSuccessMessage } from 'design-system';
+import { isEqual, keyBy, partition, pick, unionBy } from 'lodash';
 import { getActivity } from '../../api/ce';
 import {
   createGitLabConfiguration,
   deleteGitLabConfiguration,
   fetchGitLabConfigurations,
+  fetchGitlabRolesMapping,
   syncNowGitLabProvisioning,
   updateGitLabConfiguration,
+  updateGitlabRolesMapping,
 } from '../../api/gitlab-provisioning';
 import { translate } from '../../helpers/l10n';
 import { mapReactQueryResult } from '../../helpers/react-query';
-import { AlmSyncStatus, ProvisioningType } from '../../types/provisioning';
+import { AlmSyncStatus, GitLabMapping, ProvisioningType } from '../../types/provisioning';
 import { TaskStatuses, TaskTypes } from '../../types/tasks';
 
+const MAPPING_STALE_TIME = 60_000;
+
 export function useGitLabConfigurationsQuery() {
   return useQuery({
     queryKey: ['identity_provider', 'gitlab_config', 'list'],
@@ -189,3 +194,52 @@ export function useSyncWithGitLabNow() {
     canSyncNow: autoProvisioningEnabled && !syncStatus?.nextSync && !mutation.isPending,
   };
 }
+
+// Order is reversed to put custom roles at the end (their index is -1)
+const defaultRoleOrder = ['owner', 'maintainer', 'developer', 'reporter', 'guest'];
+
+export function useGitlabRolesMappingQuery() {
+  return useQuery({
+    queryKey: ['identity_provider', 'gitlab_mapping'],
+    queryFn: fetchGitlabRolesMapping,
+    staleTime: MAPPING_STALE_TIME,
+    select: (data) =>
+      [...data].sort((a, b) => {
+        if (defaultRoleOrder.includes(a.id) || defaultRoleOrder.includes(b.id)) {
+          return defaultRoleOrder.indexOf(b.id) - defaultRoleOrder.indexOf(a.id);
+        }
+        return a.gitlabRole.localeCompare(b.gitlabRole);
+      }),
+  });
+}
+
+export function useGitlabRolesMappingMutation() {
+  const client = useQueryClient();
+  const queryKey = ['identity_provider', 'gitlab_mapping'];
+  return useMutation({
+    mutationFn: async (mapping: GitLabMapping[]) => {
+      const state = keyBy(client.getQueryData<GitLabMapping[]>(queryKey), (m) => m.id);
+
+      const [maybeChangedRoles] = partition(mapping, (m) => state[m.id]);
+      const changedRoles = maybeChangedRoles.filter((item) => !isEqual(item, state[item.id]));
+
+      return {
+        addedOrChanged: await Promise.all([
+          ...changedRoles.map((data) =>
+            updateGitlabRolesMapping(data.id, pick(data, 'permissions')),
+          ),
+        ]),
+      };
+    },
+    onSuccess: ({ addedOrChanged }) => {
+      const state = client.getQueryData<GitLabMapping[]>(queryKey);
+      if (state) {
+        const newData = unionBy(addedOrChanged, state, (el) => el.id);
+        client.setQueryData(queryKey, newData);
+      }
+      addGlobalSuccessMessage(
+        translate('settings.authentication.gitlab.configuration.roles_mapping.save_success'),
+      );
+    },
+  });
+}
index 38f140a0745de1e6318d464363a3a17cf9ad3d73..ac47a51e196e1690d5f9c6c2df5064dd95ec37eb 100644 (file)
@@ -78,9 +78,8 @@ export interface GitHubConfigurationStatus {
   }[];
 }
 
-export interface GitHubMapping {
+interface DevopsRolesMapping {
   readonly baseRole?: boolean;
-  readonly githubRole: string;
   readonly id: string;
   permissions: {
     admin: boolean;
@@ -92,6 +91,14 @@ export interface GitHubMapping {
   };
 }
 
+export interface GitHubMapping extends DevopsRolesMapping {
+  readonly githubRole: string;
+}
+
+export interface GitLabMapping extends DevopsRolesMapping {
+  readonly gitlabRole: string;
+}
+
 export interface GitLabConfigurationCreateBody
   extends Pick<GitlabConfiguration, 'applicationId' | 'synchronizeGroups' | 'url'> {
   secret: string;
index 061841c2f798910ebee0993cdeda836f9bc1cb95..b99581de2acfc21b711426270adfcfac5278ee85 100644 (file)
@@ -1612,16 +1612,8 @@ settings.authentication.github.configuration.validation.details.title=Configurat
 settings.authentication.github.configuration.validation.details.valid_label=Valid
 settings.authentication.github.configuration.validation.details.invalid_label=Invalid
 settings.authentication.github.configuration.validation.details.org_not_found={0} (not found or app not installed)
-settings.authentication.github.configuration.roles_mapping.title=Role permission mapping
 settings.authentication.github.configuration.roles_mapping.description=When synchronizing users and groups, SonarQube assigns permissions based on GitHub user and team roles. You can customize the mapping of permissions. The new mapping will take effect at the next synchronization.
-settings.authentication.github.configuration.roles_mapping.button_label=Edit mapping
-settings.authentication.github.configuration.roles_mapping.dialog.title=Global GitHub role mapping
-settings.authentication.github.configuration.roles_mapping.dialog.roles_column=Roles
-settings.authentication.github.configuration.roles_mapping.dialog.add_custom_role=Add custom role:
 settings.authentication.github.configuration.roles_mapping.dialog.custom_roles_description=When a custom role name added here matches an existing GitHub custom role in any of your organizations, the mapping applies to all users with this custom role. If an existing GitHub custom role has no exact match in this list, the permissions of its inherited base role are mapped.
-settings.authentication.github.configuration.roles_mapping.dialog.delete_custom_role=Delete custom role {0}
-settings.authentication.github.configuration.roles_mapping.role_exists=Role already exists.
-settings.authentication.github.configuration.roles_mapping.empty_custom_role=Custom roles should have some permissions.
 settings.authentication.github.configuration.roles_mapping.save_success=GitHub roles mapping saved successfully.
 settings.authentication.github.configuration.unsaved_changes=You have unsaved changes.
 
@@ -1665,6 +1657,8 @@ settings.authentication.gitlab.form.provisioning.disabled=Your current edition d
 settings.authentication.gitlab.configuration.unsaved_changes=You have unsaved changes.
 settings.authentication.gitlab.configuration.valid.JIT=Configuration is valid for Just-in-Time provisioning.
 settings.authentication.gitlab.configuration.valid.AUTO_PROVISIONING=Configuration is valid for Automatic provisioning.
+settings.authentication.gitlab.configuration.roles_mapping.description=When synchronizing users and groups, SonarQube assigns permissions based on Gitlab user roles. You can customize the mapping of permissions. The new mapping will take effect at the next synchronization.
+settings.authentication.gitlab.configuration.roles_mapping.save_success=GitLab roles mapping saved successfully.
 
 # BITBUCKET
 settings.authentication.gitlab.configuration.insecure=BitBucket Authentication allows users to sign up, but no list of allowed workspaces was provided. This is potentially insecure. We recommend entering a list of allowed workspaces. {documentation}
@@ -1679,6 +1673,14 @@ settings.authentication.synchronization_details_link=More details
 settings.authentication.synchronization_successful=Last synchronization was done {0} ago.
 settings.authentication.synchronization_failed_short=Last synchronization failed. {details}
 settings.authentication.synchronization_failed=Last synchronization failed {0} ago.
+settings.authentication.configuration.roles_mapping.dialog.title=Global {0} role mapping
+settings.authentication.configuration.roles_mapping.title=Role permission mapping
+settings.authentication.configuration.roles_mapping.button_label=Edit mapping
+settings.authentication.configuration.roles_mapping.dialog.roles_column=Roles
+settings.authentication.configuration.roles_mapping.dialog.delete_custom_role=Delete custom role {0}
+settings.authentication.configuration.roles_mapping.dialog.add_custom_role=Add custom role:
+settings.authentication.configuration.roles_mapping.role_exists=Role already exists.
+settings.authentication.configuration.roles_mapping.empty_custom_role=Custom roles should have some permissions.
 
 # SAML
 settings.authentication.form.create.saml=New SAML configuration