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