]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-22666 Adding oauth email support (#11653)
authorShane Findley <shane.findley@sonarsource.com>
Fri, 30 Aug 2024 14:04:21 +0000 (16:04 +0200)
committersonartech <sonartech@sonarsource.com>
Fri, 30 Aug 2024 20:02:41 +0000 (20:02 +0000)
18 files changed:
server/sonar-web/design-system/src/components/SelectionCard.tsx
server/sonar-web/package.json
server/sonar-web/src/main/js/api/mocks/SystemServiceMock.ts
server/sonar-web/src/main/js/api/system.ts
server/sonar-web/src/main/js/apps/settings/components/AdditionalCategories.tsx
server/sonar-web/src/main/js/apps/settings/components/email-notification/AuthenticationSelector.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/email-notification/CommonSMTP.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/email-notification/EmailNotification.tsx
server/sonar-web/src/main/js/apps/settings/components/email-notification/EmailNotificationConfiguration.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/email-notification/EmailNotificationFormField.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/email-notification/SenderInformation.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/email-notification/__tests__/EmailNotification-it.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/email-notification/utils.ts [new file with mode: 0644]
server/sonar-web/src/main/js/helpers/mocks/system.ts [new file with mode: 0644]
server/sonar-web/src/main/js/queries/system.ts
server/sonar-web/src/main/js/types/system.ts
server/sonar-web/yarn.lock
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 6c39d0d92001f608f413b1cec42edc0ff4473e4b..8231724679219126e64cbbfb58a539728cfbcb30 100644 (file)
@@ -157,6 +157,8 @@ const StyledRecommended = styled.div`
   ${tw`sw-py-2 sw-px-4`}
   ${tw`sw-box-border`}
   ${tw`sw-rounded-b-2`}
+  ${tw`sw-w-full`}
+  ${tw`sw-text-left`}
 
   color: ${themeContrast('infoBackground')};
   background-color: ${themeColor('infoBackground')};
index c0d1ac4c33a25316e9b12851662eb31cb9b483fa..5dfb4282fc83028034cd91f59de7062561f39d2b 100644 (file)
@@ -15,6 +15,7 @@
     "@react-spring/web": "9.7.3",
     "@sonarsource/echoes-react": "0.6.0",
     "@tanstack/react-query": "5.18.1",
+    "@types/validator": "13.12.0",
     "axios": "1.7.2",
     "classnames": "2.5.1",
     "clipboard": "2.0.11",
@@ -46,7 +47,8 @@
     "react-virtualized": "9.22.5",
     "regenerator-runtime": "0.14.1",
     "shared-store-hook": "0.0.4",
-    "valid-url": "1.0.9"
+    "valid-url": "1.0.9",
+    "validator": "13.12.0"
   },
   "devDependencies": {
     "@emotion/jest": "11.11.0",
index 34e832b5b3f6cb03ff6dc7e4d49e21dc2edce8b7..23da678af862c2986b7d630994bdbb0909aadcfc 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, uniqueId } from 'lodash';
 import { Provider, SysInfoCluster, SysInfoLogging, SysInfoStandalone } from '../../types/types';
 
 import { LogsLevels } from '../../apps/system/utils';
-import { mockClusterSysInfo, mockLogs, mockStandaloneSysInfo } from '../../helpers/testMocks';
-import { getSystemInfo, getSystemUpgrades, setLogLevel } from '../system';
+import { mockEmailConfiguration } from '../../helpers/mocks/system';
+import {
+  mockClusterSysInfo,
+  mockLogs,
+  mockPaging,
+  mockStandaloneSysInfo,
+} from '../../helpers/testMocks';
+import { EmailConfiguration } from '../../types/system';
+import {
+  getEmailConfigurations,
+  getSystemInfo,
+  getSystemUpgrades,
+  patchEmailConfiguration,
+  postEmailConfiguration,
+  setLogLevel,
+} from '../system';
 
 jest.mock('../system');
 
@@ -44,11 +58,15 @@ export default class SystemServiceMock {
     installedVersionActive: true,
   };
 
+  emailConfigurations: EmailConfiguration[] = [];
+
   constructor() {
     this.updateSystemInfo();
     jest.mocked(getSystemInfo).mockImplementation(this.handleGetSystemInfo);
     jest.mocked(setLogLevel).mockImplementation(this.handleSetLogLevel);
     jest.mocked(getSystemUpgrades).mockImplementation(this.handleGetSystemUpgrades);
+    jest.mocked(getEmailConfigurations).mockImplementation(this.handleGetEmailConfigurations);
+    jest.mocked(postEmailConfiguration).mockImplementation(this.handlePostEmailConfiguration);
   }
 
   handleGetSystemInfo = () => {
@@ -97,10 +115,41 @@ export default class SystemServiceMock {
     this.updateSystemInfo();
   };
 
+  handleGetEmailConfigurations: typeof getEmailConfigurations = () => {
+    return this.reply({
+      emailConfigurations: this.emailConfigurations,
+      page: mockPaging({ total: this.emailConfigurations.length }),
+    });
+  };
+
+  handlePostEmailConfiguration: typeof postEmailConfiguration = (configuration) => {
+    const returnVal = mockEmailConfiguration(configuration.authMethod, {
+      ...configuration,
+      id: uniqueId('email-configuration-'),
+    });
+
+    this.emailConfigurations.push(returnVal);
+    return this.reply(returnVal);
+  };
+
+  handlePatchEmailConfiguration: typeof patchEmailConfiguration = (id, configuration) => {
+    const index = this.emailConfigurations.findIndex((c) => c.id === id);
+    this.emailConfigurations[index] = mockEmailConfiguration(configuration.authMethod, {
+      ...this.emailConfigurations[index],
+      ...configuration,
+    });
+    return this.reply(this.emailConfigurations[index]);
+  };
+
+  addEmailConfiguration = (configuration: EmailConfiguration) => {
+    this.emailConfigurations.push(configuration);
+  };
+
   reset = () => {
     this.logging = mockLogs();
     this.setIsCluster(false);
     this.updateSystemInfo();
+    this.emailConfigurations = [];
   };
 
   reply<T>(response: T): Promise<T> {
index 6640209dc60b57d587ab40c2a0511e200deb7f6b..40cfd9a7de30505e2a18a2f4e61d32d8c5489996 100644 (file)
@@ -21,10 +21,16 @@ import axios from 'axios';
 import { throwGlobalError } from '~sonar-aligned/helpers/error';
 import { getJSON } from '~sonar-aligned/helpers/request';
 import { post, postJSON, requestTryAndRepeatUntil } from '../helpers/request';
-import { MigrationStatus, MigrationsStatusResponse, SystemUpgrade } from '../types/system';
-import { SysInfoCluster, SysInfoStandalone, SysStatus } from '../types/types';
+import {
+  EmailConfiguration,
+  MigrationStatus,
+  MigrationsStatusResponse,
+  SystemUpgrade,
+} from '../types/system';
+import { Paging, SysInfoCluster, SysInfoStandalone, SysStatus } from '../types/types';
 
 const MIGRATIONS_STATUS_ENDPOINT = '/api/v2/system/migrations-status';
+const EMAIL_NOTIFICATION_PATH = '/api/v2/system/email-configurations';
 
 export function setLogLevel(level: string): Promise<void | Response> {
   return post('/api/system/change_log_level', { level }).catch(throwGlobalError);
@@ -74,3 +80,24 @@ export function waitSystemUPStatus(): Promise<{
     ({ status }) => status === 'UP',
   );
 }
+
+export function getEmailConfigurations(): Promise<{
+  emailConfigurations: EmailConfiguration[];
+  page: Paging;
+}> {
+  return axios.get(EMAIL_NOTIFICATION_PATH);
+}
+
+export function postEmailConfiguration(data: EmailConfiguration): Promise<EmailConfiguration> {
+  return axios.post<EmailConfiguration, EmailConfiguration>(EMAIL_NOTIFICATION_PATH, data);
+}
+
+export function patchEmailConfiguration(
+  id: string,
+  emailConfiguration: EmailConfiguration,
+): Promise<EmailConfiguration> {
+  return axios.patch<EmailConfiguration, EmailConfiguration>(
+    `${EMAIL_NOTIFICATION_PATH}/${id}`,
+    emailConfiguration,
+  );
+}
index dc913bcdcee6d710ae0ebf74808a6823a25f5242..0411fd4065d06d4ad9d2c60fd0faa33b67f5ee99 100644 (file)
@@ -107,7 +107,7 @@ export const ADDITIONAL_CATEGORIES: AdditionalCategory[] = [
   },
   {
     key: EMAIL_NOTIFICATION_CATEGORY,
-    name: translate('settings.email_notification.category'),
+    name: translate('email_notification.category'),
     renderComponent: getEmailNotificationComponent,
     availableGlobally: true,
     availableForProject: false,
@@ -139,6 +139,6 @@ function getPullRequestDecorationBindingComponent(props: AdditionalCategoryCompo
   return props.component && <PullRequestDecorationBinding component={props.component} />;
 }
 
-function getEmailNotificationComponent(props: AdditionalCategoryComponentProps) {
-  return <EmailNotification {...props} />;
+function getEmailNotificationComponent() {
+  return <EmailNotification />;
 }
diff --git a/server/sonar-web/src/main/js/apps/settings/components/email-notification/AuthenticationSelector.tsx b/server/sonar-web/src/main/js/apps/settings/components/email-notification/AuthenticationSelector.tsx
new file mode 100644 (file)
index 0000000..05b6595
--- /dev/null
@@ -0,0 +1,132 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { BasicSeparator, Note, SelectionCard } from 'design-system/lib';
+import React from 'react';
+import { translate } from '../../../../helpers/l10n';
+import { AuthMethod } from '../../../../types/system';
+import { EmailNotificationFormField } from './EmailNotificationFormField';
+import {
+  BASIC_PASSWORD,
+  EmailNotificationGroupProps,
+  OAUTH_AUTHENTICATION_HOST,
+  OAUTH_CLIENT_ID,
+  OAUTH_CLIENT_SECRET,
+  OAUTH_TENANT,
+  USERNAME,
+} from './utils';
+
+export function AuthenticationSelector(props: Readonly<EmailNotificationGroupProps>) {
+  const { configuration, onChange } = props;
+
+  const isOAuth = configuration?.authMethod === AuthMethod.OAuth;
+
+  return (
+    <div className="sw-pt-6">
+      <div className="sw-pb-6 sw-flex sw-gap-4 sw-space-between">
+        <SelectionCard
+          className="sw-w-full"
+          selected={!isOAuth}
+          onClick={() => onChange({ authMethod: AuthMethod.Basic })}
+          title={translate('email_notification.form.basic_auth.title')}
+        >
+          <Note>{translate('email_notification.form.basic_auth.description')}</Note>
+        </SelectionCard>
+        <SelectionCard
+          className="sw-w-full"
+          selected={isOAuth}
+          onClick={() => onChange({ authMethod: AuthMethod.OAuth })}
+          recommended
+          recommendedReason={translate('email_notification.form.oauth_auth.recommended_reason')}
+          title={translate('email_notification.form.oauth_auth.title')}
+        >
+          <Note>{translate('email_notification.form.oauth_auth.description')}</Note>
+        </SelectionCard>
+      </div>
+      <BasicSeparator />
+      <EmailNotificationFormField
+        description={translate('email_notification.form.username.description')}
+        id={USERNAME}
+        onChange={(value) => onChange({ username: value })}
+        name={translate('email_notification.form.username')}
+        required
+        value={configuration.username}
+      />
+      <BasicSeparator />
+      {isOAuth ? (
+        <>
+          <EmailNotificationFormField
+            description={translate('email_notification.form.oauth_authentication_host.description')}
+            id={OAUTH_AUTHENTICATION_HOST}
+            onChange={(value) => onChange({ oauthAuthenticationHost: value })}
+            name={translate('email_notification.form.oauth_authentication_host')}
+            required
+            value={
+              configuration.authMethod === AuthMethod.OAuth
+                ? configuration.oauthAuthenticationHost
+                : ''
+            }
+          />
+          <BasicSeparator />
+          <EmailNotificationFormField
+            description={translate('email_notification.form.oauth_client_id.description')}
+            id={OAUTH_CLIENT_ID}
+            onChange={(value) => onChange({ oauthClientId: value })}
+            name={translate('email_notification.form.oauth_client_id')}
+            required
+            type="password"
+            value={configuration.authMethod === AuthMethod.OAuth ? configuration.oauthClientId : ''}
+          />
+          <BasicSeparator />
+          <EmailNotificationFormField
+            description={translate('email_notification.form.oauth_client_secret.description')}
+            id={OAUTH_CLIENT_SECRET}
+            onChange={(value) => onChange({ oauthClientSecret: value })}
+            name={translate('email_notification.form.oauth_client_secret')}
+            required
+            type="password"
+            value={
+              configuration.authMethod === AuthMethod.OAuth ? configuration.oauthClientSecret : ''
+            }
+          />
+          <BasicSeparator />
+          <EmailNotificationFormField
+            description={translate('email_notification.form.oauth_tenant.description')}
+            id={OAUTH_TENANT}
+            onChange={(value) => onChange({ oauthTenant: value })}
+            name={translate('email_notification.form.oauth_tenant')}
+            required
+            value={configuration.authMethod === AuthMethod.OAuth ? configuration.oauthTenant : ''}
+          />
+        </>
+      ) : (
+        <EmailNotificationFormField
+          description={translate('email_notification.form.basic_password.description')}
+          id={BASIC_PASSWORD}
+          onChange={(value) => onChange({ basicPassword: value })}
+          name={translate('email_notification.form.basic_password')}
+          required
+          type="password"
+          value={configuration.authMethod === AuthMethod.Basic ? configuration.basicPassword : ''}
+        />
+      )}
+      <BasicSeparator />
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/settings/components/email-notification/CommonSMTP.tsx b/server/sonar-web/src/main/js/apps/settings/components/email-notification/CommonSMTP.tsx
new file mode 100644 (file)
index 0000000..3126613
--- /dev/null
@@ -0,0 +1,63 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { BasicSeparator } from 'design-system/lib';
+import React from 'react';
+import { translate } from '../../../../helpers/l10n';
+import { EmailNotificationFormField } from './EmailNotificationFormField';
+import { EmailNotificationGroupProps, HOST, PORT, SECURITY_PROTOCOL } from './utils';
+
+export function CommonSMTP(props: Readonly<EmailNotificationGroupProps>) {
+  const { configuration, onChange } = props;
+
+  return (
+    <div className="sw-pt-6">
+      <EmailNotificationFormField
+        description={translate('email_notification.form.host.description')}
+        id={HOST}
+        onChange={(value) => onChange({ host: value })}
+        name={translate('email_notification.form.host')}
+        required
+        value={configuration.host}
+      />
+      <BasicSeparator />
+      <EmailNotificationFormField
+        description={translate('email_notification.form.port.description')}
+        id={PORT}
+        onChange={(value) => onChange({ port: value })}
+        name={translate('email_notification.form.port')}
+        required
+        type="number"
+        value={configuration.port}
+      />
+      <BasicSeparator />
+      <EmailNotificationFormField
+        description={translate('email_notification.form.security_protocol.description')}
+        id={SECURITY_PROTOCOL}
+        onChange={(value) => onChange({ securityProtocol: value })}
+        name={translate('email_notification.form.security_protocol')}
+        options={['NONE', 'STARTTLS', 'SSLTLS']}
+        required
+        type="select"
+        value={configuration.securityProtocol}
+      />
+      <BasicSeparator />
+    </div>
+  );
+}
index b4bd95e965d6f30de8c8a35b18e8ed7da9b8526c..2895abd0f8f7b41c365b2419f65e998cffce84f3 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 { SubTitle } from 'design-system';
+import { Spinner } from '@sonarsource/echoes-react';
+import { SubTitle } from 'design-system/lib';
 import React from 'react';
 import { FormattedMessage } from 'react-intl';
-import { AdditionalCategoryComponentProps } from '../AdditionalCategories';
-import CategoryDefinitionsList from '../CategoryDefinitionsList';
-import EmailForm from './EmailForm';
+import { useGetEmailConfiguration } from '../../../../queries/system';
+import EmailNotificationConfiguration from './EmailNotificationConfiguration';
 
-export default function EmailNotification(props: Readonly<AdditionalCategoryComponentProps>) {
-  const { component, definitions } = props;
+export default function EmailNotification() {
+  const { data: configuration, isLoading } = useGetEmailConfiguration();
 
   return (
-    <div>
+    <div className="sw-p-6">
       <SubTitle as="h3">
-        <FormattedMessage id="settings.email_notification.header" />
+        <FormattedMessage id="email_notification.header" />
       </SubTitle>
-      <CategoryDefinitionsList
-        category="general"
-        component={component}
-        definitions={definitions}
-        displaySubCategoryTitle={false}
-        noPadding
-        subCategory="email"
-      />
-      <EmailForm />
+      <FormattedMessage id="email_notification.description" />
+      <Spinner isLoading={isLoading}>
+        <EmailNotificationConfiguration emailConfiguration={configuration ?? null} />
+      </Spinner>
     </div>
   );
 }
diff --git a/server/sonar-web/src/main/js/apps/settings/components/email-notification/EmailNotificationConfiguration.tsx b/server/sonar-web/src/main/js/apps/settings/components/email-notification/EmailNotificationConfiguration.tsx
new file mode 100644 (file)
index 0000000..48f98ff
--- /dev/null
@@ -0,0 +1,147 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { Button, ButtonVariety } from '@sonarsource/echoes-react';
+import { NumberedList, NumberedListItem } from 'design-system/lib';
+import React, { useCallback, useEffect } from 'react';
+import { translate } from '../../../../helpers/l10n';
+import {
+  useSaveEmailConfigurationMutation,
+  useUpdateEmailConfigurationMutation,
+} from '../../../../queries/system';
+import { AuthMethod, EmailConfiguration } from '../../../../types/system';
+import { AuthenticationSelector } from './AuthenticationSelector';
+import { CommonSMTP } from './CommonSMTP';
+import { SenderInformation } from './SenderInformation';
+import { checkEmailConfigurationValidity } from './utils';
+
+interface Props {
+  emailConfiguration: EmailConfiguration | null;
+}
+
+const FORM_ID = 'email-notifications';
+const EMAIL_CONFIGURATION_DEFAULT: EmailConfiguration = {
+  authMethod: AuthMethod.Basic,
+  basicPassword: '',
+  fromAddress: '',
+  fromName: 'SonarQube',
+  host: '',
+  port: '587',
+  securityProtocol: '',
+  subjectPrefix: '[SonarQube]',
+  username: '',
+};
+
+export default function EmailNotificationConfiguration(props: Readonly<Props>) {
+  const { emailConfiguration } = props;
+  const [canSave, setCanSave] = React.useState(false);
+
+  const [newConfiguration, setNewConfiguration] = React.useState<EmailConfiguration>(
+    EMAIL_CONFIGURATION_DEFAULT,
+  );
+
+  const { mutateAsync: saveEmailConfiguration } = useSaveEmailConfigurationMutation();
+  const { mutateAsync: updateEmailConfiguration } = useUpdateEmailConfigurationMutation();
+
+  const onChange = useCallback(
+    (newValue: Partial<EmailConfiguration>) => {
+      const newConfig = {
+        ...newConfiguration,
+        ...newValue,
+      };
+      setCanSave(checkEmailConfigurationValidity(newConfig as EmailConfiguration));
+      setNewConfiguration(newConfig as EmailConfiguration);
+    },
+    [newConfiguration, setNewConfiguration],
+  );
+
+  const onSubmit = useCallback(
+    (event: React.SyntheticEvent<HTMLFormElement>) => {
+      event.preventDefault();
+      if (canSave && newConfiguration) {
+        const authConfiguration =
+          newConfiguration.authMethod === AuthMethod.OAuth
+            ? {
+                oauthAuthenticationHost: newConfiguration.oauthAuthenticationHost,
+                oauthClientId: newConfiguration.oauthClientId,
+                oauthClientSecret: newConfiguration.oauthClientSecret,
+                oauthTenant: newConfiguration.oauthTenant,
+              }
+            : {
+                basicPassword: newConfiguration.basicPassword,
+              };
+
+        const newEmailConfiguration = {
+          ...newConfiguration,
+          ...authConfiguration,
+        };
+
+        if (newConfiguration?.id === undefined) {
+          saveEmailConfiguration(newEmailConfiguration);
+        } else {
+          newEmailConfiguration.id = newConfiguration.id;
+          updateEmailConfiguration({
+            emailConfiguration: newEmailConfiguration,
+            id: newEmailConfiguration.id,
+          });
+        }
+      }
+    },
+    [canSave, newConfiguration, saveEmailConfiguration, updateEmailConfiguration],
+  );
+
+  useEffect(() => {
+    if (emailConfiguration !== null) {
+      setNewConfiguration(emailConfiguration);
+    }
+  }, [emailConfiguration]);
+
+  return (
+    <form id={FORM_ID} onSubmit={onSubmit}>
+      <NumberedList>
+        <NumberedListItem className="sw-pt-6">
+          <span className="sw-body-sm-highlight">
+            {translate('email_notification.subheading.1')}
+          </span>
+          <AuthenticationSelector configuration={newConfiguration} onChange={onChange} />
+        </NumberedListItem>
+        <NumberedListItem className="sw-pt-6">
+          <span className="sw-body-sm-highlight">
+            {translate('email_notification.subheading.2')}
+          </span>
+          <CommonSMTP configuration={newConfiguration} onChange={onChange} />
+        </NumberedListItem>
+        <NumberedListItem className="sw-pt-6">
+          <span className="sw-body-sm-highlight">
+            {translate('email_notification.subheading.3')}
+          </span>
+          <SenderInformation configuration={newConfiguration} onChange={onChange} />
+        </NumberedListItem>
+      </NumberedList>
+      <Button
+        className="sw-ml-4"
+        isDisabled={!canSave}
+        type="submit"
+        variety={ButtonVariety.Primary}
+      >
+        {translate('email_notification.form.save_configuration')}
+      </Button>
+    </form>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/settings/components/email-notification/EmailNotificationFormField.tsx b/server/sonar-web/src/main/js/apps/settings/components/email-notification/EmailNotificationFormField.tsx
new file mode 100644 (file)
index 0000000..d881be0
--- /dev/null
@@ -0,0 +1,163 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { InputSize, Select } from '@sonarsource/echoes-react';
+import { FormField, InputField, TextError } from 'design-system/lib';
+import { isEmpty, isUndefined } from 'lodash';
+import React from 'react';
+import isEmail from 'validator/lib/isEmail';
+import { translate, translateWithParameters } from '../../../../helpers/l10n';
+
+type InputType = 'email' | 'number' | 'password' | 'select' | 'text';
+
+interface Props {
+  children?: (props: { onChange: (value: string) => void }) => React.ReactNode;
+  description: string;
+  id: string;
+  name: string;
+  onChange: (value: string) => void;
+  options?: string[];
+  required?: boolean;
+  type?: InputType;
+  value: string | undefined;
+}
+
+export function EmailNotificationFormField(props: Readonly<Props>) {
+  const { description, id, name, options, required, type = 'text', value } = props;
+
+  const [validationMessage, setValidationMessage] = React.useState<string>();
+
+  const handleCheck = (changedValue?: string) => {
+    if (isEmpty(changedValue) && required) {
+      setValidationMessage(translate('settings.state.value_cant_be_empty_no_default'));
+      return false;
+    }
+
+    if (type === 'email' && !isEmail(changedValue ?? '')) {
+      setValidationMessage(translate('email_notification.state.value_should_be_valid_email'));
+      return false;
+    }
+
+    setValidationMessage(undefined);
+    return true;
+  };
+
+  const onChange = (newValue: string) => {
+    handleCheck(newValue);
+    props.onChange(newValue);
+  };
+
+  const hasValidationMessage = !isUndefined(validationMessage);
+
+  return (
+    <FormField
+      className="sw-grid sw-grid-cols-2 sw-gap-x-4 sw-py-6 sw-px-4"
+      htmlFor={id}
+      label={translate(name)}
+      required={required}
+      requiredAriaLabel={translate('field_required')}
+    >
+      <div className="sw-row-span-2 sw-grid">
+        {type === 'select' ? (
+          <SelectInput
+            id={id}
+            name={name}
+            options={options ?? []}
+            onChange={onChange}
+            required={required}
+            value={value}
+          />
+        ) : (
+          <BasicInput
+            id={id}
+            name={name}
+            type={type}
+            onChange={onChange}
+            required={required}
+            value={value}
+          />
+        )}
+
+        {hasValidationMessage && (
+          <TextError
+            className="sw-mt-2"
+            text={translateWithParameters('settings.state.validation_failed', validationMessage)}
+          />
+        )}
+      </div>
+
+      <div className="sw-w-abs-300">
+        {!isUndefined(description) && <div className="markdown sw-mt-1">{description}</div>}
+      </div>
+    </FormField>
+  );
+}
+
+function BasicInput(
+  props: Readonly<{
+    id: string;
+    name: string;
+    onChange: (value: string) => void;
+    required?: boolean;
+    type: InputType;
+    value: string | undefined;
+  }>,
+) {
+  const { id, onChange, name, required, type, value } = props;
+
+  return (
+    <InputField
+      id={id}
+      min={type === 'number' ? 0 : undefined}
+      name={name}
+      onChange={(event) => onChange(event.target.value)}
+      required={required}
+      size="large"
+      step={type === 'number' ? 1 : undefined}
+      type={type}
+      value={value ?? ''}
+    />
+  );
+}
+
+function SelectInput(
+  props: Readonly<{
+    id: string;
+    name: string;
+    onChange: (value: string) => void;
+    options: string[];
+    required?: boolean;
+    value: string | undefined;
+  }>,
+) {
+  const { id, name, onChange, options, required, value } = props;
+
+  return (
+    <Select
+      data={options?.map((option) => ({ label: option, value: option })) ?? []}
+      id={id}
+      isNotClearable
+      isRequired={required}
+      name={name}
+      onChange={onChange}
+      size={InputSize.Large}
+      value={value}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/settings/components/email-notification/SenderInformation.tsx b/server/sonar-web/src/main/js/apps/settings/components/email-notification/SenderInformation.tsx
new file mode 100644 (file)
index 0000000..3d629d3
--- /dev/null
@@ -0,0 +1,60 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { BasicSeparator } from 'design-system/lib';
+import React from 'react';
+import { translate } from '../../../../helpers/l10n';
+import { EmailNotificationFormField } from './EmailNotificationFormField';
+import { EmailNotificationGroupProps, FROM_ADDRESS, FROM_NAME, SUBJECT_PREFIX } from './utils';
+
+export function SenderInformation(props: Readonly<EmailNotificationGroupProps>) {
+  const { configuration, onChange } = props;
+
+  return (
+    <div className="sw-pt-6">
+      <EmailNotificationFormField
+        description={translate('email_notification.form.from_address.description')}
+        id={FROM_ADDRESS}
+        name={translate('email_notification.form.from_address')}
+        onChange={(value) => onChange({ fromAddress: value })}
+        required
+        type="email"
+        value={configuration.fromAddress}
+      />
+      <BasicSeparator />
+      <EmailNotificationFormField
+        description={translate('email_notification.form.from_name.description')}
+        id={FROM_NAME}
+        onChange={(value) => onChange({ fromName: value })}
+        name={translate('email_notification.form.from_name')}
+        required
+        value={configuration.fromName}
+      />
+      <BasicSeparator />
+      <EmailNotificationFormField
+        description={translate('email_notification.form.subject_prefix.description')}
+        id={SUBJECT_PREFIX}
+        onChange={(value) => onChange({ subjectPrefix: value })}
+        name={translate('email_notification.form.subject_prefix')}
+        required
+        value={configuration.subjectPrefix}
+      />
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/settings/components/email-notification/__tests__/EmailNotification-it.tsx b/server/sonar-web/src/main/js/apps/settings/components/email-notification/__tests__/EmailNotification-it.tsx
new file mode 100644 (file)
index 0000000..28f49b6
--- /dev/null
@@ -0,0 +1,334 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { addGlobalSuccessMessage } from 'design-system/lib';
+import React from 'react';
+import { byLabelText, byRole, byText } from '~sonar-aligned/helpers/testSelector';
+import SystemServiceMock from '../../../../../api/mocks/SystemServiceMock';
+import * as api from '../../../../../api/system';
+import { mockEmailConfiguration } from '../../../../../helpers/mocks/system';
+import { renderComponent } from '../../../../../helpers/testReactTestingUtils';
+import { AuthMethod } from '../../../../../types/system';
+import EmailNotification from '../EmailNotification';
+
+jest.mock('../../../../../api/system');
+jest.mock('../../../../../api/settings');
+
+jest.mock('design-system', () => ({
+  ...jest.requireActual('design-system'),
+  addGlobalSuccessMessage: jest.fn(),
+}));
+
+const systemHandler = new SystemServiceMock();
+
+beforeEach(() => {
+  jest.clearAllMocks();
+  systemHandler.reset();
+});
+
+const ui = {
+  editSubheading1: byText('email_notification.subheading.1'),
+
+  // common fields
+  selectorBasicAuth: byRole('radio', {
+    name: 'email_notification.form.basic_auth.title email_notification.form.basic_auth.description',
+  }),
+  selectorOAuthAuth: byRole('radio', {
+    name: 'email_notification.form.oauth_auth.title email_notification.form.oauth_auth.description recommended email_notification.form.oauth_auth.recommended_reason',
+  }),
+  host: byRole('textbox', {
+    name: 'email_notification.form.host field_required',
+  }),
+  port: byRole('spinbutton', {
+    name: 'email_notification.form.port field_required',
+  }),
+  securityProtocol: byRole('searchbox', {
+    name: 'email_notification.form.security_protocol field_required',
+  }),
+  fromAddress: byRole('textbox', {
+    name: 'email_notification.form.from_address field_required',
+  }),
+  fromName: byRole('textbox', {
+    name: 'email_notification.form.from_name field_required',
+  }),
+  subjectPrefix: byRole('textbox', {
+    name: 'email_notification.form.subject_prefix field_required',
+  }),
+  username: byRole('textbox', {
+    name: 'email_notification.form.username field_required',
+  }),
+
+  // basic authentication
+  basic_password: byLabelText('email_notification.form.basic_password*'),
+
+  // oauth
+  oauth_auth_host: byRole('textbox', {
+    name: 'email_notification.form.oauth_authentication_host field_required',
+  }),
+  oauth_client_id: byLabelText('email_notification.form.oauth_client_id*'),
+  oauth_client_secret: byLabelText('email_notification.form.oauth_client_secret*'),
+  oauth_tenant: byRole('textbox', { name: 'email_notification.form.oauth_tenant field_required' }),
+
+  save: byRole('button', {
+    name: 'email_notification.form.save_configuration',
+  }),
+};
+
+describe('Email Basic Configuration', () => {
+  it('can save the basic configuration', async () => {
+    jest.spyOn(api, 'postEmailConfiguration');
+    const user = userEvent.setup();
+    renderEmailNotifications();
+    expect(await ui.editSubheading1.find()).toBeInTheDocument();
+
+    expect(ui.save.get()).toBeDisabled();
+
+    expect(ui.selectorBasicAuth.get()).toBeChecked();
+    expect(ui.username.get()).toHaveValue('');
+    expect(ui.basic_password.get()).toHaveValue('');
+    expect(ui.host.get()).toHaveValue('');
+    expect(ui.port.get()).toHaveValue(587);
+    expect(ui.securityProtocol.get()).toHaveValue('');
+    expect(ui.fromAddress.get()).toHaveValue('');
+    expect(ui.fromName.get()).toHaveValue('SonarQube');
+    expect(ui.subjectPrefix.get()).toHaveValue('[SonarQube]');
+
+    await user.type(ui.basic_password.get(), 'password');
+    await user.type(ui.host.get(), 'host');
+    await user.clear(ui.port.get());
+    await user.type(ui.port.get(), '1234');
+    await user.click(ui.securityProtocol.get());
+    await user.click(screen.getByText('SSLTLS'));
+    await user.type(ui.fromAddress.get(), 'admin@localhost.com');
+    await user.clear(ui.fromName.get());
+    await user.type(ui.fromName.get(), 'fromName');
+    await user.clear(ui.subjectPrefix.get());
+    await user.type(ui.subjectPrefix.get(), 'prefix');
+    await user.type(ui.username.get(), 'username');
+
+    expect(ui.selectorBasicAuth.get()).toBeChecked();
+    expect(ui.username.get()).toHaveValue('username');
+    expect(ui.basic_password.get()).toHaveValue('password');
+    expect(ui.host.get()).toHaveValue('host');
+    expect(ui.port.get()).toHaveValue(1234);
+    expect(ui.securityProtocol.get()).toHaveValue('SSLTLS');
+    expect(ui.fromAddress.get()).toHaveValue('admin@localhost.com');
+    expect(ui.fromName.get()).toHaveValue('fromName');
+    expect(ui.subjectPrefix.get()).toHaveValue('prefix');
+
+    expect(await ui.save.find()).toBeEnabled();
+    await user.click(ui.save.get());
+
+    expect(api.postEmailConfiguration).toHaveBeenCalledTimes(1);
+    expect(api.postEmailConfiguration).toHaveBeenCalledWith({
+      authMethod: 'BASIC',
+      basicPassword: 'password',
+      fromAddress: 'admin@localhost.com',
+      fromName: 'fromName',
+      host: 'host',
+      port: '1234',
+      securityProtocol: 'SSLTLS',
+      subjectPrefix: 'prefix',
+      username: 'username',
+    });
+
+    expect(addGlobalSuccessMessage).toHaveBeenCalledWith(
+      'email_notification.form.save_configuration.create_success',
+    );
+  });
+
+  it('can edit an existing configuration', async () => {
+    systemHandler.addEmailConfiguration(
+      mockEmailConfiguration(AuthMethod.Basic, { id: 'email-1' }),
+    );
+    jest.spyOn(api, 'patchEmailConfiguration');
+    const user = userEvent.setup();
+    renderEmailNotifications();
+    expect(await ui.editSubheading1.find()).toBeInTheDocument();
+
+    expect(ui.save.get()).toBeDisabled();
+    await user.type(ui.basic_password.get(), 'updated');
+    await user.type(ui.host.get(), '-updated');
+    await user.type(ui.port.get(), '5678');
+    await user.click(ui.securityProtocol.get());
+    await user.click(screen.getByText('STARTTLS'));
+    await user.clear(ui.fromAddress.get());
+    await user.type(ui.fromAddress.get(), 'updated@email.com');
+    await user.type(ui.fromName.get(), '-updated');
+    await user.type(ui.subjectPrefix.get(), '-updated');
+    await user.type(ui.username.get(), '-updated');
+
+    expect(await ui.save.find()).toBeEnabled();
+    await user.click(ui.save.get());
+
+    expect(api.patchEmailConfiguration).toHaveBeenCalledTimes(1);
+    expect(api.patchEmailConfiguration).toHaveBeenCalledWith('email-1', {
+      authMethod: 'BASIC',
+      basicPassword: 'updated',
+      fromAddress: 'updated@email.com',
+      fromName: 'from_name-updated',
+      host: 'host-updated',
+      id: 'email-1',
+      isBasicPasswordSet: true,
+      port: '5678',
+      securityProtocol: 'STARTTLS',
+      subjectPrefix: 'subject_prefix-updated',
+      username: 'username-updated',
+    });
+
+    expect(addGlobalSuccessMessage).toHaveBeenCalledWith(
+      'email_notification.form.save_configuration.update_success',
+    );
+  });
+});
+
+describe('Email Oauth Configuration', () => {
+  it('can save the oauth configuration', async () => {
+    jest.spyOn(api, 'postEmailConfiguration');
+    const user = userEvent.setup();
+    renderEmailNotifications();
+    expect(await ui.editSubheading1.find()).toBeInTheDocument();
+    await user.click(ui.selectorOAuthAuth.get());
+
+    expect(ui.save.get()).toBeDisabled();
+
+    expect(ui.selectorOAuthAuth.get()).toBeChecked();
+    expect(ui.oauth_auth_host.get()).toHaveValue('');
+    expect(ui.oauth_client_id.get()).toHaveValue('');
+    expect(ui.oauth_client_secret.get()).toHaveValue('');
+    expect(ui.oauth_tenant.get()).toHaveValue('');
+    expect(ui.host.get()).toHaveValue('');
+    expect(ui.port.get()).toHaveValue(587);
+    expect(ui.securityProtocol.get()).toHaveValue('');
+    expect(ui.fromAddress.get()).toHaveValue('');
+    expect(ui.fromName.get()).toHaveValue('SonarQube');
+    expect(ui.subjectPrefix.get()).toHaveValue('[SonarQube]');
+
+    await user.type(ui.oauth_auth_host.get(), 'oauth_auth_host');
+    await user.type(ui.oauth_client_id.get(), 'oauth_client_id');
+    await user.type(ui.oauth_client_secret.get(), 'oauth_client_secret');
+    await user.type(ui.oauth_tenant.get(), 'oauth_tenant');
+    await user.type(ui.host.get(), 'host');
+    await user.clear(ui.port.get());
+    await user.type(ui.port.get(), '1234');
+    await user.click(ui.securityProtocol.get());
+    await user.click(screen.getByText('SSLTLS'));
+    await user.type(ui.fromAddress.get(), 'admin@localhost.com');
+    await user.clear(ui.fromName.get());
+    await user.type(ui.fromName.get(), 'fromName');
+    await user.clear(ui.subjectPrefix.get());
+    await user.type(ui.subjectPrefix.get(), 'prefix');
+    await user.type(ui.username.get(), 'username');
+
+    expect(ui.selectorOAuthAuth.get()).toBeChecked();
+    expect(ui.username.get()).toHaveValue('username');
+    expect(ui.oauth_auth_host.get()).toHaveValue('oauth_auth_host');
+    expect(ui.oauth_client_id.get()).toHaveValue('oauth_client_id');
+    expect(ui.oauth_client_secret.get()).toHaveValue('oauth_client_secret');
+    expect(ui.oauth_tenant.get()).toHaveValue('oauth_tenant');
+    expect(ui.host.get()).toHaveValue('host');
+    expect(ui.port.get()).toHaveValue(1234);
+    expect(ui.securityProtocol.get()).toHaveValue('SSLTLS');
+    expect(ui.fromAddress.get()).toHaveValue('admin@localhost.com');
+    expect(ui.fromName.get()).toHaveValue('fromName');
+    expect(ui.subjectPrefix.get()).toHaveValue('prefix');
+
+    expect(await ui.save.find()).toBeEnabled();
+    await user.click(ui.save.get());
+
+    expect(api.postEmailConfiguration).toHaveBeenCalledTimes(1);
+    expect(api.postEmailConfiguration).toHaveBeenCalledWith({
+      authMethod: 'OAUTH',
+      basicPassword: '',
+      oauthAuthenticationHost: 'oauth_auth_host',
+      oauthClientId: 'oauth_client_id',
+      oauthClientSecret: 'oauth_client_secret',
+      oauthTenant: 'oauth_tenant',
+      fromAddress: 'admin@localhost.com',
+      fromName: 'fromName',
+      host: 'host',
+      port: '1234',
+      securityProtocol: 'SSLTLS',
+      subjectPrefix: 'prefix',
+      username: 'username',
+    });
+
+    expect(addGlobalSuccessMessage).toHaveBeenCalledWith(
+      'email_notification.form.save_configuration.create_success',
+    );
+  });
+
+  it('can edit the oauth configuration', async () => {
+    systemHandler.addEmailConfiguration(
+      mockEmailConfiguration(AuthMethod.OAuth, { id: 'email-1' }),
+    );
+    jest.spyOn(api, 'patchEmailConfiguration');
+    const user = userEvent.setup();
+    renderEmailNotifications();
+    expect(await ui.editSubheading1.find()).toBeInTheDocument();
+    await user.click(ui.selectorOAuthAuth.get());
+
+    expect(ui.save.get()).toBeDisabled();
+    await user.type(ui.oauth_auth_host.get(), '-updated');
+    await user.type(ui.oauth_client_id.get(), 'updated_id');
+    await user.type(ui.oauth_client_secret.get(), 'updated_secret');
+    await user.type(ui.oauth_tenant.get(), '-updated');
+    await user.type(ui.host.get(), '-updated');
+    await user.type(ui.port.get(), '5678');
+    await user.click(ui.securityProtocol.get());
+    await user.click(screen.getByText('STARTTLS'));
+    await user.clear(ui.fromAddress.get());
+    await user.type(ui.fromAddress.get(), 'updated@email.com');
+    await user.type(ui.fromName.get(), '-updated');
+    await user.type(ui.subjectPrefix.get(), '-updated');
+    await user.type(ui.username.get(), '-updated');
+
+    expect(await ui.save.find()).toBeEnabled();
+    await user.click(ui.save.get());
+
+    expect(api.patchEmailConfiguration).toHaveBeenCalledTimes(1);
+    expect(api.patchEmailConfiguration).toHaveBeenCalledWith('email-1', {
+      authMethod: 'OAUTH',
+      oauthAuthenticationHost: 'oauth_auth_host-updated',
+      oauthClientId: 'updated_id',
+      oauthClientSecret: 'updated_secret',
+      oauthTenant: 'oauth_tenant-updated',
+      fromAddress: 'updated@email.com',
+      fromName: 'from_name-updated',
+      host: 'host-updated',
+      id: 'email-1',
+      isOauthClientIdSet: true,
+      isOauthClientSecretSet: true,
+      port: '5678',
+      securityProtocol: 'STARTTLS',
+      subjectPrefix: 'subject_prefix-updated',
+      username: 'username-updated',
+    });
+
+    expect(addGlobalSuccessMessage).toHaveBeenCalledWith(
+      'email_notification.form.save_configuration.update_success',
+    );
+  });
+});
+
+function renderEmailNotifications() {
+  return renderComponent(<EmailNotification />);
+}
diff --git a/server/sonar-web/src/main/js/apps/settings/components/email-notification/utils.ts b/server/sonar-web/src/main/js/apps/settings/components/email-notification/utils.ts
new file mode 100644 (file)
index 0000000..4db59fa
--- /dev/null
@@ -0,0 +1,83 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { isUndefined } from 'lodash';
+import { AuthMethod, EmailConfiguration } from '../../../../types/system';
+
+export const AUTH_METHOD = 'auth-method';
+
+// Basic Authentication
+export const BASIC_PASSWORD = 'basic-password';
+export const IS_BASIC_PASSWORD_SET = 'is-basic-password-set';
+
+// OAuth Authentication
+export const IS_OAUTH_CLIENT_ID_SET = 'is-oauth-client-id-set';
+export const IS_OAUTH_CLIENT_SECRET_SET = 'is-oauth-client-secret-set';
+export const OAUTH_AUTHENTICATION_HOST = 'oauth-authentication-host';
+export const OAUTH_CLIENT_ID = 'oauth-client-id';
+export const OAUTH_CLIENT_SECRET = 'oauth-client-secret';
+export const OAUTH_TENANT = 'oauth-tenant';
+
+// Common settings
+export const USERNAME = 'username';
+export const HOST = 'host';
+export const PORT = 'port';
+export const SECURITY_PROTOCOL = 'security-protocol';
+export const FROM_ADDRESS = 'from-address';
+export const FROM_NAME = 'from-mame';
+export const SUBJECT_PREFIX = 'subject-prefix';
+
+export interface EmailNotificationGroupProps {
+  configuration: EmailConfiguration;
+  onChange: (newValue: Partial<EmailConfiguration>) => void;
+}
+
+export function checkEmailConfigurationValidity(configuration: EmailConfiguration): boolean {
+  let isValid = false;
+  const commonProps: (keyof EmailConfiguration)[] = [
+    'authMethod',
+    'username',
+    'host',
+    'port',
+    'securityProtocol',
+    'fromAddress',
+    'fromName',
+    'subjectPrefix',
+  ];
+
+  if (configuration.authMethod === AuthMethod.Basic) {
+    isValid = checkRequiredPropsAreValid(configuration, [...commonProps, 'basicPassword']);
+  } else {
+    isValid = checkRequiredPropsAreValid(configuration, [
+      ...commonProps,
+      'oauthAuthenticationHost',
+      'oauthClientId',
+      'oauthClientSecret',
+      'oauthTenant',
+    ]);
+  }
+
+  return isValid;
+}
+
+function checkRequiredPropsAreValid<T>(obj: T, props: (keyof T)[]): boolean {
+  return props.every(
+    (prop) => !isUndefined(obj[prop]) && typeof obj[prop] === 'string' && obj[prop].length > 0,
+  );
+}
diff --git a/server/sonar-web/src/main/js/helpers/mocks/system.ts b/server/sonar-web/src/main/js/helpers/mocks/system.ts
new file mode 100644 (file)
index 0000000..df2ee9a
--- /dev/null
@@ -0,0 +1,58 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { AuthMethod, EmailConfiguration } from '../../types/system';
+
+export function mockEmailConfiguration(
+  authMethod: AuthMethod,
+  overrides: Partial<EmailConfiguration> = {},
+): EmailConfiguration {
+  const base: Partial<EmailConfiguration> = {
+    fromAddress: 'from_address',
+    fromName: 'from_name',
+    host: 'host',
+    id: '1',
+    port: 'port',
+    subjectPrefix: 'subject_prefix',
+    securityProtocol: 'SSLTLS',
+    username: 'username',
+  };
+
+  const mock =
+    authMethod === AuthMethod.Basic
+      ? {
+          ...base,
+          authMethod: AuthMethod.Basic,
+          basicPassword: undefined,
+          isBasicPasswordSet: true,
+        }
+      : {
+          ...base,
+          authMethod: AuthMethod.OAuth,
+          isOauthClientIdSet: true,
+          isOauthClientSecretSet: true,
+          oauthAuthenticationHost: 'oauth_auth_host',
+          oauthClientId: undefined,
+          oauthClientSecret: undefined,
+          oauthTenant: 'oauth_tenant',
+        };
+
+  return { ...mock, ...overrides } as EmailConfiguration;
+}
index d5de4ccdf549debd1224675620fe8493897fc992..797d4e5c39d6b172a162fa789832ec3eabe8de87 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 { queryOptions } from '@tanstack/react-query';
-import { getSystemUpgrades } from '../api/system';
+import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { addGlobalSuccessMessage } from 'design-system/lib';
+import {
+  getEmailConfigurations,
+  getSystemUpgrades,
+  patchEmailConfiguration,
+  postEmailConfiguration,
+} from '../api/system';
+import { translate } from '../helpers/l10n';
+import { EmailConfiguration } from '../types/system';
 import { createQueryHook } from './common';
 
 export const useSystemUpgrades = createQueryHook(() => {
@@ -28,3 +36,49 @@ export const useSystemUpgrades = createQueryHook(() => {
     staleTime: Infinity,
   });
 });
+
+export function useGetEmailConfiguration() {
+  return useQuery({
+    queryKey: ['email_configuration'] as const,
+    queryFn: async () => {
+      const { emailConfigurations } = await getEmailConfigurations();
+      return emailConfigurations && emailConfigurations.length > 0 ? emailConfigurations[0] : null;
+    },
+  });
+}
+
+export function useSaveEmailConfigurationMutation() {
+  const queryClient = useQueryClient();
+  return useMutation({
+    mutationFn: (data: EmailConfiguration) => {
+      return postEmailConfiguration(data);
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['email_configuration'] });
+      addGlobalSuccessMessage(
+        translate('email_notification.form.save_configuration.create_success'),
+      );
+    },
+  });
+}
+
+export function useUpdateEmailConfigurationMutation() {
+  const queryClient = useQueryClient();
+  return useMutation({
+    mutationFn: ({
+      emailConfiguration,
+      id,
+    }: {
+      emailConfiguration: EmailConfiguration;
+      id: string;
+    }) => {
+      return patchEmailConfiguration(id, emailConfiguration);
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['email_configuration'] });
+      addGlobalSuccessMessage(
+        translate('email_notification.form.save_configuration.update_success'),
+      );
+    },
+  });
+}
index 12ec0ea7bc8e402e2dd118c4190a8813470ab670..137d8fa3a837b49ac271806f9b468551dc73985f 100644 (file)
@@ -53,3 +53,37 @@ export interface MigrationsStatusResponse {
   status: MigrationStatus;
   totalSteps?: number;
 }
+
+export enum AuthMethod {
+  Basic = 'BASIC',
+  OAuth = 'OAUTH',
+}
+
+export type EmailConfiguration = (
+  | {
+      authMethod: AuthMethod.Basic;
+      basicPassword: string;
+      readonly isBasicPasswordSet?: boolean;
+    }
+  | {
+      authMethod: AuthMethod.OAuth;
+      readonly isOauthClientIdSet?: boolean;
+      readonly isOauthClientSecretSet?: boolean;
+      oauthAuthenticationHost: string;
+      oauthClientId: string;
+      oauthClientSecret: string;
+      oauthTenant: string;
+    }
+) &
+  EmailConfigurationCommon;
+
+interface EmailConfigurationCommon {
+  fromAddress: string;
+  fromName: string;
+  host: string;
+  id?: string;
+  port: string;
+  securityProtocol: string;
+  subjectPrefix: string;
+  username: string;
+}
index 9cf64a7db50aa7e0fe9b7306b94fc0c60bf4a635..ca0baf45222cd801eab63fce33baa8195f0c56b8 100644 (file)
@@ -5970,6 +5970,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@types/validator@npm:13.12.0":
+  version: 13.12.0
+  resolution: "@types/validator@npm:13.12.0"
+  checksum: 10/b3344ef630ff9a3ffab4ce10da268e7be98ca2df9cbd956fb5cac860bd661c7ff6e82e0cdc7b253f037a98cf3b233fff3d04d28330bcd3ca2cafb0c52253976e
+  languageName: node
+  linkType: hard
+
 "@types/yargs-parser@npm:*":
   version: 15.0.0
   resolution: "@types/yargs-parser@npm:15.0.0"
@@ -6465,6 +6472,7 @@ __metadata:
     "@types/react-modal": "npm:3.16.3"
     "@types/react-virtualized": "npm:9.21.30"
     "@types/valid-url": "npm:1.0.7"
+    "@types/validator": "npm:13.12.0"
     "@typescript-eslint/eslint-plugin": "npm:6.21.0"
     "@typescript-eslint/parser": "npm:6.21.0"
     "@typescript-eslint/rule-tester": "npm:6.21.0"
@@ -6539,6 +6547,7 @@ __metadata:
     turbo: "npm:1.11.3"
     typescript: "npm:5.5.3"
     valid-url: "npm:1.0.9"
+    validator: "npm:13.12.0"
     whatwg-fetch: "npm:3.6.20"
   languageName: unknown
   linkType: soft
@@ -17152,6 +17161,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"validator@npm:13.12.0":
+  version: 13.12.0
+  resolution: "validator@npm:13.12.0"
+  checksum: 10/db6eb0725e2b67d60d30073ae8573982713b5903195d031dc3c7db7e82df8b74e8c13baef8e2106d146d979599fd61a06cde1fec5c148e4abd53d52817ff0fd9
+  languageName: node
+  linkType: hard
+
 "validator@npm:^13.7.0":
   version: 13.9.0
   resolution: "validator@npm:13.9.0"
index 780b6ac602d70fadcaf30c8a8f26f8e238198afa..f039cd6df6e2fa6c03e0aac68bf8fa455d426e91 100644 (file)
@@ -2680,9 +2680,50 @@ rule.clean_code_attribute.TRUSTWORTHY.title=This is a responsibility rule, the c
 
 #------------------------------------------------------------------------------
 #
-# EMAIL CONFIGURATION
-#
-#------------------------------------------------------------------------------
+# EMAIL NOTIFICATION CONFIGURATION
+#
+#------------------------------------------------------------------------------
+email_notification.category=Email Notification
+email_notification.header=SMTP Configuration
+email_notification.description=Follow the steps below to configure and test your authentication.
+email_notification.subheading.1=Select your authentication type and complete the fields below
+email_notification.subheading.2=Complete the SMTP fields below
+email_notification.subheading.3=Input the â€˜sender’ information below to identify your email notification
+email_notification.form.basic_auth.title=Basic Authentication
+email_notification.form.basic_auth.description=Authenticate with a username and password
+email_notification.form.username=SMTP username
+email_notification.form.username.description=Username used to authenticate to the SMTP server.
+email_notification.form.basic_password=SMTP password
+email_notification.form.basic_password.description=Password used to authenticate to the SMTP server.
+email_notification.form.oauth_auth.title=Modern Authentication
+email_notification.form.oauth_auth.description=Authenticate with OAuth Microsoft
+email_notification.form.oauth_auth.recommended_reason=for stronger security compliance
+email_notification.form.oauth_authentication_host=Authentication host
+email_notification.form.oauth_authentication_host.description=Host of the Identity Provider issuing access tokens.
+email_notification.form.oauth_client_id=Client ID
+email_notification.form.oauth_client_id.description=Client ID provided by Microsoft Exchange when registering the application.
+email_notification.form.oauth_client_secret=Client Secret
+email_notification.form.oauth_client_secret.description=Client password provided by Microsoft Exchange when registering the application.
+email_notification.form.oauth_tenant=Tenant
+email_notification.form.oauth_tenant.description=Microsoft tenant.
+email_notification.form.host=SMTP host
+email_notification.form.host.description=URL of your SMTP server.
+email_notification.form.port=SMTP port
+email_notification.form.port.description=Port of your SMTP server (usually 25, 587 or 465).
+email_notification.form.security_protocol=Security protocol
+email_notification.form.security_protocol.description=Security protocol used to connect to your SMTP server (SSLTLS is recommended). Valid values: NONE, SSLTLS, STARTTLS.
+email_notification.form.from_address=From address
+email_notification.form.from_address.description=Address emails will come from.
+email_notification.form.from_name=From name
+email_notification.form.from_name.description=Name emails will come from (usually "SonarQube").
+email_notification.form.subject_prefix=Subject prefix
+email_notification.form.subject_prefix.description=Prefix added to email so they can be easily recognized (usually "[SonarQube]").
+email_notification.form.save_configuration=Save configuration
+email_notification.form.save_configuration.create_success=Email configuration saved successfully.
+email_notification.form.save_configuration.update_success=Email configuration updated successfully.
+email_notification.form.delete_configuration=Delete configuration
+email_notification.state.value_should_be_valid_email=A valid email address is required.
+
 email_configuration.test.title=Test Configuration
 email_configuration.test.to_address=To
 email_configuration.test.subject=Subject