]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19084 Display Github Synchronisation status
authorguillaume-peoch-sonarsource <guillaume.peoch@sonarsource.com>
Wed, 26 Apr 2023 12:57:49 +0000 (14:57 +0200)
committersonartech <sonartech@sonarsource.com>
Thu, 11 May 2023 20:03:14 +0000 (20:03 +0000)
server/sonar-web/src/main/js/api/mocks/ComputeEngineServiceMock.ts
server/sonar-web/src/main/js/api/mocks/UsersServiceMock.ts
server/sonar-web/src/main/js/app/components/GitHubSynchronisationWarning.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/users/UsersApp.tsx
server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx
server/sonar-web/src/main/js/components/hooks/useManageProvider.ts
server/sonar-web/src/main/js/types/tasks.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 93d2e3493722f17db341173f7559d4c0633ee4ff..46cb22cbd5f217b6ece5114066a92c932e99897d 100644 (file)
@@ -47,6 +47,7 @@ const TASK_TYPES = [
   TaskTypes.ProjectImport,
   TaskTypes.ViewRefresh,
   TaskTypes.ReportSubmit,
+  TaskTypes.GithubProvisioning,
 ];
 
 const DEFAULT_TASKS: Task[] = [mockTask()];
index 020683d6171366060879141fd3b4f97ef9921305..98ded9a356bcb2fb553e928c78ec0468f70a758c 100644 (file)
@@ -290,7 +290,7 @@ export default class UsersServiceMock {
               System: {
                 'High Availability': true,
                 'Server ID': 'asd564-asd54a-5dsfg45',
-                'External Users and Groups Provisioning': 'Okta',
+                'External Users and Groups Provisioning': 'GitHub',
               },
             }
           : {}
