]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19346 Banner with GitHub sync status on Users, Groups, Authentication pages
authorVik Vorona <viktor.vorona@sonarsource.com>
Fri, 26 May 2023 07:04:36 +0000 (09:04 +0200)
committersonartech <sonartech@sonarsource.com>
Fri, 26 May 2023 20:03:08 +0000 (20:03 +0000)
21 files changed:
server/sonar-web/src/main/js/api/mocks/AuthenticationServiceMock.ts
server/sonar-web/src/main/js/api/mocks/ComputeEngineServiceMock.ts
server/sonar-web/src/main/js/api/mocks/GroupsServiceMock.ts
server/sonar-web/src/main/js/api/mocks/SettingsServiceMock.ts
server/sonar-web/src/main/js/api/provisioning.ts [new file with mode: 0644]
server/sonar-web/src/main/js/api/settings.ts
server/sonar-web/src/main/js/app/components/GitHubSynchronisationWarning.tsx
server/sonar-web/src/main/js/app/utils/startReactApp.tsx
server/sonar-web/src/main/js/apps/background-tasks/__tests__/BackgroundTasks-it.tsx
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/settings/components/SettingsApp.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-it.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/queries/IdentityProvider.ts
server/sonar-web/src/main/js/apps/users/UsersApp.tsx
server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx
server/sonar-web/src/main/js/helpers/testReactTestingUtils.tsx
server/sonar-web/src/main/js/queries/github-sync.ts [new file with mode: 0644]
server/sonar-web/src/main/js/types/provisioning.ts [new file with mode: 0644]
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index ec685999c0ed4962f5459af7b9ac6a70f6a65f77..6adf7f8010aa060733c4b72834fd5d0a46d154ca 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 { mockSettingValue } from '../../helpers/mocks/settings';
-import { BranchParameters } from '../../types/branch-like';
-import { SettingDefinition, SettingValue } from '../../types/settings';
+import { mockTask } from '../../helpers/mocks/tasks';
+import { Task, TaskStatuses, TaskTypes } from '../../types/tasks';
 import {
   activateGithubProvisioning,
   activateScim,
   deactivateGithubProvisioning,
   deactivateScim,
-  fetchIsGithubProvisioningEnabled,
+  fetchGithubProvisioningStatus,
   fetchIsScimEnabled,
-  getValues,
-  resetSettingValue,
-  setSettingValue,
-} from '../settings';
+} from '../provisioning';
+
+jest.mock('../provisioning');
 
 export default class AuthenticationServiceMock {
-  settingValues: SettingValue[];
   scimStatus: boolean;
   githubProvisioningStatus: boolean;
-  defaulSettingValues: SettingValue[] = [
-    mockSettingValue({ key: 'test1', value: '' }),
-    mockSettingValue({ key: 'test2', value: 'test2' }),
-    {
-      key: 'sonar.auth.saml.signature.enabled',
-      value: 'false',
-      inherited: true,
-    },
-    {
-      key: 'sonar.auth.saml.enabled',
-      value: 'false',
-      inherited: true,
-    },
-    {
-      key: 'sonar.auth.saml.applicationId',
-      value: 'sonarqube',
-      inherited: true,
-    },
-    {
-      key: 'sonar.auth.saml.providerName',
-      value: 'SAML',
-      inherited: true,
-    },
-  ];
+  tasks: Task[];
 
   constructor() {
-    this.settingValues = cloneDeep(this.defaulSettingValues);
     this.scimStatus = false;
     this.githubProvisioningStatus = false;
-    jest.mocked(getValues).mockImplementation(this.handleGetValues);
-    jest.mocked(setSettingValue).mockImplementation(this.handleSetValue);
-    jest.mocked(resetSettingValue).mockImplementation(this.handleResetValue);
+    this.tasks = [];
     jest.mocked(activateScim).mockImplementation(this.handleActivateScim);
     jest.mocked(deactivateScim).mockImplementation(this.handleDeactivateScim);
     jest.mocked(fetchIsScimEnabled).mockImplementation(this.handleFetchIsScimEnabled);
@@ -79,10 +49,20 @@ export default class AuthenticationServiceMock {
       .mocked(deactivateGithubProvisioning)
       .mockImplementation(this.handleDeactivateGithubProvisioning);
     jest
-      .mocked(fetchIsGithubProvisioningEnabled)
-      .mockImplementation(this.handleFetchIsGithubProvisioningEnabled);
+      .mocked(fetchGithubProvisioningStatus)
+      .mockImplementation(this.handleFetchGithubProvisioningStatus);
   }
 
+  addProvisioningTask = (overrides: Partial<Omit<Task, 'type'>> = {}) => {
+    this.tasks.push(
+      mockTask({
+        id: Math.random().toString(),
+        type: TaskTypes.GithubProvisioning,
+        ...overrides,
+      })
+    );
+  };
+
   handleActivateScim = () => {
     this.scimStatus = true;
     return Promise.resolve();
@@ -107,62 +87,37 @@ export default class AuthenticationServiceMock {
     return Promise.resolve();
   };
 
-  handleFetchIsGithubProvisioningEnabled = () => {
-    return Promise.resolve(this.githubProvisioningStatus);
-  };
-
-  handleGetValues = (
-    data: { keys: string[]; component?: string } & BranchParameters
-  ): Promise<SettingValue[]> => {
-    if (data.keys.length > 1) {
-      return Promise.resolve(this.settingValues.filter((set) => data.keys.includes(set.key)));
-    }
-    return Promise.resolve(this.settingValues);
-  };
-
-  handleSetValue = (definition: SettingDefinition, value: string | boolean | string[]) => {
-    if (value === 'error') {
-      const res = new Response('', {
-        status: 400,
-        statusText: 'fail',
-      });
-
-      return Promise.reject(res);
+  handleFetchGithubProvisioningStatus = () => {
+    if (!this.githubProvisioningStatus) {
+      return Promise.resolve({ enabled: false });
     }
-    const updatedSettingValue = this.settingValues.find((set) => set.key === definition.key);
 
-    if (updatedSettingValue) {
-      if (definition.multiValues) {
-        updatedSettingValue.values = value as string[];
-      } else {
-        updatedSettingValue.value = String(value);
-      }
-    } else if (definition.multiValues) {
-      this.settingValues.push({
-        key: definition.key,
-        values: value as string[],
-        inherited: false,
-      });
-    } else {
-      this.settingValues.push({ key: definition.key, value: String(value), inherited: false });
-    }
-    return Promise.resolve();
-  };
+    const nextSync = this.tasks.find((t: any) =>
+      [TaskStatuses.InProgress, TaskStatuses.Pending].includes(t.status)
+    );
+    const lastSync = this.tasks.find(
+      (t: any) => ![TaskStatuses.InProgress, TaskStatuses.Pending].includes(t.status)
+    );
 
-  handleResetValue = (data: { keys: string; component?: string } & BranchParameters) => {
-    if (data.keys) {
-      this.settingValues.forEach((set) => {
-        if (data.keys.includes(set.key)) {
-          set.value = '';
-          set.values = [];
-        }
-        return set;
-      });
-    }
-    return Promise.resolve();
+    return Promise.resolve({
+      enabled: true,
+      nextSync: nextSync ? { status: nextSync.status } : undefined,
+      lastSync: lastSync
+        ? {
+            status: lastSync.status,
+            finishedAt: lastSync.executedAt,
+            startedAt: lastSync.startedAt,
+            executionTimeMs: lastSync.executionTimeMs,
+            summary: lastSync.status === TaskStatuses.Success ? 'Test summary' : undefined,
+            errorMessage: lastSync.errorMessage,
+          }
+        : undefined,
+    });
   };
 
-  resetValues = () => {
-    this.settingValues = cloneDeep(this.defaulSettingValues);
+  reset = () => {
+    this.scimStatus = false;
+    this.githubProvisioningStatus = false;
+    this.tasks = [];
   };
 }
index 46cb22cbd5f217b6ece5114066a92c932e99897d..0fb9b667164d471125c0b185ba742ef37c29202d 100644 (file)
@@ -58,6 +58,8 @@ const DEFAULT_WORKERS = {
 
 const CANCELABLE_TASK_STATUSES = [TaskStatuses.Pending];
 
+jest.mock('../ce');
+
 export default class ComputeEngineServiceMock {
   tasks: Task[];
   workers = { ...DEFAULT_WORKERS };
index 1c65862e41b9373919567e63ae1ddbdb0cdfd6f5..39f45084c16ec719afd6ae8a4bf08eee994e575d 100644 (file)
@@ -212,7 +212,7 @@ export default class GroupsServiceMock {
               System: {
                 'High Availability': true,
                 'Server ID': 'asd564-asd54a-5dsfg45',
-                'External Users and Groups Provisioning': 'Okta',
+                'External Users and Groups Provisioning': 'GitHub',
               },
             }
           : {}
index 9d0901edcc202b567151efcda4ddb11a6dc6ed8f..cc1d04f61dc3f090fef047578b573dc75a0ba872 100644 (file)
@@ -24,9 +24,9 @@ import { BranchParameters } from '../../types/branch-like';
 import {
   ExtendedSettingDefinition,
   SettingDefinition,
-  SettingsKey,
   SettingType,
   SettingValue,
+  SettingsKey,
 } from '../../types/settings';
 import {
   checkSecretKey,
@@ -188,11 +188,11 @@ export default class SettingsServiceMock {
   set = (key: string | SettingsKey, value: any) => {
     const setting = this.#settingValues.find((s) => s.key === key);
     if (setting) {
-      setting.value = value;
+      setting.value = String(value);
       setting.values = value;
       setting.fieldValues = value;
     } else {
-      this.#settingValues.push({ key, value, values: value, fieldValues: value });
+      this.#settingValues.push({ key, value: String(value), values: value, fieldValues: value });
     }
     return this;
   };
diff --git a/server/sonar-web/src/main/js/api/provisioning.ts b/server/sonar-web/src/main/js/api/provisioning.ts
new file mode 100644 (file)
index 0000000..10220bb
--- /dev/null
@@ -0,0 +1,48 @@
+/*
+ * 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 { throwGlobalError } from '../helpers/error';
+import { getJSON, post } from '../helpers/request';
+import { GithubStatus } from '../types/provisioning';
+
+export function fetchIsScimEnabled(): Promise<boolean> {
+  return getJSON('/api/scim_management/status')
+    .then((r) => r.enabled)
+    .catch(throwGlobalError);
+}
+
+export function activateScim(): Promise<void> {
+  return post('/api/scim_management/enable').catch(throwGlobalError);
+}
+
+export function deactivateScim(): Promise<void> {
+  return post('/api/scim_management/disable').catch(throwGlobalError);
+}
+
+export function fetchGithubProvisioningStatus(): Promise<GithubStatus> {
+  return getJSON('/api/github_provisioning/status').catch(throwGlobalError);
+}
+
+export function activateGithubProvisioning(): Promise<void> {
+  return post('/api/github_provisioning/enable').catch(throwGlobalError);
+}
+
+export function deactivateGithubProvisioning(): Promise<void> {
+  return post('/api/github_provisioning/disable').catch(throwGlobalError);
+}
index 8e17b5e6894056a907c586929d1f348d291ab723..1d81865ddbe4e87c607114dd801dfc5cd644c620 100644 (file)
@@ -116,31 +116,3 @@ export function encryptValue(value: string): Promise<{ encryptedValue: string }>
 export function getLoginMessage(): Promise<{ message: string }> {
   return getJSON('/api/settings/login_message').catch(throwGlobalError);
 }
-
-export function fetchIsScimEnabled(): Promise<boolean> {
-  return getJSON('/api/scim_management/status')
-    .then((r) => r.enabled)
-    .catch(throwGlobalError);
-}
-
-export function activateScim(): Promise<void> {
-  return post('/api/scim_management/enable').catch(throwGlobalError);
-}
-
-export function deactivateScim(): Promise<void> {
-  return post('/api/scim_management/disable').catch(throwGlobalError);
-}
-
-export function fetchIsGithubProvisioningEnabled(): Promise<boolean> {
-  return getJSON('/api/github_provisioning/status')
-    .then((r) => r.enabled)
-    .catch(throwGlobalError);
-}
-
-export function activateGithubProvisioning(): Promise<void> {
-  return post('/api/github_provisioning/enable').catch(throwGlobalError);
-}
-
-export function deactivateGithubProvisioning(): Promise<void> {
-  return post('/api/github_provisioning/disable').catch(throwGlobalError);
-}
index 71abf62d7999847c94cb8d6adb35943f8b5b7ec2..e011d74d7bba881a1d119724a9a6846f57f58e56 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 { isEmpty } from 'lodash';
+import { formatDistance } from 'date-fns';
 import * as React from 'react';
-import { useContext, useEffect, useState } from 'react';
-import { WrappedComponentProps, injectIntl } from 'react-intl';
-import { getActivity } from '../../api/ce';
-import { formatterOption } from '../../components/intl/DateTimeFormatter';
-import { Alert, AlertVariant } from '../../components/ui/Alert';
-import { translateWithParameters } from '../../helpers/l10n';
+import { useContext } from 'react';
+import { FormattedMessage } from 'react-intl';
+import Link from '../../components/common/Link';
+import CheckIcon from '../../components/icons/CheckIcon';
+import { Alert } from '../../components/ui/Alert';
+import { translate, translateWithParameters } from '../../helpers/l10n';
+import { useSyncStatusQuery } from '../../queries/github-sync';
 import { Feature } from '../../types/features';
-import { ActivityRequestParameters, TaskStatuses, TaskTypes } from '../../types/tasks';
+import { GithubStatusEnabled } from '../../types/provisioning';
+import { TaskStatuses } from '../../types/tasks';
 import './SystemAnnouncement.css';
 import { AvailableFeaturesContext } from './available-features/AvailableFeaturesContext';
 
-function GitHubSynchronisationWarning(props: WrappedComponentProps) {
-  const { formatDate } = props.intl;
-  const [displayMessage, setDisplayMessage] = useState(false);
-  const [activityStatus, setActivityStatus] = useState<AlertVariant>('info');
-  const [message, setMessage] = useState('');
-  const hasGithubProvisioning = useContext(AvailableFeaturesContext).includes(
-    Feature.GithubProvisioning
-  );
-
-  useEffect(() => {
-    (async () => {
-      const lastActivity = await getLatestGithubSynchronisationTask();
+interface LastSyncProps {
+  short?: boolean;
+  info: Required<GithubStatusEnabled>['lastSync'];
+}
 
-      if (lastActivity === undefined) {
-        return;
-      }
-      const { status, errorMessage, executedAt } = lastActivity;
+interface GitHubSynchronisationWarningProps {
+  short?: boolean;
+}
 
-      if (executedAt === undefined) {
-        return;
-      }
-      const formattedDate = formatDate(new Date(executedAt), formatterOption);
+function LastSyncAlert({ info, short }: LastSyncProps) {
+  const { finishedAt, errorMessage, status, summary } = info;
 
-      switch (status) {
-        case TaskStatuses.Failed:
-          setMessage(
-            translateWithParameters(
-              'settings.authentication.github.background_task.synchronization_failed',
-              formattedDate,
-              errorMessage ?? ''
-            )
-          );
-          setActivityStatus('error');
-          break;
-        case TaskStatuses.Success:
-          setMessage(
-            translateWithParameters(
-              'settings.authentication.github.background_task.synchronization_successful',
-              formattedDate
-            )
-          );
-          setActivityStatus('success');
-          break;
-        case TaskStatuses.InProgress:
-          setMessage(
-            translateWithParameters(
-              'settings.authentication.github.background_task.synchronization_in_progress',
-              formattedDate
-            )
-          );
-          setActivityStatus('loading');
-          break;
-        default:
-          return;
-      }
-      setDisplayMessage(true);
-    })();
-  }, []);
+  const formattedDate = finishedAt ? formatDistance(new Date(finishedAt), new Date()) : '';
 
-  if (!displayMessage || !hasGithubProvisioning) {
-    return null;
+  if (short) {
+    return status === TaskStatuses.Success ? (
+      <div>
+        <span className="authentication-enabled spacer-left">
+          <CheckIcon className="spacer-right" />
+        </span>
+        <i>
+          {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="../settings?category=authentication&tab=github">
+                {translate('settings.authentication.github.synchronization_failed_link')}
+              </Link>
+            ),
+          }}
+        />
+      </Alert>
+    );
   }
 
-  return (
-    <Alert title={message} variant={activityStatus}>
-      {message}
+  return status === TaskStatuses.Success ? (
+    <Alert variant="success">
+      {translateWithParameters(
+        'settings.authentication.github.synchronization_successful',
+        formattedDate
+      )}
+      <br />
+      {summary ?? ''}
+    </Alert>
+  ) : (
+    <Alert variant="error">
+      <div>
+        {translateWithParameters(
+          'settings.authentication.github.synchronization_failed',
+          formattedDate
+        )}
+      </div>
+      <br />
+      {errorMessage ?? ''}
     </Alert>
   );
 }
 
-const getLatestGithubSynchronisationTask = async () => {
-  const data: ActivityRequestParameters = {
-    type: TaskTypes.GithubProvisioning,
-    onlyCurrents: true,
-    status: [TaskStatuses.InProgress, TaskStatuses.Success, TaskStatuses.Failed].join(','),
-  };
-  const activity = await getActivity(data);
+function GitHubSynchronisationWarning({ short }: GitHubSynchronisationWarningProps) {
+  const hasGithubProvisioning = useContext(AvailableFeaturesContext).includes(
+    Feature.GithubProvisioning
+  );
+  const { data } = useSyncStatusQuery({ enabled: hasGithubProvisioning });
 
-  if (isEmpty(activity.tasks)) {
-    return undefined;
+  if (!data) {
+    return null;
   }
 
-  return activity.tasks[0];
-};
+  return (
+    <>
+      {!short && data?.nextSync && (
+        <>
+          <Alert variant="loading">
+            {translate(
+              data.nextSync.status === TaskStatuses.Pending
+                ? 'settings.authentication.github.synchronization_pending'
+                : 'settings.authentication.github.synchronization_in_progress'
+            )}
+          </Alert>
+          <br />
+        </>
+      )}
+      {data?.lastSync && <LastSyncAlert short={short} info={data.lastSync} />}
+    </>
+  );
+}
 
-export default injectIntl(GitHubSynchronisationWarning);
+export default GitHubSynchronisationWarning;
index c97a41582aa6f8641555f01559acf543c42785d7..c29a16e76d5be0d60fe1a78a1f1fe71b1ba7ac65 100644 (file)
@@ -18,6 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import { ThemeProvider } from '@emotion/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { lightTheme } from 'design-system';
 import * as React from 'react';
 import { render } from 'react-dom';
@@ -66,21 +67,8 @@ import { Feature } from '../../types/features';
 import { CurrentUser } from '../../types/users';
 import AdminContainer from '../components/AdminContainer';
 import App from '../components/App';
-import { DEFAULT_APP_STATE } from '../components/app-state/AppStateContext';
-import AppStateContextProvider from '../components/app-state/AppStateContextProvider';
-import {
-  AvailableFeaturesContext,
-  DEFAULT_AVAILABLE_FEATURES,
-} from '../components/available-features/AvailableFeaturesContext';
 import ComponentContainer from '../components/ComponentContainer';
-import CurrentUserContextProvider from '../components/current-user/CurrentUserContextProvider';
 import DocumentationRedirect from '../components/DocumentationRedirect';
-import GlobalAdminPageExtension from '../components/extensions/GlobalAdminPageExtension';
-import GlobalPageExtension from '../components/extensions/GlobalPageExtension';
-import PortfolioPage from '../components/extensions/PortfolioPage';
-import PortfoliosPage from '../components/extensions/PortfoliosPage';
-import ProjectAdminPageExtension from '../components/extensions/ProjectAdminPageExtension';
-import ProjectPageExtension from '../components/extensions/ProjectPageExtension';
 import FormattingHelp from '../components/FormattingHelp';
 import GlobalContainer from '../components/GlobalContainer';
 import GlobalMessagesContainer from '../components/GlobalMessagesContainer';
@@ -93,6 +81,19 @@ import ProjectAdminContainer from '../components/ProjectAdminContainer';
 import ResetPassword from '../components/ResetPassword';
 import SimpleContainer from '../components/SimpleContainer';
 import SonarLintConnection from '../components/SonarLintConnection';
+import { DEFAULT_APP_STATE } from '../components/app-state/AppStateContext';
+import AppStateContextProvider from '../components/app-state/AppStateContextProvider';
+import {
+  AvailableFeaturesContext,
+  DEFAULT_AVAILABLE_FEATURES,
+} from '../components/available-features/AvailableFeaturesContext';
+import CurrentUserContextProvider from '../components/current-user/CurrentUserContextProvider';
+import GlobalAdminPageExtension from '../components/extensions/GlobalAdminPageExtension';
+import GlobalPageExtension from '../components/extensions/GlobalPageExtension';
+import PortfolioPage from '../components/extensions/PortfolioPage';
+import PortfoliosPage from '../components/extensions/PortfoliosPage';
+import ProjectAdminPageExtension from '../components/extensions/ProjectAdminPageExtension';
+import ProjectPageExtension from '../components/extensions/ProjectPageExtension';
 import exportModulesAsGlobals from './exportModulesAsGlobals';
 
 function renderComponentRoutes() {
@@ -170,6 +171,8 @@ function renderRedirects() {
   );
 }
 
+const queryClient = new QueryClient();
+
 export default function startReactApp(
   lang: string,
   currentUser?: CurrentUser,
@@ -187,75 +190,77 @@ export default function startReactApp(
           <CurrentUserContextProvider currentUser={currentUser}>
             <IntlProvider defaultLocale={lang} locale={lang}>
               <ThemeProvider theme={lightTheme}>
-                <GlobalMessagesContainer />
-                <BrowserRouter basename={getBaseUrl()}>
-                  <Helmet titleTemplate={translate('page_title.template.default')} />
-                  <Routes>
-                    {renderRedirects()}
+                <QueryClientProvider client={queryClient}>
+                  <GlobalMessagesContainer />
+                  <BrowserRouter basename={getBaseUrl()}>
+                    <Helmet titleTemplate={translate('page_title.template.default')} />
+                    <Routes>
+                      {renderRedirects()}
 
-                    <Route path="formatting/help" element={<FormattingHelp />} />
+                      <Route path="formatting/help" element={<FormattingHelp />} />
 
-                    <Route element={<SimpleContainer />}>{maintenanceRoutes()}</Route>
+                      <Route element={<SimpleContainer />}>{maintenanceRoutes()}</Route>
 
-                    <Route element={<MigrationContainer />}>
-                      {sessionsRoutes()}
+                      <Route element={<MigrationContainer />}>
+                        {sessionsRoutes()}
 
-                      <Route path="/" element={<App />}>
-                        <Route index={true} element={<Landing />} />
+                        <Route path="/" element={<App />}>
+                          <Route index={true} element={<Landing />} />
 
-                        <Route element={<GlobalContainer />}>
-                          {accountRoutes()}
+                          <Route element={<GlobalContainer />}>
+                            {accountRoutes()}
 
-                          {codingRulesRoutes()}
+                            {codingRulesRoutes()}
 
-                          <Route
-                            path="extension/:pluginKey/:extensionKey"
-                            element={<GlobalPageExtension />}
-                          />
+                            <Route
+                              path="extension/:pluginKey/:extensionKey"
+                              element={<GlobalPageExtension />}
+                            />
 
-                          {globalIssuesRoutes()}
+                            {globalIssuesRoutes()}
 
-                          {projectsRoutes()}
+                            {projectsRoutes()}
 
-                          {qualityGatesRoutes()}
-                          {qualityProfilesRoutes()}
+                            {qualityGatesRoutes()}
+                            {qualityProfilesRoutes()}
 
-                          <Route path="portfolios" element={<PortfoliosPage />} />
+                            <Route path="portfolios" element={<PortfoliosPage />} />
 
-                          <Route path="sonarlint/auth" element={<SonarLintConnection />} />
+                            <Route path="sonarlint/auth" element={<SonarLintConnection />} />
 
-                          {webAPIRoutes()}
+                            {webAPIRoutes()}
 
-                          {renderComponentRoutes()}
+                            {renderComponentRoutes()}
 
-                          {renderAdminRoutes()}
-                        </Route>
-                        <Route
-                          // We don't want this route to have any menu.
-                          // That is why we can not have it under the accountRoutes
-                          path="account/reset_password"
-                          element={<ResetPassword />}
-                        />
+                            {renderAdminRoutes()}
+                          </Route>
+                          <Route
+                            // We don't want this route to have any menu.
+                            // That is why we can not have it under the accountRoutes
+                            path="account/reset_password"
+                            element={<ResetPassword />}
+                          />
 
-                        <Route
-                          // We don't want this route to have any menu. This is why we define it here
-                          // rather than under the admin routes.
-                          path="admin/change_admin_password"
-                          element={<ChangeAdminPasswordApp />}
-                        />
+                          <Route
+                            // We don't want this route to have any menu. This is why we define it here
+                            // rather than under the admin routes.
+                            path="admin/change_admin_password"
+                            element={<ChangeAdminPasswordApp />}
+                          />
 
-                        <Route
-                          // We don't want this route to have any menu. This is why we define it here
-                          // rather than under the admin routes.
-                          path="admin/plugin_risk_consent"
-                          element={<PluginRiskConsent />}
-                        />
-                        <Route path="not_found" element={<NotFound />} />
-                        <Route path="*" element={<NotFound />} />
+                          <Route
+                            // We don't want this route to have any menu. This is why we define it here
+                            // rather than under the admin routes.
+                            path="admin/plugin_risk_consent"
+                            element={<PluginRiskConsent />}
+                          />
+                          <Route path="not_found" element={<NotFound />} />
+                          <Route path="*" element={<NotFound />} />
+                        </Route>
                       </Route>
-                    </Route>
-                  </Routes>
-                </BrowserRouter>
+                    </Routes>
+                  </BrowserRouter>
+                </QueryClientProvider>
               </ThemeProvider>
             </IntlProvider>
           </CurrentUserContextProvider>
index e49c2ef8e129e7665bd7eaeff5b81cb019d7ea95..c13b0281b47091de9521f4a725769680fab4608a 100644 (file)
@@ -32,8 +32,6 @@ import { EditionKey } from '../../../types/editions';
 import { TaskStatuses, TaskTypes } from '../../../types/tasks';
 import routes from '../routes';
 
-jest.mock('../../../api/ce');
-
 const computeEngineServiceMock = new ComputeEngineServiceMock();
 
 beforeAll(() => {
index 50aeba427f763624dbc534c6a1061ddeb9b36c51..42186abe04570a259e0ec9c00f982181fec4fa0d 100644 (file)
@@ -21,11 +21,12 @@ import * as React from 'react';
 import { useCallback, useEffect, useState } from 'react';
 import { Helmet } from 'react-helmet-async';
 import { searchUsersGroups } from '../../api/user_groups';
+import GitHubSynchronisationWarning from '../../app/components/GitHubSynchronisationWarning';
 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 { useManageProvider } from '../../components/hooks/useManageProvider';
+import { Provider, useManageProvider } from '../../components/hooks/useManageProvider';
 import { translate } from '../../helpers/l10n';
 import { Group, Paging } from '../../types/types';
 import Header from './components/Header';
@@ -82,6 +83,7 @@ export default function App() {
       <Helmet defer={false} title={translate('user_groups.page')} />
       <main className="page page-limited" id="groups-page">
         <Header reload={fetchGroups} manageProvider={manageProvider} />
+        {manageProvider === Provider.Github && <GitHubSynchronisationWarning short={true} />}
 
         <div className="display-flex-justify-start big-spacer-bottom big-spacer-top">
           <ManagedFilter
index 6ef453bd005750cae3866057822e21a163e78814..15246f4dd5927d05db21911b42feb12ea917ae81 100644 (file)
@@ -23,11 +23,15 @@ import userEvent from '@testing-library/user-event';
 import * as React from 'react';
 import { act } from 'react-dom/test-utils';
 import { byRole, byText } from 'testing-library-selector';
+import AuthenticationServiceMock from '../../../api/mocks/AuthenticationServiceMock';
 import GroupsServiceMock from '../../../api/mocks/GroupsServiceMock';
 import { renderApp } from '../../../helpers/testReactTestingUtils';
+import { Feature } from '../../../types/features';
+import { TaskStatuses } from '../../../types/tasks';
 import App from '../GroupsApp';
 
 const handler = new GroupsServiceMock();
+const authenticationHandler = new AuthenticationServiceMock();
 
 const ui = {
   createGroupButton: byRole('button', { name: 'groups.create_group' }),
@@ -76,10 +80,16 @@ const ui = {
   localGroupRowWithLocalBadge: byRole('row', {
     name: 'local-group local 1',
   }),
+
+  githubProvisioningPending: byText(/synchronization_pending/),
+  githubProvisioningInProgress: byText(/synchronization_in_progress/),
+  githubProvisioningSuccess: byText(/synchronization_successful/),
+  githubProvisioningAlert: byText(/synchronization_failed_short/),
 };
 
 beforeEach(() => {
   handler.reset();
+  authenticationHandler.reset();
 });
 
 describe('in non managed mode', () => {
@@ -313,8 +323,66 @@ describe('in manage mode', () => {
     expect(ui.localGroupRowWithLocalBadge.get()).toBeInTheDocument();
     expect(ui.managedGroupRow.query()).not.toBeInTheDocument();
   });
+
+  describe('Github Provisioning', () => {
+    beforeEach(() => {
+      authenticationHandler.handleActivateGithubProvisioning();
+    });
+
+    it('should display a success status when the synchronisation is a success', async () => {
+      authenticationHandler.addProvisioningTask({
+        status: TaskStatuses.Success,
+        executedAt: '2022-02-03T11:45:35+0200',
+      });
+      renderGroupsApp([Feature.GithubProvisioning]);
+      await act(async () => expect(await ui.githubProvisioningSuccess.find()).toBeInTheDocument());
+    });
+
+    it('should display a success status even when another task is pending', async () => {
+      authenticationHandler.addProvisioningTask({
+        status: TaskStatuses.Pending,
+        executedAt: '2022-02-03T11:55:35+0200',
+      });
+      authenticationHandler.addProvisioningTask({
+        status: TaskStatuses.Success,
+        executedAt: '2022-02-03T11:45:35+0200',
+      });
+      renderGroupsApp([Feature.GithubProvisioning]);
+      await act(async () => expect(await ui.githubProvisioningSuccess.find()).toBeInTheDocument());
+      expect(ui.githubProvisioningPending.query()).not.toBeInTheDocument();
+    });
+
+    it('should display an error alert when the synchronisation failed', async () => {
+      authenticationHandler.addProvisioningTask({
+        status: TaskStatuses.Failed,
+        executedAt: '2022-02-03T11:45:35+0200',
+        errorMessage: "T'es mauvais Jacques",
+      });
+      renderGroupsApp([Feature.GithubProvisioning]);
+      await act(async () => expect(await ui.githubProvisioningAlert.find()).toBeInTheDocument());
+      expect(screen.queryByText("T'es mauvais Jacques")).not.toBeInTheDocument();
+      expect(ui.githubProvisioningSuccess.query()).not.toBeInTheDocument();
+    });
+
+    it('should display an error alert even when another task is in progress', async () => {
+      authenticationHandler.addProvisioningTask({
+        status: TaskStatuses.InProgress,
+        executedAt: '2022-02-03T11:55:35+0200',
+      });
+      authenticationHandler.addProvisioningTask({
+        status: TaskStatuses.Failed,
+        executedAt: '2022-02-03T11:45:35+0200',
+        errorMessage: "T'es mauvais Jacques",
+      });
+      renderGroupsApp([Feature.GithubProvisioning]);
+      await act(async () => expect(await ui.githubProvisioningAlert.find()).toBeInTheDocument());
+      expect(screen.queryByText("T'es mauvais Jacques")).not.toBeInTheDocument();
+      expect(ui.githubProvisioningSuccess.query()).not.toBeInTheDocument();
+      expect(ui.githubProvisioningInProgress.query()).not.toBeInTheDocument();
+    });
+  });
 });
 
-function renderGroupsApp() {
-  return renderApp('admin/groups', <App />);
+function renderGroupsApp(featureList: Feature[] = []) {
+  return renderApp('admin/groups', <App />, { featureList });
 }
index bbfd8a1a4c90d1522996a05f9eb5c5155617fa72..515b7f895e3921d697889e81dde6b0876002453c 100644 (file)
@@ -36,7 +36,7 @@ export default function Header(props: HeaderProps) {
 
   return (
     <>
-      <div className="page-header" id="groups-header">
+      <div className="page-header null-spacer-bottom" id="groups-header">
         <h2 className="page-title">{translate('user_groups.page')}</h2>
 
         <div className="page-actions">
index f10b4a70348d6d7cfd1c4cc0a9f7f0998b6ef743..e05ce398856b9b7411bd008dd034a2296f18f05d 100644 (file)
@@ -17,7 +17,6 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import * as React from 'react';
 import { getDefinitions } from '../../../api/settings';
 import withComponentContext from '../../../app/components/componentContext/withComponentContext';
@@ -32,8 +31,6 @@ import { Component } from '../../../types/types';
 import '../styles.css';
 import SettingsAppRenderer from './SettingsAppRenderer';
 
-const queryClient = new QueryClient();
-
 interface Props {
   component?: Component;
 }
@@ -80,11 +77,7 @@ class SettingsApp extends React.PureComponent<Props, State> {
 
   render() {
     const { component } = this.props;
-    return (
-      <QueryClientProvider client={queryClient}>
-        <SettingsAppRenderer component={component} {...this.state} />
-      </QueryClientProvider>
-    );
+    return <SettingsAppRenderer component={component} {...this.state} />;
   }
 }
 
index 883a9111f3f175ff89a24c3b8b879ff996819bf9..64e43df962740a5a2902b9d4403cd1a082583fb9 100644 (file)
@@ -23,11 +23,14 @@ import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup';
 import React from 'react';
 import { byRole, byText } from 'testing-library-selector';
 import AuthenticationServiceMock from '../../../../../api/mocks/AuthenticationServiceMock';
+import ComputeEngineServiceMock from '../../../../../api/mocks/ComputeEngineServiceMock';
+import SettingsServiceMock from '../../../../../api/mocks/SettingsServiceMock';
 import SystemServiceMock from '../../../../../api/mocks/SystemServiceMock';
 import { AvailableFeaturesContext } from '../../../../../app/components/available-features/AvailableFeaturesContext';
 import { definitions } from '../../../../../helpers/mocks/definitions-list';
 import { renderComponent } from '../../../../../helpers/testReactTestingUtils';
 import { Feature } from '../../../../../types/features';
+import { TaskStatuses } from '../../../../../types/tasks';
 import Authentication from '../Authentication';
 
 jest.mock('../../../../../api/settings');
@@ -35,15 +38,39 @@ jest.mock('../../../../../api/system');
 
 let handler: AuthenticationServiceMock;
 let system: SystemServiceMock;
+let settingsHandler: SettingsServiceMock;
+let computeEngineHandler: ComputeEngineServiceMock;
 
 beforeEach(() => {
   handler = new AuthenticationServiceMock();
   system = new SystemServiceMock();
+  settingsHandler = new SettingsServiceMock();
+  computeEngineHandler = new ComputeEngineServiceMock();
+  [
+    {
+      key: 'sonar.auth.saml.signature.enabled',
+      value: 'false',
+    },
+    {
+      key: 'sonar.auth.saml.enabled',
+      value: 'false',
+    },
+    {
+      key: 'sonar.auth.saml.applicationId',
+      value: 'sonarqube',
+    },
+    {
+      key: 'sonar.auth.saml.providerName',
+      value: 'SAML',
+    },
+  ].forEach((setting: any) => settingsHandler.set(setting.key, setting.value));
 });
 
 afterEach(() => {
-  handler.resetValues();
+  handler.reset();
+  settingsHandler.reset();
   system.reset();
+  computeEngineHandler.reset();
 });
 
 const ui = {
@@ -132,6 +159,10 @@ const ui = {
     githubProvisioningButton: 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/),
     fillForm: async (user: UserEvent) => {
       const { github } = ui;
       await act(async () => {
@@ -152,6 +183,17 @@ const ui = {
         await user.click(github.saveConfigButton.get());
       });
     },
+    enableProvisioning: async (user: UserEvent) => {
+      const { github } = ui;
+      await act(async () => user.click(await github.tab.find()));
+
+      await github.createConfiguration(user);
+
+      await act(async () => user.click(await github.enableConfigButton.find()));
+      await user.click(await github.githubProvisioningButton.find());
+      await user.click(github.saveGithubProvisioning.get());
+      await act(() => user.click(github.confirmProvisioningButton.get()));
+    },
   },
 };
 
@@ -374,6 +416,76 @@ describe('Github tab', () => {
     expect(github.disableConfigButton.get()).toBeDisabled();
     expect(github.saveGithubProvisioning.get()).toBeDisabled();
   });
+
+  describe('Github Provisioning', () => {
+    beforeEach(() => {
+      jest.useFakeTimers({
+        advanceTimers: true,
+        now: new Date('2022-02-04T12:00:59Z'),
+      });
+    });
+    it('should display a success status when the synchronisation is a success', async () => {
+      const user = userEvent.setup();
+      handler.addProvisioningTask({
+        status: TaskStatuses.Success,
+        executedAt: '2022-02-03T11:45:35+0200',
+      });
+
+      renderAuthentication([Feature.GithubProvisioning]);
+      await github.enableProvisioning(user);
+      expect(github.githubProvisioningSuccess.get()).toBeInTheDocument();
+      expect(github.githubProvisioningButton.get()).toHaveTextContent('Test summary');
+    });
+
+    it('should display a success status even when another task is pending', async () => {
+      const user = userEvent.setup();
+      handler.addProvisioningTask({
+        status: TaskStatuses.Pending,
+        executedAt: '2022-02-03T11:55:35+0200',
+      });
+      handler.addProvisioningTask({
+        status: TaskStatuses.Success,
+        executedAt: '2022-02-03T11:45:35+0200',
+      });
+      renderAuthentication([Feature.GithubProvisioning]);
+      await github.enableProvisioning(user);
+      expect(await github.githubProvisioningSuccess.find()).toBeInTheDocument();
+      expect(await github.githubProvisioningPending.find()).toBeInTheDocument();
+    });
+
+    it('should display an error alert when the synchronisation failed', async () => {
+      const user = userEvent.setup();
+      handler.addProvisioningTask({
+        status: TaskStatuses.Failed,
+        executedAt: '2022-02-03T11:45:35+0200',
+        errorMessage: "T'es mauvais Jacques",
+      });
+      renderAuthentication([Feature.GithubProvisioning]);
+      await github.enableProvisioning(user);
+      expect(await github.githubProvisioningAlert.find()).toBeInTheDocument();
+      expect(github.githubProvisioningButton.get()).toHaveTextContent("T'es mauvais Jacques");
+      expect(github.githubProvisioningSuccess.query()).not.toBeInTheDocument();
+    });
+
+    it('should display an error alert even when another task is in progress', async () => {
+      const user = userEvent.setup();
+      handler.addProvisioningTask({
+        status: TaskStatuses.InProgress,
+        executedAt: '2022-02-03T11:55:35+0200',
+      });
+      handler.addProvisioningTask({
+        status: TaskStatuses.Failed,
+        executedAt: '2022-02-03T11:45:35+0200',
+        errorMessage: "T'es mauvais Jacques",
+      });
+      renderAuthentication([Feature.GithubProvisioning]);
+      await github.enableProvisioning(user);
+      expect(await github.githubProvisioningAlert.find()).toBeInTheDocument();
+      expect(github.githubProvisioningButton.get()).toHaveTextContent("T'es mauvais Jacques");
+      expect(github.githubProvisioningSuccess.query()).not.toBeInTheDocument();
+      expect(github.githubProvisioningInProgress.get()).toBeInTheDocument();
+    });
+  });
 });
 
 function renderAuthentication(features: Feature[] = []) {
index 9a0687c630fa849d1710b3e8296461c2f3b9ff8a..d5b5858339af543f88fd07829b9f9b88622dc51c 100644 (file)
@@ -25,11 +25,11 @@ import {
   activateScim,
   deactivateGithubProvisioning,
   deactivateScim,
-  fetchIsGithubProvisioningEnabled,
   fetchIsScimEnabled,
-} from '../../../../../api/settings';
+} from '../../../../../api/provisioning';
 import { getSystemInfo } from '../../../../../api/system';
 import { AvailableFeaturesContext } from '../../../../../app/components/available-features/AvailableFeaturesContext';
+import { useSyncStatusQuery } from '../../../../../queries/github-sync';
 import { Feature } from '../../../../../types/features';
 import { SysInfoCluster } from '../../../../../types/types';
 
@@ -56,12 +56,9 @@ export function useGithubStatusQuery() {
     Feature.GithubProvisioning
   );
 
-  return useQuery(['identity_provider', 'github_status'], () => {
-    if (!hasGithubProvisioning) {
-      return false;
-    }
-    return fetchIsGithubProvisioningEnabled();
-  });
+  const res = useSyncStatusQuery({ enabled: hasGithubProvisioning });
+
+  return { ...res, data: res.data?.enabled };
 }
 
 export function useToggleScimMutation() {
@@ -81,6 +78,7 @@ export function useToggleGithubProvisioningMutation() {
       activate ? activateGithubProvisioning() : deactivateGithubProvisioning(),
     onSuccess: () => {
       client.invalidateQueries({ queryKey: ['identity_provider'] });
+      client.invalidateQueries({ queryKey: ['github_sync'] });
     },
   });
 }
index 94531984701a1b720287b90bc27b7fb86d4caa90..b8d53a8992bc5702725842dc12faa1c48307cd3a 100644 (file)
@@ -126,7 +126,7 @@ export default function UsersApp() {
       <Suggestions suggestions="users" />
       <Helmet defer={false} title={translate('users.page')} />
       <Header onUpdateUsers={fetchUsers} manageProvider={manageProvider} />
-      {manageProvider === Provider.Github && <GitHubSynchronisationWarning />}
+      {manageProvider === Provider.Github && <GitHubSynchronisationWarning short={true} />}
       <div className="display-flex-justify-start big-spacer-bottom big-spacer-top">
         <ManagedFilter
           manageProvider={manageProvider}
index 705dfab75242b081ca6937e36c98e8d46680867a..e15c5c56af4174a0c94483ea5874b19856799a31 100644 (file)
@@ -23,28 +23,27 @@ import userEvent from '@testing-library/user-event';
 import * as React from 'react';
 import selectEvent from 'react-select-event';
 import { byLabelText, byRole, byText } from 'testing-library-selector';
+import AuthenticationServiceMock from '../../../api/mocks/AuthenticationServiceMock';
 import ComponentsServiceMock from '../../../api/mocks/ComponentsServiceMock';
-import ComputeEngineServiceMock from '../../../api/mocks/ComputeEngineServiceMock';
 import SettingsServiceMock from '../../../api/mocks/SettingsServiceMock';
 import UserTokensMock from '../../../api/mocks/UserTokensMock';
 import UsersServiceMock from '../../../api/mocks/UsersServiceMock';
 import { mockCurrentUser, mockLoggedInUser } from '../../../helpers/testMocks';
 import { renderApp } from '../../../helpers/testReactTestingUtils';
 import { Feature } from '../../../types/features';
-import { TaskStatuses, TaskTypes } from '../../../types/tasks';
+import { TaskStatuses } from '../../../types/tasks';
 import { ChangePasswordResults, CurrentUser } from '../../../types/users';
 import UsersApp from '../UsersApp';
 
 jest.mock('../../../api/user-tokens');
 jest.mock('../../../api/components');
 jest.mock('../../../api/settings');
-jest.mock('../../../api/ce');
 
 const userHandler = new UsersServiceMock();
 const tokenHandler = new UserTokensMock();
 const componentsHandler = new ComponentsServiceMock();
 const settingsHandler = new SettingsServiceMock();
-const computeEngineHandler = new ComputeEngineServiceMock();
+const authenticationHandler = new AuthenticationServiceMock();
 
 const ui = {
   createUserButton: byRole('button', { name: 'users.create_user' }),
@@ -125,9 +124,10 @@ const ui = {
   confirmPassword: byLabelText('my_profile.password.confirm', { selector: 'input', exact: false }),
   tokenNameInput: byRole('textbox', { name: 'users.tokens.name' }),
   deleteUserCheckbox: byRole('checkbox', { name: 'users.delete_user' }),
-  githubProvisioningInProgress: byRole('status', { name: /synchronization_in_progress/ }),
-  githubProvisioningSuccess: byRole('status', { name: /synchronization_successful/ }),
-  githubProvisioningAlert: byRole('alert', { name: /synchronization_failed/ }),
+  githubProvisioningPending: byText(/synchronization_pending/),
+  githubProvisioningInProgress: byText(/synchronization_in_progress/),
+  githubProvisioningSuccess: byText(/synchronization_successful/),
+  githubProvisioningAlert: byText(/synchronization_failed_short/),
 };
 
 beforeEach(() => {
@@ -135,6 +135,7 @@ beforeEach(() => {
   userHandler.reset();
   componentsHandler.reset();
   settingsHandler.reset();
+  authenticationHandler.reset();
 });
 
 describe('different filters combinations', () => {
@@ -450,10 +451,6 @@ describe('in manage mode', () => {
     userHandler.setIsManaged(true);
   });
 
-  afterEach(() => {
-    computeEngineHandler.clearTasks();
-  });
-
   it('should not be able to create a user"', async () => {
     renderUsersApp();
 
@@ -559,35 +556,61 @@ describe('in manage mode', () => {
   });
 
   describe('Github Provisioning', () => {
-    it('should display an info status when the synchronisation is in Progress', async () => {
-      computeEngineHandler.addTask({
-        status: TaskStatuses.InProgress,
-        type: TaskTypes.GithubProvisioning,
+    beforeEach(() => {
+      authenticationHandler.handleActivateGithubProvisioning();
+    });
+
+    it('should display a success status when the synchronisation is a success', async () => {
+      authenticationHandler.addProvisioningTask({
+        status: TaskStatuses.Success,
         executedAt: '2022-02-03T11:45:35+0200',
       });
       renderUsersApp([Feature.GithubProvisioning]);
-      expect(await ui.githubProvisioningInProgress.find()).toBeInTheDocument();
+      await act(async () => expect(await ui.githubProvisioningSuccess.find()).toBeInTheDocument());
     });
 
-    it('should display a success status when the synchronisation is a success', async () => {
-      computeEngineHandler.addTask({
+    it('should display a success status even when another task is pending', async () => {
+      authenticationHandler.addProvisioningTask({
+        status: TaskStatuses.Pending,
+        executedAt: '2022-02-03T11:55:35+0200',
+      });
+      authenticationHandler.addProvisioningTask({
         status: TaskStatuses.Success,
-        type: TaskTypes.GithubProvisioning,
         executedAt: '2022-02-03T11:45:35+0200',
       });
       renderUsersApp([Feature.GithubProvisioning]);
-      expect(await ui.githubProvisioningSuccess.find()).toBeInTheDocument();
+      await act(async () => expect(await ui.githubProvisioningSuccess.find()).toBeInTheDocument());
+      expect(ui.githubProvisioningPending.query()).not.toBeInTheDocument();
     });
 
     it('should display an error alert when the synchronisation failed', async () => {
-      computeEngineHandler.addTask({
+      authenticationHandler.addProvisioningTask({
+        status: TaskStatuses.Failed,
+        executedAt: '2022-02-03T11:45:35+0200',
+        errorMessage: "T'es mauvais Jacques",
+      });
+      renderUsersApp([Feature.GithubProvisioning]);
+      await act(async () => expect(await ui.githubProvisioningAlert.find()).toBeInTheDocument());
+      expect(screen.queryByText("T'es mauvais Jacques")).not.toBeInTheDocument();
+
+      expect(ui.githubProvisioningSuccess.query()).not.toBeInTheDocument();
+    });
+
+    it('should display an error alert even when another task is in progress', async () => {
+      authenticationHandler.addProvisioningTask({
+        status: TaskStatuses.InProgress,
+        executedAt: '2022-02-03T11:55:35+0200',
+      });
+      authenticationHandler.addProvisioningTask({
         status: TaskStatuses.Failed,
-        type: TaskTypes.GithubProvisioning,
         executedAt: '2022-02-03T11:45:35+0200',
         errorMessage: "T'es mauvais Jacques",
       });
       renderUsersApp([Feature.GithubProvisioning]);
-      expect(await ui.githubProvisioningAlert.find()).toBeInTheDocument();
+      await act(async () => expect(await ui.githubProvisioningAlert.find()).toBeInTheDocument());
+      expect(screen.queryByText("T'es mauvais Jacques")).not.toBeInTheDocument();
+      expect(ui.githubProvisioningSuccess.query()).not.toBeInTheDocument();
+      expect(ui.githubProvisioningInProgress.query()).not.toBeInTheDocument();
     });
   });
 });
index 765b22ef21c67f6934910a279d53a3ea4c04f153..e5ced658c5ea7ccf345c2fa590a0e5c190be0acc 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { fireEvent, Matcher, render, RenderResult, screen, within } from '@testing-library/react';
+import { Matcher, RenderResult, fireEvent, render, screen, within } from '@testing-library/react';
 import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup';
 import { omit } from 'lodash';
 import * as React from 'react';
 import { HelmetProvider } from 'react-helmet-async';
 import { IntlProvider } from 'react-intl';
-import { MemoryRouter, Outlet, parsePath, Route, Routes } from 'react-router-dom';
+import { MemoryRouter, Outlet, Route, Routes, parsePath } from 'react-router-dom';
 import AdminContext from '../app/components/AdminContext';
+import GlobalMessagesContainer from '../app/components/GlobalMessagesContainer';
 import AppStateContextProvider from '../app/components/app-state/AppStateContextProvider';
 import { AvailableFeaturesContext } from '../app/components/available-features/AvailableFeaturesContext';
 import { ComponentContext } from '../app/components/componentContext/ComponentContext';
 import CurrentUserContextProvider from '../app/components/current-user/CurrentUserContextProvider';
-import GlobalMessagesContainer from '../app/components/GlobalMessagesContainer';
 import IndexationContextProvider from '../app/components/indexation/IndexationContextProvider';
 import { LanguagesContext } from '../app/components/languages/LanguagesContext';
 import { MetricsContext } from '../app/components/metrics/MetricsContext';
@@ -44,7 +44,6 @@ import { mockComponent } from './mocks/component';
 import { DEFAULT_METRICS } from './mocks/metrics';
 import { mockAppState, mockCurrentUser } from './testMocks';
 
-const queryClient = new QueryClient();
 export interface RenderContext {
   metrics?: Dict<Metric>;
   appState?: AppState;
@@ -98,6 +97,8 @@ export function renderComponent(
   { appState = mockAppState() }: RenderContext = {}
 ) {
   function Wrapper({ children }: { children: React.ReactElement }) {
+    const queryClient = new QueryClient();
+
     return (
       <IntlProvider defaultLocale="en" locale="en">
         <QueryClientProvider client={queryClient}>
@@ -188,6 +189,7 @@ function renderRoutedApp(
 ): RenderResult {
   const path = parsePath(navigateTo);
   path.pathname = `/${path.pathname}`;
+  const queryClient = new QueryClient();
 
   return render(
     <HelmetProvider context={{}}>
@@ -198,13 +200,15 @@ function renderRoutedApp(
               <CurrentUserContextProvider currentUser={currentUser}>
                 <AppStateContextProvider appState={appState}>
                   <IndexationContextProvider>
-                    <GlobalMessagesContainer />
-                    <MemoryRouter initialEntries={[path]}>
-                      <Routes>
-                        {children}
-                        <Route path="*" element={<CatchAll />} />
-                      </Routes>
-                    </MemoryRouter>
+                    <QueryClientProvider client={queryClient}>
+                      <GlobalMessagesContainer />
+                      <MemoryRouter initialEntries={[path]}>
+                        <Routes>
+                          {children}
+                          <Route path="*" element={<CatchAll />} />
+                        </Routes>
+                      </MemoryRouter>
+                    </QueryClientProvider>
                   </IndexationContextProvider>
                 </AppStateContextProvider>
               </CurrentUserContextProvider>
diff --git a/server/sonar-web/src/main/js/queries/github-sync.ts b/server/sonar-web/src/main/js/queries/github-sync.ts
new file mode 100644 (file)
index 0000000..d2be135
--- /dev/null
@@ -0,0 +1,25 @@
+/*
+ * 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 { useQuery } from '@tanstack/react-query';
+import { fetchGithubProvisioningStatus } from '../api/provisioning';
+
+export const useSyncStatusQuery = ({ enabled }: { enabled?: boolean }) => {
+  return useQuery(['github_sync', 'status'], fetchGithubProvisioningStatus, { enabled });
+};
diff --git a/server/sonar-web/src/main/js/types/provisioning.ts b/server/sonar-web/src/main/js/types/provisioning.ts
new file mode 100644 (file)
index 0000000..6d6f05a
--- /dev/null
@@ -0,0 +1,49 @@
+/*
+ * 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 { TaskStatuses } from './tasks';
+
+export type GithubStatusDisabled = {
+  enabled: false;
+  nextSync?: never;
+  lastSync?: never;
+};
+export type GithubStatusEnabled = {
+  enabled: true;
+  nextSync?: { status: TaskStatuses.Pending | TaskStatuses.InProgress };
+  lastSync?: {
+    executionTimeMs: number;
+    finishedAt: number;
+    startedAt: number;
+  } & (
+    | {
+        status: TaskStatuses.Success;
+        summary?: string;
+        errorMessage?: never;
+      }
+    | {
+        status: TaskStatuses.Canceled | TaskStatuses.Failed;
+        summary?: never;
+        errorMessage?: string;
+      }
+  );
+};
+
+export type GithubStatus = GithubStatusDisabled | GithubStatusEnabled;
index da4911599feab9436a4d53d3277e83c4cdea9e09..291c3fedba335bb8b858e358d16a5c0a494e8400 100644 (file)
@@ -1386,9 +1386,12 @@ settings.authentication.github.form.provisioning_with_github=Automatic user and
 settings.authentication.github.form.provisioning_with_github.description=Users and groups are automatically provisioned from your GitHub organizations. Once activated, managed users and groups can only be modified from your GitHub organizations/teams. Existing local users and groups will be kept.
 settings.authentication.github.form.provisioning_with_github.description.doc=For more details, see {documentation}.
 settings.authentication.github.form.provisioning.disabled=Your current edition does not support provisioning with GitHub. See the {documentation} for more information.
-settings.authentication.github.background_task.synchronization_in_progress=Synchronization is in progress. Started on {0}.
-settings.authentication.github.background_task.synchronization_successful=Successful synchronization on {0}.
-settings.authentication.github.background_task.synchronization_failed=Synchronization failed on {0}. {1}
+settings.authentication.github.synchronization_in_progress=Synchronization is in progress.
+settings.authentication.github.synchronization_pending=Synchronization is scheduled.
+settings.authentication.github.synchronization_successful=Last synchronization was done {0} ago.
+settings.authentication.github.synchronization_failed=Last synchronization failed {0} ago.
+settings.authentication.github.synchronization_failed_short=Last synchronization failed. {details}
+settings.authentication.github.synchronization_failed_link=More details
 
 # SAML
 settings.authentication.form.create.saml=New SAML configuration