Browse Source

SONAR-20392 Add Edit mapping button for auto-provisioning

tags/10.3.0.82913
Viktor Vorona 9 months ago
parent
commit
22771f490d

+ 2
- 2
server/sonar-web/src/main/js/api/mocks/AuthenticationServiceMock.ts View File

@@ -241,11 +241,11 @@ export default class AuthenticationServiceMock {

handleUpdateGithubRolesMapping: typeof updateGithubRolesMapping = (id, data) => {
this.githubMapping = this.githubMapping.map((mapping) =>
mapping.id === id ? { ...mapping, ...data } : mapping
mapping.id === id ? { ...mapping, ...data } : mapping,
);

return Promise.resolve(
this.githubMapping.find((mapping) => mapping.id === id) as GitHubMapping
this.githubMapping.find((mapping) => mapping.id === id) as GitHubMapping,
);
};


+ 8
- 4
server/sonar-web/src/main/js/api/provisioning.ts View File

@@ -56,13 +56,17 @@ export function syncNowGithubProvisioning(): Promise<void> {
return post('/api/github_provisioning/sync').catch(throwGlobalError);
}

export function fetchGithubRolesMapping(): Promise<GitHubMapping[]> {
return axios.get('/api/v2/github-permissions-mapping');
export function fetchGithubRolesMapping() {
return axios
.get<unknown, { githubPermissionsMappings: GitHubMapping[] }>(
'/api/v2/github-permission-mappings',
)
.then((data) => data.githubPermissionsMappings);
}

export function updateGithubRolesMapping(
role: string,
data: Partial<Pick<GitHubMapping, 'permissions'>>
data: Partial<Pick<GitHubMapping, 'permissions'>>,
): Promise<GitHubMapping> {
return axios.patch(`/api/v2/github-permissions-mapping/${role}`, data);
return axios.patch(`/api/v2/github-permission-mappings/${role}`, data);
}

+ 3
- 1
server/sonar-web/src/main/js/app/index.ts View File

@@ -40,13 +40,15 @@ initApplication();

async function initApplication() {
axiosToCatch.interceptors.response.use((response) => response.data);
axiosToCatch.defaults.headers.patch['Content-Type'] = 'application/merge-patch+json';
axios.defaults.headers.patch['Content-Type'] = 'application/merge-patch+json';
axios.interceptors.response.use(
(response) => response.data,
(error) => {
const { response } = error;
addGlobalErrorMessage(parseErrorResponse(response));
return Promise.reject(response);
}
},
);
const [l10nBundle, currentUser, appState, availableFeatures] = await Promise.all([
loadL10nBundle(),

+ 49
- 53
server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationFormField.tsx View File

@@ -21,9 +21,9 @@ import React from 'react';
import ValidationInput, {
ValidationInputErrorPlacement,
} from '../../../../components/controls/ValidationInput';
import MandatoryFieldMarker from '../../../../components/ui/MandatoryFieldMarker';
import { ExtendedSettingDefinition, SettingType } from '../../../../types/settings';
import { getPropertyDescription, getPropertyName, isSecuredDefinition } from '../../utils';
import AuthenticationFormFieldWrapper from './AuthenticationFormFieldWrapper';
import AuthenticationMultiValueField from './AuthenticationMultiValuesField';
import AuthenticationSecuredField from './AuthenticationSecuredField';
import AuthenticationToggleField from './AuthenticationToggleField';
@@ -44,58 +44,54 @@ export default function AuthenticationFormField(props: SamlToggleFieldProps) {
const description = getPropertyDescription(definition);

return (
<div className="settings-definition">
<div className="settings-definition-left">
<label className="h3" htmlFor={definition.key}>
{name}
</label>
{mandatory && <MandatoryFieldMarker />}
{definition.description && <div className="markdown small spacer-top">{description}</div>}
</div>
<div className="settings-definition-right big-padded-top display-flex-column">
{definition.multiValues && (
<AuthenticationMultiValueField
definition={definition}
settingValue={settingValue as string[]}
onFieldChange={(value) => props.onFieldChange(definition.key, value)}
/>
<AuthenticationFormFieldWrapper
title={name}
defKey={definition.key}
mandatory={mandatory}
description={description}
>
{definition.multiValues && (
<AuthenticationMultiValueField
definition={definition}
settingValue={settingValue as string[]}
onFieldChange={(value) => props.onFieldChange(definition.key, value)}
/>
)}
{isSecuredDefinition(definition) && (
<AuthenticationSecuredField
definition={definition}
settingValue={String(settingValue ?? '')}
onFieldChange={props.onFieldChange}
isNotSet={isNotSet}
/>
)}
{!isSecuredDefinition(definition) && definition.type === SettingType.BOOLEAN && (
<AuthenticationToggleField
definition={definition}
settingValue={settingValue as string | boolean}
onChange={(value) => props.onFieldChange(definition.key, value)}
/>
)}
{!isSecuredDefinition(definition) &&
definition.type === undefined &&
!definition.multiValues && (
<ValidationInput
error={error}
errorPlacement={ValidationInputErrorPlacement.Bottom}
isValid={false}
isInvalid={Boolean(error)}
>
<input
className="width-100"
id={definition.key}
maxLength={4000}
name={definition.key}
onChange={(e) => props.onFieldChange(definition.key, e.currentTarget.value)}
type="text"
value={String(settingValue ?? '')}
/>
</ValidationInput>
)}
{isSecuredDefinition(definition) && (
<AuthenticationSecuredField
definition={definition}
settingValue={String(settingValue ?? '')}
onFieldChange={props.onFieldChange}
isNotSet={isNotSet}
/>
)}
{!isSecuredDefinition(definition) && definition.type === SettingType.BOOLEAN && (
<AuthenticationToggleField
definition={definition}
settingValue={settingValue as string | boolean}
onChange={(value) => props.onFieldChange(definition.key, value)}
/>
)}
{!isSecuredDefinition(definition) &&
definition.type === undefined &&
!definition.multiValues && (
<ValidationInput
error={error}
errorPlacement={ValidationInputErrorPlacement.Bottom}
isValid={false}
isInvalid={Boolean(error)}
>
<input
className="width-100"
id={definition.key}
maxLength={4000}
name={definition.key}
onChange={(e) => props.onFieldChange(definition.key, e.currentTarget.value)}
type="text"
value={String(settingValue ?? '')}
/>
</ValidationInput>
)}
</div>
</div>
</AuthenticationFormFieldWrapper>
);
}

+ 45
- 0
server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationFormFieldWrapper.tsx View File

@@ -0,0 +1,45 @@
/*
* SonarQube
* Copyright (C) 2009-2023 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import React, { PropsWithChildren } from 'react';
import MandatoryFieldMarker from '../../../../components/ui/MandatoryFieldMarker';

interface Props {
readonly title: string;
readonly description?: string;
readonly defKey?: string;
readonly mandatory?: boolean;
}

export default function AuthenticationFormFieldWrapper(props: PropsWithChildren<Props>) {
const { mandatory = false, title, description, defKey, children } = props;

return (
<div className="settings-definition">
<div className="settings-definition-left">
<label className="h3" htmlFor={defKey}>
{title}
</label>
{mandatory && <MandatoryFieldMarker />}
{description && <div className="markdown small spacer-top">{description}</div>}
</div>
<div className="settings-definition-right big-padded-top display-flex-column">{children}</div>
</div>
);
}

+ 10
- 17
server/sonar-web/src/main/js/apps/settings/components/authentication/GitHubMappingModal.tsx View File

@@ -34,26 +34,20 @@ import { useGithubRolesMappingQuery } from '../../../../queries/identity-provide
import { GitHubMapping } from '../../../../types/provisioning';

interface Props {
mapping: GitHubMapping[] | null;
setMapping: React.Dispatch<React.SetStateAction<GitHubMapping[] | null>>;
onClose: () => void;
readonly mapping: GitHubMapping[] | null;
readonly setMapping: React.Dispatch<React.SetStateAction<GitHubMapping[] | null>>;
readonly onClose: () => void;
}

export default function GitHubMappingModal({ mapping, setMapping, onClose }: Props) {
const { data: roles, isLoading } = useGithubRolesMappingQuery();
const permissions = convertToPermissionDefinitions(
PERMISSIONS_ORDER_FOR_PROJECT_TEMPLATE,
'projects_role'
'projects_role',
);

React.useEffect(() => {
if (!mapping && roles) {
setMapping(roles);
}
}, [roles, mapping, setMapping]);

const header = translate(
'settings.authentication.github.configuration.roles_mapping.dialog.title'
'settings.authentication.github.configuration.roles_mapping.dialog.title',
);

return (
@@ -68,7 +62,7 @@ export default function GitHubMappingModal({ mapping, setMapping, onClose }: Pro
<th scope="col" className="nowrap bordered-bottom sw-pl-[10px] sw-align-middle">
<b>
{translate(
'settings.authentication.github.configuration.roles_mapping.dialog.roles_column'
'settings.authentication.github.configuration.roles_mapping.dialog.roles_column',
)}
</b>
</th>
@@ -77,14 +71,13 @@ export default function GitHubMappingModal({ mapping, setMapping, onClose }: Pro
key={
isPermissionDefinitionGroup(permission) ? permission.category : permission.key
}
onSelectPermission={() => {}}
permission={permission}
/>
))}
</tr>
</thead>
<tbody>
{mapping?.map(({ id, roleName, permissions }) => (
{(mapping ?? roles)?.map(({ id, roleName, permissions }) => (
<tr key={id}>
<th scope="row" className="nowrap text-middle sw-pl-[10px]">
<b>{roleName}</b>
@@ -95,11 +88,11 @@ export default function GitHubMappingModal({ mapping, setMapping, onClose }: Pro
checked={value}
onCheck={(newValue) =>
setMapping(
mapping.map((item) =>
(mapping ?? roles)?.map((item) =>
item.id === id
? { ...item, permissions: { ...item.permissions, [key]: newValue } }
: item
)
: item,
) ?? null,
)
}
/>

+ 37
- 20
server/sonar-web/src/main/js/apps/settings/components/authentication/GithubAuthenticationTab.tsx View File

@@ -38,6 +38,7 @@ import { AlmKeys } from '../../../../types/alm-settings';
import { ExtendedSettingDefinition } from '../../../../types/settings';
import { AuthenticationTabs, DOCUMENTATION_LINK_SUFFIXES } from './Authentication';
import AuthenticationFormField from './AuthenticationFormField';
import AuthenticationFormFieldWrapper from './AuthenticationFormFieldWrapper';
import AutoProvisioningConsent from './AutoProvisionningConsent';
import ConfigurationForm from './ConfigurationForm';
import GitHubConfigurationValidity from './GitHubConfigurationValidity';
@@ -78,12 +79,11 @@ export default function GithubAuthenticationTab(props: GithubAuthenticationProps
hasGithubProvisioningTypeChange,
hasGithubProvisioningConfigChange,
resetJitSetting,
saveGroup,
changeProvisioning,
toggleEnable,
rolesMapping,
setRolesMapping,
saveMapping,
applyAdditionalOptions,
hasLegacyConfiguration,
deleteMutation: { isLoading: isDeleting, mutate: deleteConfiguration },
} = useGithubConfiguration(definitions);
@@ -106,10 +106,7 @@ export default function GithubAuthenticationTab(props: GithubAuthenticationProps
if (hasGithubProvisioningTypeChange) {
setShowConfirmProvisioningModal(true);
} else {
saveGroup();
if (newGithubProvisioningStatus ?? githubProvisioningStatus) {
saveMapping();
}
applyAdditionalOptions();
}
};

@@ -197,6 +194,7 @@ export default function GithubAuthenticationTab(props: GithubAuthenticationProps
{enabled ? (
<div className="display-flex-column spacer-top">
<RadioCard
className="sw-min-h-0"
label={translate('settings.authentication.form.provisioning_at_login')}
title={translate('settings.authentication.form.provisioning_at_login')}
selected={!(newGithubProvisioningStatus ?? githubProvisioningStatus)}
@@ -246,7 +244,7 @@ export default function GithubAuthenticationTab(props: GithubAuthenticationProps
)}
</RadioCard>
<RadioCard
className="spacer-top"
className="spacer-top sw-min-h-0"
label={translate(
'settings.authentication.github.form.provisioning_with_github',
)}
@@ -287,18 +285,17 @@ export default function GithubAuthenticationTab(props: GithubAuthenticationProps
</p>

{githubProvisioningStatus && <GitHubSynchronisationWarning />}

<div className="sw-flex sw-flex-1 spacer-bottom">
<Button
className="spacer-top width-30"
onClick={synchronizeNow}
disabled={!canSyncNow}
>
{translate('settings.authentication.github.synchronize_now')}
</Button>
</div>
{(newGithubProvisioningStatus ?? githubProvisioningStatus) && (
<>
<div className="sw-flex sw-flex-1 spacer-bottom">
<Button
className="spacer-top width-30"
onClick={synchronizeNow}
disabled={!canSyncNow}
>
{translate('settings.authentication.github.synchronize_now')}
</Button>
</div>
<hr />
{Object.values(values).map((val) => {
if (!GITHUB_PROVISIONING_FIELDS.includes(val.key)) {
@@ -318,6 +315,23 @@ export default function GithubAuthenticationTab(props: GithubAuthenticationProps
</div>
);
})}
<AuthenticationFormFieldWrapper
title={translate(
'settings.authentication.github.configuration.roles_mapping.title',
)}
description={translate(
'settings.authentication.github.configuration.roles_mapping.description',
)}
>
<Button
className="spacer-top"
onClick={() => setShowMappingModal(true)}
>
{translate(
'settings.authentication.github.configuration.roles_mapping.button_label',
)}
</Button>
</AuthenticationFormFieldWrapper>
</>
)}
</>
@@ -348,12 +362,11 @@ export default function GithubAuthenticationTab(props: GithubAuthenticationProps
)}
</fieldset>
{enabled && (
<>
<div className="sw-flex sw-gap-2 sw-h-8 sw-items-center">
<SubmitButton disabled={!hasGithubProvisioningConfigChange}>
{translate('save')}
</SubmitButton>
<ResetButtonLink
className="spacer-left"
onClick={() => {
setProvisioningType(undefined);
resetJitSetting();
@@ -362,7 +375,11 @@ export default function GithubAuthenticationTab(props: GithubAuthenticationProps
>
{translate('cancel')}
</ResetButtonLink>
</>
<Alert variant="warning" className="sw-w-[300px] sw-mb-0">
{hasGithubProvisioningConfigChange &&
translate('settings.authentication.github.configuration.unsaved_changes')}
</Alert>
</div>
)}
{showConfirmProvisioningModal && (
<ConfirmModal

+ 2
- 1
server/sonar-web/src/main/js/apps/settings/components/authentication/SamlAuthenticationTab.tsx View File

@@ -184,6 +184,7 @@ export default function SamlAuthenticationTab(props: SamlAuthenticationProps) {
{samlEnabled ? (
<div className="display-flex-column spacer-top">
<RadioCard
className="sw-min-h-0"
label={translate('settings.authentication.saml.form.provisioning_at_login')}
title={translate('settings.authentication.saml.form.provisioning_at_login')}
selected={!(newScimStatus ?? scimStatus)}
@@ -194,7 +195,7 @@ export default function SamlAuthenticationTab(props: SamlAuthenticationProps) {
</p>
</RadioCard>
<RadioCard
className="spacer-top"
className="spacer-top sw-min-h-0"
label={translate('settings.authentication.saml.form.provisioning_with_scim')}
title={translate('settings.authentication.saml.form.provisioning_with_scim')}
selected={newScimStatus ?? scimStatus}

+ 15
- 16
server/sonar-web/src/main/js/apps/settings/components/authentication/hook/useGithubConfiguration.ts View File

@@ -70,7 +70,9 @@ export default function useGithubConfiguration(definitions: ExtendedSettingDefin
newGithubProvisioningStatus !== undefined &&
newGithubProvisioningStatus !== githubProvisioningStatus;
const hasGithubProvisioningConfigChange =
some(GITHUB_ADDITIONAL_FIELDS, isValueChange) || hasGithubProvisioningTypeChange || rolesMapping;
some(GITHUB_ADDITIONAL_FIELDS, isValueChange) ||
hasGithubProvisioningTypeChange ||
rolesMapping;

const resetJitSetting = () => {
GITHUB_ADDITIONAL_FIELDS.forEach((s) => setNewValue(s));
@@ -78,7 +80,7 @@ export default function useGithubConfiguration(definitions: ExtendedSettingDefin

const { mutate: saveSetting } = useSaveValueMutation();
const { mutate: saveSettings } = useSaveValuesMutation();
const { mutate: updateMapping } = useGithubRolesMappingMutation();
const { mutateAsync: updateMapping } = useGithubRolesMappingMutation();

const enabled = values[GITHUB_ENABLED_FIELD]?.value === 'true';
const appId = values[GITHUB_APP_ID_FIELD]?.value as string;
@@ -89,22 +91,20 @@ export default function useGithubConfiguration(definitions: ExtendedSettingDefin
if (hasGithubProvisioningTypeChange) {
await toggleGithubProvisioning.mutateAsync(!!newGithubProvisioningStatus);
}
if (!newGithubProvisioningStatus || !githubProvisioningStatus) {
saveGroup();
}
if (newGithubProvisioningStatus ?? githubProvisioningStatus) {
saveMapping();
}
applyAdditionalOptions();
};

const saveGroup = () => {
const applyAdditionalOptions = () => {
const newValues = GITHUB_ADDITIONAL_FIELDS.map((settingKey) => values[settingKey]);
saveSettings(newValues);
};

const saveMapping = () => {
if (rolesMapping) {
updateMapping(rolesMapping);
if (newGithubProvisioningStatus ?? githubProvisioningStatus) {
if (rolesMapping) {
updateMapping(rolesMapping)
.then(() => {
setRolesMapping(null);
})
.catch(() => {});
}
}
};

@@ -132,12 +132,11 @@ export default function useGithubConfiguration(definitions: ExtendedSettingDefin
hasGithubProvisioningTypeChange,
hasGithubProvisioningConfigChange,
changeProvisioning,
saveGroup,
resetJitSetting,
toggleEnable,
rolesMapping,
setRolesMapping,
saveMapping,
applyAdditionalOptions,
hasLegacyConfiguration,
};
}

+ 9
- 8
server/sonar-web/src/main/js/queries/identity-provider.ts View File

@@ -126,10 +126,11 @@ export function useGithubRolesMappingQuery() {
return useQuery(['identity_provider', 'github_mapping'], fetchGithubRolesMapping, {
staleTime: MAPPING_STALE_TIME,
select: (data) =>
data.sort((a, b) => {
const hardcodedValues = ['admin', 'maintain', 'write', 'triage', 'read'];
if (hardcodedValues.includes(a.id) || hardcodedValues.includes(b.id)) {
return hardcodedValues.indexOf(b.id) - hardcodedValues.indexOf(a.id);
[...data].sort((a, b) => {
// Order is reversed to put non-existing roles at the end (their index is -1)
const defaultRoleOrder = ['admin', 'maintain', 'write', 'triage', 'read'];
if (defaultRoleOrder.includes(a.id) || defaultRoleOrder.includes(b.id)) {
return defaultRoleOrder.indexOf(b.id) - defaultRoleOrder.indexOf(a.id);
}
return a.roleName.localeCompare(b.roleName);
}),
@@ -147,12 +148,12 @@ export function useGithubRolesMappingMutation() {
(item) =>
!isEqual(
item,
state.find((el) => el.id === item.id)
)
state.find((el) => el.id === item.id),
),
)
: mapping;
return Promise.all(
changedRoles.map((data) => updateGithubRolesMapping(data.id, omit(data, 'id', 'roleName')))
changedRoles.map((data) => updateGithubRolesMapping(data.id, omit(data, 'id', 'roleName'))),
);
},
onSuccess: (data) => {
@@ -163,7 +164,7 @@ export function useGithubRolesMappingMutation() {
state.map((item) => {
const changed = data.find((el) => el.id === item.id);
return changed ?? item;
})
}),
);
}
},

+ 4
- 2
server/sonar-web/src/main/js/queries/settings.ts View File

@@ -71,8 +71,10 @@ export function useSaveValuesMutation() {
}),
);
},
onSuccess: () => {
queryClient.invalidateQueries(['settings']);
onSuccess: (data) => {
if (data.length > 0) {
queryClient.invalidateQueries(['settings']);
}
},
});
}

+ 29
- 0
server/sonar-web/yarn.lock View File

@@ -5247,6 +5247,7 @@ __metadata:
"@typescript-eslint/parser": 5.59.11
"@wojtekmaj/enzyme-adapter-react-17": 0.8.0
autoprefixer: 10.4.15
axios: 1.5.0
chalk: 4.1.2
chokidar: 3.5.3
classnames: 2.3.2
@@ -5767,6 +5768,17 @@ __metadata:
languageName: node
linkType: hard

"axios@npm:1.5.0":
version: 1.5.0
resolution: "axios@npm:1.5.0"
dependencies:
follow-redirects: ^1.15.0
form-data: ^4.0.0
proxy-from-env: ^1.1.0
checksum: e7405a5dbbea97760d0e6cd58fecba311b0401ddb4a8efbc4108f5537da9b3f278bde566deb777935a960beec4fa18e7b8353881f2f465e4f2c0e949fead35be
languageName: node
linkType: hard

"axobject-query@npm:^3.1.1":
version: 3.1.1
resolution: "axobject-query@npm:3.1.1"
@@ -8342,6 +8354,16 @@ __metadata:
languageName: node
linkType: hard

"follow-redirects@npm:^1.15.0":
version: 1.15.2
resolution: "follow-redirects@npm:1.15.2"
peerDependenciesMeta:
debug:
optional: true
checksum: faa66059b66358ba65c234c2f2a37fcec029dc22775f35d9ad6abac56003268baf41e55f9ee645957b32c7d9f62baf1f0b906e68267276f54ec4b4c597c2b190
languageName: node
linkType: hard

"for-each@npm:^0.3.3":
version: 0.3.3
resolution: "for-each@npm:0.3.3"
@@ -11916,6 +11938,13 @@ __metadata:
languageName: node
linkType: hard

"proxy-from-env@npm:^1.1.0":
version: 1.1.0
resolution: "proxy-from-env@npm:1.1.0"
checksum: ed7fcc2ba0a33404958e34d95d18638249a68c430e30fcb6c478497d72739ba64ce9810a24f53a7d921d0c065e5b78e3822759800698167256b04659366ca4d4
languageName: node
linkType: hard

"psl@npm:^1.1.33":
version: 1.8.0
resolution: "psl@npm:1.8.0"

+ 4
- 0
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -1530,8 +1530,12 @@ 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 we rely on GitHub user and team roles to assign sonarqube permissions. You can customize how the mapping will be applied. Be aware that the mapping will take effect from the next synchronization.
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.unsaved_changes=You have unsaved changes.

# SAML
settings.authentication.form.create.saml=New SAML configuration

Loading…
Cancel
Save