]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-20532 Add custom roles to mapping modal
authorViktor Vorona <viktor.vorona@sonarsource.com>
Tue, 26 Sep 2023 14:25:45 +0000 (16:25 +0200)
committersonartech <sonartech@sonarsource.com>
Thu, 28 Sep 2023 20:03:11 +0000 (20:03 +0000)
server/sonar-web/src/main/js/apps/settings/components/authentication/GitHubMappingModal.tsx
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/types/axios.d.ts
server/sonar-web/src/main/js/types/provisioning.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 7b1f0306abfb54dcfcf41901b0113cb6d6442e75..7c4026ceba5fddc63f28e9b3e05aa478f17ce83d 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 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 { DeleteButton, SubmitButton } from '../../../../components/controls/buttons';
 import PermissionHeader from '../../../../components/permissions/PermissionHeader';
+import { Alert } from '../../../../components/ui/Alert';
 import Spinner from '../../../../components/ui/Spinner';
 import { translate } from '../../../../helpers/l10n';
 import {
@@ -34,22 +34,105 @@ import { useGithubRolesMappingQuery } from '../../../../queries/identity-provide
 import { GitHubMapping } from '../../../../types/provisioning';
 
 interface Props {
-  readonly mapping: GitHubMapping[] | null;
-  readonly setMapping: React.Dispatch<React.SetStateAction<GitHubMapping[] | null>>;
-  readonly onClose: () => void;
+  mapping: GitHubMapping[] | null;
+  setMapping: React.Dispatch<React.SetStateAction<GitHubMapping[] | null>>;
+  onClose: () => void;
 }
 
-export default function GitHubMappingModal({ mapping, setMapping, onClose }: Props) {
+interface PermissionCellProps {
+  mapping: GitHubMapping;
+  setMapping: React.Dispatch<React.SetStateAction<GitHubMapping[] | null>>;
+  list?: GitHubMapping[];
+}
+
+const DEFAULT_CUSTOM_ROLE_PERMISSIONS: GitHubMapping['permissions'] = {
+  user: false,
+  codeviewer: false,
+  issueadmin: false,
+  securityhotspotadmin: false,
+  admin: false,
+  scan: false,
+};
+
+function PermissionRow(props: Readonly<PermissionCellProps>) {
+  const { mapping, list } = props;
+
+  return (
+    <tr>
+      <th scope="row" className="nowrap text-middle sw-pl-[10px]">
+        <div className="sw-flex sw-max-w-[150px] sw-items-center">
+          <b
+            className={mapping.isBaseRole ? 'sw-capitalize' : 'sw-truncate'}
+            title={mapping.roleName}
+          >
+            {mapping.roleName}
+          </b>
+          {!mapping.isBaseRole && (
+            <DeleteButton
+              onClick={() => {
+                props.setMapping(list?.filter((r) => r.roleName !== mapping.roleName) ?? null);
+              }}
+            />
+          )}
+        </div>
+      </th>
+      {Object.entries(mapping.permissions).map(([key, value]) => (
+        <td key={key} className="permission-column text-center text-middle">
+          <Checkbox
+            checked={value}
+            onCheck={(newValue) =>
+              props.setMapping(
+                list?.map((item) =>
+                  item.id === mapping.id
+                    ? { ...item, permissions: { ...item.permissions, [key]: newValue } }
+                    : item,
+                ) ?? null,
+              )
+            }
+          />
+        </td>
+      ))}
+    </tr>
+  );
+}
+
+export default function GitHubMappingModal({ mapping, setMapping, onClose }: Readonly<Props>) {
   const { data: roles, isLoading } = 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.isBaseRole ? el.roleName.toLowerCase() === value.toLowerCase() : el.roleName === value,
+      )
+    ) {
+      setMapping([
+        {
+          id: customRoleInput,
+          roleName: customRoleInput,
+          permissions: { ...DEFAULT_CUSTOM_ROLE_PERMISSIONS },
+        },
+        ...(list ?? []),
+      ]);
+      setCustomRoleInput('');
+    } else {
+      setCustomRoleError(true);
+    }
+  };
+
   return (
     <Modal contentLabel={header} onRequestClose={onClose} shouldCloseOnEsc size="medium">
       <div className="modal-head">
@@ -58,8 +141,11 @@ export default function GitHubMappingModal({ mapping, setMapping, onClose }: Pro
       <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">
+            <tr className="sw-sticky sw-top-0 sw-bg-white sw-z-filterbar">
+              <th
+                scope="col"
+                className="nowrap bordered-bottom sw-pl-[10px] sw-align-middle sw-w-[150px]"
+              >
                 <b>
                   {translate(
                     'settings.authentication.github.configuration.roles_mapping.dialog.roles_column',
@@ -77,29 +163,65 @@ export default function GitHubMappingModal({ mapping, setMapping, onClose }: Pro
             </tr>
           </thead>
           <tbody>
-            {(mapping ?? roles)?.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 ?? roles)?.map((item) =>
-                            item.id === id
-                              ? { ...item, permissions: { ...item.permissions, [key]: newValue } }
-                              : item,
-                          ) ?? null,
-                        )
-                      }
-                    />
-                  </td>
-                ))}
-              </tr>
-            ))}
+            {list
+              ?.filter((r) => r.isBaseRole)
+              .map((mapping) => (
+                <PermissionRow
+                  key={mapping.id}
+                  mapping={mapping}
+                  setMapping={setMapping}
+                  list={list}
+                />
+              ))}
+            <tr>
+              <td colSpan={7} className="sw-pt-5 sw-border-t">
+                <Alert variant="info">
+                  {translate(
+                    'settings.authentication.github.configuration.roles_mapping.dialog.custom_roles_description',
+                  )}
+                </Alert>
+                <form
+                  className="sw-flex sw-h-9 sw-items-center"
+                  onSubmit={validateAndAddCustomRole}
+                >
+                  <label htmlFor="custom-role-input">
+                    {translate(
+                      'settings.authentication.github.configuration.roles_mapping.dialog.add_custom_role',
+                    )}
+                  </label>
+                  <input
+                    className="sw-w-[300px] sw-mx-2"
+                    id="custom-role-input"
+                    maxLength={4000}
+                    value={customRoleInput}
+                    onChange={(event) => {
+                      setCustomRoleError(false);
+                      setCustomRoleInput(event.currentTarget.value);
+                    }}
+                    type="text"
+                  />
+                  <SubmitButton disabled={!customRoleInput.trim() || customRoleError}>
+                    {translate('add_verb')}
+                  </SubmitButton>
+                  <Alert variant="error" className="sw-inline-block sw-ml-2 sw-mb-0">
+                    {customRoleError &&
+                      translate(
+                        'settings.authentication.github.configuration.roles_mapping.role_exists',
+                      )}
+                  </Alert>
+                </form>
+              </td>
+            </tr>
+            {list
+              ?.filter((r) => !r.isBaseRole)
+              .map((mapping) => (
+                <PermissionRow
+                  key={mapping.id}
+                  mapping={mapping}
+                  setMapping={setMapping}
+                  list={list}
+                />
+              ))}
           </tbody>
         </table>
         <Spinner loading={isLoading} />
