]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-20392: Mapping modal
authorViktor Vorona <viktor.vorona@sonarsource.com>
Tue, 12 Sep 2023 12:27:07 +0000 (14:27 +0200)
committersonartech <sonartech@sonarsource.com>
Fri, 15 Sep 2023 20:03:05 +0000 (20:03 +0000)
13 files changed:
server/sonar-web/package.json
server/sonar-web/src/main/js/api/mocks/AuthenticationServiceMock.ts
server/sonar-web/src/main/js/api/provisioning.ts
server/sonar-web/src/main/js/app/index.ts
server/sonar-web/src/main/js/apps/settings/components/authentication/GitHubMappingModal.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/__tests__/Authentication-it.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/hook/useGithubConfiguration.ts
server/sonar-web/src/main/js/components/permissions/PermissionHeader.tsx
server/sonar-web/src/main/js/helpers/request.ts
server/sonar-web/src/main/js/queries/identity-provider.ts
server/sonar-web/src/main/js/types/provisioning.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 3bc3ab6b290b489e871b22b754e2a64152f27fe2..970d24b289c61712521f9454ce22a88ab720bc6a 100644 (file)
@@ -14,6 +14,7 @@
     "@react-spring/rafz": "9.7.3",
     "@react-spring/web": "9.7.3",
     "@tanstack/react-query": "4.33.0",
+    "axios": "1.5.0",
     "classnames": "2.3.2",
     "clipboard": "2.0.11",
     "core-js": "3.32.1",
index a3a7385ac13c58850b29dd4e14f2541b4631df98..4a427162eadb5fdc5bc1613acaca806455031597 100644 (file)
  */
 import { cloneDeep } from 'lodash';
 import { mockTask } from '../../helpers/mocks/tasks';
-import { GitHubConfigurationStatus, GitHubProvisioningStatus } from '../../types/provisioning';
+import {
+  GitHubConfigurationStatus,
+  GitHubMapping,
+  GitHubProvisioningStatus,
+} from '../../types/provisioning';
 import { Task, TaskStatuses, TaskTypes } from '../../types/tasks';
 import {
   activateGithubProvisioning,
@@ -28,7 +32,9 @@ import {
   deactivateGithubProvisioning,
   deactivateScim,
   fetchGithubProvisioningStatus,
+  fetchGithubRolesMapping,
   fetchIsScimEnabled,
+  updateGithubRolesMapping,
 } from '../provisioning';
 
 jest.mock('../provisioning');
@@ -55,16 +61,81 @@ const defaultConfigurationStatus: GitHubConfigurationStatus = {
   ],
 };
 
