]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-21119 UI for GitLab Authentication tab with users and groups provisioning
authorguillaume-peoch-sonarsource <guillaume.peoch@sonarsource.com>
Thu, 30 Nov 2023 11:09:06 +0000 (12:09 +0100)
committersonartech <sonartech@sonarsource.com>
Fri, 22 Dec 2023 20:03:01 +0000 (20:03 +0000)
44 files changed:
server/sonar-web/src/main/js/api/mocks/AuthenticationServiceMock.ts
server/sonar-web/src/main/js/api/mocks/GroupsServiceMock.ts
server/sonar-web/src/main/js/api/mocks/SystemServiceMock.ts
server/sonar-web/src/main/js/api/provisioning.ts
server/sonar-web/src/main/js/api/settings.ts
server/sonar-web/src/main/js/app/components/AlmSynchronisationWarning.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/GitHubSynchronisationWarning.tsx
server/sonar-web/src/main/js/app/components/GitLabSynchronisationWarning.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/groups/GroupsApp.tsx
server/sonar-web/src/main/js/apps/groups/__tests__/GroupsApp-it.tsx
server/sonar-web/src/main/js/apps/groups/components/Header.tsx
server/sonar-web/src/main/js/apps/groups/components/List.tsx
server/sonar-web/src/main/js/apps/groups/components/ListItem.tsx
server/sonar-web/src/main/js/apps/permissions/project/components/__tests__/PermissionsProject-it.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/Authentication.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationFormField.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationMultiValuesField.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationSecuredField.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationToggleField.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/GitLabAuthenticationTab.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/authentication/GitLabConfigurationForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/authentication/GithubAuthenticationTab.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/SamlAuthenticationTab.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-it.tsx
server/sonar-web/src/main/js/apps/settings/utils.ts
server/sonar-web/src/main/js/apps/users/UsersApp.tsx
server/sonar-web/src/main/js/apps/users/UsersList.tsx
server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx
server/sonar-web/src/main/js/apps/users/components/UserActions.tsx
server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx
server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx
server/sonar-web/src/main/js/components/controls/ManagedFilter.tsx
server/sonar-web/src/main/js/components/hooks/useManageProvider.ts [deleted file]
server/sonar-web/src/main/js/components/permissions/GroupHolder.tsx
server/sonar-web/src/main/js/components/permissions/UserHolder.tsx
server/sonar-web/src/main/js/helpers/mocks/alm-integrations.ts
server/sonar-web/src/main/js/helpers/mocks/definitions-list.ts
server/sonar-web/src/main/js/queries/identity-provider.ts
server/sonar-web/src/main/js/types/features.ts
server/sonar-web/src/main/js/types/provisioning.ts
server/sonar-web/src/main/js/types/settings.ts
server/sonar-web/src/main/js/types/tasks.ts
server/sonar-web/src/main/js/types/types.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index c4664fc7ddccbfacf0a8b6ca930d5828acbdb7f4..d382f32c511748f4d36107545e60d7a2d4172f90 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { 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);
   };
 }
index fbd14e24eb6bf59dff587c4714fdab49abd9ef70..46a2d5e1063666156d8df39d1d12a03dc6dea2a6 100644 (file)
  */
 
 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');
index ca31714c1dc6571bd1025e6cd1a1155cb685b9c2..6c9b3c67324777349f26098a9d7634ed0636843e 100644 (file)
  * 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';
 
index e8c7ea90dc1eac27e653dd4e91159382b77267c0..27f5a31ca017b067c68a6c10681d5625e576ae73 100644 (file)
  * 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(',') });