diff --git a/server/sonar-web/src/main/js/app/components/GitHubSynchronisationWarning.tsx b/server/sonar-web/src/main/js/app/components/GitHubSynchronisationWarning.tsx
new file mode 100644 (file)
index 0000000..67e7002
--- /dev/null
@@ -0,0 +1,118 @@
+/*
+ * 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 { isEmpty } from 'lodash';
+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 { Feature } from '../../types/features';
+import { ActivityRequestParameters, TaskStatuses, TaskTypes } 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();
+
+      if (lastActivity === undefined) {
+        return;
+      }
+      const { status, errorMessage, executedAt } = lastActivity;
+
+      if (executedAt === undefined) {
+        return;
+      }
+      const formattedDate = formatDate(new Date(executedAt), formatterOption);
+
+      switch (status) {
+        case TaskStatuses.Failed:
+          setMessage(
+            translateWithParameters(
+              'settings.authentication.github.background_task.synchronisation_failed',
+              formattedDate,
+              errorMessage ?? ''
+            )
+          );
+          setActivityStatus('error');
+          break;
+        case TaskStatuses.Success:
+          setMessage(
+            translateWithParameters(
+              'settings.authentication.github.background_task.synchronisation_successful',
+              formattedDate
+            )
+          );
+          setActivityStatus('success');
+          break;
+        case TaskStatuses.InProgress:
+          setMessage(
+            translateWithParameters(
+              'settings.authentication.github.background_task.synchronisation_in_progress',
+              formattedDate
+            )
+          );
+          setActivityStatus('loading');
+          break;
+        default:
+          return;
+      }
+      setDisplayMessage(true);
+    })();
+  }, []);
+
+  if (!displayMessage || !hasGithubProvisioning) {
+    return null;
+  }
+
+  return (
+    <Alert title={message} variant={activityStatus}>
+      {message}
+    </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);
+
+  if (isEmpty(activity.tasks)) {
+    return undefined;
+  }
+
+  return activity.tasks[0];
+};
+
+export default injectIntl(GitHubSynchronisationWarning);
index b1092b998a0b218fbac629730232307537db8549..8a64c40ea81c28adaa36f5505d8c3462723f490d 100644 (file)
@@ -26,10 +26,12 @@ import {
   resetSettingValue,
   setSettingValue,
 } from '../../../../api/settings';
+import GitHubSynchronisationWarning from '../../../../app/components/GitHubSynchronisationWarning';
 import DocLink from '../../../../components/common/DocLink';
 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';
@@ -82,7 +84,7 @@ export default function GithubAuthenticationTab(props: GithubAuthenticationProps
     resetJitSetting,
   } = useGithubConfiguration(definitions, props.onReload);
 
-  const hasDifferentProvider = provider !== undefined && provider !== 'github';
+  const hasDifferentProvider = provider !== undefined && provider !== Provider.Github;
 
   const handleCreateConfiguration = () => {
     setShowEditModal(true);
@@ -217,7 +219,7 @@ export default function GithubAuthenticationTab(props: GithubAuthenticationProps
                               'settings.authentication.github.form.provisioning_with_github.description'
                             )}
                           </p>
-                          <p>
+                          <p className="spacer-bottom">
                             <FormattedMessage
                               id="settings.authentication.github.form.provisioning_with_github.description.doc"
                               defaultMessage={translate(
@@ -236,6 +238,7 @@ export default function GithubAuthenticationTab(props: GithubAuthenticationProps
                               }}
                             />
                           </p>
+                          {githubProvisioningStatus && <GitHubSynchronisationWarning />}
                         </>
                       ) : (
                         <p>
index d376ecc3f59249f4edd59019210cc55475999951..44722f68f3705503487a40de51fc03f905eff8d1 100644 (file)
@@ -31,6 +31,7 @@ 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';
@@ -82,7 +83,7 @@ export default function SamlAuthenticationTab(props: SamlAuthenticationProps) {
     deleteConfiguration,
   } = useSamlConfiguration(definitions, onReload);
 
-  const hasDifferentProvider = provider !== undefined && provider !== name;
+  const hasDifferentProvider = provider !== undefined && provider !== Provider.Scim;
 
   const handleCreateConfiguration = () => {
     setShowEditModal(true);
index c17382ae7fd04ad565aa09544e7bbe413d8e46ea..94531984701a1b720287b90bc27b7fb86d4caa90 100644 (file)
@@ -21,13 +21,14 @@ import { subDays, subSeconds } from 'date-fns';
 import React, { useCallback, useEffect, useMemo, useState } from 'react';
 import { Helmet } from 'react-helmet-async';
 import { getIdentityProviders, searchUsers } from '../../api/users';
+import GitHubSynchronisationWarning from '../../app/components/GitHubSynchronisationWarning';
 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 { useManageProvider } from '../../components/hooks/useManageProvider';
+import { Provider, useManageProvider } from '../../components/hooks/useManageProvider';
 import DeferredSpinner from '../../components/ui/DeferredSpinner';
 import { now, toISO8601WithOffsetString } from '../../helpers/dates';
 import { translate } from '../../helpers/l10n';
@@ -125,6 +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 />}
       <div className="display-flex-justify-start big-spacer-bottom big-spacer-top">
         <ManagedFilter
           manageProvider={manageProvider}
index c1bc94a971eb09f6986c48f6c439e5b6eb2ef6e3..b3cbccd36683e5d6681be159724109689328206d 100644 (file)
@@ -24,22 +24,27 @@ import * as React from 'react';
 import selectEvent from 'react-select-event';
 import { byLabelText, byRole, byText } from 'testing-library-selector';
 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 { 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 ui = {
   createUserButton: byRole('button', { name: 'users.create_user' }),
@@ -67,7 +72,6 @@ const ui = {
     byRole('button', {
       name: `remove_x.users.create_user.scm_account_${value ? `x.${value}` : 'new'}`,
     }),
-
   userRows: byRole('row', {
     name: (accessibleName) => /^[A-Z]+ /.test(accessibleName),
   }),
@@ -121,6 +125,9 @@ 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: /synchronisation_in_progress/ }),
+  githubProvisioningSuccess: byRole('status', { name: /synchronisation_successful/ }),
+  githubProvisioningAlert: byRole('alert', { name: /synchronisation_failed/ }),
 };
 
 beforeEach(() => {
@@ -379,7 +386,7 @@ describe('in non managed mode', () => {
   it('should change a password', async () => {
     const user = userEvent.setup();
     const currentUser = mockLoggedInUser({ login: 'alice.merveille' });
-    renderUsersApp(currentUser);
+    renderUsersApp([], currentUser);
 
     await act(async () =>
       user.click(
@@ -443,6 +450,10 @@ describe('in manage mode', () => {
     userHandler.setIsManaged(true);
   });
 
+  afterEach(() => {
+    computeEngineHandler.clearTasks();
+  });
+
   it('should not be able to create a user"', async () => {
     renderUsersApp();
 
@@ -546,6 +557,39 @@ describe('in manage mode', () => {
     await user.click(ui.doneButton.get());
     expect(ui.dialogTokens.query()).not.toBeInTheDocument();
   });
+
+  describe('Github Provisioning', () => {
+    it('should display an info status when the synchronisation is in Progress', async () => {
+      computeEngineHandler.addTask({
+        status: TaskStatuses.InProgress,
+        type: TaskTypes.GithubProvisioning,
+        executedAt: '2022-02-03T11:45:35+0200',
+      });
+      renderUsersApp([Feature.GithubProvisioning]);
+      expect(await ui.githubProvisioningInProgress.find()).toBeInTheDocument();
+    });
+
+    it('should display a success status when the synchronisation is a success', async () => {
+      computeEngineHandler.addTask({
+        status: TaskStatuses.Success,
+        type: TaskTypes.GithubProvisioning,
+        executedAt: '2022-02-03T11:45:35+0200',
+      });
+      renderUsersApp([Feature.GithubProvisioning]);
+      expect(await ui.githubProvisioningSuccess.find()).toBeInTheDocument();
+    });
+
+    it('should display an error alert when the synchronisation failed', async () => {
+      computeEngineHandler.addTask({
+        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();
+    });
+  });
 });
 
 it('should render external identity Providers', async () => {
@@ -555,7 +599,10 @@ it('should render external identity Providers', async () => {
   expect(await ui.denisRow.find()).toHaveTextContent(/test2: UnknownExternalProvider/);
 });
 
-function renderUsersApp(currentUser?: CurrentUser) {
+function renderUsersApp(featureList: Feature[] = [], currentUser?: CurrentUser) {
   // eslint-disable-next-line testing-library/no-unnecessary-act
-  return renderApp('admin/users', <UsersApp />, { currentUser: mockCurrentUser(currentUser) });
+  renderApp('admin/users', <UsersApp />, {
+    currentUser: mockCurrentUser(currentUser),
+    featureList,
+  });
 }
index f27b576924c35705776a4cd1b5c95bf5128de002..db7c0a4c7b8ae74a4d2de3fe4aeeaf098a98629d 100644 (file)
@@ -23,6 +23,11 @@ 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<string | undefined>();
 
index 63795c9bec4ad78cc830142f9390d91598bfed9d..52794ab71376bcf372b73ac486f3fe0658d2d69c 100644 (file)
@@ -20,6 +20,7 @@
 export enum TaskTypes {
   Report = 'REPORT',
   IssueSync = 'ISSUE_SYNC',
+  GithubProvisioning = 'GITHUB_AUTH_PROVISIONING',
   AppRefresh = 'APP_REFRESH',
   ViewRefresh = 'VIEW_REFRESH',
   ProjectExport = 'PROJECT_EXPORT',
index 2aa64b28d0c63f17e562699005bc6cff7b6af2b0..a12defd8812eb043d58768ed7ad0a07b4ab3a027 100644 (file)
@@ -1347,6 +1347,9 @@ 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.synchronisation_in_progress=Synchronisation is in progress. Started on {0}.
+settings.authentication.github.background_task.synchronisation_successful=Successful synchronisation on {0}.
+settings.authentication.github.background_task.synchronisation_failed=Synchronisation failed on {0}. {1}
 
 # SAML
 settings.authentication.form.create.saml=New SAML configuration
@@ -3248,6 +3251,7 @@ 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_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.