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 }>(
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,
+ );
+}
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');
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 = () => {
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);
};
--- /dev/null
+/*
+ * 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>
+ );
+}
<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">
* 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;
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}
+ />
);
}
* 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';
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';
import ConfirmProvisioningModal from './ConfirmProvisioningModal';
import GitLabConfigurationForm from './GitLabConfigurationForm';
import GitLabConfigurationValidity from './GitLabConfigurationValidity';
+import GitLabMappingModal from './GitLabMappingModal';
import ProvisioningSection from './ProvisioningSection';
import TabHeader from './TabHeader';
};
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,
};
const updateProvisioning = () => {
- if (!changes || !configuration) {
+ if ((!changes && !rolesMapping) || !configuration) {
return;
}
},
},
);
+
+ if (provisioningType === ProvisioningType.auto && rolesMapping) {
+ updateMapping(rolesMapping)
+ .then(() => {
+ setRolesMapping(null);
+ })
+ .catch(() => {});
+ }
};
const setJIT = () =>
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 !== '');
}
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={
}
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>
</>
}
/>
provisioningStatus={provisioningType}
/>
)}
+ {isMappingModalOpen && (
+ <GitLabMappingModal
+ mapping={rolesMapping}
+ setMapping={setRolesMapping}
+ onClose={() => setIsMappingModalOpen(false)}
+ />
+ )}
{openForm && (
<GitLabConfigurationForm
gitlabConfiguration={configuration ?? null}
--- /dev/null
+/*
+ * 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}
+ />
+ );
+}
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',
}),
* 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';
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',
}),
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 () => {
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[] = []) {
*/
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'],
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'),
+ );
+ },
+ });
+}
}[];
}
-export interface GitHubMapping {
+interface DevopsRolesMapping {
readonly baseRole?: boolean;
- readonly githubRole: string;
readonly id: string;
permissions: {
admin: boolean;
};
}
+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;
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.
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}
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