index 9d66fc0ccfe091c66a7bb2b4ce96ec73e3eb5548..6ba0fc587bc46d96a50000c330ffb780f683ea4e 100644 (file)
@@ -399,14 +399,14 @@ export default function GithubAuthenticationTab(props: GithubAuthenticationProps
                   )}
                 </ConfirmModal>
               )}
-              {showMappingModal && (
-                <GitHubMappingModal
-                  mapping={rolesMapping}
-                  setMapping={setRolesMapping}
-                  onClose={() => setShowMappingModal(false)}
-                />
-              )}
             </form>
+            {showMappingModal && (
+              <GitHubMappingModal
+                mapping={rolesMapping}
+                setMapping={setRolesMapping}
+                onClose={() => setShowMappingModal(false)}
+              />
+            )}
           </div>
         </>
       )}
index ef05268b16769c0fe38fd5fc4bb3e825b5876bf0..a14e5ac963c433f2d06def7893c34af4996a7ca8 100644 (file)
@@ -154,6 +154,8 @@ const ui = {
     mappingRow: byRole('dialog', {
       name: 'settings.authentication.github.configuration.roles_mapping.dialog.title',
     }).byRole('row'),
+    getMappingRowByRole: (text: string) =>
+      ui.github.mappingRow.getAll().find((row) => within(row).queryByText(text) !== null),
     mappingCheckbox: byRole('checkbox'),
     mappingDialogClose: byRole('dialog', {
       name: 'settings.authentication.github.configuration.roles_mapping.dialog.title',
@@ -850,12 +852,17 @@ describe('Github tab', () => {
       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');
+      const rows = (await github.mappingRow.findAll()).filter(
+        (row) => within(row).queryAllByRole('checkbox').length > 0,
+      );
+
+      expect(rows).toHaveLength(5);
+
+      expect(rows[0]).toHaveTextContent('read');
+      expect(rows[1]).toHaveTextContent('triage');
+      expect(rows[2]).toHaveTextContent('write');
+      expect(rows[3]).toHaveTextContent('maintain');
+      expect(rows[4]).toHaveTextContent('admin');
     });
 
     it('should apply new mapping and new provisioning type at the same time', async () => {
@@ -872,18 +879,18 @@ describe('Github tab', () => {
       expect(await github.editMappingButton.find()).toBeInTheDocument();
       await user.click(github.editMappingButton.get());
 
-      expect(await github.mappingRow.findAll()).toHaveLength(6);
+      expect(await github.mappingRow.findAll()).toHaveLength(7);
 
-      let rowOneCheckboxes = github.mappingCheckbox.getAll(github.mappingRow.getAt(1));
-      let rowFiveCheckboxes = github.mappingCheckbox.getAll(github.mappingRow.getAt(5));
+      let readCheckboxes = github.mappingCheckbox.getAll(github.getMappingRowByRole('read'));
+      let adminCheckboxes = github.mappingCheckbox.getAll(github.getMappingRowByRole('admin'));
 
-      expect(rowOneCheckboxes[0]).toBeChecked();
-      expect(rowOneCheckboxes[5]).not.toBeChecked();
-      expect(rowFiveCheckboxes[5]).toBeChecked();
+      expect(readCheckboxes[0]).toBeChecked();
+      expect(readCheckboxes[5]).not.toBeChecked();
+      expect(adminCheckboxes[5]).toBeChecked();
 
-      await user.click(rowOneCheckboxes[0]);
-      await user.click(rowOneCheckboxes[5]);
-      await user.click(rowFiveCheckboxes[5]);
+      await user.click(readCheckboxes[0]);
+      await user.click(readCheckboxes[5]);
+      await user.click(adminCheckboxes[5]);
       await user.click(github.mappingDialogClose.get());
 
       await user.click(github.saveGithubProvisioning.get());
@@ -894,12 +901,12 @@ describe('Github tab', () => {
       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));
+      readCheckboxes = github.mappingCheckbox.getAll(github.getMappingRowByRole('read'));
+      adminCheckboxes = github.mappingCheckbox.getAll(github.getMappingRowByRole('admin'));
 
-      expect(rowOneCheckboxes[0]).not.toBeChecked();
-      expect(rowOneCheckboxes[5]).toBeChecked();
-      expect(rowFiveCheckboxes[5]).not.toBeChecked();
+      expect(readCheckboxes[0]).not.toBeChecked();
+      expect(readCheckboxes[5]).toBeChecked();
+      expect(adminCheckboxes[5]).not.toBeChecked();
       await user.click(github.mappingDialogClose.get());
     });
 
@@ -913,13 +920,13 @@ describe('Github tab', () => {
       expect(await github.saveGithubProvisioning.find()).toBeDisabled();
       await user.click(github.editMappingButton.get());
 
-      expect(await github.mappingRow.findAll()).toHaveLength(6);
+      expect(await github.mappingRow.findAll()).toHaveLength(7);
 
-      let rowOneCheckbox = github.mappingCheckbox.getAll(github.mappingRow.getAt(1))[0];
+      let readCheckboxes = github.mappingCheckbox.getAll(github.getMappingRowByRole('read'))[0];
 
-      expect(rowOneCheckbox).toBeChecked();
+      expect(readCheckboxes).toBeChecked();
 
-      await user.click(rowOneCheckbox);
+      await user.click(readCheckboxes);
       await user.click(github.mappingDialogClose.get());
 
       expect(await github.saveGithubProvisioning.find()).toBeEnabled();
@@ -931,9 +938,9 @@ describe('Github tab', () => {
       await user.click(github.githubProvisioningButton.get());
 
       await user.click(github.editMappingButton.get());
-      rowOneCheckbox = github.mappingCheckbox.getAll(github.mappingRow.getAt(1))[0];
+      readCheckboxes = github.mappingCheckbox.getAll(github.getMappingRowByRole('read'))[0];
 
-      expect(rowOneCheckbox).not.toBeChecked();
+      expect(readCheckboxes).not.toBeChecked();
       await user.click(github.mappingDialogClose.get());
     });
   });
index c9b67a2667106ad9ba902c1e3457bddf4c19ad61..7e9132e9ff41f87fdbe1d7e414064af12fe3d20c 100644 (file)
 
 import 'axios';
 
+type IfEquals<X, Y, A = X, B = never> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y
+  ? 1
+  : 2
+  ? A
+  : B;
+
+type WritableKeys<T> = {
+  [P in keyof T]-?: IfEquals<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, P>;
+}[keyof T];
+
+type OmitReadonly<T> = Pick<T, WritableKeys<T>>;
+
 declare module 'axios' {
   export interface AxiosInstance {
     get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>;
     delete<T = void>(url: string, config?: AxiosRequestConfig): Promise<T>;
     post<T = any, D = any>(url: string, data?: D, config?: AxiosRequestConfig<D>): Promise<T>;
-    patch<T = any, D = any>(url: string, data?: D, config?: AxiosRequestConfig<D>): Promise<T>;
+    patch<T = any, D = Partial<OmitReadonly<T>>>(
+      url: string,
+      data?: D,
+      config?: AxiosRequestConfig<D>,
+    ): Promise<T>;
 
     defaults: Omit<AxiosDefaults, 'headers'> & {
       headers: HeadersDefaults & {
index d361b8a31cf761b4c51b24b9f7a288d29d8f72e9..f59b1b610de71b54bf72c39683ee11a3353de15d 100644 (file)
@@ -77,8 +77,9 @@ export interface GitHubConfigurationStatus {
 }
 
 export interface GitHubMapping {
-  id: string;
-  roleName: string;
+  readonly id: string;
+  readonly roleName: string;
+  readonly isBaseRole?: boolean;
   permissions: {
     user: boolean;
     codeviewer: boolean;
index 2291d1fea4f1a86d1dc0e55a4192dbe560555978..bccc8c54921d4a361b35f70b607a2777b874980e 100644 (file)
@@ -1539,6 +1539,9 @@ settings.authentication.github.configuration.roles_mapping.description=When sync
 settings.authentication.github.configuration.roles_mapping.button_label=Edit mapping
 settings.authentication.github.configuration.roles_mapping.dialog.title=GitHub Roles 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.role_exists=Role already exists
+settings.authentication.github.configuration.roles_mapping.dialog.custom_roles_description=Define a custom GitHub role to create a mapping. If the custom role name matches an existing role in any of your organizations, this mapping will apply to all occurrences of that role. If the existing custom role doesn't match any entries in this mapping, it will gracefully fall back to its base role.
 settings.authentication.github.configuration.unsaved_changes=You have unsaved changes.
 
 # SAML