+const defaultMapping: GitHubMapping[] = [
+  {
+    id: 'read',
+    roleName: 'read',
+    permissions: {
+      user: true,
+      codeviewer: true,
+      issueadmin: false,
+      securityhotspotadmin: false,
+      admin: false,
+      scan: false,
+    },
+  },
+  {
+    id: 'write',
+    roleName: 'write',
+    permissions: {
+      user: true,
+      codeviewer: true,
+      issueadmin: true,
+      securityhotspotadmin: true,
+      admin: false,
+      scan: true,
+    },
+  },
+  {
+    id: 'triage',
+    roleName: 'triage',
+    permissions: {
+      user: true,
+      codeviewer: true,
+      issueadmin: false,
+      securityhotspotadmin: false,
+      admin: false,
+      scan: false,
+    },
+  },
+  {
+    id: 'maintain',
+    roleName: 'maintain',
+    permissions: {
+      user: true,
+      codeviewer: true,
+      issueadmin: true,
+      securityhotspotadmin: true,
+      admin: false,
+      scan: true,
+    },
+  },
+  {
+    id: 'admin',
+    roleName: 'admin',
+    permissions: {
+      user: true,
+      codeviewer: true,
+      issueadmin: true,
+      securityhotspotadmin: true,
+      admin: true,
+      scan: true,
+    },
+  },
+];
+
 export default class AuthenticationServiceMock {
   scimStatus: boolean;
   githubProvisioningStatus: boolean;
   githubConfigurationStatus: GitHubConfigurationStatus;
+  githubMapping: GitHubMapping[];
   tasks: Task[];
 
   constructor() {
     this.scimStatus = false;
     this.githubProvisioningStatus = false;
     this.githubConfigurationStatus = cloneDeep(defaultConfigurationStatus);
+    this.githubMapping = cloneDeep(defaultMapping);
     this.tasks = [];
     jest.mocked(activateScim).mockImplementation(this.handleActivateScim);
     jest.mocked(deactivateScim).mockImplementation(this.handleDeactivateScim);
@@ -81,6 +152,8 @@ export default class AuthenticationServiceMock {
     jest
       .mocked(checkConfigurationValidity)
       .mockImplementation(this.handleCheckConfigurationValidity);
+    jest.mocked(fetchGithubRolesMapping).mockImplementation(this.handleFetchGithubRolesMapping);
+    jest.mocked(updateGithubRolesMapping).mockImplementation(this.handleUpdateGithubRolesMapping);
   }
 
   addProvisioningTask = (overrides: Partial<Omit<Task, 'type'>> = {}) => {
@@ -162,10 +235,25 @@ export default class AuthenticationServiceMock {
     return Promise.resolve(this.githubConfigurationStatus);
   };
 
+  handleFetchGithubRolesMapping: typeof fetchGithubRolesMapping = () => {
+    return Promise.resolve(this.githubMapping);
+  };
+
+  handleUpdateGithubRolesMapping: typeof updateGithubRolesMapping = (id, data) => {
+    this.githubMapping = this.githubMapping.map((mapping) =>
+      mapping.id === id ? { ...mapping, ...data } : mapping
+    );
+
+    return Promise.resolve(
+      this.githubMapping.find((mapping) => mapping.id === id) as GitHubMapping
+    );
+  };
+
   reset = () => {
     this.scimStatus = false;
     this.githubProvisioningStatus = false;
     this.githubConfigurationStatus = cloneDeep(defaultConfigurationStatus);
+    this.githubMapping = cloneDeep(defaultMapping);
     this.tasks = [];
   };
 }
index 00c9232ff49c9a8d8942b99764c599ee661c0538..d196a32eee24d7bf5d2c823f5f37cc698afb24de 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 axios from 'axios';
 import { throwGlobalError } from '../helpers/error';
 import { getJSON, post, postJSON } from '../helpers/request';
-import { GitHubConfigurationStatus, GithubStatus } from '../types/provisioning';
+import { GitHubConfigurationStatus, GitHubMapping, GithubStatus } from '../types/provisioning';
 
 export function fetchIsScimEnabled(): Promise<boolean> {
   return getJSON('/api/scim_management/status')
@@ -54,3 +55,14 @@ export function checkConfigurationValidity(): Promise<GitHubConfigurationStatus>
 export function syncNowGithubProvisioning(): Promise<void> {
   return post('/api/github_provisioning/sync').catch(throwGlobalError);
 }
+
+export function fetchGithubRolesMapping(): Promise<GitHubMapping[]> {
+  return axios.get('/api/v2/github-permissions-mapping');
+}
+
+export function updateGithubRolesMapping(
+  role: string,
+  data: Partial<Pick<GitHubMapping, 'permissions'>>
+): Promise<GitHubMapping> {
+  return axios.patch(`/api/v2/github-permissions-mapping/${role}`, data);
+}
index 8dfc13fcf50f2944751304257869e641ae048925..9cb5f85850178c6d3d34e0ff5ef70841c8e36bed 100644 (file)
 /* String.prototype.replaceAll. This is why we also import core-js.                             */
 import 'core-js/stable';
 /*                                                                                              */
+import axios from 'axios';
 import { getAvailableFeatures } from '../api/features';
 import { getGlobalNavigation } from '../api/navigation';
 import { getCurrentUser } from '../api/users';
 import { installExtensionsHandler, installWebAnalyticsHandler } from '../helpers/extensionsHandler';
+import { addGlobalErrorMessage } from '../helpers/globalMessages';
 import { loadL10nBundle } from '../helpers/l10nBundle';
+import { axiosToCatch, parseErrorResponse } from '../helpers/request';
 import { getBaseUrl, getSystemStatus, initAppVariables } from '../helpers/system';
 import './styles/sonar.ts';
 
@@ -36,6 +39,15 @@ initAppVariables();
 initApplication();
 
 async function initApplication() {
+  axiosToCatch.interceptors.response.use((response) => response.data);
+  axios.interceptors.response.use(
+    (response) => response.data,
+    (error) => {
+      const { response } = error;
+      addGlobalErrorMessage(parseErrorResponse(response));
+      return Promise.reject(response);
+    }
+  );
   const [l10nBundle, currentUser, appState, availableFeatures] = await Promise.all([
     loadL10nBundle(),
     isMainApp() ? getCurrentUser() : undefined,
diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/GitHubMappingModal.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/GitHubMappingModal.tsx
new file mode 100644 (file)
index 0000000..8f38c14
--- /dev/null
@@ -0,0 +1,119 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 classNames from 'classnames';
+import * as React from 'react';
+import Checkbox from '../../../../components/controls/Checkbox';
+import Modal from '../../../../components/controls/Modal';
+import { SubmitButton } from '../../../../components/controls/buttons';
+import PermissionHeader from '../../../../components/permissions/PermissionHeader';
+import Spinner from '../../../../components/ui/Spinner';
+import { translate } from '../../../../helpers/l10n';
+import {
+  PERMISSIONS_ORDER_FOR_PROJECT_TEMPLATE,
+  convertToPermissionDefinitions,
+  isPermissionDefinitionGroup,
+} from '../../../../helpers/permissions';
+import { useGithubRolesMappingQuery } from '../../../../queries/identity-provider';
+import { GitHubMapping } from '../../../../types/provisioning';
+
+interface Props {
+  mapping: GitHubMapping[] | null;
+  setMapping: React.Dispatch<React.SetStateAction<GitHubMapping[] | null>>;
+  onClose: () => void;
+}
+
+export default function GitHubMappingModal({ mapping, setMapping, onClose }: Props) {
+  const { data: roles, isLoading } = useGithubRolesMappingQuery();
+  const permissions = convertToPermissionDefinitions(
+    PERMISSIONS_ORDER_FOR_PROJECT_TEMPLATE,
+    'projects_role'
+  );
+
+  React.useEffect(() => {
+    if (!mapping && roles) {
+      setMapping(roles);
+    }
+  }, [roles, mapping, setMapping]);
+
+  const header = translate(
+    'settings.authentication.github.configuration.roles_mapping.dialog.title'
+  );
+
+  return (
+    <Modal contentLabel={header} onRequestClose={onClose} shouldCloseOnEsc size="medium">
+      <div className="modal-head">
+        <h2>{header}</h2>
+      </div>
+      <div className="modal-body modal-container sw-p-0">
+        <table className="data zebra permissions-table">
+          <thead>
+            <tr>
+              <th scope="col" className="nowrap bordered-bottom sw-pl-[10px] sw-align-middle">
+                <b>
+                  {translate(
+                    'settings.authentication.github.configuration.roles_mapping.dialog.roles_column'
+                  )}
+                </b>
+              </th>
+              {permissions.map((permission) => (
+                <PermissionHeader
+                  key={
+                    isPermissionDefinitionGroup(permission) ? permission.category : permission.key
+                  }
+                  onSelectPermission={() => {}}
+                  permission={permission}
+                />
+              ))}
+            </tr>
+          </thead>
+          <tbody>
+            {mapping?.map(({ id, roleName, permissions }) => (
+              <tr key={id}>
+                <th scope="row" className="nowrap text-middle sw-pl-[10px]">
+                  <b>{roleName}</b>
+                </th>
+                {Object.entries(permissions).map(([key, value]) => (
+                  <td key={key} className={classNames('permission-column text-center text-middle')}>
+                    <Checkbox
+                      checked={value}
+                      onCheck={(newValue) =>
+                        setMapping(
+                          mapping.map((item) =>
+                            item.id === id
+                              ? { ...item, permissions: { ...item.permissions, [key]: newValue } }
+                              : item
+                          )
+                        )
+                      }
+                    />
+                  </td>
+                ))}
+              </tr>
+            ))}
+          </tbody>
+        </table>
+        <Spinner loading={isLoading} />
+      </div>
+      <div className="modal-foot">
+        <SubmitButton onClick={onClose}>{translate('close')}</SubmitButton>
+      </div>
+    </Modal>
+  );
+}
index 8445bc7b6c3f819bd8f43ed09a7ecc6701f0ce4d..9b7326a0a237ffaf4878c556c0b3756177dd8353 100644 (file)
@@ -17,7 +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 React, { useState } from 'react';
+import React, { FormEvent, useState } from 'react';
 import { FormattedMessage } from 'react-intl';
 import GitHubSynchronisationWarning from '../../../../app/components/GitHubSynchronisationWarning';
 import DocLink from '../../../../components/common/DocLink';
@@ -41,6 +41,7 @@ import AuthenticationFormField from './AuthenticationFormField';
 import AutoProvisioningConsent from './AutoProvisionningConsent';
 import ConfigurationForm from './ConfigurationForm';
 import GitHubConfigurationValidity from './GitHubConfigurationValidity';
+import GitHubMappingModal from './GitHubMappingModal';
 import useGithubConfiguration, {
   GITHUB_ADDITIONAL_FIELDS,
   GITHUB_JIT_FIELDS,
@@ -58,6 +59,7 @@ export default function GithubAuthenticationTab(props: GithubAuthenticationProps
   const { definitions, currentTab } = props;
   const { data } = useIdentityProviderQuery();
   const [showEditModal, setShowEditModal] = useState(false);
+  const [showMappingModal, setShowMappingModal] = useState(false);
   const [showConfirmProvisioningModal, setShowConfirmProvisioningModal] = useState(false);
 
   const {
@@ -72,13 +74,16 @@ export default function GithubAuthenticationTab(props: GithubAuthenticationProps
     appId,
     enabled,
     newGithubProvisioningStatus,
-    setNewGithubProvisioningStatus,
+    setProvisioningType,
     hasGithubProvisioningTypeChange,
     hasGithubProvisioningConfigChange,
     resetJitSetting,
     saveGroup,
     changeProvisioning,
     toggleEnable,
+    rolesMapping,
+    setRolesMapping,
+    saveMapping,
     hasLegacyConfiguration,
     deleteMutation: { isLoading: isDeleting, mutate: deleteConfiguration },
   } = useGithubConfiguration(definitions);
@@ -96,6 +101,18 @@ export default function GithubAuthenticationTab(props: GithubAuthenticationProps
     setShowEditModal(false);
   };
 
+  const handleSubmit = (e: FormEvent) => {
+    e.preventDefault();
+    if (hasGithubProvisioningTypeChange) {
+      setShowConfirmProvisioningModal(true);
+    } else {
+      saveGroup();
+      if (newGithubProvisioningStatus ?? githubProvisioningStatus) {
+        saveMapping();
+      }
+    }
+  };
+
   return (
     <div className="authentication-configuration">
       <div className="spacer-bottom display-flex-space-between display-flex-center">
@@ -171,16 +188,7 @@ export default function GithubAuthenticationTab(props: GithubAuthenticationProps
             </div>
           </div>
           <div className="spacer-bottom big-padded bordered display-flex-space-between">
-            <form
-              onSubmit={async (e) => {
-                e.preventDefault();
-                if (hasGithubProvisioningTypeChange) {
-                  setShowConfirmProvisioningModal(true);
-                } else {
-                  await saveGroup();
-                }
-              }}
-            >
+            <form onSubmit={handleSubmit}>
               <fieldset className="display-flex-column big-spacer-bottom">
                 <label className="h5">
                   {translate('settings.authentication.form.provisioning')}
@@ -192,7 +200,7 @@ export default function GithubAuthenticationTab(props: GithubAuthenticationProps
                       label={translate('settings.authentication.form.provisioning_at_login')}
                       title={translate('settings.authentication.form.provisioning_at_login')}
                       selected={!(newGithubProvisioningStatus ?? githubProvisioningStatus)}
-                      onClick={() => setNewGithubProvisioningStatus(false)}
+                      onClick={() => setProvisioningType(false)}
                     >
                       <p className="spacer-bottom">
                         <FormattedMessage id="settings.authentication.github.form.provisioning_at_login.description" />
@@ -246,7 +254,7 @@ export default function GithubAuthenticationTab(props: GithubAuthenticationProps
                         'settings.authentication.github.form.provisioning_with_github',
                       )}
                       selected={newGithubProvisioningStatus ?? githubProvisioningStatus}
-                      onClick={() => setNewGithubProvisioningStatus(true)}
+                      onClick={() => setProvisioningType(true)}
                       disabled={!hasGithubProvisioning || hasDifferentProvider}
                     >
                       {hasGithubProvisioning ? (
@@ -347,7 +355,7 @@ export default function GithubAuthenticationTab(props: GithubAuthenticationProps
                   <ResetButtonLink
                     className="spacer-left"
                     onClick={() => {
-                      setNewGithubProvisioningStatus(undefined);
+                      setProvisioningType(undefined);
                       resetJitSetting();
                     }}
                     disabled={!hasGithubProvisioningConfigChange}
@@ -374,6 +382,13 @@ export default function GithubAuthenticationTab(props: GithubAuthenticationProps
                   )}
                 </ConfirmModal>
               )}
+              {showMappingModal && (
+                <GitHubMappingModal
+                  mapping={rolesMapping}
+                  setMapping={setRolesMapping}
+                  onClose={() => setShowMappingModal(false)}
+                />
+              )}
             </form>
           </div>
         </>
index 3abce50cbf35cc51fcf2442af0434d366b8ef8e0..ef05268b16769c0fe38fd5fc4bb3e825b5876bf0 100644 (file)
@@ -148,6 +148,18 @@ const ui = {
     enableConfigButton: byRole('button', { name: 'settings.authentication.form.enable' }),
     disableConfigButton: byRole('button', { name: 'settings.authentication.form.disable' }),
     editConfigButton: byRole('button', { name: 'settings.authentication.form.edit' }),
+    editMappingButton: byRole('button', {
+      name: 'settings.authentication.github.configuration.roles_mapping.button_label',
+    }),
+    mappingRow: byRole('dialog', {
+      name: 'settings.authentication.github.configuration.roles_mapping.dialog.title',
+    }).byRole('row'),
+    mappingCheckbox: byRole('checkbox'),
+    mappingDialogClose: byRole('dialog', {
+      name: 'settings.authentication.github.configuration.roles_mapping.dialog.title',
+    }).byRole('button', {
+      name: 'close',
+    }),
     deleteOrg: (org: string) =>
       byRole('button', {
         name: `settings.definition.delete_value.property.sonar.auth.github.organizations.name.${org}`,
@@ -827,6 +839,103 @@ describe('Github tab', () => {
       expect(await github.jitProvisioningButton.find()).toBeChecked();
       expect(github.consentDialog.query()).not.toBeInTheDocument();
     });
+
+    it('should sort mapping rows', async () => {
+      const user = userEvent.setup();
+      settingsHandler.presetGithubAutoProvisioning();
+      handler.enableGithubProvisioning();
+      renderAuthentication([Feature.GithubProvisioning]);
+      await user.click(await github.tab.find());
+
+      expect(await github.editMappingButton.find()).toBeInTheDocument();
+      await user.click(github.editMappingButton.get());
+
+      expect(await github.mappingRow.findAll()).toHaveLength(6);
+      expect(github.mappingRow.getAt(1)).toHaveTextContent('read');
+      expect(github.mappingRow.getAt(2)).toHaveTextContent('triage');
+      expect(github.mappingRow.getAt(3)).toHaveTextContent('write');
+      expect(github.mappingRow.getAt(4)).toHaveTextContent('maintain');
+      expect(github.mappingRow.getAt(5)).toHaveTextContent('admin');
+    });
+
+    it('should apply new mapping and new provisioning type at the same time', async () => {
+      const user = userEvent.setup();
+      renderAuthentication([Feature.GithubProvisioning]);
+      await user.click(await github.tab.find());
+
+      await github.createConfiguration(user);
+      await user.click(await github.enableConfigButton.find());
+
+      expect(await github.jitProvisioningButton.find()).toBeChecked();
+      expect(github.editMappingButton.query()).not.toBeInTheDocument();
+      await user.click(github.githubProvisioningButton.get());
+      expect(await github.editMappingButton.find()).toBeInTheDocument();
+      await user.click(github.editMappingButton.get());
+
+      expect(await github.mappingRow.findAll()).toHaveLength(6);
+
+      let rowOneCheckboxes = github.mappingCheckbox.getAll(github.mappingRow.getAt(1));
+      let rowFiveCheckboxes = github.mappingCheckbox.getAll(github.mappingRow.getAt(5));
+
+      expect(rowOneCheckboxes[0]).toBeChecked();
+      expect(rowOneCheckboxes[5]).not.toBeChecked();
+      expect(rowFiveCheckboxes[5]).toBeChecked();
+
+      await user.click(rowOneCheckboxes[0]);
+      await user.click(rowOneCheckboxes[5]);
+      await user.click(rowFiveCheckboxes[5]);
+      await user.click(github.mappingDialogClose.get());
+
+      await user.click(github.saveGithubProvisioning.get());
+      await act(() => user.click(github.confirmProvisioningButton.get()));
+
+      // Clean local mapping state
+      await user.click(github.jitProvisioningButton.get());
+      await user.click(github.githubProvisioningButton.get());
+
+      await user.click(github.editMappingButton.get());
+      rowOneCheckboxes = github.mappingCheckbox.getAll(github.mappingRow.getAt(1));
+      rowFiveCheckboxes = github.mappingCheckbox.getAll(github.mappingRow.getAt(5));
+
+      expect(rowOneCheckboxes[0]).not.toBeChecked();
+      expect(rowOneCheckboxes[5]).toBeChecked();
+      expect(rowFiveCheckboxes[5]).not.toBeChecked();
+      await user.click(github.mappingDialogClose.get());
+    });
+
+    it('should apply new mapping on auto-provisioning', async () => {
+      const user = userEvent.setup();
+      settingsHandler.presetGithubAutoProvisioning();
+      handler.enableGithubProvisioning();
+      renderAuthentication([Feature.GithubProvisioning]);
+      await user.click(await github.tab.find());
+
+      expect(await github.saveGithubProvisioning.find()).toBeDisabled();
+      await user.click(github.editMappingButton.get());
+
+      expect(await github.mappingRow.findAll()).toHaveLength(6);
+
+      let rowOneCheckbox = github.mappingCheckbox.getAll(github.mappingRow.getAt(1))[0];
+
+      expect(rowOneCheckbox).toBeChecked();
+
+      await user.click(rowOneCheckbox);
+      await user.click(github.mappingDialogClose.get());
+
+      expect(await github.saveGithubProvisioning.find()).toBeEnabled();
+
+      await act(() => user.click(github.saveGithubProvisioning.get()));
+
+      // Clean local mapping state
+      await user.click(github.jitProvisioningButton.get());
+      await user.click(github.githubProvisioningButton.get());
+
+      await user.click(github.editMappingButton.get());
+      rowOneCheckbox = github.mappingCheckbox.getAll(github.mappingRow.getAt(1))[0];
+
+      expect(rowOneCheckbox).not.toBeChecked();
+      await user.click(github.mappingDialogClose.get());
+    });
   });
 });
 
index c6d8c8db224e3e2ae913bf1a59fcfb2df5a4f0fc..30b51acb236e07dfb8b598889cd6ec05b987ee87 100644 (file)
@@ -22,10 +22,12 @@ import { useContext, useState } from 'react';
 import { AvailableFeaturesContext } from '../../../../../app/components/available-features/AvailableFeaturesContext';
 import {
   useGithubProvisioningEnabledQuery,
+  useGithubRolesMappingMutation,
   useToggleGithubProvisioningMutation,
 } from '../../../../../queries/identity-provider';
 import { useSaveValueMutation, useSaveValuesMutation } from '../../../../../queries/settings';
 import { Feature } from '../../../../../types/features';
+import { GitHubMapping } from '../../../../../types/provisioning';
 import { ExtendedSettingDefinition } from '../../../../../types/settings';
 import useConfiguration from './useConfiguration';
 
@@ -63,11 +65,12 @@ export default function useGithubConfiguration(definitions: ExtendedSettingDefin
   const { data: githubProvisioningStatus } = useGithubProvisioningEnabledQuery();
   const toggleGithubProvisioning = useToggleGithubProvisioningMutation();
   const [newGithubProvisioningStatus, setNewGithubProvisioningStatus] = useState<boolean>();
+  const [rolesMapping, setRolesMapping] = useState<GitHubMapping[] | null>(null);
   const hasGithubProvisioningTypeChange =
     newGithubProvisioningStatus !== undefined &&
     newGithubProvisioningStatus !== githubProvisioningStatus;
   const hasGithubProvisioningConfigChange =
-    some(GITHUB_ADDITIONAL_FIELDS, isValueChange) || hasGithubProvisioningTypeChange;
+    some(GITHUB_ADDITIONAL_FIELDS, isValueChange) || hasGithubProvisioningTypeChange || rolesMapping;
 
   const resetJitSetting = () => {
     GITHUB_ADDITIONAL_FIELDS.forEach((s) => setNewValue(s));
@@ -75,6 +78,7 @@ export default function useGithubConfiguration(definitions: ExtendedSettingDefin
 
   const { mutate: saveSetting } = useSaveValueMutation();
   const { mutate: saveSettings } = useSaveValuesMutation();
+  const { mutate: updateMapping } = useGithubRolesMappingMutation();
 
   const enabled = values[GITHUB_ENABLED_FIELD]?.value === 'true';
   const appId = values[GITHUB_APP_ID_FIELD]?.value as string;
@@ -88,6 +92,9 @@ export default function useGithubConfiguration(definitions: ExtendedSettingDefin
     if (!newGithubProvisioningStatus || !githubProvisioningStatus) {
       saveGroup();
     }
+    if (newGithubProvisioningStatus ?? githubProvisioningStatus) {
+      saveMapping();
+    }
   };
 
   const saveGroup = () => {
@@ -95,6 +102,12 @@ export default function useGithubConfiguration(definitions: ExtendedSettingDefin
     saveSettings(newValues);
   };
 
+  const saveMapping = () => {
+    if (rolesMapping) {
+      updateMapping(rolesMapping);
+    }
+  };
+
   const toggleEnable = () => {
     const value = values[GITHUB_ENABLED_FIELD];
     saveSetting({ newValue: !enabled, definition: value.definition });
@@ -102,6 +115,11 @@ export default function useGithubConfiguration(definitions: ExtendedSettingDefin
 
   const hasLegacyConfiguration = appId === undefined && !clientIdIsNotSet;
 
+  const setProvisioningType = (value: boolean | undefined) => {
+    setRolesMapping(null);
+    setNewGithubProvisioningStatus(value);
+  };
+
   return {
     ...config,
     url,
@@ -110,13 +128,16 @@ export default function useGithubConfiguration(definitions: ExtendedSettingDefin
     hasGithubProvisioning,
     githubProvisioningStatus,
     newGithubProvisioningStatus,
-    setNewGithubProvisioningStatus,
+    setProvisioningType,
     hasGithubProvisioningTypeChange,
     hasGithubProvisioningConfigChange,
     changeProvisioning,
     saveGroup,
     resetJitSetting,
     toggleEnable,
+    rolesMapping,
+    setRolesMapping,
+    saveMapping,
     hasLegacyConfiguration,
   };
 }
index d1689a96a4a4026ae0bb336f920d4674f7f632d8..f7852acd0385fc7cc609167fc5e74e1c13faaaa6 100644 (file)
@@ -81,6 +81,7 @@ export default class PermissionHeader extends React.PureComponent<Props> {
     }
     return (
       <th
+        scope="col"
         className={classNames('permission-column text-center text-middle', {
           selected:
             !isPermissionDefinitionGroup(permission) &&
index 95005676fcfbdb704ad50aa5810d2300adda6eb6..6c3f3ac4f1352d231f2f7723e8d2973e75437749 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 axios, { AxiosResponse } from 'axios';
 import { isNil, omitBy } from 'lodash';
 import { Dict } from '../types/types';
 import { getCookie } from './cookies';
@@ -189,10 +190,27 @@ export function parseText(response: Response): Promise<string> {
 export function parseError(response: Response): Promise<string> {
   const DEFAULT_MESSAGE = translate('default_error_message');
   return parseJSON(response)
-    .then(({ errors, message }) => message ?? errors.map((error: any) => error.msg).join('. '))
+    .then(parseErrorResponse)
     .catch(() => DEFAULT_MESSAGE);
 }
 
+export function parseErrorResponse(response?: AxiosResponse | Response): string {
+  const DEFAULT_MESSAGE = translate('default_error_message');
+  let data;
+  if (!response) {
+    return DEFAULT_MESSAGE;
+  }
+  if ('data' in response) {
+    ({ data } = response);
+  } else {
+    data = response;
+  }
+  const { message, errors } = data;
+  return (
+    message ?? errors?.map((error: { msg: string }) => error.msg).join('. ') ?? DEFAULT_MESSAGE
+  );
+}
+
 /**
  * Shortcut to do a GET request and return a Response
  */
@@ -348,3 +366,5 @@ export enum HttpStatus {
   ServiceUnavailable = 503,
   GatewayTimeout = 504,
 }
+
+export const axiosToCatch = axios.create();
index 73b282d70b01f6b58c985ed2289c119a1ed83694..cd5f5fef94eee0841e2b3686b83bf60f80ff3605 100644 (file)
@@ -19,6 +19,7 @@
  */
 
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { isEqual, omit } from 'lodash';
 import { useContext } from 'react';
 import {
   activateGithubProvisioning,
@@ -27,15 +28,20 @@ import {
   deactivateGithubProvisioning,
   deactivateScim,
   fetchGithubProvisioningStatus,
+  fetchGithubRolesMapping,
   fetchIsScimEnabled,
   syncNowGithubProvisioning,
+  updateGithubRolesMapping,
 } from '../api/provisioning';
 import { getSystemInfo } from '../api/system';
 import { AvailableFeaturesContext } from '../app/components/available-features/AvailableFeaturesContext';
 import { mapReactQueryResult } from '../helpers/react-query';
 import { Feature } from '../types/features';
+import { GitHubMapping } from '../types/provisioning';
 import { SysInfoCluster } from '../types/types';
 
+const MAPPING_STALE_TIME = 60_000;
+
 export function useIdentityProviderQuery() {
   return useQuery(['identity_provider'], async () => {
     const info = (await getSystemInfo()) as SysInfoCluster;
@@ -115,3 +121,51 @@ export function useSyncWithGitHubNow() {
     canSyncNow: data?.enabled && !data.nextSync && !mutation.isLoading,
   };
 }
+
+export function useGithubRolesMappingQuery() {
+  return useQuery(['identity_provider', 'github_mapping'], fetchGithubRolesMapping, {
+    staleTime: MAPPING_STALE_TIME,
+    select: (data) =>
+      data.sort((a, b) => {
+        const hardcodedValues = ['admin', 'maintain', 'write', 'triage', 'read'];
+        if (hardcodedValues.includes(a.id) || hardcodedValues.includes(b.id)) {
+          return hardcodedValues.indexOf(b.id) - hardcodedValues.indexOf(a.id);
+        }
+        return a.roleName.localeCompare(b.roleName);
+      }),
+  });
+}
+
+export function useGithubRolesMappingMutation() {
+  const client = useQueryClient();
+  const queryKey = ['identity_provider', 'github_mapping'];
+  return useMutation({
+    mutationFn: (mapping: GitHubMapping[]) => {
+      const state = client.getQueryData<GitHubMapping[]>(queryKey);
+      const changedRoles = state
+        ? mapping.filter(
+            (item) =>
+              !isEqual(
+                item,
+                state.find((el) => el.id === item.id)
+              )
+          )
+        : mapping;
+      return Promise.all(
+        changedRoles.map((data) => updateGithubRolesMapping(data.id, omit(data, 'id', 'roleName')))
+      );
+    },
+    onSuccess: (data) => {
+      const state = client.getQueryData<GitHubMapping[]>(queryKey);
+      if (state) {
+        client.setQueryData(
+          queryKey,
+          state.map((item) => {
+            const changed = data.find((el) => el.id === item.id);
+            return changed ?? item;
+          })
+        );
+      }
+    },
+  });
+}
index 81ff4c6d1c977113f40cb42aeba38dbe98fb40b8..d361b8a31cf761b4c51b24b9f7a288d29d8f72e9 100644 (file)
@@ -75,3 +75,16 @@ export interface GitHubConfigurationStatus {
     autoProvisioning: GitHubProvisioning;
   }[];
 }
+
+export interface GitHubMapping {
+  id: string;
+  roleName: string;
+  permissions: {
+    user: boolean;
+    codeviewer: boolean;
+    issueadmin: boolean;
+    securityhotspotadmin: boolean;
+    admin: boolean;
+    scan: boolean;
+  };
+}
index 34729262a1aae566d52a343650f08b828c136638..66dd6f922b7aa04fff22d3c51804a15f147f7e0e 100644 (file)
@@ -1530,6 +1530,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.dialog.title=GitHub Roles Mapping
+settings.authentication.github.configuration.roles_mapping.dialog.roles_column=Roles
 
 # SAML
 settings.authentication.form.create.saml=New SAML configuration