diff options
author | guillaume-peoch-sonarsource <guillaume.peoch@sonarsource.com> | 2023-11-30 12:09:06 +0100 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2023-12-22 20:03:01 +0000 |
commit | e4e087dd7877352e2d77f9531535258224820e9f (patch) | |
tree | 1ac7d7cc6a7645b796a1702500e2829f8dc63d09 /server | |
parent | 3cbfd8163ffdc42f89631382031dd115c0df65b6 (diff) | |
download | sonarqube-e4e087dd7877352e2d77f9531535258224820e9f.tar.gz sonarqube-e4e087dd7877352e2d77f9531535258224820e9f.zip |
SONAR-21119 UI for GitLab Authentication tab with users and groups provisioning
Diffstat (limited to 'server')
42 files changed, 2023 insertions, 442 deletions
diff --git a/server/sonar-web/src/main/js/api/mocks/AuthenticationServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/AuthenticationServiceMock.ts index c4664fc7ddc..d382f32c511 100644 --- a/server/sonar-web/src/main/js/api/mocks/AuthenticationServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/AuthenticationServiceMock.ts @@ -17,12 +17,15 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { cloneDeep } from 'lodash'; +import { cloneDeep, omit } from 'lodash'; +import { mockGitlabConfiguration } from '../../helpers/mocks/alm-integrations'; import { mockTask } from '../../helpers/mocks/tasks'; +import { mockPaging } from '../../helpers/testMocks'; import { GitHubConfigurationStatus, GitHubMapping, GitHubProvisioningStatus, + GitlabConfiguration, } from '../../types/provisioning'; import { Task, TaskStatuses, TaskTypes } from '../../types/tasks'; import { @@ -30,12 +33,17 @@ import { activateScim, addGithubRolesMapping, checkConfigurationValidity, + createGitLabConfiguration, deactivateGithubProvisioning, deactivateScim, + deleteGitLabConfiguration, deleteGithubRolesMapping, + fetchGitLabConfiguration, + fetchGitLabConfigurations, fetchGithubProvisioningStatus, fetchGithubRolesMapping, fetchIsScimEnabled, + updateGitLabConfiguration, updateGithubRolesMapping, } from '../provisioning'; @@ -63,6 +71,10 @@ const defaultConfigurationStatus: GitHubConfigurationStatus = { ], }; +const defaultGitlabConfiguration: GitlabConfiguration[] = [ + mockGitlabConfiguration({ id: '1', enabled: true }), +]; + const githubMappingMock = ( id: string, permissions: (keyof GitHubMapping['permissions'])[], @@ -107,6 +119,7 @@ export default class AuthenticationServiceMock { githubConfigurationStatus: GitHubConfigurationStatus; githubMapping: GitHubMapping[]; tasks: Task[]; + gitlabConfigurations: GitlabConfiguration[]; constructor() { this.scimStatus = false; @@ -114,6 +127,7 @@ export default class AuthenticationServiceMock { this.githubConfigurationStatus = cloneDeep(defaultConfigurationStatus); this.githubMapping = cloneDeep(defaultMapping); this.tasks = []; + this.gitlabConfigurations = cloneDeep(defaultGitlabConfiguration); jest.mocked(activateScim).mockImplementation(this.handleActivateScim); jest.mocked(deactivateScim).mockImplementation(this.handleDeactivateScim); jest.mocked(fetchIsScimEnabled).mockImplementation(this.handleFetchIsScimEnabled); @@ -133,6 +147,11 @@ export default class AuthenticationServiceMock { jest.mocked(updateGithubRolesMapping).mockImplementation(this.handleUpdateGithubRolesMapping); jest.mocked(addGithubRolesMapping).mockImplementation(this.handleAddGithubRolesMapping); jest.mocked(deleteGithubRolesMapping).mockImplementation(this.handleDeleteGithubRolesMapping); + 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); } addProvisioningTask = (overrides: Partial<Omit<Task, 'type'>> = {}) => { @@ -244,11 +263,52 @@ export default class AuthenticationServiceMock { this.githubMapping = [...this.githubMapping, githubMappingMock(id, permissions)]; }; + handleFetchGitLabConfigurations: typeof fetchGitLabConfigurations = () => { + return Promise.resolve({ + configurations: this.gitlabConfigurations, + page: mockPaging({ total: this.gitlabConfigurations.length }), + }); + }; + + handleFetchGitLabConfiguration: typeof fetchGitLabConfiguration = (id: string) => { + const configuration = this.gitlabConfigurations.find((c) => c.id === id); + if (!configuration) { + return Promise.reject(); + } + return Promise.resolve(configuration); + }; + + handleCreateGitLabConfiguration: typeof createGitLabConfiguration = (data) => { + const newConfig = mockGitlabConfiguration({ + ...omit(data, 'applicationId', 'clientSecret'), + id: '1', + enabled: true, + }); + this.gitlabConfigurations = [...this.gitlabConfigurations, newConfig]; + return Promise.resolve(newConfig); + }; + + handleUpdateGitLabConfiguration: typeof updateGitLabConfiguration = (id, data) => { + const index = this.gitlabConfigurations.findIndex((c) => c.id === id); + this.gitlabConfigurations[index] = { ...this.gitlabConfigurations[index], ...data }; + return Promise.resolve(this.gitlabConfigurations[index]); + }; + + handleDeleteGitLabConfiguration: typeof deleteGitLabConfiguration = (id) => { + this.gitlabConfigurations = this.gitlabConfigurations.filter((c) => c.id !== id); + return Promise.resolve(); + }; + + setGitlabConfigurations = (gitlabConfigurations: GitlabConfiguration[]) => { + this.gitlabConfigurations = gitlabConfigurations; + }; + reset = () => { this.scimStatus = false; this.githubProvisioningStatus = false; this.githubConfigurationStatus = cloneDeep(defaultConfigurationStatus); this.githubMapping = cloneDeep(defaultMapping); this.tasks = []; + this.gitlabConfigurations = cloneDeep(defaultGitlabConfiguration); }; } diff --git a/server/sonar-web/src/main/js/api/mocks/GroupsServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/GroupsServiceMock.ts index fbd14e24eb6..46a2d5e1063 100644 --- a/server/sonar-web/src/main/js/api/mocks/GroupsServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/GroupsServiceMock.ts @@ -19,14 +19,13 @@ */ import { cloneDeep } from 'lodash'; -import { Provider } from '../../components/hooks/useManageProvider'; import { mockGroup, mockIdentityProvider, mockPaging, mockUserGroupMember, } from '../../helpers/testMocks'; -import { Group, IdentityProvider, Paging } from '../../types/types'; +import { Group, IdentityProvider, Paging, Provider } from '../../types/types'; import { createGroup, deleteGroup, getUsersGroups, updateGroup } from '../user_groups'; jest.mock('../user_groups'); diff --git a/server/sonar-web/src/main/js/api/mocks/SystemServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/SystemServiceMock.ts index ca31714c1dc..6c9b3c67324 100644 --- a/server/sonar-web/src/main/js/api/mocks/SystemServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/SystemServiceMock.ts @@ -18,10 +18,9 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import { cloneDeep } from 'lodash'; -import { SysInfoCluster, SysInfoLogging, SysInfoStandalone } from '../../types/types'; +import { Provider, SysInfoCluster, SysInfoLogging, SysInfoStandalone } from '../../types/types'; import { LogsLevels } from '../../apps/system/utils'; -import { Provider } from '../../components/hooks/useManageProvider'; import { mockClusterSysInfo, mockLogs, mockStandaloneSysInfo } from '../../helpers/testMocks'; import { getSystemInfo, setLogLevel } from '../system'; diff --git a/server/sonar-web/src/main/js/api/provisioning.ts b/server/sonar-web/src/main/js/api/provisioning.ts index e8c7ea90dc1..27f5a31ca01 100644 --- a/server/sonar-web/src/main/js/api/provisioning.ts +++ b/server/sonar-web/src/main/js/api/provisioning.ts @@ -18,9 +18,20 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import axios from 'axios'; +import { keyBy } from 'lodash'; import { throwGlobalError } from '../helpers/error'; import { getJSON, post, postJSON } from '../helpers/request'; -import { GitHubConfigurationStatus, GitHubMapping, GithubStatus } from '../types/provisioning'; +import { + GitHubConfigurationStatus, + GitHubMapping, + GitLabConfigurationCreateBody, + GitLabConfigurationUpdateBody, + GithubStatus, + GitlabConfiguration, + ProvisioningType, +} from '../types/provisioning'; +import { Paging } from '../types/types'; +import { getValues, resetSettingValue, setSimpleSettingValue } from './settings'; const GITHUB_PERMISSION_MAPPINGS = '/api/v2/dop-translation/github-permission-mappings'; @@ -81,3 +92,125 @@ export function addGithubRolesMapping(data: Omit<GitHubMapping, 'id'>) { export function deleteGithubRolesMapping(role: string) { return axios.delete(`${GITHUB_PERMISSION_MAPPINGS}/${encodeURIComponent(role)}`); } + +const GITLAB_SETTING_ENABLED = 'sonar.auth.gitlab.enabled'; +const GITLAB_SETTING_URL = 'sonar.auth.gitlab.url'; +const GITLAB_SETTING_APP_ID = 'sonar.auth.gitlab.applicationId.secured'; +const GITLAB_SETTING_SECRET = 'sonar.auth.gitlab.secret.secured'; +export const GITLAB_SETTING_ALLOW_SIGNUP = 'sonar.auth.gitlab.allowUsersToSignUp'; +const GITLAB_SETTING_GROUPS_SYNC = 'sonar.auth.gitlab.groupsSync'; +const GITLAB_SETTING_PROVISIONING_ENABLED = 'provisioning.gitlab.enabled'; +export const GITLAB_SETTING_GROUP_TOKEN = 'provisioning.gitlab.token.secured'; +export const GITLAB_SETTING_GROUPS = 'provisioning.gitlab.groups'; + +const gitlabKeys = [ + GITLAB_SETTING_ENABLED, + GITLAB_SETTING_URL, + GITLAB_SETTING_APP_ID, + GITLAB_SETTING_SECRET, + GITLAB_SETTING_ALLOW_SIGNUP, + GITLAB_SETTING_GROUPS_SYNC, + GITLAB_SETTING_PROVISIONING_ENABLED, + GITLAB_SETTING_GROUP_TOKEN, + GITLAB_SETTING_GROUPS, +]; + +const fieldKeyMap = { + enabled: GITLAB_SETTING_ENABLED, + url: GITLAB_SETTING_URL, + applicationId: GITLAB_SETTING_APP_ID, + clientSecret: GITLAB_SETTING_SECRET, + allowUsersToSignUp: GITLAB_SETTING_ALLOW_SIGNUP, + synchronizeUserGroups: GITLAB_SETTING_GROUPS_SYNC, + type: GITLAB_SETTING_PROVISIONING_ENABLED, + provisioningToken: GITLAB_SETTING_GROUP_TOKEN, + groups: GITLAB_SETTING_GROUPS, +}; + +const getGitLabConfiguration = async (): Promise<GitlabConfiguration | null> => { + const values = await getValues({ + keys: gitlabKeys, + }); + const valuesMap = keyBy(values, 'key'); + if (!valuesMap[GITLAB_SETTING_APP_ID] || !valuesMap[GITLAB_SETTING_SECRET]) { + return null; + } + return { + id: '1', + enabled: valuesMap[GITLAB_SETTING_ENABLED]?.value === 'true', + url: valuesMap[GITLAB_SETTING_URL]?.value ?? 'https://gitlab.com', + synchronizeUserGroups: valuesMap[GITLAB_SETTING_GROUPS_SYNC]?.value === 'true', + type: + valuesMap[GITLAB_SETTING_PROVISIONING_ENABLED]?.value === 'true' + ? ProvisioningType.auto + : ProvisioningType.jit, + groups: valuesMap[GITLAB_SETTING_GROUPS]?.values + ? valuesMap[GITLAB_SETTING_GROUPS]?.values + : [], + allowUsersToSignUp: valuesMap[GITLAB_SETTING_ALLOW_SIGNUP]?.value === 'true', + }; +}; + +export async function fetchGitLabConfigurations(): Promise<{ + configurations: GitlabConfiguration[]; + page: Paging; +}> { + const config = await getGitLabConfiguration(); + return { + configurations: config ? [config] : [], + page: { + pageIndex: 1, + pageSize: 1, + total: config ? 1 : 0, + }, + }; +} + +export async function fetchGitLabConfiguration(_id: string): Promise<GitlabConfiguration> { + const configuration = await getGitLabConfiguration(); + if (!configuration) { + return Promise.reject(new Error('GitLab configuration not found')); + } + return Promise.resolve(configuration); +} + +export async function createGitLabConfiguration( + configuration: GitLabConfigurationCreateBody, +): Promise<GitlabConfiguration> { + await Promise.all( + Object.entries(configuration).map( + ([key, value]: [key: keyof GitLabConfigurationCreateBody, value: string]) => + setSimpleSettingValue({ key: fieldKeyMap[key], value }), + ), + ); + await setSimpleSettingValue({ key: fieldKeyMap.enabled, value: 'true' }); + return fetchGitLabConfiguration(''); +} + +export async function updateGitLabConfiguration( + _id: string, + configuration: Partial<GitLabConfigurationUpdateBody>, +): Promise<GitlabConfiguration> { + await Promise.all( + Object.entries(configuration).map( + ([key, value]: [key: keyof typeof fieldKeyMap, value: string | string[]]) => { + if (fieldKeyMap[key] === GITLAB_SETTING_PROVISIONING_ENABLED) { + return setSimpleSettingValue({ + key: fieldKeyMap[key], + value: value === ProvisioningType.auto ? 'true' : 'false', + }); + } else if (typeof value === 'boolean') { + return setSimpleSettingValue({ key: fieldKeyMap[key], value: value ? 'true' : 'false' }); + } else if (Array.isArray(value)) { + return setSimpleSettingValue({ key: fieldKeyMap[key], values: value }); + } + return setSimpleSettingValue({ key: fieldKeyMap[key], value }); + }, + ), + ); + return fetchGitLabConfiguration(''); +} + +export function deleteGitLabConfiguration(_id: string): Promise<void> { + return resetSettingValue({ keys: gitlabKeys.join(',') }); +} diff --git a/server/sonar-web/src/main/js/api/settings.ts b/server/sonar-web/src/main/js/api/settings.ts index 2c2f5111526..53580197b9b 100644 --- a/server/sonar-web/src/main/js/api/settings.ts +++ b/server/sonar-web/src/main/js/api/settings.ts @@ -86,7 +86,7 @@ export function setSettingValue( } export function setSimpleSettingValue( - data: { component?: string; value: string; key: string } & BranchParameters, + data: { component?: string; value?: string; values?: string[]; key: string } & BranchParameters, ): Promise<void | Response> { return post('/api/settings/set', data).catch(throwGlobalError); } diff --git a/server/sonar-web/src/main/js/app/components/AlmSynchronisationWarning.tsx b/server/sonar-web/src/main/js/app/components/AlmSynchronisationWarning.tsx new file mode 100644 index 00000000000..83e933e4858 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/AlmSynchronisationWarning.tsx @@ -0,0 +1,167 @@ +/* + * 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 { formatDistance } from 'date-fns'; +import * as React from 'react'; +import { FormattedMessage } from 'react-intl'; +import Link from '../../components/common/Link'; +import CheckIcon from '../../components/icons/CheckIcon'; +import WarningIcon from '../../components/icons/WarningIcon'; +import { Alert } from '../../components/ui/Alert'; +import { translate, translateWithParameters } from '../../helpers/l10n'; +import { AlmSyncStatus } from '../../types/provisioning'; +import { TaskStatuses } from '../../types/tasks'; +import './SystemAnnouncement.css'; + +interface SynchronisationWarningProps { + short?: boolean; + data: AlmSyncStatus; +} + +interface LastSyncProps { + short?: boolean; + info: AlmSyncStatus['lastSync']; +} + +function LastSyncAlert({ info, short }: Readonly<LastSyncProps>) { + if (info === undefined) { + return null; + } + const { finishedAt, errorMessage, status, summary, warningMessage } = info; + + const formattedDate = finishedAt ? formatDistance(new Date(finishedAt), new Date()) : ''; + + if (short) { + return status === TaskStatuses.Success ? ( + <div> + <span className="authentication-enabled spacer-left"> + {warningMessage ? ( + <WarningIcon className="spacer-right" /> + ) : ( + <CheckIcon className="spacer-right" /> + )} + </span> + <i> + {warningMessage ? ( + <FormattedMessage + id="settings.authentication.github.synchronization_successful.with_warning" + defaultMessage={translate( + 'settings.authentication.github.synchronization_successful.with_warning', + )} + values={{ + date: formattedDate, + details: ( + <Link to="/admin/settings?category=authentication&tab=github"> + {translate('settings.authentication.github.synchronization_details_link')} + </Link> + ), + }} + /> + ) : ( + translateWithParameters( + 'settings.authentication.github.synchronization_successful', + formattedDate, + ) + )} + </i> + </div> + ) : ( + <Alert variant="error"> + <FormattedMessage + id="settings.authentication.github.synchronization_failed_short" + defaultMessage={translate('settings.authentication.github.synchronization_failed_short')} + values={{ + details: ( + <Link to="/admin/settings?category=authentication&tab=github"> + {translate('settings.authentication.github.synchronization_details_link')} + </Link> + ), + }} + /> + </Alert> + ); + } + + return ( + <> + <Alert + variant={status === TaskStatuses.Success ? 'success' : 'error'} + role="alert" + aria-live="assertive" + > + {status === TaskStatuses.Success ? ( + <> + {translateWithParameters( + 'settings.authentication.github.synchronization_successful', + formattedDate, + )} + <br /> + {summary ?? ''} + </> + ) : ( + <React.Fragment key={`synch-alert-${finishedAt}`}> + <div> + {translateWithParameters( + 'settings.authentication.github.synchronization_failed', + formattedDate, + )} + </div> + <br /> + {errorMessage ?? ''} + </React.Fragment> + )} + </Alert> + <Alert variant="warning" role="alert" aria-live="assertive"> + {warningMessage} + </Alert> + </> + ); +} + +export default function AlmSynchronisationWarning({ + short, + data, +}: Readonly<SynchronisationWarningProps>) { + return ( + <> + <Alert + variant="loading" + className="spacer-bottom" + aria-atomic + role="alert" + aria-live="assertive" + aria-label={ + data.nextSync === undefined + ? translate('settings.authentication.github.synchronization_finish') + : '' + } + > + {!short && + data?.nextSync && + translate( + data.nextSync.status === TaskStatuses.Pending + ? 'settings.authentication.github.synchronization_pending' + : 'settings.authentication.github.synchronization_in_progress', + )} + </Alert> + + <LastSyncAlert short={short} info={data.lastSync} /> + </> + ); +} diff --git a/server/sonar-web/src/main/js/app/components/GitHubSynchronisationWarning.tsx b/server/sonar-web/src/main/js/app/components/GitHubSynchronisationWarning.tsx index 09860d1d703..22dcf68356e 100644 --- a/server/sonar-web/src/main/js/app/components/GitHubSynchronisationWarning.tsx +++ b/server/sonar-web/src/main/js/app/components/GitHubSynchronisationWarning.tsx @@ -17,156 +17,23 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { formatDistance } from 'date-fns'; import * as React from 'react'; -import { FormattedMessage } from 'react-intl'; -import Link from '../../components/common/Link'; -import CheckIcon from '../../components/icons/CheckIcon'; -import WarningIcon from '../../components/icons/WarningIcon'; -import { Alert } from '../../components/ui/Alert'; -import { translate, translateWithParameters } from '../../helpers/l10n'; import { useGitHubSyncStatusQuery } from '../../queries/identity-provider'; -import { GithubStatusEnabled } from '../../types/provisioning'; -import { TaskStatuses } from '../../types/tasks'; +import AlmSynchronisationWarning from './AlmSynchronisationWarning'; import './SystemAnnouncement.css'; -interface LastSyncProps { +interface Props { short?: boolean; - info: GithubStatusEnabled['lastSync']; } -interface GitHubSynchronisationWarningProps { - short?: boolean; -} - -function LastSyncAlert({ info, short }: LastSyncProps) { - if (info === undefined) { - return null; - } - const { finishedAt, errorMessage, status, summary, warningMessage } = info; - - const formattedDate = finishedAt ? formatDistance(new Date(finishedAt), new Date()) : ''; - - if (short) { - return status === TaskStatuses.Success ? ( - <div> - <span className="authentication-enabled spacer-left"> - {warningMessage ? ( - <WarningIcon className="spacer-right" /> - ) : ( - <CheckIcon className="spacer-right" /> - )} - </span> - <i> - {warningMessage ? ( - <FormattedMessage - id="settings.authentication.github.synchronization_successful.with_warning" - defaultMessage={translate( - 'settings.authentication.github.synchronization_successful.with_warning', - )} - values={{ - date: formattedDate, - details: ( - <Link to="/admin/settings?category=authentication&tab=github"> - {translate('settings.authentication.github.synchronization_details_link')} - </Link> - ), - }} - /> - ) : ( - translateWithParameters( - 'settings.authentication.github.synchronization_successful', - formattedDate, - ) - )} - </i> - </div> - ) : ( - <Alert variant="error"> - <FormattedMessage - id="settings.authentication.github.synchronization_failed_short" - defaultMessage={translate('settings.authentication.github.synchronization_failed_short')} - values={{ - details: ( - <Link to="/admin/settings?category=authentication&tab=github"> - {translate('settings.authentication.github.synchronization_details_link')} - </Link> - ), - }} - /> - </Alert> - ); - } - - return ( - <> - <Alert - variant={status === TaskStatuses.Success ? 'success' : 'error'} - role="alert" - aria-live="assertive" - > - {status === TaskStatuses.Success ? ( - <> - {translateWithParameters( - 'settings.authentication.github.synchronization_successful', - formattedDate, - )} - <br /> - {summary ?? ''} - </> - ) : ( - <React.Fragment key={`synch-alert-${finishedAt}`}> - <div> - {translateWithParameters( - 'settings.authentication.github.synchronization_failed', - formattedDate, - )} - </div> - <br /> - {errorMessage ?? ''} - </React.Fragment> - )} - </Alert> - <Alert variant="warning" role="alert" aria-live="assertive"> - {warningMessage} - </Alert> - </> - ); -} - -function GitHubSynchronisationWarning({ short }: GitHubSynchronisationWarningProps) { +function GitHubSynchronisationWarning({ short }: Readonly<Props>) { const { data } = useGitHubSyncStatusQuery(); if (!data) { return null; } - return ( - <> - <Alert - variant="loading" - className="spacer-bottom" - aria-atomic - role="alert" - aria-live="assertive" - aria-label={ - data.nextSync === undefined - ? translate('settings.authentication.github.synchronization_finish') - : '' - } - > - {!short && - data?.nextSync && - translate( - data.nextSync.status === TaskStatuses.Pending - ? 'settings.authentication.github.synchronization_pending' - : 'settings.authentication.github.synchronization_in_progress', - )} - </Alert> - - <LastSyncAlert short={short} info={data.lastSync} /> - </> - ); + return <AlmSynchronisationWarning short={short} data={data} />; } export default GitHubSynchronisationWarning; diff --git a/server/sonar-web/src/main/js/components/hooks/useManageProvider.ts b/server/sonar-web/src/main/js/app/components/GitLabSynchronisationWarning.tsx index e91145365b1..227e2e55129 100644 --- a/server/sonar-web/src/main/js/components/hooks/useManageProvider.ts +++ b/server/sonar-web/src/main/js/app/components/GitLabSynchronisationWarning.tsx @@ -17,26 +17,23 @@ * 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 { useEffect } from 'react'; -import { getSystemInfo } from '../../api/system'; -import { SysInfoCluster } from '../../types/types'; +import { useGitLabSyncStatusQuery } from '../../queries/identity-provider'; +import AlmSynchronisationWarning from './AlmSynchronisationWarning'; +import './SystemAnnouncement.css'; -export enum Provider { - Github = 'GitHub', - Scim = 'SCIM', +interface Props { + short?: boolean; } -export function useManageProvider(): string | undefined { - const [manageProvider, setManageProvider] = React.useState<Provider | undefined>(); +function GitLabSynchronisationWarning({ short }: Readonly<Props>) { + const { data } = useGitLabSyncStatusQuery(); - useEffect(() => { - (async () => { - const info = (await getSystemInfo()) as SysInfoCluster; - setManageProvider(info.System['External Users and Groups Provisioning'] as Provider); - })(); - }, []); + if (!data) { + return null; + } - return manageProvider; + return <AlmSynchronisationWarning short={short} data={data} />; } + +export default GitLabSynchronisationWarning; diff --git a/server/sonar-web/src/main/js/apps/groups/GroupsApp.tsx b/server/sonar-web/src/main/js/apps/groups/GroupsApp.tsx index 6aef407edc8..4927e76985c 100644 --- a/server/sonar-web/src/main/js/apps/groups/GroupsApp.tsx +++ b/server/sonar-web/src/main/js/apps/groups/GroupsApp.tsx @@ -25,9 +25,10 @@ import ListFooter from '../../components/controls/ListFooter'; import { ManagedFilter } from '../../components/controls/ManagedFilter'; import SearchBox from '../../components/controls/SearchBox'; import Suggestions from '../../components/embed-docs-modal/Suggestions'; -import { Provider, useManageProvider } from '../../components/hooks/useManageProvider'; import { translate } from '../../helpers/l10n'; import { useGroupsQueries } from '../../queries/groups'; +import { useIdentityProviderQuery } from '../../queries/identity-provider'; +import { Provider } from '../../types/types'; import Header from './components/Header'; import List from './components/List'; import './groups.css'; @@ -35,7 +36,7 @@ import './groups.css'; export default function GroupsApp() { const [search, setSearch] = useState<string>(''); const [managed, setManaged] = useState<boolean | undefined>(); - const manageProvider = useManageProvider(); + const { data: manageProvider } = useIdentityProviderQuery(); const { data, isLoading, fetchNextPage } = useGroupsQueries({ q: search, @@ -49,12 +50,12 @@ export default function GroupsApp() { <Suggestions suggestions="user_groups" /> <Helmet defer={false} title={translate('user_groups.page')} /> <main className="page page-limited" id="groups-page"> - <Header manageProvider={manageProvider} /> - {manageProvider === Provider.Github && <GitHubSynchronisationWarning short />} + <Header manageProvider={manageProvider?.provider} /> + {manageProvider?.provider === Provider.Github && <GitHubSynchronisationWarning short />} <div className="display-flex-justify-start big-spacer-bottom big-spacer-top"> <ManagedFilter - manageProvider={manageProvider} + manageProvider={manageProvider?.provider} loading={isLoading} managed={managed} setManaged={setManaged} @@ -68,7 +69,7 @@ export default function GroupsApp() { /> </div> - <List groups={groups} manageProvider={manageProvider} /> + <List groups={groups} manageProvider={manageProvider?.provider} /> <div id="groups-list-footer"> <ListFooter diff --git a/server/sonar-web/src/main/js/apps/groups/__tests__/GroupsApp-it.tsx b/server/sonar-web/src/main/js/apps/groups/__tests__/GroupsApp-it.tsx index 265da70be5f..f1fbe9bdaae 100644 --- a/server/sonar-web/src/main/js/apps/groups/__tests__/GroupsApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/groups/__tests__/GroupsApp-it.tsx @@ -26,12 +26,12 @@ import GroupMembershipsServiceMock from '../../../api/mocks/GroupMembersipsServi import GroupsServiceMock from '../../../api/mocks/GroupsServiceMock'; import SystemServiceMock from '../../../api/mocks/SystemServiceMock'; import UsersServiceMock from '../../../api/mocks/UsersServiceMock'; -import { Provider } from '../../../components/hooks/useManageProvider'; import { mockGroupMembership, mockRestUser } from '../../../helpers/testMocks'; import { renderApp } from '../../../helpers/testReactTestingUtils'; import { byRole, byText } from '../../../helpers/testSelector'; import { Feature } from '../../../types/features'; import { TaskStatuses } from '../../../types/tasks'; +import { Provider } from '../../../types/types'; import GroupsApp from '../GroupsApp'; const systemHandler = new SystemServiceMock(); @@ -275,6 +275,7 @@ describe('in manage mode', () => { it('should not be able to create a group', async () => { renderGroupsApp(); + expect(await ui.createGroupButton.find()).toBeInTheDocument(); expect(await ui.createGroupButton.find()).toBeDisabled(); expect(ui.infoManageMode.get()).toBeInTheDocument(); }); diff --git a/server/sonar-web/src/main/js/apps/groups/components/Header.tsx b/server/sonar-web/src/main/js/apps/groups/components/Header.tsx index f285ff37c38..70f64ed4912 100644 --- a/server/sonar-web/src/main/js/apps/groups/components/Header.tsx +++ b/server/sonar-web/src/main/js/apps/groups/components/Header.tsx @@ -23,14 +23,14 @@ import DocLink from '../../../components/common/DocLink'; import { Button } from '../../../components/controls/buttons'; import { Alert } from '../../../components/ui/Alert'; import { translate } from '../../../helpers/l10n'; +import { Provider } from '../../../types/types'; import GroupForm from './GroupForm'; interface HeaderProps { - manageProvider?: string; + manageProvider: Provider | undefined; } -export default function Header(props: HeaderProps) { - const { manageProvider } = props; +export default function Header({ manageProvider }: Readonly<HeaderProps>) { const [createModal, setCreateModal] = React.useState(false); return ( diff --git a/server/sonar-web/src/main/js/apps/groups/components/List.tsx b/server/sonar-web/src/main/js/apps/groups/components/List.tsx index b699b7eb70a..e90e3cf1cf0 100644 --- a/server/sonar-web/src/main/js/apps/groups/components/List.tsx +++ b/server/sonar-web/src/main/js/apps/groups/components/List.tsx @@ -20,12 +20,12 @@ import { sortBy } from 'lodash'; import * as React from 'react'; import { translate } from '../../../helpers/l10n'; -import { Group } from '../../../types/types'; +import { Group, Provider } from '../../../types/types'; import ListItem from './ListItem'; interface Props { groups: Group[]; - manageProvider: string | undefined; + manageProvider: Provider | undefined; } export default function List(props: Props) { diff --git a/server/sonar-web/src/main/js/apps/groups/components/ListItem.tsx b/server/sonar-web/src/main/js/apps/groups/components/ListItem.tsx index 57452ea1138..8b35206f5a6 100644 --- a/server/sonar-web/src/main/js/apps/groups/components/ListItem.tsx +++ b/server/sonar-web/src/main/js/apps/groups/components/ListItem.tsx @@ -24,18 +24,17 @@ import ActionsDropdown, { ActionsDropdownDivider, ActionsDropdownItem, } from '../../../components/controls/ActionsDropdown'; -import { Provider } from '../../../components/hooks/useManageProvider'; import { translate, translateWithParameters } from '../../../helpers/l10n'; import { getBaseUrl } from '../../../helpers/system'; import { useGroupMembersCountQuery } from '../../../queries/group-memberships'; -import { Group } from '../../../types/types'; +import { Group, Provider } from '../../../types/types'; import DeleteGroupForm from './DeleteGroupForm'; import GroupForm from './GroupForm'; import Members from './Members'; export interface ListItemProps { group: Group; - manageProvider: string | undefined; + manageProvider: Provider | undefined; } export default function ListItem(props: ListItemProps) { diff --git a/server/sonar-web/src/main/js/apps/permissions/project/components/__tests__/PermissionsProject-it.tsx b/server/sonar-web/src/main/js/apps/permissions/project/components/__tests__/PermissionsProject-it.tsx index 5b95545450d..a36e2ca6b33 100644 --- a/server/sonar-web/src/main/js/apps/permissions/project/components/__tests__/PermissionsProject-it.tsx +++ b/server/sonar-web/src/main/js/apps/permissions/project/components/__tests__/PermissionsProject-it.tsx @@ -23,6 +23,7 @@ import userEvent from '@testing-library/user-event'; import AlmSettingsServiceMock from '../../../../../api/mocks/AlmSettingsServiceMock'; import AuthenticationServiceMock from '../../../../../api/mocks/AuthenticationServiceMock'; import PermissionsServiceMock from '../../../../../api/mocks/PermissionsServiceMock'; +import SystemServiceMock from '../../../../../api/mocks/SystemServiceMock'; import { mockComponent } from '../../../../../helpers/mocks/component'; import { mockPermissionGroup, mockPermissionUser } from '../../../../../helpers/mocks/permissions'; import { @@ -41,17 +42,19 @@ import { } from '../../../../../types/component'; import { Feature } from '../../../../../types/features'; import { Permissions } from '../../../../../types/permissions'; -import { Component, PermissionGroup, PermissionUser } from '../../../../../types/types'; +import { Component, PermissionGroup, PermissionUser, Provider } from '../../../../../types/types'; import { projectPermissionsRoutes } from '../../../routes'; import { getPageObject } from '../../../test-utils'; let serviceMock: PermissionsServiceMock; let authHandler: AuthenticationServiceMock; let almHandler: AlmSettingsServiceMock; +let systemHandler: SystemServiceMock; beforeAll(() => { serviceMock = new PermissionsServiceMock(); authHandler = new AuthenticationServiceMock(); almHandler = new AlmSettingsServiceMock(); + systemHandler = new SystemServiceMock(); }); afterEach(() => { @@ -237,195 +240,205 @@ it('should correctly handle pagination', async () => { expect(screen.getAllByRole('row').length).toBe(21); }); -it('should not allow to change visibility for GH Project with auto-provisioning', async () => { - const user = userEvent.setup(); - const ui = getPageObject(user); - authHandler.githubProvisioningStatus = true; - almHandler.handleSetProjectBinding(AlmKeys.GitHub, { - almSetting: 'test', - repository: 'test', - monorepo: false, - project: 'my-project', +describe('GH provisioning', () => { + beforeEach(() => { + systemHandler.setProvider(Provider.Github); }); - renderPermissionsProjectApp({}, { featureList: [Feature.GithubProvisioning] }); - await ui.appLoaded(); - expect(ui.visibilityRadio(Visibility.Public).get()).toBeDisabled(); - expect(ui.visibilityRadio(Visibility.Public).get()).toBeChecked(); - expect(ui.visibilityRadio(Visibility.Private).get()).toBeDisabled(); - await act(async () => { - await ui.turnProjectPrivate(); - }); - expect(ui.visibilityRadio(Visibility.Private).get()).not.toBeChecked(); -}); + it('should not allow to change visibility for GH Project with auto-provisioning', async () => { + const user = userEvent.setup(); + const ui = getPageObject(user); + authHandler.githubProvisioningStatus = true; + almHandler.handleSetProjectBinding(AlmKeys.GitHub, { + almSetting: 'test', + repository: 'test', + monorepo: false, + project: 'my-project', + }); + renderPermissionsProjectApp({}, { featureList: [Feature.GithubProvisioning] }); + await ui.appLoaded(); -it('should allow to change visibility for non-GH Project', async () => { - const user = userEvent.setup(); - const ui = getPageObject(user); - authHandler.githubProvisioningStatus = true; - almHandler.handleSetProjectBinding(AlmKeys.Azure, { - almSetting: 'test', - repository: 'test', - monorepo: false, - project: 'my-project', + expect(ui.visibilityRadio(Visibility.Public).get()).toBeDisabled(); + expect(ui.visibilityRadio(Visibility.Public).get()).toBeChecked(); + expect(ui.visibilityRadio(Visibility.Private).get()).toBeDisabled(); + await act(async () => { + await ui.turnProjectPrivate(); + }); + expect(ui.visibilityRadio(Visibility.Private).get()).not.toBeChecked(); }); - renderPermissionsProjectApp({}, { featureList: [Feature.GithubProvisioning] }); - await ui.appLoaded(); - expect(ui.visibilityRadio(Visibility.Public).get()).not.toHaveClass('disabled'); - expect(ui.visibilityRadio(Visibility.Public).get()).toBeChecked(); - expect(ui.visibilityRadio(Visibility.Private).get()).not.toHaveClass('disabled'); - await act(async () => { - await ui.turnProjectPrivate(); - }); - expect(ui.visibilityRadio(Visibility.Private).get()).toBeChecked(); -}); + it('should allow to change visibility for non-GH Project', async () => { + const user = userEvent.setup(); + const ui = getPageObject(user); + authHandler.githubProvisioningStatus = true; + almHandler.handleSetProjectBinding(AlmKeys.Azure, { + almSetting: 'test', + repository: 'test', + monorepo: false, + project: 'my-project', + }); + renderPermissionsProjectApp({}, { featureList: [Feature.GithubProvisioning] }); + await ui.appLoaded(); -it('should allow to change visibility for GH Project with disabled auto-provisioning', async () => { - const user = userEvent.setup(); - const ui = getPageObject(user); - authHandler.githubProvisioningStatus = false; - almHandler.handleSetProjectBinding(AlmKeys.GitHub, { - almSetting: 'test', - repository: 'test', - monorepo: false, - project: 'my-project', + expect(ui.visibilityRadio(Visibility.Public).get()).not.toHaveClass('disabled'); + expect(ui.visibilityRadio(Visibility.Public).get()).toBeChecked(); + expect(ui.visibilityRadio(Visibility.Private).get()).not.toHaveClass('disabled'); + await act(async () => { + await ui.turnProjectPrivate(); + }); + expect(ui.visibilityRadio(Visibility.Private).get()).toBeChecked(); }); - renderPermissionsProjectApp({}, { featureList: [Feature.GithubProvisioning] }); - await ui.appLoaded(); - expect(ui.visibilityRadio(Visibility.Public).get()).not.toHaveClass('disabled'); - expect(ui.visibilityRadio(Visibility.Public).get()).toBeChecked(); - expect(ui.visibilityRadio(Visibility.Private).get()).not.toHaveClass('disabled'); - await act(async () => { - await ui.turnProjectPrivate(); - }); - expect(ui.visibilityRadio(Visibility.Private).get()).toBeChecked(); -}); + it('should allow to change visibility for GH Project with disabled auto-provisioning', async () => { + const user = userEvent.setup(); + const ui = getPageObject(user); + authHandler.githubProvisioningStatus = false; + almHandler.handleSetProjectBinding(AlmKeys.GitHub, { + almSetting: 'test', + repository: 'test', + monorepo: false, + project: 'my-project', + }); + renderPermissionsProjectApp({}, { featureList: [Feature.GithubProvisioning] }); + await ui.appLoaded(); -it('should have disabled permissions for GH Project', async () => { - const user = userEvent.setup(); - const ui = getPageObject(user); - authHandler.githubProvisioningStatus = true; - almHandler.handleSetProjectBinding(AlmKeys.GitHub, { - almSetting: 'test', - repository: 'test', - monorepo: false, - project: 'my-project', + expect(ui.visibilityRadio(Visibility.Public).get()).not.toHaveClass('disabled'); + expect(ui.visibilityRadio(Visibility.Public).get()).toBeChecked(); + expect(ui.visibilityRadio(Visibility.Private).get()).not.toHaveClass('disabled'); + await act(async () => { + await ui.turnProjectPrivate(); + }); + expect(ui.visibilityRadio(Visibility.Private).get()).toBeChecked(); }); - renderPermissionsProjectApp( - {}, - { featureList: [Feature.GithubProvisioning] }, - { - component: mockComponent({ visibility: Visibility.Private }), - }, - ); - await ui.appLoaded(); - - expect(ui.pageTitle.get()).toBeInTheDocument(); - await waitFor(() => - expect(ui.pageTitle.get()).toHaveAccessibleName(/project_permission.github_managed/), - ); - expect(ui.pageTitle.byRole('img').get()).toBeInTheDocument(); - expect(ui.githubExplanations.get()).toBeInTheDocument(); - - expect(ui.projectPermissionCheckbox('John', Permissions.Admin).get()).toBeChecked(); - expect(ui.projectPermissionCheckbox('John', Permissions.Admin).get()).toBeDisabled(); - expect(ui.projectPermissionCheckbox('Alexa', Permissions.IssueAdmin).get()).toBeChecked(); - expect(ui.projectPermissionCheckbox('Alexa', Permissions.IssueAdmin).get()).toBeEnabled(); - await ui.toggleProjectPermission('Alexa', Permissions.IssueAdmin); - expect(ui.confirmRemovePermissionDialog.get()).toBeInTheDocument(); - expect(ui.confirmRemovePermissionDialog.get()).toHaveTextContent( - `${Permissions.IssueAdmin}Alexa`, - ); - await act(() => - user.click(ui.confirmRemovePermissionDialog.byRole('button', { name: 'confirm' }).get()), - ); - expect(ui.projectPermissionCheckbox('Alexa', Permissions.IssueAdmin).get()).not.toBeChecked(); - - expect(ui.projectPermissionCheckbox('sonar-users', Permissions.Browse).get()).toBeChecked(); - expect(ui.projectPermissionCheckbox('sonar-users', Permissions.Browse).get()).toBeEnabled(); - await ui.toggleProjectPermission('sonar-users', Permissions.Browse); - expect(ui.confirmRemovePermissionDialog.get()).toBeInTheDocument(); - expect(ui.confirmRemovePermissionDialog.get()).toHaveTextContent( - `${Permissions.Browse}sonar-users`, - ); - await act(() => - user.click(ui.confirmRemovePermissionDialog.byRole('button', { name: 'confirm' }).get()), - ); - expect(ui.projectPermissionCheckbox('sonar-users', Permissions.Browse).get()).not.toBeChecked(); - expect(ui.projectPermissionCheckbox('sonar-admins', Permissions.Admin).get()).toBeChecked(); - expect(ui.projectPermissionCheckbox('sonar-admins', Permissions.Admin).get()).toHaveAttribute( - 'disabled', - ); - const johnRow = screen.getAllByRole('row')[4]; - expect(johnRow).toHaveTextContent('John'); - expect(ui.githubLogo.get(johnRow)).toBeInTheDocument(); - const alexaRow = screen.getAllByRole('row')[5]; - expect(alexaRow).toHaveTextContent('Alexa'); - expect(ui.githubLogo.query(alexaRow)).not.toBeInTheDocument(); - const usersGroupRow = screen.getAllByRole('row')[1]; - expect(usersGroupRow).toHaveTextContent('sonar-users'); - expect(ui.githubLogo.query(usersGroupRow)).not.toBeInTheDocument(); - const adminsGroupRow = screen.getAllByRole('row')[2]; - expect(adminsGroupRow).toHaveTextContent('sonar-admins'); - expect(ui.githubLogo.query(adminsGroupRow)).toBeInTheDocument(); - - expect(ui.applyTemplateBtn.query()).not.toBeInTheDocument(); - - // not possible to grant permissions at all - expect( - screen - .getAllByRole('checkbox', { checked: false }) - .every((item) => item.getAttributeNames().includes('disabled')), - ).toBe(true); -}); + it('should have disabled permissions for GH Project', async () => { + const user = userEvent.setup(); + const ui = getPageObject(user); + authHandler.githubProvisioningStatus = true; + almHandler.handleSetProjectBinding(AlmKeys.GitHub, { + almSetting: 'test', + repository: 'test', + monorepo: false, + project: 'my-project', + }); + renderPermissionsProjectApp( + {}, + { featureList: [Feature.GithubProvisioning] }, + { + component: mockComponent({ visibility: Visibility.Private }), + }, + ); + await ui.appLoaded(); -it('should allow to change permissions for GH Project without auto-provisioning', async () => { - const user = userEvent.setup(); - const ui = getPageObject(user); - authHandler.githubProvisioningStatus = false; - almHandler.handleSetProjectBinding(AlmKeys.GitHub, { - almSetting: 'test', - repository: 'test', - monorepo: false, - project: 'my-project', + expect(ui.pageTitle.get()).toBeInTheDocument(); + await waitFor(() => + expect(ui.pageTitle.get()).toHaveAccessibleName(/project_permission.github_managed/), + ); + expect(ui.pageTitle.byRole('img').get()).toBeInTheDocument(); + expect(ui.githubExplanations.get()).toBeInTheDocument(); + + expect(ui.projectPermissionCheckbox('John', Permissions.Admin).get()).toBeChecked(); + expect(ui.projectPermissionCheckbox('John', Permissions.Admin).get()).toBeDisabled(); + expect(ui.projectPermissionCheckbox('Alexa', Permissions.IssueAdmin).get()).toBeChecked(); + expect(ui.projectPermissionCheckbox('Alexa', Permissions.IssueAdmin).get()).toBeEnabled(); + await ui.toggleProjectPermission('Alexa', Permissions.IssueAdmin); + expect(ui.confirmRemovePermissionDialog.get()).toBeInTheDocument(); + expect(ui.confirmRemovePermissionDialog.get()).toHaveTextContent( + `${Permissions.IssueAdmin}Alexa`, + ); + await act(() => + user.click(ui.confirmRemovePermissionDialog.byRole('button', { name: 'confirm' }).get()), + ); + expect(ui.projectPermissionCheckbox('Alexa', Permissions.IssueAdmin).get()).not.toBeChecked(); + + expect(ui.projectPermissionCheckbox('sonar-users', Permissions.Browse).get()).toBeChecked(); + expect(ui.projectPermissionCheckbox('sonar-users', Permissions.Browse).get()).toBeEnabled(); + await ui.toggleProjectPermission('sonar-users', Permissions.Browse); + expect(ui.confirmRemovePermissionDialog.get()).toBeInTheDocument(); + expect(ui.confirmRemovePermissionDialog.get()).toHaveTextContent( + `${Permissions.Browse}sonar-users`, + ); + await act(() => + user.click(ui.confirmRemovePermissionDialog.byRole('button', { name: 'confirm' }).get()), + ); + expect(ui.projectPermissionCheckbox('sonar-users', Permissions.Browse).get()).not.toBeChecked(); + expect(ui.projectPermissionCheckbox('sonar-admins', Permissions.Admin).get()).toBeChecked(); + expect(ui.projectPermissionCheckbox('sonar-admins', Permissions.Admin).get()).toHaveAttribute( + 'disabled', + ); + + const johnRow = screen.getAllByRole('row')[4]; + expect(johnRow).toHaveTextContent('John'); + expect(ui.githubLogo.get(johnRow)).toBeInTheDocument(); + const alexaRow = screen.getAllByRole('row')[5]; + expect(alexaRow).toHaveTextContent('Alexa'); + expect(ui.githubLogo.query(alexaRow)).not.toBeInTheDocument(); + const usersGroupRow = screen.getAllByRole('row')[1]; + expect(usersGroupRow).toHaveTextContent('sonar-users'); + expect(ui.githubLogo.query(usersGroupRow)).not.toBeInTheDocument(); + const adminsGroupRow = screen.getAllByRole('row')[2]; + expect(adminsGroupRow).toHaveTextContent('sonar-admins'); + expect(ui.githubLogo.query(adminsGroupRow)).toBeInTheDocument(); + + expect(ui.applyTemplateBtn.query()).not.toBeInTheDocument(); + + // not possible to grant permissions at all + expect( + screen + .getAllByRole('checkbox', { checked: false }) + .every((item) => item.getAttributeNames().includes('disabled')), + ).toBe(true); }); - renderPermissionsProjectApp( - { visibility: Visibility.Private }, - { featureList: [Feature.GithubProvisioning] }, - ); - await ui.appLoaded(); - expect(ui.pageTitle.get()).toBeInTheDocument(); - expect(ui.pageTitle.byRole('img').query()).not.toBeInTheDocument(); + it('should allow to change permissions for GH Project without auto-provisioning', async () => { + const user = userEvent.setup(); + const ui = getPageObject(user); + authHandler.githubProvisioningStatus = false; + almHandler.handleSetProjectBinding(AlmKeys.GitHub, { + almSetting: 'test', + repository: 'test', + monorepo: false, + project: 'my-project', + }); + renderPermissionsProjectApp( + { visibility: Visibility.Private }, + { featureList: [Feature.GithubProvisioning] }, + ); + await ui.appLoaded(); - expect(ui.applyTemplateBtn.get()).toBeInTheDocument(); + expect(ui.pageTitle.get()).toBeInTheDocument(); + expect(ui.pageTitle.byRole('img').query()).not.toBeInTheDocument(); - // no restrictions - expect( - screen.getAllByRole('checkbox').every((item) => item.getAttributeNames().includes('disabled')), - ).toBe(false); -}); + expect(ui.applyTemplateBtn.get()).toBeInTheDocument(); -it('should allow to change permissions for non-GH Project', async () => { - const user = userEvent.setup(); - const ui = getPageObject(user); - authHandler.githubProvisioningStatus = true; - renderPermissionsProjectApp({}, { featureList: [Feature.GithubProvisioning] }); - await ui.appLoaded(); + // no restrictions + expect( + screen + .getAllByRole('checkbox') + .every((item) => item.getAttributeNames().includes('disabled')), + ).toBe(false); + }); + + it('should allow to change permissions for non-GH Project', async () => { + const user = userEvent.setup(); + const ui = getPageObject(user); + authHandler.githubProvisioningStatus = true; + renderPermissionsProjectApp({}, { featureList: [Feature.GithubProvisioning] }); + await ui.appLoaded(); - expect(ui.pageTitle.get()).toBeInTheDocument(); - expect(ui.nonGHProjectWarning.get()).toBeInTheDocument(); - expect(ui.pageTitle.byRole('img').query()).not.toBeInTheDocument(); + expect(ui.pageTitle.get()).toBeInTheDocument(); + expect(ui.nonGHProjectWarning.get()).toBeInTheDocument(); + expect(ui.pageTitle.byRole('img').query()).not.toBeInTheDocument(); - expect(ui.applyTemplateBtn.get()).toBeInTheDocument(); + expect(ui.applyTemplateBtn.get()).toBeInTheDocument(); - // no restrictions - expect( - screen.getAllByRole('checkbox').every((item) => item.getAttributeNames().includes('disabled')), - ).toBe(false); + // no restrictions + expect( + screen + .getAllByRole('checkbox') + .every((item) => item.getAttributeNames().includes('disabled')), + ).toBe(false); + }); }); function renderPermissionsProjectApp( diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/Authentication.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/Authentication.tsx index a095444c20b..12d5d723408 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/authentication/Authentication.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/Authentication.tsx @@ -37,6 +37,7 @@ import { Feature } from '../../../../types/features'; import { ExtendedSettingDefinition } from '../../../../types/settings'; import { AUTHENTICATION_CATEGORY } from '../../constants'; import CategoryDefinitionsList from '../CategoryDefinitionsList'; +import GitLabAuthenticationTab from './GitLabAuthenticationTab'; import GithubAuthenticationTab from './GithubAuthenticationTab'; import SamlAuthenticationTab, { SAML } from './SamlAuthenticationTab'; @@ -112,10 +113,11 @@ export function Authentication(props: Props & WithAvailableFeaturesProps) { }, ] as const; - const [samlDefinitions, githubDefinitions] = React.useMemo( + const [samlDefinitions, githubDefinitions, gitlabDefinitions] = React.useMemo( () => [ definitions.filter((def) => def.subCategory === SAML), definitions.filter((def) => def.subCategory === AlmKeys.GitHub), + definitions.filter((def) => def.subCategory === AlmKeys.GitLab), ], [definitions], ); @@ -161,7 +163,7 @@ export function Authentication(props: Props & WithAvailableFeaturesProps) { <div style={{ maxHeight: - tab.key !== SAML && tab.key !== AlmKeys.GitHub + tab.key === AlmKeys.BitbucketServer ? `calc(100vh - ${top + HEIGHT_ADJUSTMENT}px)` : '', }} @@ -183,7 +185,11 @@ export function Authentication(props: Props & WithAvailableFeaturesProps) { /> )} - {tab.key !== SAML && tab.key !== AlmKeys.GitHub && ( + {tab.key === AlmKeys.GitLab && ( + <GitLabAuthenticationTab definitions={gitlabDefinitions} /> + )} + + {tab.key === AlmKeys.BitbucketServer && ( <> <Alert variant="info"> <FormattedMessage diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationFormField.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationFormField.tsx index 26d50fe903d..8a8ca9d5501 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationFormField.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationFormField.tsx @@ -21,23 +21,23 @@ import React from 'react'; import ValidationInput, { ValidationInputErrorPlacement, } from '../../../../components/controls/ValidationInput'; -import { ExtendedSettingDefinition, SettingType } from '../../../../types/settings'; +import { DefinitionV2, 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'; -interface SamlToggleFieldProps { +interface Props { settingValue?: string | boolean | string[]; - definition: ExtendedSettingDefinition; + definition: ExtendedSettingDefinition | DefinitionV2; mandatory?: boolean; onFieldChange: (key: string, value: string | boolean | string[]) => void; isNotSet: boolean; error?: string; } -export default function AuthenticationFormField(props: SamlToggleFieldProps) { +export default function AuthenticationFormField(props: Readonly<Props>) { const { mandatory = false, definition, settingValue, isNotSet, error } = props; const name = getPropertyName(definition); diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationMultiValuesField.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationMultiValuesField.tsx index 74caf05ff9d..e818928729c 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationMultiValuesField.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationMultiValuesField.tsx @@ -20,13 +20,13 @@ import * as React from 'react'; import { DeleteButton } from '../../../../components/controls/buttons'; import { translateWithParameters } from '../../../../helpers/l10n'; -import { ExtendedSettingDefinition } from '../../../../types/settings'; +import { DefinitionV2, ExtendedSettingDefinition } from '../../../../types/settings'; import { getPropertyName } from '../../utils'; interface Props { onFieldChange: (value: string[]) => void; settingValue?: string[]; - definition: ExtendedSettingDefinition; + definition: ExtendedSettingDefinition | DefinitionV2; } export default function AuthenticationMultiValueField(props: Props) { diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationSecuredField.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationSecuredField.tsx index 1b2f2baa811..7074bb257e9 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationSecuredField.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationSecuredField.tsx @@ -20,13 +20,13 @@ import React, { useEffect } from 'react'; import { ButtonLink } from '../../../../components/controls/buttons'; import { translate } from '../../../../helpers/l10n'; -import { ExtendedSettingDefinition, SettingType } from '../../../../types/settings'; +import { DefinitionV2, ExtendedSettingDefinition, SettingType } from '../../../../types/settings'; import { isSecuredDefinition } from '../../utils'; interface SamlToggleFieldProps { onFieldChange: (key: string, value: string) => void; settingValue?: string; - definition: ExtendedSettingDefinition; + definition: ExtendedSettingDefinition | DefinitionV2; optional?: boolean; isNotSet: boolean; } diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationToggleField.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationToggleField.tsx index cb82310df92..df75a345fbc 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationToggleField.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationToggleField.tsx @@ -19,12 +19,12 @@ */ import React from 'react'; import Toggle from '../../../../components/controls/Toggle'; -import { ExtendedSettingDefinition } from '../../../../types/settings'; +import { DefinitionV2, ExtendedSettingDefinition } from '../../../../types/settings'; interface SamlToggleFieldProps { onChange: (value: boolean) => void; settingValue?: string | boolean; - definition: ExtendedSettingDefinition; + definition: ExtendedSettingDefinition | DefinitionV2; } export default function AuthenticationToggleField(props: SamlToggleFieldProps) { 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 new file mode 100644 index 00000000000..03fa8f27e11 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/GitLabAuthenticationTab.tsx @@ -0,0 +1,443 @@ +/* + * 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 { isEqual, omitBy } from 'lodash'; +import React, { FormEvent, useContext } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { + GITLAB_SETTING_ALLOW_SIGNUP, + GITLAB_SETTING_GROUPS, + GITLAB_SETTING_GROUP_TOKEN, +} from '../../../../api/provisioning'; +import GitLabSynchronisationWarning from '../../../../app/components/GitLabSynchronisationWarning'; +import { AvailableFeaturesContext } from '../../../../app/components/available-features/AvailableFeaturesContext'; +import DocLink from '../../../../components/common/DocLink'; +import ConfirmModal from '../../../../components/controls/ConfirmModal'; +import RadioCard from '../../../../components/controls/RadioCard'; +import Tooltip from '../../../../components/controls/Tooltip'; +import { Button, ResetButtonLink, SubmitButton } from '../../../../components/controls/buttons'; +import DeleteIcon from '../../../../components/icons/DeleteIcon'; +import EditIcon from '../../../../components/icons/EditIcon'; +import { Alert } from '../../../../components/ui/Alert'; +import Spinner from '../../../../components/ui/Spinner'; +import { translate } from '../../../../helpers/l10n'; +import { + useDeleteGitLabConfigurationMutation, + useGitLabConfigurationsQuery, + useIdentityProviderQuery, + useUpdateGitLabConfigurationMutation, +} from '../../../../queries/identity-provider'; +import { AlmKeys } from '../../../../types/alm-settings'; +import { Feature } from '../../../../types/features'; +import { GitLabConfigurationUpdateBody, ProvisioningType } from '../../../../types/provisioning'; +import { ExtendedSettingDefinition } from '../../../../types/settings'; +import { Provider } from '../../../../types/types'; +import { DOCUMENTATION_LINK_SUFFIXES } from './Authentication'; +import AuthenticationFormField from './AuthenticationFormField'; +import GitLabConfigurationForm from './GitLabConfigurationForm'; + +interface GitLabAuthenticationTab { + definitions: ExtendedSettingDefinition[]; +} + +interface ChangesForm { + type?: GitLabConfigurationUpdateBody['type']; + allowUsersToSignUp?: GitLabConfigurationUpdateBody['allowUsersToSignUp']; + provisioningToken?: GitLabConfigurationUpdateBody['provisioningToken']; + groups?: GitLabConfigurationUpdateBody['groups']; +} + +export default function GitLabAuthenticationTab(props: Readonly<GitLabAuthenticationTab>) { + const { definitions } = props; + + 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 hasGitlabProvisioningFeature = useContext(AvailableFeaturesContext).includes( + Feature.GitlabProvisioning, + ); + + const { data: identityProvider } = useIdentityProviderQuery(); + const { data: list, isLoading: isLoadingList } = useGitLabConfigurationsQuery(); + const configuration = list?.configurations[0]; + + const { mutate: updateConfig, isLoading: isUpdating } = useUpdateGitLabConfigurationMutation(); + const { mutate: deleteConfig, isLoading: isDeleting } = useDeleteGitLabConfigurationMutation(); + + const toggleEnable = () => { + if (!configuration) { + return; + } + updateConfig({ id: configuration.id, data: { enabled: !configuration.enabled } }); + }; + + const deleteConfiguration = () => { + if (!configuration) { + return; + } + deleteConfig(configuration.id); + }; + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (changes?.type !== undefined) { + setShowConfirmProvisioningModal(true); + } else { + updateProvisioning(); + } + }; + + const updateProvisioning = () => { + if (!changes || !configuration) { + return; + } + + updateConfig( + { id: configuration.id, data: omitBy(changes, (value) => value === undefined) }, + { + onSuccess: () => { + setChanges(undefined); + setTokenKey(tokenKey + 1); + }, + }, + ); + }; + + const setJIT = () => + setChangesWithCheck({ + type: ProvisioningType.jit, + provisioningToken: undefined, + groups: undefined, + }); + + const setAuto = () => + setChangesWithCheck({ + type: ProvisioningType.auto, + allowUsersToSignUp: undefined, + }); + + const hasDifferentProvider = + identityProvider?.provider !== undefined && identityProvider.provider !== Provider.Gitlab; + const allowUsersToSignUpDefinition = definitions.find( + (d) => d.key === GITLAB_SETTING_ALLOW_SIGNUP, + ); + const provisioningTokenDefinition = definitions.find((d) => d.key === GITLAB_SETTING_GROUP_TOKEN); + const provisioningGroupDefinition = definitions.find((d) => d.key === GITLAB_SETTING_GROUPS); + + const provisioningType = changes?.type ?? configuration?.type; + const allowUsersToSignUp = changes?.allowUsersToSignUp ?? configuration?.allowUsersToSignUp; + const provisioningToken = changes?.provisioningToken; + const groups = changes?.groups ?? configuration?.groups; + + const canSave = () => { + if (!configuration || changes === undefined) { + return false; + } + const type = changes.type ?? configuration.type; + if (type === ProvisioningType.auto) { + const hasConfigGroups = configuration.groups && configuration.groups.length > 0; + const hasGroups = changes.groups ? changes.groups.length > 0 : hasConfigGroups; + const hasToken = hasConfigGroups + ? changes.provisioningToken !== '' + : !!changes.provisioningToken; + return hasGroups && hasToken; + } + return true; + }; + + const setChangesWithCheck = (newChanges: ChangesForm) => { + const newValue = { + type: configuration?.type === newChanges.type ? undefined : newChanges.type, + allowUsersToSignUp: + configuration?.allowUsersToSignUp === newChanges.allowUsersToSignUp + ? undefined + : newChanges.allowUsersToSignUp, + provisioningToken: newChanges.provisioningToken, + groups: isEqual(configuration?.groups, newChanges.groups) ? undefined : newChanges.groups, + }; + if (Object.values(newValue).some((v) => v !== undefined)) { + setChanges(newValue); + } else { + setChanges(undefined); + } + }; + + return ( + <Spinner loading={isLoadingList}> + <div className="authentication-configuration"> + <div className="spacer-bottom display-flex-space-between display-flex-center"> + <h4>{translate('settings.authentication.gitlab.configuration')}</h4> + {!configuration && ( + <div> + <Button onClick={() => setOpenForm(true)}> + {translate('settings.authentication.form.create')} + </Button> + </div> + )} + </div> + {!configuration && ( + <div className="big-padded text-center huge-spacer-bottom authentication-no-config"> + {translate('settings.authentication.gitlab.form.not_configured')} + </div> + )} + {configuration && ( + <div className="spacer-bottom big-padded bordered display-flex-space-between"> + <div> + <p>{configuration.url}</p> + <Tooltip + overlay={ + configuration.type === ProvisioningType.auto + ? translate('settings.authentication.form.disable.tooltip') + : null + } + > + <Button + className="spacer-top" + onClick={toggleEnable} + disabled={isUpdating || configuration.type === ProvisioningType.auto} + > + {configuration.enabled + ? translate('settings.authentication.form.disable') + : translate('settings.authentication.form.enable')} + </Button> + </Tooltip> + </div> + <div> + <Button className="spacer-right" onClick={() => setOpenForm(true)}> + <EditIcon /> + {translate('settings.authentication.form.edit')} + </Button> + <Tooltip + overlay={ + configuration.enabled + ? translate('settings.authentication.form.delete.tooltip') + : null + } + > + <Button + className="button-red" + disabled={configuration.enabled || isDeleting} + onClick={deleteConfiguration} + > + <DeleteIcon /> + {translate('settings.authentication.form.delete')} + </Button> + </Tooltip> + </div> + </div> + )} + <div className="spacer-bottom big-padded bordered"> + <form onSubmit={handleSubmit}> + <fieldset className="display-flex-column big-spacer-bottom"> + <label className="h5">{translate('settings.authentication.form.provisioning')}</label> + + {configuration?.enabled ? ( + <div className="display-flex-column spacer-top"> + <RadioCard + className="sw-min-h-0" + label={translate('settings.authentication.gitlab.provisioning_at_login')} + title={translate('settings.authentication.gitlab.provisioning_at_login')} + selected={provisioningType === ProvisioningType.jit} + onClick={setJIT} + > + <p className="spacer-bottom"> + <FormattedMessage id="settings.authentication.gitlab.provisioning_at_login.description" /> + </p> + <p className="spacer-bottom"> + <FormattedMessage + id="settings.authentication.gitlab.description.doc" + values={{ + documentation: ( + <DocLink + to={`/instance-administration/authentication/${ + DOCUMENTATION_LINK_SUFFIXES[AlmKeys.GitLab] + }/`} + > + {translate('documentation')} + </DocLink> + ), + }} + /> + </p> + {provisioningType === ProvisioningType.jit && + allowUsersToSignUpDefinition !== undefined && ( + <AuthenticationFormField + settingValue={allowUsersToSignUp} + definition={allowUsersToSignUpDefinition} + mandatory + onFieldChange={(_, value) => + setChangesWithCheck({ + ...changes, + allowUsersToSignUp: value as boolean, + }) + } + isNotSet={configuration.type !== ProvisioningType.auto} + /> + )} + </RadioCard> + <RadioCard + className="spacer-top sw-min-h-0" + label={translate( + 'settings.authentication.gitlab.form.provisioning_with_gitlab', + )} + title={translate( + 'settings.authentication.gitlab.form.provisioning_with_gitlab', + )} + selected={provisioningType === ProvisioningType.auto} + onClick={setAuto} + disabled={!hasGitlabProvisioningFeature || hasDifferentProvider} + > + {hasGitlabProvisioningFeature ? ( + <> + {hasDifferentProvider && ( + <p className="spacer-bottom text-bold "> + {translate('settings.authentication.form.other_provisioning_enabled')} + </p> + )} + <p className="spacer-bottom"> + {translate( + 'settings.authentication.gitlab.form.provisioning_with_gitlab.description', + )} + </p> + <p className="spacer-bottom"> + <FormattedMessage + id="settings.authentication.gitlab.description.doc" + values={{ + documentation: ( + <DocLink + to={`/instance-administration/authentication/${ + DOCUMENTATION_LINK_SUFFIXES[AlmKeys.GitLab] + }/`} + > + {translate('documentation')} + </DocLink> + ), + }} + /> + </p> + + {configuration?.type === ProvisioningType.auto && ( + <> + <GitLabSynchronisationWarning /> + <hr className="spacer-top" /> + </> + )} + + {provisioningType === ProvisioningType.auto && + provisioningTokenDefinition !== undefined && + provisioningGroupDefinition !== undefined && ( + <> + <AuthenticationFormField + settingValue={provisioningToken} + key={tokenKey} + definition={provisioningTokenDefinition} + mandatory + onFieldChange={(_, value) => + setChangesWithCheck({ + ...changes, + provisioningToken: value as string, + }) + } + isNotSet={ + configuration.type !== ProvisioningType.auto && + configuration.groups?.length === 0 + } + /> + <AuthenticationFormField + settingValue={groups} + definition={provisioningGroupDefinition} + mandatory + onFieldChange={(_, values) => + setChangesWithCheck({ ...changes, groups: values as string[] }) + } + isNotSet={configuration.type !== ProvisioningType.auto} + /> + </> + )} + </> + ) : ( + <p> + <FormattedMessage + id="settings.authentication.gitlab.form.provisioning.disabled" + defaultMessage={translate( + 'settings.authentication.gitlab.form.provisioning.disabled', + )} + values={{ + documentation: ( + <DocLink to="/instance-administration/authentication/gitlab"> + {translate('documentation')} + </DocLink> + ), + }} + /> + </p> + )} + </RadioCard> + </div> + ) : ( + <Alert className="big-spacer-top" variant="info"> + {translate('settings.authentication.github.enable_first')} + </Alert> + )} + </fieldset> + {configuration?.enabled && ( + <div className="sw-flex sw-gap-2 sw-h-8 sw-items-center"> + <SubmitButton disabled={!canSave()}>{translate('save')}</SubmitButton> + <ResetButtonLink + onClick={() => { + setChanges(undefined); + setTokenKey(tokenKey + 1); + }} + disabled={false} + > + {translate('cancel')} + </ResetButtonLink> + <Alert variant="warning" className="sw-mb-0"> + {canSave() && + translate('settings.authentication.gitlab.configuration.unsaved_changes')} + </Alert> + </div> + )} + {showConfirmProvisioningModal && provisioningType && ( + <ConfirmModal + onConfirm={updateProvisioning} + header={translate('settings.authentication.gitlab.confirm', provisioningType)} + onClose={() => setShowConfirmProvisioningModal(false)} + confirmButtonText={translate( + 'settings.authentication.gitlab.provisioning_change.confirm_changes', + )} + > + {translate( + 'settings.authentication.gitlab.confirm', + provisioningType, + 'description', + )} + </ConfirmModal> + )} + </form> + </div> + </div> + {openForm && ( + <GitLabConfigurationForm data={configuration ?? null} onClose={() => setOpenForm(false)} /> + )} + </Spinner> + ); +} diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/GitLabConfigurationForm.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/GitLabConfigurationForm.tsx new file mode 100644 index 00000000000..0aa4379247c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/GitLabConfigurationForm.tsx @@ -0,0 +1,207 @@ +/* + * 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 { keyBy } from 'lodash'; +import * as React from 'react'; +import { FormattedMessage } from 'react-intl'; +import DocLink from '../../../../components/common/DocLink'; +import Modal from '../../../../components/controls/Modal'; +import { ResetButtonLink, SubmitButton } from '../../../../components/controls/buttons'; +import { Alert } from '../../../../components/ui/Alert'; +import Spinner from '../../../../components/ui/Spinner'; +import { translate } from '../../../../helpers/l10n'; +import { + useCreateGitLabConfigurationMutation, + useUpdateGitLabConfigurationMutation, +} from '../../../../queries/identity-provider'; +import { GitLabConfigurationCreateBody, GitlabConfiguration } from '../../../../types/provisioning'; +import { DefinitionV2, SettingType } from '../../../../types/settings'; +import { DOCUMENTATION_LINK_SUFFIXES } from './Authentication'; +import AuthenticationFormField from './AuthenticationFormField'; + +interface Props { + data: GitlabConfiguration | null; + onClose: () => void; +} + +interface ErrorValue { + key: string; + message: string; +} + +interface FormData { + value: string | boolean; + required: boolean; + definition: DefinitionV2; +} + +const DEFAULT_URL = 'https://gitlab.com'; + +export default function GitLabConfigurationForm(props: Readonly<Props>) { + const { data } = props; + const isCreate = data === null; + const [errors, setErrors] = React.useState<Record<string, ErrorValue>>({}); + const { mutate: createConfig, isLoading: createLoading } = useCreateGitLabConfigurationMutation(); + const { mutate: updateConfig, isLoading: updateLoading } = useUpdateGitLabConfigurationMutation(); + + const [formData, setFormData] = React.useState< + Record<keyof GitLabConfigurationCreateBody, FormData> + >({ + applicationId: { + value: '', + required: true, + definition: { + name: translate('settings.authentication.gitlab.form.applicationId.name'), + key: 'applicationId', + description: translate('settings.authentication.gitlab.form.applicationId.description'), + secured: true, + }, + }, + url: { + value: data?.url ?? DEFAULT_URL, + required: true, + definition: { + name: translate('settings.authentication.gitlab.form.url.name'), + secured: false, + key: 'url', + description: translate('settings.authentication.gitlab.form.url.description'), + }, + }, + clientSecret: { + value: '', + required: true, + definition: { + name: translate('settings.authentication.gitlab.form.clientSecret.name'), + secured: true, + key: 'clientSecret', + description: translate('settings.authentication.gitlab.form.clientSecret.description'), + }, + }, + synchronizeUserGroups: { + value: data?.synchronizeUserGroups ?? false, + required: false, + definition: { + name: translate('settings.authentication.gitlab.form.synchronizeUserGroups.name'), + secured: false, + key: 'synchronizeUserGroups', + description: translate( + 'settings.authentication.gitlab.form.synchronizeUserGroups.description', + ), + type: SettingType.BOOLEAN, + }, + }, + }); + + const headerLabel = translate( + 'settings.authentication.gitlab.form', + isCreate ? 'create' : 'edit', + ); + + const canBeSaved = Object.values(formData).every( + (v) => (!isCreate && v.definition.secured) || !v.required || v.value !== '', + ); + + const handleSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => { + event.preventDefault(); + + if (canBeSaved) { + const submitData = Object.entries(formData).reduce<GitLabConfigurationCreateBody>( + (acc, [key, { value }]: [keyof GitLabConfigurationCreateBody, FormData]) => { + if (value === '') { + return acc; + } + return { + ...acc, + [key]: value, + }; + }, + {} as GitLabConfigurationCreateBody, + ); + if (data) { + updateConfig({ id: data.id, data: submitData }, { onSuccess: props.onClose }); + } else { + createConfig(submitData, { onSuccess: props.onClose }); + } + } else { + const errors = Object.entries(formData) + .filter(([_, v]) => v.required && !v.value) + .map(([key]) => ({ key, message: translate('field_required') })); + setErrors(keyBy(errors, 'key')); + } + }; + + return ( + <Modal + contentLabel={headerLabel} + onRequestClose={props.onClose} + shouldCloseOnOverlayClick={false} + shouldCloseOnEsc + size="medium" + > + <form onSubmit={handleSubmit}> + <div className="modal-head"> + <h2>{headerLabel}</h2> + </div> + <div className="modal-body modal-container"> + <Alert variant="info"> + <FormattedMessage + id="settings.authentication.help" + values={{ + link: ( + <DocLink + to={`/instance-administration/authentication/${DOCUMENTATION_LINK_SUFFIXES.gitlab}/`} + > + {translate('settings.authentication.help.link')} + </DocLink> + ), + }} + /> + </Alert> + {Object.entries(formData).map( + ([key, { value, required, definition }]: [ + key: keyof GitLabConfigurationCreateBody, + FormData, + ]) => ( + <div key={key}> + <AuthenticationFormField + settingValue={value} + definition={definition} + mandatory={required} + onFieldChange={(_, value) => { + setFormData((prev) => ({ ...prev, [key]: { ...prev[key], value } })); + }} + isNotSet={isCreate} + error={errors[key]?.message} + /> + </div> + ), + )} + </div> + + <div className="modal-foot"> + <SubmitButton disabled={!canBeSaved}> + {translate('settings.almintegration.form.save')} + <Spinner className="spacer-left" loading={createLoading || updateLoading} /> + </SubmitButton> + <ResetButtonLink onClick={props.onClose}>{translate('cancel')}</ResetButtonLink> + </div> + </form> + </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 665e5c027e6..4c2e271de26 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 @@ -25,7 +25,6 @@ import ConfirmModal from '../../../../components/controls/ConfirmModal'; import RadioCard from '../../../../components/controls/RadioCard'; import Tooltip from '../../../../components/controls/Tooltip'; import { Button, ResetButtonLink, SubmitButton } from '../../../../components/controls/buttons'; -import { Provider } from '../../../../components/hooks/useManageProvider'; import DeleteIcon from '../../../../components/icons/DeleteIcon'; import EditIcon from '../../../../components/icons/EditIcon'; import { Alert } from '../../../../components/ui/Alert'; @@ -37,6 +36,7 @@ import { } from '../../../../queries/identity-provider'; import { AlmKeys } from '../../../../types/alm-settings'; import { ExtendedSettingDefinition } from '../../../../types/settings'; +import { Provider } from '../../../../types/types'; import { AuthenticationTabs, DOCUMENTATION_LINK_SUFFIXES } from './Authentication'; import AuthenticationFormField from './AuthenticationFormField'; import AuthenticationFormFieldWrapper from './AuthenticationFormFieldWrapper'; @@ -223,7 +223,6 @@ export default function GithubAuthenticationTab(props: GithubAuthenticationProps <p className="spacer-bottom"> <FormattedMessage id="settings.authentication.github.form.description.doc" - tagName="p" values={{ documentation: ( <DocLink diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlAuthenticationTab.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlAuthenticationTab.tsx index c0d63db413e..a191a859e52 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlAuthenticationTab.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlAuthenticationTab.tsx @@ -24,7 +24,6 @@ import Link from '../../../../components/common/Link'; import ConfirmModal from '../../../../components/controls/ConfirmModal'; import RadioCard from '../../../../components/controls/RadioCard'; import { Button, ResetButtonLink, SubmitButton } from '../../../../components/controls/buttons'; -import { Provider } from '../../../../components/hooks/useManageProvider'; import CheckIcon from '../../../../components/icons/CheckIcon'; import DeleteIcon from '../../../../components/icons/DeleteIcon'; import EditIcon from '../../../../components/icons/EditIcon'; @@ -36,6 +35,7 @@ import { } from '../../../../queries/identity-provider'; import { useSaveValueMutation } from '../../../../queries/settings'; import { ExtendedSettingDefinition } from '../../../../types/settings'; +import { Provider } from '../../../../types/types'; import ConfigurationForm from './ConfigurationForm'; import useSamlConfiguration, { SAML_ENABLED_FIELD, diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-it.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-it.tsx index 639d12b5545..f8d1a8684d5 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-it.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-it.tsx @@ -26,12 +26,13 @@ import ComputeEngineServiceMock from '../../../../../api/mocks/ComputeEngineServ import SettingsServiceMock from '../../../../../api/mocks/SettingsServiceMock'; import SystemServiceMock from '../../../../../api/mocks/SystemServiceMock'; import { AvailableFeaturesContext } from '../../../../../app/components/available-features/AvailableFeaturesContext'; +import { mockGitlabConfiguration } from '../../../../../helpers/mocks/alm-integrations'; import { definitions } from '../../../../../helpers/mocks/definitions-list'; import { renderComponent } from '../../../../../helpers/testReactTestingUtils'; import { byRole, byText } from '../../../../../helpers/testSelector'; import { Feature } from '../../../../../types/features'; -import { GitHubProvisioningStatus } from '../../../../../types/provisioning'; -import { TaskStatuses } from '../../../../../types/tasks'; +import { GitHubProvisioningStatus, ProvisioningType } from '../../../../../types/provisioning'; +import { TaskStatuses, TaskTypes } from '../../../../../types/tasks'; import Authentication from '../Authentication'; let handler: AuthenticationServiceMock; @@ -71,6 +72,10 @@ afterEach(() => { computeEngineHandler.reset(); }); +const ghContainer = byRole('tabpanel', { name: 'github GitHub' }); +const glContainer = byRole('tabpanel', { name: 'gitlab GitLab' }); +const samlContainer = byRole('tabpanel', { name: 'SAML' }); + const ui = { saveButton: byRole('button', { name: 'settings.authentication.saml.form.save' }), customMessageInformation: byText('settings.authentication.custom_message_information'), @@ -80,7 +85,9 @@ const ui = { textbox2: byRole('textbox', { name: 'test2' }), saml: { noSamlConfiguration: byText('settings.authentication.saml.form.not_configured'), - createConfigButton: byRole('button', { name: 'settings.authentication.form.create' }), + createConfigButton: samlContainer.byRole('button', { + name: 'settings.authentication.form.create', + }), providerName: byRole('textbox', { name: 'property.sonar.auth.saml.providerName.name' }), providerId: byRole('textbox', { name: 'property.sonar.auth.saml.providerId.name' }), providerCertificate: byRole('textbox', { @@ -93,10 +100,16 @@ const ui = { confirmProvisioningButton: byRole('button', { name: 'yes', }), - saveScim: byRole('button', { name: 'save' }), - enableConfigButton: byRole('button', { name: 'settings.authentication.form.enable' }), - disableConfigButton: byRole('button', { name: 'settings.authentication.form.disable' }), - editConfigButton: byRole('button', { name: 'settings.authentication.form.edit' }), + saveScim: samlContainer.byRole('button', { name: 'save' }), + enableConfigButton: samlContainer.byRole('button', { + name: 'settings.authentication.form.enable', + }), + disableConfigButton: samlContainer.byRole('button', { + name: 'settings.authentication.form.disable', + }), + editConfigButton: samlContainer.byRole('button', { + name: 'settings.authentication.form.edit', + }), enableFirstMessage: byText('settings.authentication.saml.enable_first'), jitProvisioningButton: byRole('radio', { name: 'settings.authentication.saml.form.provisioning_at_login', @@ -118,7 +131,7 @@ const ui = { createConfiguration: async (user: UserEvent) => { const { saml } = ui; - await user.click((await saml.createConfigButton.findAll())[0]); + await user.click(await saml.createConfigButton.find()); await saml.fillForm(user); await user.click(saml.saveConfigButton.get()); }, @@ -126,10 +139,16 @@ const ui = { github: { tab: byRole('tab', { name: 'github GitHub' }), noGithubConfiguration: byText('settings.authentication.github.form.not_configured'), - createConfigButton: byRole('button', { name: 'settings.authentication.form.create' }), - clientId: byRole('textbox', { name: 'property.sonar.auth.github.clientId.secured.name' }), + createConfigButton: ghContainer.byRole('button', { + name: 'settings.authentication.form.create', + }), + clientId: byRole('textbox', { + name: 'property.sonar.auth.github.clientId.secured.name', + }), appId: byRole('textbox', { name: 'property.sonar.auth.github.appId.name' }), - privateKey: byRole('textbox', { name: 'property.sonar.auth.github.privateKey.secured.name' }), + privateKey: byRole('textbox', { + name: 'property.sonar.auth.github.privateKey.secured.name', + }), clientSecret: byRole('textbox', { name: 'property.sonar.auth.github.clientSecret.secured.name', }), @@ -138,17 +157,27 @@ const ui = { allowUserToSignUp: byRole('switch', { name: 'sonar.auth.github.allowUsersToSignUp', }), - organizations: byRole('textbox', { name: 'property.sonar.auth.github.organizations.name' }), + organizations: byRole('textbox', { + name: 'property.sonar.auth.github.organizations.name', + }), saveConfigButton: byRole('button', { name: 'settings.almintegration.form.save' }), confirmProvisioningButton: byRole('button', { name: 'settings.authentication.github.provisioning_change.confirm_changes', }), - saveGithubProvisioning: byRole('button', { name: 'save' }), - groupAttribute: byRole('textbox', { name: 'property.sonar.auth.github.group.name.name' }), - enableConfigButton: byRole('button', { name: 'settings.authentication.form.enable' }), - disableConfigButton: byRole('button', { name: 'settings.authentication.form.disable' }), - editConfigButton: byRole('button', { name: 'settings.authentication.form.edit' }), - editMappingButton: byRole('button', { + saveGithubProvisioning: ghContainer.byRole('button', { name: 'save' }), + groupAttribute: byRole('textbox', { + name: 'property.sonar.auth.github.group.name.name', + }), + enableConfigButton: ghContainer.byRole('button', { + name: 'settings.authentication.form.enable', + }), + disableConfigButton: ghContainer.byRole('button', { + name: 'settings.authentication.form.disable', + }), + editConfigButton: ghContainer.byRole('button', { + name: 'settings.authentication.form.edit', + }), + editMappingButton: ghContainer.byRole('button', { name: 'settings.authentication.github.configuration.roles_mapping.button_label', }), mappingRow: byRole('dialog', { @@ -181,35 +210,35 @@ const ui = { byRole('button', { name: `settings.definition.delete_value.property.sonar.auth.github.organizations.name.${org}`, }), - enableFirstMessage: byText('settings.authentication.github.enable_first'), - jitProvisioningButton: byRole('radio', { + enableFirstMessage: ghContainer.byText('settings.authentication.github.enable_first'), + jitProvisioningButton: ghContainer.byRole('radio', { name: 'settings.authentication.form.provisioning_at_login', }), - githubProvisioningButton: byRole('radio', { + githubProvisioningButton: ghContainer.byRole('radio', { name: 'settings.authentication.github.form.provisioning_with_github', }), - githubProvisioningPending: byText(/synchronization_pending/), - githubProvisioningInProgress: byText(/synchronization_in_progress/), - githubProvisioningSuccess: byText(/synchronization_successful/), - githubProvisioningAlert: byText(/synchronization_failed/), - configurationValidityLoading: byRole('status', { + githubProvisioningPending: ghContainer.byText(/synchronization_pending/), + githubProvisioningInProgress: ghContainer.byText(/synchronization_in_progress/), + githubProvisioningSuccess: ghContainer.byText(/synchronization_successful/), + githubProvisioningAlert: ghContainer.byText(/synchronization_failed/), + configurationValidityLoading: ghContainer.byRole('status', { name: /github.configuration.validation.loading/, }), - configurationValiditySuccess: byRole('status', { + configurationValiditySuccess: ghContainer.byRole('status', { name: /github.configuration.validation.valid/, }), - configurationValidityError: byRole('status', { + configurationValidityError: ghContainer.byRole('status', { name: /github.configuration.validation.invalid/, }), - syncWarning: byText(/Warning/), - syncSummary: byText(/Test summary/), - configurationValidityWarning: byRole('status', { + syncWarning: ghContainer.byText(/Warning/), + syncSummary: ghContainer.byText(/Test summary/), + configurationValidityWarning: ghContainer.byRole('status', { name: /github.configuration.validation.valid.short/, }), - checkConfigButton: byRole('button', { + checkConfigButton: ghContainer.byRole('button', { name: 'settings.authentication.github.configuration.validation.test', }), - viewConfigValidityDetailsButton: byRole('button', { + viewConfigValidityDetailsButton: ghContainer.byRole('button', { name: 'settings.authentication.github.configuration.validation.details', }), configDetailsDialog: byRole('dialog', { @@ -240,7 +269,7 @@ const ui = { createConfiguration: async (user: UserEvent) => { const { github } = ui; - await user.click((await github.createConfigButton.findAll())[1]); + await user.click(await github.createConfigButton.find()); await github.fillForm(user); await user.click(github.saveConfigButton.get()); @@ -263,6 +292,78 @@ const ui = { await user.click(github.confirmProvisioningButton.get()); }, }, + gitlab: { + tab: byRole('tab', { name: 'gitlab GitLab' }), + noGitlabConfiguration: glContainer.byText('settings.authentication.gitlab.form.not_configured'), + createConfigButton: glContainer.byRole('button', { + name: 'settings.authentication.form.create', + }), + editConfigButton: glContainer.byRole('button', { + name: 'settings.authentication.form.edit', + }), + deleteConfigButton: glContainer.byRole('button', { + name: 'settings.authentication.form.delete', + }), + enableConfigButton: glContainer.byRole('button', { + name: 'settings.authentication.form.enable', + }), + disableConfigButton: glContainer.byRole('button', { + name: 'settings.authentication.form.disable', + }), + createDialog: byRole('dialog', { + name: 'settings.authentication.gitlab.form.create', + }), + editDialog: byRole('dialog', { + name: 'settings.authentication.gitlab.form.edit', + }), + applicationId: byRole('textbox', { + name: 'property.applicationId.name', + }), + url: byRole('textbox', { name: 'property.url.name' }), + clientSecret: byRole('textbox', { + name: 'property.clientSecret.name', + }), + synchronizeUserGroups: byRole('switch', { + name: 'synchronizeUserGroups', + }), + saveConfigButton: byRole('button', { name: 'settings.almintegration.form.save' }), + jitProvisioningRadioButton: glContainer.byRole('radio', { + name: 'settings.authentication.gitlab.provisioning_at_login', + }), + autoProvisioningRadioButton: glContainer.byRole('radio', { + name: 'settings.authentication.gitlab.form.provisioning_with_gitlab', + }), + jitAllowUsersToSignUpToggle: byRole('switch', { name: 'sonar.auth.gitlab.allowUsersToSignUp' }), + autoProvisioningToken: byRole('textbox', { + name: 'property.provisioning.gitlab.token.secured.name', + }), + autoProvisioningUpdateTokenButton: byRole('button', { + name: 'settings.almintegration.form.secret.update_field', + }), + autoProvisioningGroupsInput: byRole('textbox', { + name: 'property.provisioning.gitlab.groups.name', + }), + removeProvisioniongGroup: byRole('button', { + name: /settings.definition.delete_value.property.provisioning.gitlab.groups.name./, + }), + saveProvisioning: glContainer.byRole('button', { name: 'save' }), + cancelProvisioningChanges: glContainer.byRole('button', { name: 'cancel' }), + confirmAutoProvisioningDialog: byRole('dialog', { + name: 'settings.authentication.gitlab.confirm.Auto', + }), + confirmJitProvisioningDialog: byRole('dialog', { + name: 'settings.authentication.gitlab.confirm.JIT', + }), + confirmProvisioningChange: byRole('button', { + name: 'settings.authentication.gitlab.provisioning_change.confirm_changes', + }), + syncSummary: glContainer.byText(/Test summary/), + syncWarning: glContainer.byText(/Warning/), + gitlabProvisioningPending: glContainer.byText(/synchronization_pending/), + gitlabProvisioningInProgress: glContainer.byText(/synchronization_in_progress/), + gitlabProvisioningSuccess: glContainer.byText(/synchronization_successful/), + gitlabProvisioningAlert: glContainer.byText(/synchronization_failed/), + }, }; it('should render tabs and allow navigation', async () => { @@ -306,7 +407,7 @@ describe('SAML tab', () => { const user = userEvent.setup(); renderAuthentication(); - await user.click((await saml.createConfigButton.findAll())[0]); + await user.click(await saml.createConfigButton.find()); expect(saml.saveConfigButton.get()).toBeDisabled(); await saml.fillForm(user); @@ -384,7 +485,7 @@ describe('Github tab', () => { renderAuthentication(); await user.click(await github.tab.find()); - await user.click((await github.createConfigButton.findAll())[1]); + await user.click(await github.createConfigButton.find()); expect(github.saveConfigButton.get()).toBeDisabled(); @@ -1062,6 +1163,359 @@ describe('Github tab', () => { }); }); +describe('GitLab', () => { + const { gitlab } = ui; + + it('should create a Gitlab configuration and disable it', async () => { + handler.setGitlabConfigurations([]); + renderAuthentication(); + const user = userEvent.setup(); + await user.click(await gitlab.tab.find()); + + expect(await gitlab.noGitlabConfiguration.find()).toBeInTheDocument(); + expect(gitlab.createConfigButton.get()).toBeInTheDocument(); + + await user.click(gitlab.createConfigButton.get()); + expect(await gitlab.createDialog.find()).toBeInTheDocument(); + await user.type(gitlab.applicationId.get(), '123'); + await user.type(gitlab.url.get(), 'https://company.gitlab.com'); + await user.type(gitlab.clientSecret.get(), '123'); + await user.click(gitlab.synchronizeUserGroups.get()); + await user.click(gitlab.saveConfigButton.get()); + + expect(await gitlab.editConfigButton.find()).toBeInTheDocument(); + expect(gitlab.noGitlabConfiguration.query()).not.toBeInTheDocument(); + expect(glContainer.get()).toHaveTextContent('https://company.gitlab.com'); + + expect(gitlab.disableConfigButton.get()).toBeInTheDocument(); + await user.click(gitlab.disableConfigButton.get()); + expect(gitlab.enableConfigButton.get()).toBeInTheDocument(); + expect(gitlab.disableConfigButton.query()).not.toBeInTheDocument(); + }); + + it('should edit/delete configuration', async () => { + const user = userEvent.setup(); + renderAuthentication(); + await user.click(await gitlab.tab.find()); + + expect(await gitlab.editConfigButton.find()).toBeInTheDocument(); + expect(glContainer.get()).toHaveTextContent('URL'); + expect(gitlab.disableConfigButton.get()).toBeInTheDocument(); + expect(gitlab.deleteConfigButton.get()).toBeInTheDocument(); + expect(gitlab.deleteConfigButton.get()).toBeDisabled(); + + await user.click(gitlab.editConfigButton.get()); + expect(await gitlab.editDialog.find()).toBeInTheDocument(); + expect(gitlab.url.get()).toHaveValue('URL'); + expect(gitlab.applicationId.query()).not.toBeInTheDocument(); + expect(gitlab.clientSecret.query()).not.toBeInTheDocument(); + expect(gitlab.synchronizeUserGroups.get()).toBeChecked(); + await user.clear(gitlab.url.get()); + await user.type(gitlab.url.get(), 'https://company.gitlab.com'); + await user.click(gitlab.saveConfigButton.get()); + + expect(glContainer.get()).not.toHaveTextContent('URL'); + expect(glContainer.get()).toHaveTextContent('https://company.gitlab.com'); + + expect(gitlab.disableConfigButton.get()).toBeInTheDocument(); + await user.click(gitlab.disableConfigButton.get()); + expect(await gitlab.enableConfigButton.find()).toBeInTheDocument(); + expect(gitlab.deleteConfigButton.get()).toBeEnabled(); + await user.click(gitlab.deleteConfigButton.get()); + expect(await gitlab.noGitlabConfiguration.find()).toBeInTheDocument(); + expect(gitlab.editConfigButton.query()).not.toBeInTheDocument(); + }); + + it('should change from just-in-time to Auto Provisioning with proper validation', async () => { + const user = userEvent.setup(); + renderAuthentication([Feature.GitlabProvisioning]); + await user.click(await gitlab.tab.find()); + + expect(await gitlab.editConfigButton.find()).toBeInTheDocument(); + expect(gitlab.jitProvisioningRadioButton.get()).toBeChecked(); + + user.click(gitlab.autoProvisioningRadioButton.get()); + expect(await gitlab.autoProvisioningRadioButton.find()).toBeEnabled(); + expect(gitlab.saveProvisioning.get()).toBeDisabled(); + + await user.type(gitlab.autoProvisioningToken.get(), 'JRR Tolkien'); + expect(await gitlab.saveProvisioning.find()).toBeDisabled(); + + await user.type(gitlab.autoProvisioningGroupsInput.get(), 'NWA'); + user.click(gitlab.autoProvisioningRadioButton.get()); + expect(await gitlab.saveProvisioning.find()).toBeEnabled(); + + await user.click(gitlab.removeProvisioniongGroup.get()); + expect(await gitlab.saveProvisioning.find()).toBeDisabled(); + await user.type(gitlab.autoProvisioningGroupsInput.get(), 'Wu-Tang Clan'); + expect(await gitlab.saveProvisioning.find()).toBeEnabled(); + + await user.clear(gitlab.autoProvisioningToken.get()); + expect(await gitlab.saveProvisioning.find()).toBeDisabled(); + await user.type(gitlab.autoProvisioningToken.get(), 'tiktoken'); + expect(await gitlab.saveProvisioning.find()).toBeEnabled(); + + await user.click(gitlab.saveProvisioning.get()); + expect(gitlab.confirmAutoProvisioningDialog.get()).toBeInTheDocument(); + await user.click(gitlab.confirmProvisioningChange.get()); + expect(gitlab.confirmAutoProvisioningDialog.query()).not.toBeInTheDocument(); + + expect(gitlab.autoProvisioningRadioButton.get()).toBeChecked(); + expect(await gitlab.saveProvisioning.find()).toBeDisabled(); + }); + + it('should change from auto provisioning to JIT with proper validation', async () => { + handler.setGitlabConfigurations([ + mockGitlabConfiguration({ + allowUsersToSignUp: false, + enabled: true, + type: ProvisioningType.auto, + groups: ['D12'], + }), + ]); + const user = userEvent.setup(); + renderAuthentication([Feature.GitlabProvisioning]); + await user.click(await gitlab.tab.find()); + + expect(await gitlab.editConfigButton.find()).toBeInTheDocument(); + + expect(gitlab.jitProvisioningRadioButton.get()).not.toBeChecked(); + expect(gitlab.autoProvisioningRadioButton.get()).toBeChecked(); + expect(gitlab.autoProvisioningGroupsInput.get()).toHaveValue('D12'); + + expect(gitlab.autoProvisioningToken.query()).not.toBeInTheDocument(); + expect(gitlab.autoProvisioningUpdateTokenButton.get()).toBeInTheDocument(); + + await user.click(gitlab.jitProvisioningRadioButton.get()); + expect(await gitlab.jitProvisioningRadioButton.find()).toBeChecked(); + + expect(await gitlab.saveProvisioning.find()).toBeEnabled(); + + expect(gitlab.jitAllowUsersToSignUpToggle.get()).toBeInTheDocument(); + + await user.click(gitlab.saveProvisioning.get()); + expect(gitlab.confirmJitProvisioningDialog.get()).toBeInTheDocument(); + await user.click(gitlab.confirmProvisioningChange.get()); + expect(gitlab.confirmJitProvisioningDialog.query()).not.toBeInTheDocument(); + + expect(gitlab.jitProvisioningRadioButton.get()).toBeChecked(); + expect(await gitlab.saveProvisioning.find()).toBeDisabled(); + }); + + it('should be able to allow user to sign up for JIT with proper validation', async () => { + handler.setGitlabConfigurations([ + mockGitlabConfiguration({ + allowUsersToSignUp: false, + enabled: true, + type: ProvisioningType.jit, + }), + ]); + const user = userEvent.setup(); + renderAuthentication([Feature.GitlabProvisioning]); + await user.click(await gitlab.tab.find()); + + expect(await gitlab.editConfigButton.find()).toBeInTheDocument(); + + expect(gitlab.jitProvisioningRadioButton.get()).toBeChecked(); + expect(gitlab.autoProvisioningRadioButton.get()).not.toBeChecked(); + + expect(gitlab.jitAllowUsersToSignUpToggle.get()).not.toBeChecked(); + + expect(gitlab.saveProvisioning.get()).toBeDisabled(); + await user.click(gitlab.jitAllowUsersToSignUpToggle.get()); + expect(gitlab.saveProvisioning.get()).toBeEnabled(); + await user.click(gitlab.jitAllowUsersToSignUpToggle.get()); + expect(gitlab.saveProvisioning.get()).toBeDisabled(); + await user.click(gitlab.jitAllowUsersToSignUpToggle.get()); + + await user.click(gitlab.saveProvisioning.get()); + + expect(gitlab.jitProvisioningRadioButton.get()).toBeChecked(); + expect(gitlab.jitAllowUsersToSignUpToggle.get()).toBeChecked(); + expect(await gitlab.saveProvisioning.find()).toBeDisabled(); + }); + + it('should be able to edit groups and token for Auto provisioning with proper validation', async () => { + handler.setGitlabConfigurations([ + mockGitlabConfiguration({ + allowUsersToSignUp: false, + enabled: true, + type: ProvisioningType.auto, + groups: ['Cypress Hill', 'Public Enemy'], + }), + ]); + const user = userEvent.setup(); + renderAuthentication([Feature.GitlabProvisioning]); + await user.click(await gitlab.tab.find()); + + expect(gitlab.autoProvisioningRadioButton.get()).toBeChecked(); + expect(gitlab.autoProvisioningUpdateTokenButton.get()).toBeInTheDocument(); + expect(gitlab.autoProvisioningGroupsInput.get()).toHaveValue('Cypress Hill'); + + expect(gitlab.saveProvisioning.get()).toBeDisabled(); + + // Changing the Provisioning token should enable save + await user.click(gitlab.autoProvisioningUpdateTokenButton.get()); + await user.type(gitlab.autoProvisioningGroupsInput.get(), 'Tok Token!'); + expect(gitlab.saveProvisioning.get()).toBeEnabled(); + await user.click(gitlab.cancelProvisioningChanges.get()); + expect(gitlab.saveProvisioning.get()).toBeDisabled(); + + // Adding a group should enable save + await user.click(gitlab.autoProvisioningGroupsInput.get()); + await user.tab(); + await user.tab(); + await user.tab(); + await user.tab(); + await user.keyboard('Run DMC'); + expect(gitlab.saveProvisioning.get()).toBeEnabled(); + await user.tab(); + await user.keyboard('{Enter}'); + expect(gitlab.saveProvisioning.get()).toBeDisabled(); + + // Removing a group should enable save + await user.click(gitlab.autoProvisioningGroupsInput.get()); + await user.tab(); + await user.keyboard('{Enter}'); + expect(gitlab.saveProvisioning.get()).toBeEnabled(); + + // Removing all groups should disable save + await user.click(gitlab.autoProvisioningGroupsInput.get()); + await user.tab(); + await user.keyboard('{Enter}'); + expect(gitlab.saveProvisioning.get()).toBeDisabled(); + }); + + it('should be able to reset Auto Provisioning changes', async () => { + handler.setGitlabConfigurations([ + mockGitlabConfiguration({ + allowUsersToSignUp: false, + enabled: true, + type: ProvisioningType.auto, + groups: ['Cypress Hill', 'Public Enemy'], + }), + ]); + const user = userEvent.setup(); + renderAuthentication([Feature.GitlabProvisioning]); + await user.click(await gitlab.tab.find()); + + expect(gitlab.autoProvisioningRadioButton.get()).toBeChecked(); + + // Cancel doesn't fully work yet as the AuthenticationFormField needs to be worked on + await user.click(gitlab.autoProvisioningGroupsInput.get()); + await user.tab(); + await user.tab(); + await user.tab(); + await user.tab(); + await user.keyboard('A Tribe Called Quest'); + await user.click(gitlab.autoProvisioningGroupsInput.get()); + await user.tab(); + await user.keyboard('{Enter}'); + await user.click(gitlab.autoProvisioningUpdateTokenButton.get()); + await user.type(gitlab.autoProvisioningGroupsInput.get(), 'ToToken!'); + expect(gitlab.saveProvisioning.get()).toBeEnabled(); + await user.click(gitlab.cancelProvisioningChanges.get()); + // expect(gitlab.autoProvisioningUpdateTokenButton.get()).toBeInTheDocument(); + expect(gitlab.autoProvisioningGroupsInput.get()).toHaveValue('Cypress Hill'); + }); + + describe('Gitlab Provisioning', () => { + beforeEach(() => { + jest.useFakeTimers({ + advanceTimers: true, + now: new Date('2022-02-04T12:00:59Z'), + }); + handler.setGitlabConfigurations([ + mockGitlabConfiguration({ + id: '1', + enabled: true, + type: ProvisioningType.auto, + groups: ['Test'], + }), + ]); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('should display a success status when the synchronisation is a success', async () => { + computeEngineHandler.addTask({ + status: TaskStatuses.Success, + executedAt: '2022-02-03T11:45:35+0200', + infoMessages: ['Test summary'], + type: TaskTypes.GitlabProvisioning, + }); + + renderAuthentication([Feature.GitlabProvisioning]); + expect(await gitlab.gitlabProvisioningSuccess.find()).toBeInTheDocument(); + expect(gitlab.syncSummary.get()).toBeInTheDocument(); + }); + + it('should display a success status even when another task is pending', async () => { + computeEngineHandler.addTask({ + status: TaskStatuses.Pending, + executedAt: '2022-02-03T11:55:35+0200', + type: TaskTypes.GitlabProvisioning, + }); + computeEngineHandler.addTask({ + status: TaskStatuses.Success, + executedAt: '2022-02-03T11:45:35+0200', + type: TaskTypes.GitlabProvisioning, + }); + renderAuthentication([Feature.GitlabProvisioning]); + expect(await gitlab.gitlabProvisioningSuccess.find()).toBeInTheDocument(); + expect(gitlab.gitlabProvisioningPending.get()).toBeInTheDocument(); + }); + + it('should display an error alert when the synchronisation failed', async () => { + computeEngineHandler.addTask({ + status: TaskStatuses.Failed, + executedAt: '2022-02-03T11:45:35+0200', + errorMessage: "T'es mauvais Jacques", + type: TaskTypes.GitlabProvisioning, + }); + renderAuthentication([Feature.GitlabProvisioning]); + expect(await gitlab.gitlabProvisioningAlert.find()).toBeInTheDocument(); + expect(gitlab.autoProvisioningRadioButton.get()).toHaveTextContent("T'es mauvais Jacques"); + expect(gitlab.gitlabProvisioningSuccess.query()).not.toBeInTheDocument(); + }); + + it('should display an error alert even when another task is in progress', async () => { + computeEngineHandler.addTask({ + status: TaskStatuses.InProgress, + executedAt: '2022-02-03T11:55:35+0200', + type: TaskTypes.GitlabProvisioning, + }); + computeEngineHandler.addTask({ + status: TaskStatuses.Failed, + executedAt: '2022-02-03T11:45:35+0200', + errorMessage: "T'es mauvais Jacques", + type: TaskTypes.GitlabProvisioning, + }); + renderAuthentication([Feature.GitlabProvisioning]); + expect(await gitlab.gitlabProvisioningAlert.find()).toBeInTheDocument(); + expect(gitlab.autoProvisioningRadioButton.get()).toHaveTextContent("T'es mauvais Jacques"); + expect(gitlab.gitlabProvisioningSuccess.query()).not.toBeInTheDocument(); + expect(gitlab.gitlabProvisioningInProgress.get()).toBeInTheDocument(); + }); + + it('should show warning', async () => { + computeEngineHandler.addTask({ + status: TaskStatuses.Success, + warnings: ['Warning'], + infoMessages: ['Test summary'], + type: TaskTypes.GitlabProvisioning, + }); + renderAuthentication([Feature.GitlabProvisioning]); + + expect(await gitlab.syncWarning.find()).toBeInTheDocument(); + expect(gitlab.syncSummary.get()).toBeInTheDocument(); + }); + }); +}); + const appLoaded = async () => { await waitFor(async () => { expect(await screen.findByText('loading')).not.toBeInTheDocument(); diff --git a/server/sonar-web/src/main/js/apps/settings/utils.ts b/server/sonar-web/src/main/js/apps/settings/utils.ts index 7b02ab120e5..dd3a9ec98b6 100644 --- a/server/sonar-web/src/main/js/apps/settings/utils.ts +++ b/server/sonar-web/src/main/js/apps/settings/utils.ts @@ -24,6 +24,7 @@ import { hasMessage, translate } from '../../helpers/l10n'; import { getGlobalSettingsUrl, getProjectSettingsUrl } from '../../helpers/urls'; import { AlmKeys } from '../../types/alm-settings'; import { + DefinitionV2, ExtendedSettingDefinition, Setting, SettingDefinition, @@ -57,7 +58,7 @@ export interface DefaultInputProps { value: any; } -export function getPropertyName(definition: SettingDefinition) { +export function getPropertyName(definition: SettingDefinition | DefinitionV2) { const key = `property.${definition.key}.name`; if (hasMessage(key)) { return translate(key); @@ -66,7 +67,7 @@ export function getPropertyName(definition: SettingDefinition) { return definition.name ?? definition.key; } -export function getPropertyDescription(definition: SettingDefinition) { +export function getPropertyDescription(definition: SettingDefinition | DefinitionV2) { const key = `property.${definition.key}.description`; return hasMessage(key) ? translate(key) : definition.description; } @@ -148,8 +149,8 @@ export function isURLKind(definition: SettingDefinition) { ].includes(definition.key); } -export function isSecuredDefinition(item: SettingDefinition): boolean { - return item.key.endsWith('.secured'); +export function isSecuredDefinition(item: SettingDefinition | DefinitionV2): boolean { + return 'secured' in item ? item.secured : item.key.endsWith('.secured'); } export function isCategoryDefinition(item: SettingDefinition): item is ExtendedSettingDefinition { diff --git a/server/sonar-web/src/main/js/apps/users/UsersApp.tsx b/server/sonar-web/src/main/js/apps/users/UsersApp.tsx index f2a4f0d1538..70b7cfc9ca6 100644 --- a/server/sonar-web/src/main/js/apps/users/UsersApp.tsx +++ b/server/sonar-web/src/main/js/apps/users/UsersApp.tsx @@ -22,18 +22,19 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Helmet } from 'react-helmet-async'; import { getIdentityProviders } from '../../api/users'; import GitHubSynchronisationWarning from '../../app/components/GitHubSynchronisationWarning'; +import GitLabSynchronisationWarning from '../../app/components/GitLabSynchronisationWarning'; import HelpTooltip from '../../components/controls/HelpTooltip'; import ListFooter from '../../components/controls/ListFooter'; import { ManagedFilter } from '../../components/controls/ManagedFilter'; import SearchBox from '../../components/controls/SearchBox'; import Select, { LabelValueSelectOption } from '../../components/controls/Select'; import Suggestions from '../../components/embed-docs-modal/Suggestions'; -import { Provider, useManageProvider } from '../../components/hooks/useManageProvider'; import Spinner from '../../components/ui/Spinner'; import { now, toISO8601WithOffsetString } from '../../helpers/dates'; import { translate } from '../../helpers/l10n'; +import { useIdentityProviderQuery } from '../../queries/identity-provider'; import { useUsersQueries } from '../../queries/users'; -import { IdentityProvider } from '../../types/types'; +import { IdentityProvider, Provider } from '../../types/types'; import { RestUserDetailed } from '../../types/users'; import Header from './Header'; import UsersList from './UsersList'; @@ -46,6 +47,8 @@ export default function UsersApp() { const [usersActivity, setUsersActivity] = useState<UserActivity>(UserActivity.AnyActivity); const [managed, setManaged] = useState<boolean | undefined>(undefined); + const { data: manageProvider } = useIdentityProviderQuery(); + const usersActivityParams = useMemo(() => { const nowDate = now(); const nowDateMinus30Days = subDays(nowDate, USER_INACTIVITY_DAYS_THRESHOLD); @@ -78,8 +81,6 @@ export default function UsersApp() { const users = data?.pages.flatMap((page) => page.users) ?? []; - const manageProvider = useManageProvider(); - useEffect(() => { (async () => { const { identityProviders } = await getIdentityProviders(); @@ -91,11 +92,12 @@ export default function UsersApp() { <main className="page page-limited" id="users-page"> <Suggestions suggestions="users" /> <Helmet defer={false} title={translate('users.page')} /> - <Header manageProvider={manageProvider} /> - {manageProvider === Provider.Github && <GitHubSynchronisationWarning short />} + <Header manageProvider={manageProvider?.provider} /> + {manageProvider?.provider === Provider.Github && <GitHubSynchronisationWarning short />} + {manageProvider?.provider === Provider.Gitlab && <GitLabSynchronisationWarning short />} <div className="display-flex-justify-start big-spacer-bottom big-spacer-top"> <ManagedFilter - manageProvider={manageProvider} + manageProvider={manageProvider?.provider} loading={isLoading} managed={managed} setManaged={(m) => setManaged(m)} @@ -136,7 +138,7 @@ export default function UsersApp() { <UsersList identityProviders={identityProviders} users={users} - manageProvider={manageProvider} + manageProvider={manageProvider?.provider} /> </Spinner> diff --git a/server/sonar-web/src/main/js/apps/users/UsersList.tsx b/server/sonar-web/src/main/js/apps/users/UsersList.tsx index 5420e2c3a80..4899bdef2bb 100644 --- a/server/sonar-web/src/main/js/apps/users/UsersList.tsx +++ b/server/sonar-web/src/main/js/apps/users/UsersList.tsx @@ -20,14 +20,14 @@ import * as React from 'react'; import HelpTooltip from '../../components/controls/HelpTooltip'; import { translate } from '../../helpers/l10n'; -import { IdentityProvider } from '../../types/types'; +import { IdentityProvider, Provider } from '../../types/types'; import { RestUserDetailed } from '../../types/users'; import UserListItem from './components/UserListItem'; interface Props { identityProviders: IdentityProvider[]; users: RestUserDetailed[]; - manageProvider: string | undefined; + manageProvider: Provider | undefined; } export default function UsersList({ identityProviders, users, manageProvider }: Props) { diff --git a/server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx b/server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx index a9e090eaf1a..6fb3a709866 100644 --- a/server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx @@ -28,12 +28,12 @@ import SettingsServiceMock from '../../../api/mocks/SettingsServiceMock'; import SystemServiceMock from '../../../api/mocks/SystemServiceMock'; import UserTokensMock from '../../../api/mocks/UserTokensMock'; import UsersServiceMock from '../../../api/mocks/UsersServiceMock'; -import { Provider } from '../../../components/hooks/useManageProvider'; import { mockCurrentUser, mockLoggedInUser, mockRestUser } from '../../../helpers/testMocks'; import { renderApp } from '../../../helpers/testReactTestingUtils'; import { byLabelText, byRole, byText } from '../../../helpers/testSelector'; import { Feature } from '../../../types/features'; import { TaskStatuses } from '../../../types/tasks'; +import { Provider } from '../../../types/types'; import { ChangePasswordResults, CurrentUser } from '../../../types/users'; import UsersApp from '../UsersApp'; diff --git a/server/sonar-web/src/main/js/apps/users/components/UserActions.tsx b/server/sonar-web/src/main/js/apps/users/components/UserActions.tsx index e71502ae7b9..a46bf37d7be 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UserActions.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/UserActions.tsx @@ -23,6 +23,7 @@ import ActionsDropdown, { ActionsDropdownItem, } from '../../../components/controls/ActionsDropdown'; import { translate, translateWithParameters } from '../../../helpers/l10n'; +import { Provider } from '../../../types/types'; import { RestUserDetailed, isUserActive } from '../../../types/users'; import DeactivateForm from './DeactivateForm'; import PasswordForm from './PasswordForm'; @@ -30,7 +31,7 @@ import UserForm from './UserForm'; interface Props { user: RestUserDetailed; - manageProvider: string | undefined; + manageProvider: Provider | undefined; } export default function UserActions(props: Props) { diff --git a/server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx b/server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx index 59cddecdc25..9ea6301a7b2 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx @@ -25,7 +25,7 @@ import LegacyAvatar from '../../../components/ui/LegacyAvatar'; import Spinner from '../../../components/ui/Spinner'; import { translate, translateWithParameters } from '../../../helpers/l10n'; import { useUserGroupsCountQuery, useUserTokensQuery } from '../../../queries/users'; -import { IdentityProvider } from '../../../types/types'; +import { IdentityProvider, Provider } from '../../../types/types'; import { RestUserDetailed } from '../../../types/users'; import GroupsForm from './GroupsForm'; import TokensFormModal from './TokensFormModal'; @@ -36,7 +36,7 @@ import UserScmAccounts from './UserScmAccounts'; export interface UserListItemProps { identityProvider?: IdentityProvider; user: RestUserDetailed; - manageProvider: string | undefined; + manageProvider: Provider | undefined; } export default function UserListItem(props: UserListItemProps) { diff --git a/server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx b/server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx index c432e59b623..8a873882854 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx @@ -23,13 +23,13 @@ import * as React from 'react'; import { colors } from '../../../app/theme'; import { translate } from '../../../helpers/l10n'; import { getBaseUrl } from '../../../helpers/system'; -import { IdentityProvider } from '../../../types/types'; +import { IdentityProvider, Provider } from '../../../types/types'; import { RestUserDetailed } from '../../../types/users'; export interface Props { identityProvider?: IdentityProvider; user: RestUserDetailed; - manageProvider?: string; + manageProvider: Provider | undefined; } export default function UserListItemIdentity({ identityProvider, user, manageProvider }: Props) { diff --git a/server/sonar-web/src/main/js/components/controls/ManagedFilter.tsx b/server/sonar-web/src/main/js/components/controls/ManagedFilter.tsx index 4f25eeb57e1..c328bbd3fbe 100644 --- a/server/sonar-web/src/main/js/components/controls/ManagedFilter.tsx +++ b/server/sonar-web/src/main/js/components/controls/ManagedFilter.tsx @@ -19,10 +19,11 @@ */ import * as React from 'react'; import { translate } from '../../helpers/l10n'; +import { Provider } from '../../types/types'; import ButtonToggle from './ButtonToggle'; interface ManagedFilterProps { - manageProvider: string | undefined; + manageProvider: Provider | undefined; loading: boolean; managed: boolean | undefined; setManaged: (managed: boolean | undefined) => void; diff --git a/server/sonar-web/src/main/js/components/permissions/GroupHolder.tsx b/server/sonar-web/src/main/js/components/permissions/GroupHolder.tsx index f8d07b0ae40..434258ca1aa 100644 --- a/server/sonar-web/src/main/js/components/permissions/GroupHolder.tsx +++ b/server/sonar-web/src/main/js/components/permissions/GroupHolder.tsx @@ -22,8 +22,9 @@ import * as React from 'react'; import { translate } from '../../helpers/l10n'; import { isPermissionDefinitionGroup } from '../../helpers/permissions'; import { getBaseUrl } from '../../helpers/system'; +import { useIdentityProviderQuery } from '../../queries/identity-provider'; import { Permissions } from '../../types/permissions'; -import { PermissionDefinitions, PermissionGroup } from '../../types/types'; +import { PermissionDefinitions, PermissionGroup, Provider } from '../../types/types'; import GroupIcon from '../icons/GroupIcon'; import PermissionCell from './PermissionCell'; import usePermissionChange from './usePermissionChange'; @@ -57,6 +58,7 @@ export default function GroupHolder(props: Props) { permissions, removeOnly, }); + const { data: identityProvider } = useIdentityProviderQuery(); const description = group.name === ANYONE ? translate('user_groups.anyone.description') : group.description; @@ -71,15 +73,17 @@ export default function GroupHolder(props: Props) { <div className="sw-flex-1 sw-text-ellipsis sw-whitespace-nowrap sw-overflow-hidden sw-min-w-0"> <strong>{group.name}</strong> </div> - {isGitHubProject && group.managed && ( - <img - alt="github" - className="sw-my-2" - aria-label={translate('project_permission.github_managed')} - height={16} - src={`${getBaseUrl()}/images/alm/github.svg`} - /> - )} + {isGitHubProject && + identityProvider?.provider === Provider.Github && + group.managed && ( + <img + alt="github" + className="sw-ml-2" + aria-label={translate('project_permission.github_managed')} + height={16} + src={`${getBaseUrl()}/images/alm/github.svg`} + /> + )} {group.name === ANYONE && ( <Badge className="sw-ml-2" variant="deleted"> {translate('deprecated')} diff --git a/server/sonar-web/src/main/js/components/permissions/UserHolder.tsx b/server/sonar-web/src/main/js/components/permissions/UserHolder.tsx index 0b11501bd10..acbb275b506 100644 --- a/server/sonar-web/src/main/js/components/permissions/UserHolder.tsx +++ b/server/sonar-web/src/main/js/components/permissions/UserHolder.tsx @@ -22,7 +22,8 @@ import * as React from 'react'; import { translate } from '../../helpers/l10n'; import { isPermissionDefinitionGroup } from '../../helpers/permissions'; import { getBaseUrl } from '../../helpers/system'; -import { PermissionDefinitions, PermissionUser } from '../../types/types'; +import { useIdentityProviderQuery } from '../../queries/identity-provider'; +import { PermissionDefinitions, PermissionUser, Provider } from '../../types/types'; import PermissionCell from './PermissionCell'; import usePermissionChange from './usePermissionChange'; @@ -44,6 +45,7 @@ export default function UserHolder(props: Props) { permissions, removeOnly, }); + const { data: identityProvider } = useIdentityProviderQuery(); const permissionCells = permissions.map((permission) => ( <PermissionCell @@ -90,15 +92,17 @@ export default function UserHolder(props: Props) { <strong>{user.name}</strong> <Note className="sw-ml-2">{user.login}</Note> </div> - {isGitHubProject && user.managed && ( - <img - alt="github" - className="sw-my-2" - height={16} - aria-label={translate('project_permission.github_managed')} - src={`${getBaseUrl()}/images/alm/github.svg`} - /> - )} + {isGitHubProject && + identityProvider?.provider === Provider.Github && + user.managed && ( + <img + alt="github" + className="sw-ml-2" + height={16} + aria-label={translate('project_permission.github_managed')} + src={`${getBaseUrl()}/images/alm/github.svg`} + /> + )} </div> {user.email && ( <div className="sw-mt-2 sw-max-w-100 sw-text-ellipsis sw-whitespace-nowrap sw-overflow-hidden"> diff --git a/server/sonar-web/src/main/js/helpers/mocks/alm-integrations.ts b/server/sonar-web/src/main/js/helpers/mocks/alm-integrations.ts index 2e5bec68af0..fb3e7206f2d 100644 --- a/server/sonar-web/src/main/js/helpers/mocks/alm-integrations.ts +++ b/server/sonar-web/src/main/js/helpers/mocks/alm-integrations.ts @@ -26,6 +26,7 @@ import { GithubRepository, GitlabProject, } from '../../types/alm-integration'; +import { GitlabConfiguration, ProvisioningType } from '../../types/provisioning'; export function mockAzureProject(overrides: Partial<AzureProject> = {}): AzureProject { return { @@ -98,3 +99,18 @@ export function mockGitlabProject(overrides: Partial<GitlabProject> = {}): Gitla ...overrides, }; } + +export function mockGitlabConfiguration( + overrides: Partial<GitlabConfiguration> = {}, +): GitlabConfiguration { + return { + id: Math.random().toString(), + enabled: false, + url: 'URL', + allowUsersToSignUp: false, + synchronizeUserGroups: true, + type: ProvisioningType.jit, + groups: [], + ...overrides, + }; +} diff --git a/server/sonar-web/src/main/js/helpers/mocks/definitions-list.ts b/server/sonar-web/src/main/js/helpers/mocks/definitions-list.ts index b981aeb97ac..05a75dff539 100644 --- a/server/sonar-web/src/main/js/helpers/mocks/definitions-list.ts +++ b/server/sonar-web/src/main/js/helpers/mocks/definitions-list.ts @@ -255,6 +255,28 @@ export const definitions: ExtendedSettingDefinition[] = [ fields: [], }, { + key: 'provisioning.gitlab.token.secured', + name: 'Provisioning token', + description: + 'Token used for provisioning users. Both a group or a personal access token can be used as soon as it has visibility on desired groups.', + type: SettingType.PASSWORD, + category: 'authentication', + subCategory: 'gitlab', + options: [], + fields: [], + }, + { + key: 'provisioning.gitlab.groups', + name: 'Groups', + description: + 'Only members of these groups (and sub-groups) will be able to authenticate to the server.', + category: 'authentication', + subCategory: 'gitlab', + multiValues: true, + options: [], + fields: [], + }, + { key: 'sonar.auth.saml.loginUrl', name: 'SAML login url', description: 'The URL where the Identity Provider expects to receive SAML requests.', diff --git a/server/sonar-web/src/main/js/queries/identity-provider.ts b/server/sonar-web/src/main/js/queries/identity-provider.ts index c37a6248494..68bddee21bc 100644 --- a/server/sonar-web/src/main/js/queries/identity-provider.ts +++ b/server/sonar-web/src/main/js/queries/identity-provider.ts @@ -21,18 +21,23 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { isEqual, keyBy, partition, pick, unionBy } from 'lodash'; import { useContext } from 'react'; +import { getActivity } from '../api/ce'; import { activateGithubProvisioning, activateScim, addGithubRolesMapping, checkConfigurationValidity, + createGitLabConfiguration, deactivateGithubProvisioning, deactivateScim, + deleteGitLabConfiguration, deleteGithubRolesMapping, + fetchGitLabConfigurations, fetchGithubProvisioningStatus, fetchGithubRolesMapping, fetchIsScimEnabled, syncNowGithubProvisioning, + updateGitLabConfiguration, updateGithubRolesMapping, } from '../api/provisioning'; import { getSystemInfo } from '../api/system'; @@ -41,7 +46,8 @@ import { addGlobalSuccessMessage } from '../helpers/globalMessages'; import { translate } from '../helpers/l10n'; import { mapReactQueryResult } from '../helpers/react-query'; import { Feature } from '../types/features'; -import { GitHubMapping } from '../types/provisioning'; +import { AlmSyncStatus, GitHubMapping } from '../types/provisioning'; +import { TaskStatuses, TaskTypes } from '../types/tasks'; import { SysInfoCluster } from '../types/types'; const MAPPING_STALE_TIME = 60_000; @@ -183,3 +189,127 @@ export function useGithubRolesMappingMutation() { }, }); } + +export function useGitLabConfigurationsQuery() { + return useQuery(['identity_provider', 'gitlab_config', 'list'], fetchGitLabConfigurations); +} + +export function useCreateGitLabConfigurationMutation() { + const client = useQueryClient(); + return useMutation({ + mutationFn: (data: Parameters<typeof createGitLabConfiguration>[0]) => + createGitLabConfiguration(data), + onSuccess(data) { + client.setQueryData(['identity_provider', 'gitlab_config', 'list'], { + configurations: [data], + page: { + pageIndex: 1, + pageSize: 1, + total: 1, + }, + }); + }, + }); +} + +export function useUpdateGitLabConfigurationMutation() { + const client = useQueryClient(); + return useMutation({ + mutationFn: ({ + id, + data, + }: { + id: Parameters<typeof updateGitLabConfiguration>[0]; + data: Parameters<typeof updateGitLabConfiguration>[1]; + }) => updateGitLabConfiguration(id, data), + onSuccess(data) { + client.invalidateQueries({ queryKey: ['identity_provider'] }); + client.setQueryData(['identity_provider', 'gitlab_config', 'list'], { + configurations: [data], + page: { + pageIndex: 1, + pageSize: 1, + total: 1, + }, + }); + }, + }); +} + +export function useDeleteGitLabConfigurationMutation() { + const client = useQueryClient(); + return useMutation({ + mutationFn: (id: Parameters<typeof deleteGitLabConfiguration>[0]) => + deleteGitLabConfiguration(id), + onSuccess() { + client.setQueryData(['identity_provider', 'gitlab_config', 'list'], { + configurations: [], + page: { + pageIndex: 1, + pageSize: 1, + total: 0, + }, + }); + }, + }); +} + +export function useGitLabSyncStatusQuery() { + const getLastSync = async () => { + const lastSyncTasks = await getActivity({ + type: TaskTypes.GitlabProvisioning, + p: 1, + ps: 1, + status: [TaskStatuses.Success, TaskStatuses.Failed, TaskStatuses.Canceled].join(','), + }); + const lastSync = lastSyncTasks?.tasks[0]; + if (!lastSync) { + return undefined; + } + const summary = lastSync.infoMessages ? lastSync.infoMessages?.join(', ') : ''; + const errorMessage = lastSync.errorMessage ?? ''; + return { + executionTimeMs: lastSync?.executionTimeMs ?? 0, + startedAt: +new Date(lastSync?.startedAt ?? 0), + finishedAt: +new Date(lastSync?.startedAt ?? 0) + (lastSync?.executionTimeMs ?? 0), + warningMessage: + lastSync.warnings && lastSync.warnings.length > 0 + ? lastSync.warnings?.join(', ') + : undefined, + status: lastSync?.status as + | TaskStatuses.Success + | TaskStatuses.Failed + | TaskStatuses.Canceled, + ...(lastSync.status === TaskStatuses.Success ? { summary } : {}), + ...(lastSync.status !== TaskStatuses.Success ? { errorMessage } : {}), + }; + }; + + const getNextSync = async () => { + const nextSyncTasks = await getActivity({ + type: TaskTypes.GitlabProvisioning, + p: 1, + ps: 1, + status: [TaskStatuses.Pending, TaskStatuses.InProgress].join(','), + }); + const nextSync = nextSyncTasks?.tasks[0]; + if (!nextSync) { + return undefined; + } + return { status: nextSync.status as TaskStatuses.Pending | TaskStatuses.InProgress }; + }; + + return useQuery( + ['identity_provider', 'gitlab_sync', 'status'], + async () => { + const [lastSync, nextSync] = await Promise.all([getLastSync(), getNextSync()]); + return { + lastSync, + nextSync, + } as AlmSyncStatus; + }, + { + refetchInterval: 10_000, + }, + ); +} diff --git a/server/sonar-web/src/main/js/types/features.ts b/server/sonar-web/src/main/js/types/features.ts index fdeb6ab31a5..0b4758da867 100644 --- a/server/sonar-web/src/main/js/types/features.ts +++ b/server/sonar-web/src/main/js/types/features.ts @@ -27,4 +27,5 @@ export enum Feature { RegulatoryReport = 'regulatory-reports', Scim = 'scim', GithubProvisioning = 'github-provisioning', + GitlabProvisioning = 'gitlab-provisioning', } diff --git a/server/sonar-web/src/main/js/types/provisioning.ts b/server/sonar-web/src/main/js/types/provisioning.ts index e3ff30b3b9c..9a7700dd840 100644 --- a/server/sonar-web/src/main/js/types/provisioning.ts +++ b/server/sonar-web/src/main/js/types/provisioning.ts @@ -25,8 +25,11 @@ export type GithubStatusDisabled = { nextSync?: never; lastSync?: never; }; -export type GithubStatusEnabled = { +export interface GithubStatusEnabled extends AlmSyncStatus { enabled: true; +} + +export interface AlmSyncStatus { nextSync?: { status: TaskStatuses.Pending | TaskStatuses.InProgress }; lastSync?: { executionTimeMs: number; @@ -45,7 +48,7 @@ export type GithubStatusEnabled = { errorMessage?: string; } ); -}; +} export type GithubStatus = GithubStatusDisabled | GithubStatusEnabled; @@ -89,3 +92,37 @@ export interface GitHubMapping { scan: boolean; }; } + +export interface GitLabConfigurationCreateBody { + applicationId: string; + url: string; + clientSecret: string; + synchronizeUserGroups: boolean; +} + +export type GitLabConfigurationUpdateBody = { + applicationId?: string; + url?: string; + clientSecret?: string; + synchronizeUserGroups?: boolean; + enabled?: boolean; + type?: ProvisioningType; + provisioningToken?: string; + groups?: string[]; + allowUsersToSignUp?: boolean; +}; + +export type GitlabConfiguration = { + id: string; + enabled: boolean; + synchronizeUserGroups: boolean; + url: string; + type: ProvisioningType; + groups: string[]; + allowUsersToSignUp: boolean; +}; + +export enum ProvisioningType { + jit = 'JIT', + auto = 'Auto', +} diff --git a/server/sonar-web/src/main/js/types/settings.ts b/server/sonar-web/src/main/js/types/settings.ts index 7eef37418c3..93cea170308 100644 --- a/server/sonar-web/src/main/js/types/settings.ts +++ b/server/sonar-web/src/main/js/types/settings.ts @@ -89,6 +89,15 @@ export interface ExtendedSettingDefinition extends SettingDefinition { subCategory: string; } +export interface DefinitionV2 { + name: string; + key: string; + description?: string; + secured: boolean; + multiValues?: boolean; + type?: SettingType; +} + export interface SettingValueResponse { settings: SettingValue[]; setSecuredSettings: string[]; diff --git a/server/sonar-web/src/main/js/types/tasks.ts b/server/sonar-web/src/main/js/types/tasks.ts index 1b8998f0b35..a6fa74513ff 100644 --- a/server/sonar-web/src/main/js/types/tasks.ts +++ b/server/sonar-web/src/main/js/types/tasks.ts @@ -22,6 +22,7 @@ export enum TaskTypes { IssueSync = 'ISSUE_SYNC', GithubProvisioning = 'GITHUB_AUTH_PROVISIONING', GithubProjectPermissionsProvisioning = 'GITHUB_PROJECT_PERMISSIONS_PROVISIONING', + GitlabProvisioning = 'GITLAB_AUTH_PROVISIONING', AppRefresh = 'APP_REFRESH', ViewRefresh = 'VIEW_REFRESH', ProjectExport = 'PROJECT_EXPORT', @@ -63,6 +64,7 @@ export interface Task { type: TaskTypes; warningCount?: number; warnings?: string[]; + infoMessages?: string[]; } export interface TaskWarning { diff --git a/server/sonar-web/src/main/js/types/types.ts b/server/sonar-web/src/main/js/types/types.ts index eb2e4f8bc03..3ad82365053 100644 --- a/server/sonar-web/src/main/js/types/types.ts +++ b/server/sonar-web/src/main/js/types/types.ts @@ -741,6 +741,12 @@ export interface SysInfoBase extends SysInfoValueObject { }; } +export enum Provider { + Github = 'github', + Gitlab = 'gitlab', + Scim = 'SCIM', +} + export interface SysInfoCluster extends SysInfoBase { 'Application Nodes': SysInfoAppNode[]; 'Search Nodes': SysInfoSearchNode[]; @@ -752,7 +758,7 @@ export interface SysInfoCluster extends SysInfoBase { 'High Availability': true; 'Server ID': string; Version: string; - 'External Users and Groups Provisioning'?: string; + 'External Users and Groups Provisioning'?: Provider; }; } |