+}
index 2c2f51115268c86b07f1c3fd2bdb8cfc75df2594..53580197b9b8b9cd84434b2f66317c278ea5ca00 100644 (file)
@@ -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 (file)
index 0000000..83e933e
--- /dev/null
@@ -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} />
+    </>
+  );
+}
index 09860d1d703306130ed3b4e6dd431591caf1bdbd..22dcf68356e3a9083bc19e845e14f78e2156daa9 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { 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/app/components/GitLabSynchronisationWarning.tsx b/server/sonar-web/src/main/js/app/components/GitLabSynchronisationWarning.tsx
new file mode 100644 (file)
index 0000000..227e2e5
--- /dev/null
@@ -0,0 +1,39 @@
+/*
+ * 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 * as React from 'react';
+import { useGitLabSyncStatusQuery } from '../../queries/identity-provider';
+import AlmSynchronisationWarning from './AlmSynchronisationWarning';
+import './SystemAnnouncement.css';
+
+interface Props {
+  short?: boolean;
+}
+
+function GitLabSynchronisationWarning({ short }: Readonly<Props>) {
+  const { data } = useGitLabSyncStatusQuery();
+
+  if (!data) {
+    return null;
+  }
+
+  return <AlmSynchronisationWarning short={short} data={data} />;
+}
+
+export default GitLabSynchronisationWarning;
index 6aef407edc8ea7144d114e6c20cce2d77c5a3359..4927e76985c6086792ac61c37e2f3ecdbad44adc 100644 (file)
@@ -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
index 265da70be5fe042c163e9997e74c29fd1e4101fc..f1fbe9bdaaee395671ffb851c4addcc0664a13f6 100644 (file)
@@ -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();
   });
index f285ff37c38d42e24132ca7903133bfc8866fb59..70f64ed491264c9009924b813db7b5fe4a3705bf 100644 (file)
@@ -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 (
index b699b7eb70a859715a3fa3cef226932ba326f5b9..e90e3cf1cf01df8eeaca1091b89f0ea6710e60d8 100644 (file)
 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) {
index 57452ea113843a4d8c910e558450b4e2b251e78e..8b35206f5a6c1355e54a866489dbb65457cf8108 100644 (file)
@@ -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) {
index 5b95545450d4a3e2eec8d130ff2b655a6ec6fef4..a36e2ca6b33a40cd84ecd99985e2392659fdea11 100644 (file)
@@ -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(
index a095444c20bfc6f94e5234d53ef0f4661c190577..12d5d723408031d3fdbc25ad1dfd432a158c3952 100644 (file)
@@ -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
index 26d50fe903d9a4c827d8599142a783024ece153b..8a8ca9d550135439974d02bd01218b336431dc11 100644 (file)
@@ -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);
index 74caf05ff9def11b40eacad32b13f7db12f63394..e818928729c5d9783b7021769c9730879240ef3a 100644 (file)
 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) {
index 1b2f2baa811c49dbffc4803cda35a20d24dd58b5..7074bb257e95575dae24088706fc0d9d7d77a6c2 100644 (file)
 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;
 }
index cb82310df920f57b7543cda155892b77b533e723..df75a345fbc79b00f2a0c5b34eab6178e663b814 100644 (file)
  */
 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 (file)
index 0000000..03fa8f2
--- /dev/null
@@ -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 (file)
index 0000000..0aa4379
--- /dev/null
@@ -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>
+  );
+}
index 665e5c027e608885640726691d7e943051e7aecc..4c2e271de2625381456af804424b61810b6fc1ba 100644 (file)
@@ -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
index c0d63db413e33300f62cdc4bbf65a42774a1d8f1..a191a859e525ffb26ef0e0402d303144069efd19 100644 (file)
@@ -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,
index 639d12b55456d48b284aa8c172adca4beeac2a4a..f8d1a8684d54517eae11d3e229d4638b50a3a53d 100644 (file)
@@ -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();
index 7b02ab120e56c6f0844e606f65d2ea5508068911..dd3a9ec98b6e10c937c4568607f5a3779c156ef7 100644 (file)
@@ -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 {
index f2a4f0d1538563b7093404b4399c7be06662c074..70b7cfc9ca682efb67e39025d0db7f6ab73194c3 100644 (file)
@@ -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>
 
index 5420e2c3a80d32fa3b03a98dc1c1e090c3c86b95..4899bdef2bb40c9eadf57880202563fd2e308907 100644 (file)
 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) {
index a9e090eaf1a70e6977f6c82a5b3fd77090cd375a..6fb3a709866318077c7de1f1f230aa5305c5ce88 100644 (file)
@@ -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';
 
index e71502ae7b90b6c28460e97803f026ae141dd865..a46bf37d7be1954b79072c2b7cc879a5971f1d4f 100644 (file)
@@ -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) {
index 59cddecdc253d03896495aa0c2d26b1af4d2718e..9ea6301a7b27911d71dea42043b023060c7e5cc4 100644 (file)
@@ -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) {
index c432e59b62348d07a664a8cc2833aa107bff77d5..8a8738828547eee98d4bfc1e164eb726c6330228 100644 (file)
@@ -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) {
index 4f25eeb57e14b8fc49d4054df38ad3fbd36883ba..c328bbd3fbe9517b285cdf1d33e3edb349144dd2 100644 (file)
  */
 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/hooks/useManageProvider.ts b/server/sonar-web/src/main/js/components/hooks/useManageProvider.ts
deleted file mode 100644 (file)
index e911453..0000000
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- * 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 * as React from 'react';
-import { useEffect } from 'react';
-import { getSystemInfo } from '../../api/system';
-import { SysInfoCluster } from '../../types/types';
-
-export enum Provider {
-  Github = 'GitHub',
-  Scim = 'SCIM',
-}
-
-export function useManageProvider(): string | undefined {
-  const [manageProvider, setManageProvider] = React.useState<Provider | undefined>();
-
-  useEffect(() => {
-    (async () => {
-      const info = (await getSystemInfo()) as SysInfoCluster;
-      setManageProvider(info.System['External Users and Groups Provisioning'] as Provider);
-    })();
-  }, []);
-
-  return manageProvider;
-}
index f8d07b0ae409b2a9e5f9dedf249509170764bb72..434258ca1aadee759899c2e101e4700db681730a 100644 (file)
@@ -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')}
index 0b11501bd10c4bec10f9b54fef7f690e882d4cba..acbb275b506fb579f90444461d46605ef8cc4f10 100644 (file)
@@ -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">
index 2e5bec68af09b46cbe8614c55863d109574b794f..fb3e7206f2d0fbe4db263f40b056e3bd4160abbb 100644 (file)
@@ -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,
+  };
+}
index b981aeb97ac2afa575a9b3e5ab4de70e76804777..05a75dff539d3377122ecefa62e1bfa1a7c64f91 100644 (file)
@@ -254,6 +254,28 @@ export const definitions: ExtendedSettingDefinition[] = [
     options: [],
     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',
index c37a624849479bd23fef56f5708b44e2afa3fbab..68bddee21bcb2e05121a5945d56e6b924e5b0e96 100644 (file)
 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,
+    },
+  );
+}
index fdeb6ab31a5350107f8fd394db665268fe256367..0b4758da867f0352d11447dc41e26e29751315c9 100644 (file)
@@ -27,4 +27,5 @@ export enum Feature {
   RegulatoryReport = 'regulatory-reports',
   Scim = 'scim',
   GithubProvisioning = 'github-provisioning',
+  GitlabProvisioning = 'gitlab-provisioning',
 }
index e3ff30b3b9cd3f1f88fa1d8acc26449cdff877a8..9a7700dd84043143422a49a171752bc65951b50c 100644 (file)
@@ -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',
+}
index 7eef37418c3a9f9725d058c367410085a19572b1..93cea17030830a425ea24a9d004f9574f3ee3a91 100644 (file)
@@ -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[];
index 1b8998f0b35fc8f0c11508356e06e8e6dacaec9c..a6fa74513ff69eac71e2df3951ed8f780ea02fd9 100644 (file)
@@ -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 {
index eb2e4f8bc03ce2fc73035b11108ea292b09bce70..3ad82365053d9e985b0a8e274f5c0f63883c89bc 100644 (file)
@@ -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;
   };
 }
 
index d7af0cf37d262b894f5970cf7a82240d059058a2..406b80257bd2749570b8e50316c4d7aa79ce79a2 100644 (file)
@@ -1577,6 +1577,32 @@ settings.authentication.github.configuration.roles_mapping.empty_custom_role=Cus
 settings.authentication.github.configuration.roles_mapping.save_success=GitHub roles mapping saved successfully.
 settings.authentication.github.configuration.unsaved_changes=You have unsaved changes.
 
+# GITLAB
+settings.authentication.gitlab.configuration=GitLab Configuration
+settings.authentication.gitlab.form.not_configured=GitLab App is not configured
+settings.authentication.gitlab.form.create=New GitLab Configuration
+settings.authentication.gitlab.form.edit=Edit GitLab Configuration
+settings.authentication.gitlab.form.applicationId.name=Application ID
+settings.authentication.gitlab.form.applicationId.description=Application ID provided by GitLab when registering the application.
+settings.authentication.gitlab.form.url.name=GitLab URL
+settings.authentication.gitlab.form.url.description=URL to access GitLab.
+settings.authentication.gitlab.form.clientSecret.name=Secret
+settings.authentication.gitlab.form.clientSecret.description=Secret provided by GitLab when registering the application.
+settings.authentication.gitlab.form.synchronizeUserGroups.name=Synchronize user groups
+settings.authentication.gitlab.form.synchronizeUserGroups.description=For each GitLab group they belong to, the user will be associated to a group with the same name (if it exists) in SonarQube. If enabled, the GitLab Oauth2 application will need to provide the api scope.
+settings.authentication.gitlab.provisioning_at_login=Just-in-Time user provisioning (default)
+settings.authentication.gitlab.provisioning_at_login.description=Users are synchronized only when users log in to SonarQube.
+settings.authentication.gitlab.description.doc=For more details, see {documentation}.
+settings.authentication.gitlab.confirm.Auto=Switch to automatic provisioning
+settings.authentication.gitlab.confirm.JIT=Switch to Just-in-Time provisioning
+settings.authentication.gitlab.confirm.Auto.description=Once you transition to automatic provisioning users on GitLab projects will be inherited from GitLab. You will no longer have the ability to edit them within SonarQube. Do you want to proceed with this change?
+settings.authentication.gitlab.confirm.JIT.description=Switching to Just-in-Time provisioning removes the automatic synchronization of users. Users are provisioned and updated only at user login. Are you sure?
+settings.authentication.gitlab.provisioning_change.confirm_changes=Confirm Changes
+settings.authentication.gitlab.form.provisioning_with_gitlab=Automatic user provisioning
+settings.authentication.gitlab.form.provisioning_with_gitlab.description=Users are automatically provisioned from your GitLab organizations. Once activated, users can only be created and modified from your GitLab groups. Existing local users will be kept and can only be deactivated.
+settings.authentication.gitlab.form.provisioning.disabled=Your current edition does not support provisioning with GitLab. See the {documentation} for more information.
+settings.authentication.gitlab.configuration.unsaved_changes=You have unsaved changes.
+
 # SAML
 settings.authentication.form.create.saml=New SAML configuration
 settings.authentication.form.edit.saml=Edit SAML configuration
@@ -3656,8 +3682,9 @@ background_task.type.PROJECT_EXPORT=Project Export
 background_task.type.PROJECT_IMPORT=Project Import
 background_task.type.AUDIT_PURGE=Audit Log Purge
 background_task.type.REPORT_SUBMIT=Report Email Submit
-background_task.type.GITHUB_AUTH_PROVISIONING=Github Provisioning
-background_task.type.GITHUB_PROJECT_PERMISSIONS_PROVISIONING=Github Project Permission Sync
+background_task.type.GITHUB_AUTH_PROVISIONING=GitHub Provisioning
+background_task.type.GITHUB_PROJECT_PERMISSIONS_PROVISIONING=GitHub Project Permission Sync
+background_task.type.GITLAB_AUTH_PROVISIONING=GitLab Provisioning
 
 background_tasks.page=Background Tasks
 background_tasks.page.description=This page allows monitoring of the queue of tasks running asynchronously on the server. It also gives access to the history of finished tasks and their status. Analysis report processing is the most common kind of background task.