]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR 22666 Edit email configuration (#11638)
authorShane Findley <shane.findley@sonarsource.com>
Mon, 2 Sep 2024 13:49:00 +0000 (15:49 +0200)
committersonartech <sonartech@sonarsource.com>
Mon, 2 Sep 2024 20:02:50 +0000 (20:02 +0000)
server/sonar-web/src/main/js/api/mocks/SystemServiceMock.ts
server/sonar-web/src/main/js/apps/settings/components/email-notification/AuthenticationSelector.tsx
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
server/sonar-web/src/main/js/apps/settings/components/email-notification/EmailNotificationFormField.tsx
server/sonar-web/src/main/js/apps/settings/components/email-notification/EmailNotificationOverview.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/email-notification/__tests__/EmailNotification-it.tsx
server/sonar-web/src/main/js/apps/settings/components/email-notification/__tests__/utils-test.ts [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/email-notification/utils.ts
server/sonar-web/src/main/js/types/system.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 23da678af862c2986b7d630994bdbb0909aadcfc..2476e88b6bb60bf9b939af9f0deb4401d1985a02 100644 (file)
@@ -67,6 +67,7 @@ export default class SystemServiceMock {
     jest.mocked(getSystemUpgrades).mockImplementation(this.handleGetSystemUpgrades);
     jest.mocked(getEmailConfigurations).mockImplementation(this.handleGetEmailConfigurations);
     jest.mocked(postEmailConfiguration).mockImplementation(this.handlePostEmailConfiguration);
+    jest.mocked(patchEmailConfiguration).mockImplementation(this.handlePatchEmailConfiguration);
   }
 
   handleGetSystemInfo = () => {
index 05b65957c403b22087dbfa6f72b097adf28960ca..ece46a15cb8000e82a73946663cd3ab37d2b3230 100644 (file)
@@ -57,6 +57,7 @@ export function AuthenticationSelector(props: Readonly<EmailNotificationGroupPro
           title={translate('email_notification.form.oauth_auth.title')}
         >
           <Note>{translate('email_notification.form.oauth_auth.description')}</Note>
+          <Note>{translate('email_notification.form.oauth_auth.supported')}</Note>
         </SelectionCard>
       </div>
       <BasicSeparator />
@@ -77,33 +78,30 @@ export function AuthenticationSelector(props: Readonly<EmailNotificationGroupPro
             onChange={(value) => onChange({ oauthAuthenticationHost: value })}
             name={translate('email_notification.form.oauth_authentication_host')}
             required
-            value={
-              configuration.authMethod === AuthMethod.OAuth
-                ? configuration.oauthAuthenticationHost
-                : ''
-            }
+            value={configuration.oauthAuthenticationHost ?? ''}
           />
           <BasicSeparator />
           <EmailNotificationFormField
             description={translate('email_notification.form.oauth_client_id.description')}
+            hasValue={configuration.isOauthClientIdSet}
             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 : ''}
+            value={configuration.oauthClientId ?? ''}
           />
           <BasicSeparator />
           <EmailNotificationFormField
             description={translate('email_notification.form.oauth_client_secret.description')}
+            hasValue={configuration.isOauthClientSecretSet}
             id={OAUTH_CLIENT_SECRET}
             onChange={(value) => onChange({ oauthClientSecret: value })}
             name={translate('email_notification.form.oauth_client_secret')}
             required
+            requiresRevaluation
             type="password"
-            value={
-              configuration.authMethod === AuthMethod.OAuth ? configuration.oauthClientSecret : ''
-            }
+            value={configuration.oauthClientSecret ?? ''}
           />
           <BasicSeparator />
           <EmailNotificationFormField
@@ -112,18 +110,20 @@ export function AuthenticationSelector(props: Readonly<EmailNotificationGroupPro
             onChange={(value) => onChange({ oauthTenant: value })}
             name={translate('email_notification.form.oauth_tenant')}
             required
-            value={configuration.authMethod === AuthMethod.OAuth ? configuration.oauthTenant : ''}
+            value={configuration.oauthTenant ?? ''}
           />
         </>
       ) : (
         <EmailNotificationFormField
           description={translate('email_notification.form.basic_password.description')}
           id={BASIC_PASSWORD}
+          hasValue={configuration.isBasicPasswordSet}
           onChange={(value) => onChange({ basicPassword: value })}
           name={translate('email_notification.form.basic_password')}
           required
+          requiresRevaluation
           type="password"
-          value={configuration.authMethod === AuthMethod.Basic ? configuration.basicPassword : ''}
+          value={configuration.basicPassword ?? ''}
         />
       )}
       <BasicSeparator />
index 2895abd0f8f7b41c365b2419f65e998cffce84f3..2c43f2532b3771602e7dce8e5f35cb635a92e7f5 100644 (file)
@@ -23,8 +23,10 @@ import React from 'react';
 import { FormattedMessage } from 'react-intl';
 import { useGetEmailConfiguration } from '../../../../queries/system';
 import EmailNotificationConfiguration from './EmailNotificationConfiguration';
+import EmailNotificationOverview from './EmailNotificationOverview';
 
 export default function EmailNotification() {
+  const [isEditing, setIsEditing] = React.useState(false);
   const { data: configuration, isLoading } = useGetEmailConfiguration();
 
   return (
@@ -34,7 +36,18 @@ export default function EmailNotification() {
       </SubTitle>
       <FormattedMessage id="email_notification.description" />
       <Spinner isLoading={isLoading}>
-        <EmailNotificationConfiguration emailConfiguration={configuration ?? null} />
+        {configuration == null || isEditing ? (
+          <EmailNotificationConfiguration
+            emailConfiguration={configuration ?? null}
+            onCancel={() => setIsEditing(false)}
+            onSubmitted={() => setIsEditing(false)}
+          />
+        ) : (
+          <EmailNotificationOverview
+            onEditClicked={() => setIsEditing(true)}
+            emailConfiguration={configuration}
+          />
+        )}
       </Spinner>
     </div>
   );
index 48f98ff70855f92b77baa97d6a128a121b9631b2..929ec4946a9cab47c47b894cc2ab980abe82210e 100644 (file)
@@ -19,6 +19,7 @@
  */
 import { Button, ButtonVariety } from '@sonarsource/echoes-react';
 import { NumberedList, NumberedListItem } from 'design-system/lib';
+import { noop } from 'lodash';
 import React, { useCallback, useEffect } from 'react';
 import { translate } from '../../../../helpers/l10n';
 import {
@@ -29,10 +30,12 @@ import { AuthMethod, EmailConfiguration } from '../../../../types/system';
 import { AuthenticationSelector } from './AuthenticationSelector';
 import { CommonSMTP } from './CommonSMTP';
 import { SenderInformation } from './SenderInformation';
-import { checkEmailConfigurationValidity } from './utils';
+import { checkEmailConfigurationHasChanges } from './utils';
 
 interface Props {
   emailConfiguration: EmailConfiguration | null;
+  onCancel: () => void;
+  onSubmitted: () => void;
 }
 
 const FORM_ID = 'email-notifications';
@@ -49,7 +52,7 @@ const EMAIL_CONFIGURATION_DEFAULT: EmailConfiguration = {
 };
 
 export default function EmailNotificationConfiguration(props: Readonly<Props>) {
-  const { emailConfiguration } = props;
+  const { emailConfiguration, onCancel, onSubmitted } = props;
   const [canSave, setCanSave] = React.useState(false);
 
   const [newConfiguration, setNewConfiguration] = React.useState<EmailConfiguration>(
@@ -59,16 +62,20 @@ export default function EmailNotificationConfiguration(props: Readonly<Props>) {
   const { mutateAsync: saveEmailConfiguration } = useSaveEmailConfigurationMutation();
   const { mutateAsync: updateEmailConfiguration } = useUpdateEmailConfigurationMutation();
 
+  const hasConfiguration = emailConfiguration !== undefined;
+
   const onChange = useCallback(
     (newValue: Partial<EmailConfiguration>) => {
       const newConfig = {
         ...newConfiguration,
         ...newValue,
       };
-      setCanSave(checkEmailConfigurationValidity(newConfig as EmailConfiguration));
+      setCanSave(
+        checkEmailConfigurationHasChanges(newConfig as EmailConfiguration, emailConfiguration),
+      );
       setNewConfiguration(newConfig as EmailConfiguration);
     },
-    [newConfiguration, setNewConfiguration],
+    [emailConfiguration, newConfiguration],
   );
 
   const onSubmit = useCallback(
@@ -92,18 +99,24 @@ export default function EmailNotificationConfiguration(props: Readonly<Props>) {
           ...authConfiguration,
         };
 
-        if (newConfiguration?.id === undefined) {
-          saveEmailConfiguration(newEmailConfiguration);
+        if (emailConfiguration?.id === undefined) {
+          saveEmailConfiguration(newEmailConfiguration).then(() => onSubmitted(), noop);
         } else {
-          newEmailConfiguration.id = newConfiguration.id;
           updateEmailConfiguration({
             emailConfiguration: newEmailConfiguration,
-            id: newEmailConfiguration.id,
-          });
+            id: emailConfiguration.id,
+          }).then(() => onSubmitted(), noop);
         }
       }
     },
-    [canSave, newConfiguration, saveEmailConfiguration, updateEmailConfiguration],
+    [
+      canSave,
+      emailConfiguration,
+      onSubmitted,
+      newConfiguration,
+      saveEmailConfiguration,
+      updateEmailConfiguration,
+    ],
   );
 
   useEffect(() => {
@@ -142,6 +155,11 @@ export default function EmailNotificationConfiguration(props: Readonly<Props>) {
       >
         {translate('email_notification.form.save_configuration')}
       </Button>
+      {hasConfiguration && (
+        <Button className="sw-ml-2" onClick={onCancel} variety={ButtonVariety.Primary}>
+          Cancel
+        </Button>
+      )}
     </form>
   );
 }
index d881be0d2d846716a72a7e841a3612dd7f2de122..e5111eab7021d6b21828d9bc39ab22bcf171972f 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 { InputSize, Select } from '@sonarsource/echoes-react';
+import {
+  ButtonIcon,
+  ButtonVariety,
+  IconDelete,
+  IconEdit,
+  InputSize,
+  Select,
+} from '@sonarsource/echoes-react';
 import { FormField, InputField, TextError } from 'design-system/lib';
 import { isEmpty, isUndefined } from 'lodash';
-import React from 'react';
+import React, { useEffect } from 'react';
 import isEmail from 'validator/lib/isEmail';
 import { translate, translateWithParameters } from '../../../../helpers/l10n';
 
@@ -28,23 +35,35 @@ type InputType = 'email' | 'number' | 'password' | 'select' | 'text';
 
 interface Props {
   children?: (props: { onChange: (value: string) => void }) => React.ReactNode;
-  description: string;
+  description: React.ReactNode;
+  hasValue?: boolean;
   id: string;
   name: string;
-  onChange: (value: string) => void;
+  onChange: (value: string | undefined) => void;
   options?: string[];
   required?: boolean;
+  requiresRevaluation?: boolean;
   type?: InputType;
   value: string | undefined;
 }
 
 export function EmailNotificationFormField(props: Readonly<Props>) {
-  const { description, id, name, options, required, type = 'text', value } = props;
+  const {
+    description,
+    hasValue,
+    id,
+    name,
+    options,
+    required,
+    requiresRevaluation,
+    type = 'text',
+    value,
+  } = props;
 
   const [validationMessage, setValidationMessage] = React.useState<string>();
 
-  const handleCheck = (changedValue?: string) => {
-    if (isEmpty(changedValue) && required) {
+  const handleCheck = (changedValue: string | undefined) => {
+    if (changedValue !== undefined && isEmpty(changedValue) && required) {
       setValidationMessage(translate('settings.state.value_cant_be_empty_no_default'));
       return false;
     }
@@ -58,7 +77,7 @@ export function EmailNotificationFormField(props: Readonly<Props>) {
     return true;
   };
 
-  const onChange = (newValue: string) => {
+  const onChange = (newValue: string | undefined) => {
     handleCheck(newValue);
     props.onChange(newValue);
   };
@@ -74,25 +93,17 @@ export function EmailNotificationFormField(props: Readonly<Props>) {
       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}
-          />
-        )}
+        <EmailInput
+          hasValue={hasValue}
+          id={id}
+          name={name}
+          options={options ?? []}
+          onChange={onChange}
+          required={required}
+          requiresRevaluation={requiresRevaluation}
+          type={type}
+          value={value}
+        />
 
         {hasValidationMessage && (
           <TextError
@@ -109,8 +120,33 @@ export function EmailNotificationFormField(props: Readonly<Props>) {
   );
 }
 
+function EmailInput(
+  props: Readonly<{
+    hasValue: boolean | undefined;
+    id: string;
+    name: string;
+    onChange: (value: string | undefined) => void;
+    options: string[];
+    required?: boolean;
+    requiresRevaluation?: boolean;
+    type: InputType;
+    value: string | undefined;
+  }>,
+) {
+  const { type } = props;
+  switch (type) {
+    case 'password':
+      return <PasswordInput {...props} />;
+    case 'select':
+      return <SelectInput {...props} />;
+    default:
+      return <BasicInput {...props} />;
+  }
+}
+
 function BasicInput(
   props: Readonly<{
+    hasValue: boolean | undefined;
     id: string;
     name: string;
     onChange: (value: string) => void;
@@ -119,7 +155,7 @@ function BasicInput(
     value: string | undefined;
   }>,
 ) {
-  const { id, onChange, name, required, type, value } = props;
+  const { hasValue, id, onChange, name, required, type, value } = props;
 
   return (
     <InputField
@@ -127,6 +163,9 @@ function BasicInput(
       min={type === 'number' ? 0 : undefined}
       name={name}
       onChange={(event) => onChange(event.target.value)}
+      placeholder={
+        type === 'password' && hasValue ? translate('email_notification.form.private') : undefined
+      }
       required={required}
       size="large"
       step={type === 'number' ? 1 : undefined}
@@ -136,6 +175,61 @@ function BasicInput(
   );
 }
 
+function PasswordInput(
+  props: Readonly<{
+    hasValue: boolean | undefined;
+    id: string;
+    name: string;
+    onChange: (value: string | undefined) => void;
+    required?: boolean;
+    requiresRevaluation?: boolean;
+    value: string | undefined;
+  }>,
+) {
+  const { hasValue, id, onChange, name, required, requiresRevaluation, value } = props;
+  const [isEditing, setIsEditing] = React.useState<boolean>(requiresRevaluation === true);
+
+  useEffect(() => {
+    if (!requiresRevaluation) {
+      setIsEditing(!hasValue);
+    }
+  }, [hasValue, requiresRevaluation]);
+
+  return (
+    <div className="sw-flex">
+      <InputField
+        disabled={!isEditing && !requiresRevaluation}
+        id={id}
+        name={name}
+        onChange={(event) => onChange(event.target.value)}
+        required={isEditing && required}
+        size="large"
+        type="password"
+        value={
+          hasValue && !isEditing && !requiresRevaluation
+            ? translate('email_notification.form.private')
+            : value ?? ''
+        }
+      />
+      {!requiresRevaluation && (
+        <ButtonIcon
+          ariaLabel={isEditing ? translate('reset_verb') : translate('edit')}
+          data-testid={`${name}-${isEditing ? 'reset' : 'edit'}`}
+          className="sw-ml-2"
+          Icon={isEditing ? IconDelete : IconEdit}
+          onClick={() => {
+            if (isEditing) {
+              onChange(undefined);
+            }
+            setIsEditing(!isEditing);
+          }}
+          variety={ButtonVariety.Default}
+        />
+      )}
+    </div>
+  );
+}
+
 function SelectInput(
   props: Readonly<{
     id: string;
diff --git a/server/sonar-web/src/main/js/apps/settings/components/email-notification/EmailNotificationOverview.tsx b/server/sonar-web/src/main/js/apps/settings/components/email-notification/EmailNotificationOverview.tsx
new file mode 100644 (file)
index 0000000..0075203
--- /dev/null
@@ -0,0 +1,117 @@
+/*
+ * 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 { BasicSeparator, CodeSnippet } from 'design-system/lib';
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import { translate } from '../../../../helpers/l10n';
+import { AuthMethod, EmailConfiguration } from '../../../../types/system';
+
+interface EmailTestModalProps {
+  emailConfiguration: EmailConfiguration;
+  onEditClicked: () => void;
+}
+
+export default function EmailNotificationOverview(props: Readonly<EmailTestModalProps>) {
+  const { emailConfiguration, onEditClicked } = props;
+
+  return (
+    <>
+      <BasicSeparator className="sw-my-6" />
+      <div className="sw-flex sw-justify-between">
+        <div className="sw-grid sw-gap-4">
+          <span className="sw-body-md-highlight sw-col-span-2">
+            {translate('email_notification.overview.heading')}
+          </span>
+
+          <PublicValue
+            messageKey="email_notification.overview.authentication_type"
+            value={emailConfiguration.authMethod}
+          />
+          <PublicValue
+            messageKey="email_notification.form.username"
+            value={emailConfiguration.username}
+          />
+
+          {emailConfiguration.authMethod === AuthMethod.Basic ? (
+            <PrivateValue messageKey="email_notification.form.basic_password" />
+          ) : (
+            <>
+              <PublicValue
+                messageKey="email_notification.form.oauth_authentication_host"
+                value={emailConfiguration.oauthAuthenticationHost}
+              />
+              <PrivateValue messageKey="email_notification.form.oauth_client_id" />
+              <PrivateValue messageKey="email_notification.form.oauth_client_secret" />
+              <PublicValue
+                messageKey="email_notification.form.oauth_tenant"
+                value={emailConfiguration.oauthTenant}
+              />
+            </>
+          )}
+
+          <PublicValue messageKey="email_notification.form.host" value={emailConfiguration.host} />
+          <PublicValue messageKey="email_notification.form.port" value={emailConfiguration.port} />
+          <PublicValue
+            messageKey="email_notification.form.security_protocol"
+            value={emailConfiguration.securityProtocol}
+          />
+          <PublicValue
+            messageKey="email_notification.form.from_address"
+            value={emailConfiguration.fromAddress}
+          />
+          <PublicValue
+            messageKey="email_notification.form.from_name"
+            value={emailConfiguration.fromName}
+          />
+          <PublicValue
+            messageKey="email_notification.form.subject_prefix"
+            value={emailConfiguration.subjectPrefix}
+          />
+        </div>
+        <Button onClick={onEditClicked} variety={ButtonVariety.DefaultGhost}>
+          {translate('edit')}
+        </Button>
+      </div>
+    </>
+  );
+}
+
+function PublicValue({ messageKey, value }: Readonly<{ messageKey: string; value: string }>) {
+  return (
+    <>
+      <label className="sw-body-sm-highlight">{translate(messageKey)}</label>
+      <div data-testid={`${messageKey}.value`}>
+        <CodeSnippet className="sw-px-1 sw-truncate" isOneLine noCopy snippet={value} />
+      </div>
+    </>
+  );
+}
+
+function PrivateValue({ messageKey }: Readonly<{ messageKey: string }>) {
+  return (
+    <>
+      <label className="sw-body-sm-highlight">{translate(messageKey)}</label>
+      <span data-testid={`${messageKey}.value`}>
+        <FormattedMessage id="email_notification.overview.private" />
+      </span>
+    </>
+  );
+}
index 28f49b63b67281890e01fa95758094f6492080c9..b481742cbf879777dd246ab5ee191715b53bb6a9 100644 (file)
@@ -21,7 +21,7 @@ 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 { byLabelText, byRole, byTestId, byText } from '~sonar-aligned/helpers/testSelector';
 import SystemServiceMock from '../../../../../api/mocks/SystemServiceMock';
 import * as api from '../../../../../api/system';
 import { mockEmailConfiguration } from '../../../../../helpers/mocks/system';
@@ -52,7 +52,7 @@ const ui = {
     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',
+    name: 'email_notification.form.oauth_auth.title email_notification.form.oauth_auth.description email_notification.form.oauth_auth.supported recommended email_notification.form.oauth_auth.recommended_reason',
   }),
   host: byRole('textbox', {
     name: 'email_notification.form.host field_required',
@@ -84,12 +84,34 @@ const ui = {
     name: 'email_notification.form.oauth_authentication_host field_required',
   }),
   oauth_client_id: byLabelText('email_notification.form.oauth_client_id*'),
+  oauth_client_id_edit: byTestId('email_notification.form.oauth_client_id-edit'),
+  oauth_client_id_reset: byTestId('email_notification.form.oauth_client_id-reset'),
   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',
   }),
+
+  // overview values
+  overviewHeading: byText('email_notification.overview.heading'),
+  overview_auth_mode: byTestId('email_notification.overview.authentication_type.value'),
+  overview_username: byTestId('email_notification.form.username.value'),
+  overview_basic_password: byTestId('email_notification.form.basic_password.value'),
+  overview_oauth_auth_host: byTestId('email_notification.form.oauth_authentication_host.value'),
+  overview_oauth_client_id: byTestId('email_notification.form.oauth_client_id.value'),
+  overview_oauth_client_secret: byTestId('email_notification.form.oauth_client_secret.value'),
+  overview_oauth_tenant: byTestId('email_notification.form.oauth_tenant.value'),
+  overview_host: byTestId('email_notification.form.host.value'),
+  overview_port: byTestId('email_notification.form.port.value'),
+  overview_security_protocol: byTestId('email_notification.form.security_protocol.value'),
+  overview_from_address: byTestId('email_notification.form.from_address.value'),
+  overview_from_name: byTestId('email_notification.form.from_name.value'),
+  overview_subject_prefix: byTestId('email_notification.form.subject_prefix.value'),
+
+  edit: byRole('button', {
+    name: 'edit',
+  }),
 };
 
 describe('Email Basic Configuration', () => {
@@ -153,6 +175,41 @@ describe('Email Basic Configuration', () => {
     expect(addGlobalSuccessMessage).toHaveBeenCalledWith(
       'email_notification.form.save_configuration.create_success',
     );
+
+    expect(await ui.overviewHeading.find()).toBeInTheDocument();
+
+    expect(ui.overview_auth_mode.get()).toHaveTextContent(AuthMethod.Basic);
+    expect(ui.overview_username.get()).toHaveTextContent('username');
+    expect(ui.overview_basic_password.get()).toHaveTextContent(
+      'email_notification.overview.private',
+    );
+    expect(ui.overview_host.get()).toHaveTextContent('host');
+    expect(ui.overview_port.get()).toHaveTextContent('1234');
+    expect(ui.overview_security_protocol.get()).toHaveTextContent('SSLTLS');
+    expect(ui.overview_from_address.get()).toHaveTextContent('admin@localhost.com');
+    expect(ui.overview_from_name.get()).toHaveTextContent('fromName');
+    expect(ui.overview_subject_prefix.get()).toHaveTextContent('prefix');
+  });
+
+  it('renders the overview after loading configuration', async () => {
+    systemHandler.addEmailConfiguration(
+      mockEmailConfiguration(AuthMethod.Basic, { id: 'email-1' }),
+    );
+
+    renderEmailNotifications();
+
+    expect(await ui.overviewHeading.find()).toBeInTheDocument();
+    expect(ui.overview_auth_mode.get()).toHaveTextContent(AuthMethod.Basic);
+    expect(ui.overview_username.get()).toHaveTextContent('username');
+    expect(ui.overview_basic_password.get()).toHaveTextContent(
+      'email_notification.overview.private',
+    );
+    expect(ui.overview_host.get()).toHaveTextContent('host');
+    expect(ui.overview_port.get()).toHaveTextContent('port');
+    expect(ui.overview_security_protocol.get()).toHaveTextContent('SSLTLS');
+    expect(ui.overview_from_address.get()).toHaveTextContent('from_address');
+    expect(ui.overview_from_name.get()).toHaveTextContent('from_name');
+    expect(ui.overview_subject_prefix.get()).toHaveTextContent('subject_prefix');
   });
 
   it('can edit an existing configuration', async () => {
@@ -162,6 +219,11 @@ describe('Email Basic Configuration', () => {
     jest.spyOn(api, 'patchEmailConfiguration');
     const user = userEvent.setup();
     renderEmailNotifications();
+
+    expect(await ui.overviewHeading.find()).toBeInTheDocument();
+
+    await user.click(ui.edit.get());
+
     expect(await ui.editSubheading1.find()).toBeInTheDocument();
 
     expect(ui.save.get()).toBeDisabled();
@@ -197,6 +259,20 @@ describe('Email Basic Configuration', () => {
     expect(addGlobalSuccessMessage).toHaveBeenCalledWith(
       'email_notification.form.save_configuration.update_success',
     );
+
+    expect(await ui.overviewHeading.find()).toBeInTheDocument();
+
+    expect(ui.overview_auth_mode.get()).toHaveTextContent(AuthMethod.Basic);
+    expect(ui.overview_username.get()).toHaveTextContent('username-updated');
+    expect(ui.overview_basic_password.get()).toHaveTextContent(
+      'email_notification.overview.private',
+    );
+    expect(ui.overview_host.get()).toHaveTextContent('host-updated');
+    expect(ui.overview_port.get()).toHaveTextContent('5678');
+    expect(ui.overview_security_protocol.get()).toHaveTextContent('STARTTLS');
+    expect(ui.overview_from_address.get()).toHaveTextContent('updated@email.com');
+    expect(ui.overview_from_name.get()).toHaveTextContent('from_name-updated');
+    expect(ui.overview_subject_prefix.get()).toHaveTextContent('subject_prefix-updated');
   });
 });
 
@@ -274,20 +350,70 @@ describe('Email Oauth Configuration', () => {
     expect(addGlobalSuccessMessage).toHaveBeenCalledWith(
       'email_notification.form.save_configuration.create_success',
     );
+
+    expect(await ui.overviewHeading.find()).toBeInTheDocument();
+
+    expect(ui.overview_auth_mode.get()).toHaveTextContent(AuthMethod.OAuth);
+    expect(ui.overview_oauth_auth_host.get()).toHaveTextContent('oauth_auth_host');
+    expect(ui.overview_oauth_client_id.get()).toHaveTextContent(
+      'email_notification.overview.private',
+    );
+    expect(ui.overview_oauth_client_secret.get()).toHaveTextContent(
+      'email_notification.overview.private',
+    );
+    expect(ui.overview_oauth_tenant.get()).toHaveTextContent('oauth_tenant');
+    expect(ui.overview_host.get()).toHaveTextContent('host');
+    expect(ui.overview_port.get()).toHaveTextContent('1234');
+    expect(ui.overview_security_protocol.get()).toHaveTextContent('SSLTLS');
+    expect(ui.overview_from_address.get()).toHaveTextContent('admin@localhost.com');
+    expect(ui.overview_from_name.get()).toHaveTextContent('fromName');
+    expect(ui.overview_subject_prefix.get()).toHaveTextContent('prefix');
+    expect(ui.overview_username.get()).toHaveTextContent('username');
+  });
+
+  it('renders the overview after loading configuration', async () => {
+    systemHandler.addEmailConfiguration(
+      mockEmailConfiguration(AuthMethod.OAuth, { id: 'email-2' }),
+    );
+
+    renderEmailNotifications();
+
+    expect(await ui.overviewHeading.find()).toBeInTheDocument();
+    expect(ui.overview_auth_mode.get()).toHaveTextContent(AuthMethod.OAuth);
+    expect(ui.overview_oauth_auth_host.get()).toHaveTextContent('oauth_auth_host');
+    expect(ui.overview_oauth_client_id.get()).toHaveTextContent(
+      'email_notification.overview.private',
+    );
+    expect(ui.overview_oauth_client_secret.get()).toHaveTextContent(
+      'email_notification.overview.private',
+    );
+    expect(ui.overview_oauth_tenant.get()).toHaveTextContent('oauth_tenant');
+    expect(ui.overview_host.get()).toHaveTextContent('host');
+    expect(ui.overview_port.get()).toHaveTextContent('port');
+    expect(ui.overview_security_protocol.get()).toHaveTextContent('SSLTLS');
+    expect(ui.overview_from_address.get()).toHaveTextContent('from_address');
+    expect(ui.overview_from_name.get()).toHaveTextContent('from_name');
+    expect(ui.overview_subject_prefix.get()).toHaveTextContent('subject_prefix');
   });
 
-  it('can edit the oauth configuration', async () => {
+  it('can edit the configuration', async () => {
     systemHandler.addEmailConfiguration(
       mockEmailConfiguration(AuthMethod.OAuth, { id: 'email-1' }),
     );
     jest.spyOn(api, 'patchEmailConfiguration');
     const user = userEvent.setup();
     renderEmailNotifications();
+
+    expect(await ui.overviewHeading.find()).toBeInTheDocument();
+    await user.click(ui.edit.get());
+
     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.click(ui.oauth_client_id_edit.get());
     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');
@@ -326,6 +452,25 @@ describe('Email Oauth Configuration', () => {
     expect(addGlobalSuccessMessage).toHaveBeenCalledWith(
       'email_notification.form.save_configuration.update_success',
     );
+
+    expect(await ui.overviewHeading.find()).toBeInTheDocument();
+
+    expect(ui.overview_auth_mode.get()).toHaveTextContent(AuthMethod.OAuth);
+    expect(ui.overview_oauth_auth_host.get()).toHaveTextContent('oauth_auth_host');
+    expect(ui.overview_oauth_client_id.get()).toHaveTextContent(
+      'email_notification.overview.private',
+    );
+    expect(ui.overview_oauth_client_secret.get()).toHaveTextContent(
+      'email_notification.overview.private',
+    );
+    expect(ui.overview_oauth_tenant.get()).toHaveTextContent('oauth_tenant');
+    expect(ui.overview_host.get()).toHaveTextContent('host');
+    expect(ui.overview_port.get()).toHaveTextContent('5678');
+    expect(ui.overview_security_protocol.get()).toHaveTextContent('STARTTLS');
+    expect(ui.overview_from_address.get()).toHaveTextContent('updated@email.com');
+    expect(ui.overview_from_name.get()).toHaveTextContent('from_name-updated');
+    expect(ui.overview_subject_prefix.get()).toHaveTextContent('subject_prefix-updated');
+    expect(ui.overview_username.get()).toHaveTextContent('username-updated');
   });
 });
 
diff --git a/server/sonar-web/src/main/js/apps/settings/components/email-notification/__tests__/utils-test.ts b/server/sonar-web/src/main/js/apps/settings/components/email-notification/__tests__/utils-test.ts
new file mode 100644 (file)
index 0000000..ac86bf7
--- /dev/null
@@ -0,0 +1,648 @@
+/*
+ * 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 { mockEmailConfiguration } from '../../../../../helpers/mocks/system';
+import { AuthMethod, EmailConfiguration } from '../../../../../types/system';
+import { checkEmailConfigurationHasChanges } from '../utils';
+
+const validBasicValues = { basicPassword: 'password' };
+const validOAuthValues = { oauthClientId: 'oauthClientId', oauthClientSecret: 'oauthClientSecret' };
+
+describe('checkEmailConfigurationValidity empty configurations', () => {
+  it('should return false if configuration is undefined', () => {
+    expect(checkEmailConfigurationHasChanges(null, null)).toBe(false);
+  });
+
+  it('should return true if configuration is valid and no existing configuration', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, validBasicValues),
+        null,
+      ),
+    ).toBe(true);
+  });
+});
+
+describe('checkEmailConfigurationValidity common props', () => {
+  it('should return false if authMethod is not set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, {
+          ...validBasicValues,
+          authMethod: undefined,
+        }),
+        null,
+      ),
+    ).toBe(false);
+  });
+
+  it('should return true if authMethod is set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, {
+          ...validBasicValues,
+          authMethod: AuthMethod.Basic,
+        }),
+        null,
+      ),
+    ).toBe(true);
+  });
+
+  it('should return false if username is not set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, {
+          ...validBasicValues,
+          username: undefined,
+        }),
+        null,
+      ),
+    ).toBe(false);
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, { ...validBasicValues, username: '' }),
+        null,
+      ),
+    ).toBe(false);
+  });
+
+  it('should return true if username is set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, { ...validBasicValues, username: 'username' }),
+        null,
+      ),
+    ).toBe(true);
+  });
+
+  it('should return false if host is not set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, {
+          ...validBasicValues,
+          host: undefined,
+        }),
+        null,
+      ),
+    ).toBe(false);
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, { ...validBasicValues, host: '' }),
+        null,
+      ),
+    ).toBe(false);
+  });
+
+  it('should return true if host is set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, { ...validBasicValues, host: 'username' }),
+        null,
+      ),
+    ).toBe(true);
+  });
+
+  it('should return false if port is not set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, {
+          ...validBasicValues,
+          port: undefined,
+        }),
+        null,
+      ),
+    ).toBe(false);
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, { ...validBasicValues, port: '' }),
+        null,
+      ),
+    ).toBe(false);
+  });
+
+  it('should return true if port is set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, { ...validBasicValues, port: 'port' }),
+        null,
+      ),
+    ).toBe(true);
+  });
+
+  it('should return false if securityProtocol is not set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, {
+          ...validBasicValues,
+          securityProtocol: undefined,
+        }),
+        null,
+      ),
+    ).toBe(false);
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, {
+          ...validBasicValues,
+          securityProtocol: '',
+        }),
+        null,
+      ),
+    ).toBe(false);
+  });
+
+  it('should return true if securityProtocol is set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, {
+          ...validBasicValues,
+          securityProtocol: 'securityProtocol',
+        }),
+        null,
+      ),
+    ).toBe(true);
+  });
+
+  it('should return false if fromAddress is not set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, {
+          ...validBasicValues,
+          fromAddress: undefined,
+        }),
+        null,
+      ),
+    ).toBe(false);
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, {
+          ...validBasicValues,
+          fromAddress: '',
+        }),
+        null,
+      ),
+    ).toBe(false);
+  });
+
+  it('should return true if fromAddress is set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, {
+          ...validBasicValues,
+          fromAddress: 'fromAddress',
+        }),
+        null,
+      ),
+    ).toBe(true);
+  });
+
+  it('should return false if fromName is not set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, {
+          ...validBasicValues,
+          fromName: undefined,
+        }),
+        null,
+      ),
+    ).toBe(false);
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, {
+          ...validBasicValues,
+          fromName: '',
+        }) as EmailConfiguration,
+        null,
+      ),
+    ).toBe(false);
+  });
+
+  it('should return true if fromName is set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, { ...validBasicValues, fromName: 'fromName' }),
+        null,
+      ),
+    ).toBe(true);
+  });
+
+  it('should return false if subjectPrefix is not set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, {
+          ...validBasicValues,
+          subjectPrefix: undefined,
+        }),
+        null,
+      ),
+    ).toBe(false);
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, {
+          ...validBasicValues,
+          subjectPrefix: '',
+        }),
+        null,
+      ),
+    ).toBe(false);
+  });
+
+  it('should return true if subjectPrefix is set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, {
+          ...validBasicValues,
+          subjectPrefix: 'subjectPrefix',
+        }),
+        null,
+      ),
+    ).toBe(true);
+  });
+});
+
+describe('checkEmailConfigurationValidity basic-auth props', () => {
+  it('should return false if basicPassword is not set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, {
+          basicPassword: undefined,
+          isBasicPasswordSet: undefined,
+        }),
+        null,
+      ),
+    ).toBe(false);
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, {
+          basicPassword: '',
+          isBasicPasswordSet: undefined,
+        }),
+        null,
+      ),
+    ).toBe(false);
+  });
+
+  it('should return true if basicPassword is set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, {
+          basicPassword: 'basicPassword',
+          isBasicPasswordSet: undefined,
+        }),
+        null,
+      ),
+    ).toBe(true);
+  });
+});
+
+describe('checkEmailConfigurationValidity editing basic-auth props', () => {
+  it('should return false if basicPassword is not set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, { basicPassword: undefined }),
+        mockEmailConfiguration(AuthMethod.Basic, validBasicValues),
+      ),
+    ).toBe(false);
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, { basicPassword: '' }),
+        mockEmailConfiguration(AuthMethod.Basic, validBasicValues),
+      ),
+    ).toBe(false);
+  });
+
+  it('should return true if basicPassword is set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, { basicPassword: 'basicPassword' }),
+        mockEmailConfiguration(AuthMethod.Basic, validBasicValues),
+      ),
+    ).toBe(true);
+  });
+});
+
+describe('checkEmailConfigurationValidity oauth-auth props', () => {
+  it('should return false if oauthAuthenticationHost is not set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.OAuth, {
+          ...validOAuthValues,
+          oauthAuthenticationHost: undefined,
+        }),
+        null,
+      ),
+    ).toBe(false);
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.OAuth, {
+          ...validOAuthValues,
+          oauthAuthenticationHost: '',
+        }),
+        null,
+      ),
+    ).toBe(false);
+  });
+
+  it('should return true if oauthAuthenticationHost is set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.OAuth, {
+          ...validOAuthValues,
+          oauthAuthenticationHost: 'oauthAuthenticationHost',
+        }),
+        null,
+      ),
+    ).toBe(true);
+  });
+
+  it('should return false if oauthClientId is not set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.OAuth, {
+          ...validOAuthValues,
+          oauthClientId: undefined,
+        }),
+        null,
+      ),
+    ).toBe(false);
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.OAuth, {
+          ...validOAuthValues,
+          oauthClientId: '',
+        }),
+        null,
+      ),
+    ).toBe(false);
+  });
+
+  it('should return true if oauthClientId is set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.OAuth, {
+          ...validOAuthValues,
+          oauthClientId: 'oauthClientId',
+        }),
+        null,
+      ),
+    ).toBe(true);
+  });
+
+  it('should return false if oauthClientSecret is not set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.OAuth, {
+          ...validOAuthValues,
+          oauthClientSecret: undefined,
+        }),
+        null,
+      ),
+    ).toBe(false);
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.OAuth, {
+          ...validOAuthValues,
+          oauthClientSecret: '',
+        }),
+        null,
+      ),
+    ).toBe(false);
+  });
+
+  it('should return true if oauthClientSecret is set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.OAuth, {
+          ...validOAuthValues,
+          oauthClientSecret: 'oauthClientSecret',
+        }),
+        null,
+      ),
+    ).toBe(true);
+  });
+
+  it('should return false if oauthTenant is not set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.OAuth, {
+          ...validOAuthValues,
+          oauthTenant: undefined,
+        }),
+        null,
+      ),
+    ).toBe(false);
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.OAuth, {
+          ...validOAuthValues,
+          oauthTenant: '',
+        }),
+        null,
+      ),
+    ).toBe(false);
+  });
+
+  it('should return true if oauthTenant is set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.OAuth, {
+          ...validOAuthValues,
+          oauthTenant: 'oauthTenant',
+        }),
+        null,
+      ),
+    ).toBe(true);
+  });
+});
+
+describe('checkEmailConfigurationValidity editing oauth-auth props', () => {
+  it('should return false if oauthAuthenticationHost is not set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.OAuth, {
+          ...validOAuthValues,
+          oauthAuthenticationHost: undefined,
+        }),
+        mockEmailConfiguration(AuthMethod.OAuth),
+      ),
+    ).toBe(false);
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.OAuth, {
+          ...validOAuthValues,
+          oauthAuthenticationHost: '',
+        }),
+        mockEmailConfiguration(AuthMethod.OAuth),
+      ),
+    ).toBe(false);
+  });
+
+  it('should return true if oauthAuthenticationHost is set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.OAuth, {
+          ...validOAuthValues,
+          oauthAuthenticationHost: 'oauthAuthenticationHost',
+        }),
+        mockEmailConfiguration(AuthMethod.OAuth),
+      ),
+    ).toBe(true);
+  });
+
+  it('should return true if oauthClientId is set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.OAuth, {
+          ...validOAuthValues,
+          oauthClientId: 'oauthClientId',
+        }),
+        mockEmailConfiguration(AuthMethod.OAuth),
+      ),
+    ).toBe(true);
+  });
+
+  it('should return true if oauthClientSecret is set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.OAuth, {
+          ...validOAuthValues,
+          oauthClientSecret: 'oauthClientSecret',
+        }),
+        mockEmailConfiguration(AuthMethod.OAuth),
+      ),
+    ).toBe(true);
+  });
+
+  it('should return false if oauthTenant is not set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.OAuth, {
+          ...validOAuthValues,
+          oauthTenant: undefined,
+        }),
+        mockEmailConfiguration(AuthMethod.OAuth),
+      ),
+    ).toBe(false);
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.OAuth, {
+          ...validOAuthValues,
+          oauthTenant: '',
+        }),
+        mockEmailConfiguration(AuthMethod.OAuth),
+      ),
+    ).toBe(false);
+  });
+
+  it('should return true if oauthTenant is set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.OAuth, {
+          ...validOAuthValues,
+          oauthTenant: 'oauthTenant',
+        }),
+        mockEmailConfiguration(AuthMethod.OAuth),
+      ),
+    ).toBe(true);
+  });
+
+  it('should return false if oauthClientId is edited in isolation', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.OAuth, {
+          oauthClientId: 'oauthClientId',
+        }),
+        mockEmailConfiguration(AuthMethod.OAuth),
+      ),
+    ).toBe(false);
+  });
+
+  it('should return true if oauthClientSecret  is edited in isolation', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.OAuth, {
+          oauthClientSecret: 'oauthClientSecret',
+        }),
+        mockEmailConfiguration(AuthMethod.OAuth),
+      ),
+    ).toBe(true);
+  });
+});
+
+describe('checkEmailConfigurationValidity editing', () => {
+  it('should return false if configuration has not changed', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic),
+        mockEmailConfiguration(AuthMethod.Basic),
+      ),
+    ).toBe(false);
+  });
+
+  it('should return true if configuration has changed', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, { ...validBasicValues, username: 'new-username' }),
+        mockEmailConfiguration(AuthMethod.Basic),
+      ),
+    ).toBe(true);
+  });
+
+  it('should return false if configuration type has changed and password not set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.OAuth, {
+          isOauthClientIdSet: false,
+          isOauthClientSecretSet: false,
+        }),
+        mockEmailConfiguration(AuthMethod.Basic),
+      ),
+    ).toBe(false);
+  });
+
+  it('should return true if configuration type has changed and password is set', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.OAuth, validOAuthValues),
+        mockEmailConfiguration(AuthMethod.Basic),
+      ),
+    ).toBe(true);
+  });
+
+  it('should return true if configuration type has changed and password already exists', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.Basic, validBasicValues),
+        mockEmailConfiguration(AuthMethod.OAuth, { isBasicPasswordSet: true }),
+      ),
+    ).toBe(true);
+  });
+
+  it('should return true if configuration type has changed and oauthClientId & oauthClientSecret already exist', () => {
+    expect(
+      checkEmailConfigurationHasChanges(
+        mockEmailConfiguration(AuthMethod.OAuth, { oauthClientSecret: 'oauthClientSecret' }),
+        mockEmailConfiguration(AuthMethod.Basic, {
+          isOauthClientIdSet: true,
+          isOauthClientSecretSet: true,
+        }),
+      ),
+    ).toBe(true);
+  });
+});
index 4db59fac05f7a4cd7cebd61c504716fcd4760836..ab13a9a23806ecf01729dad7880c20fac20cd5ed 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 { isUndefined } from 'lodash';
-import { AuthMethod, EmailConfiguration } from '../../../../types/system';
+import {
+  AuthMethod,
+  EmailConfiguration,
+  EmailConfigurationBasicAuth,
+  EmailConfigurationOAuth,
+} from '../../../../types/system';
 
 export const AUTH_METHOD = 'auth-method';
 
@@ -48,36 +52,57 @@ export interface EmailNotificationGroupProps {
   onChange: (newValue: Partial<EmailConfiguration>) => void;
 }
 
-export function checkEmailConfigurationValidity(configuration: EmailConfiguration): boolean {
+const COMMON_EMAIL_PROPS: (keyof EmailConfiguration)[] = [
+  'authMethod',
+  'username',
+  'host',
+  'port',
+  'securityProtocol',
+  'fromAddress',
+  'fromName',
+  'subjectPrefix',
+];
+
+export function checkEmailConfigurationHasChanges(
+  configuration: EmailConfiguration | null,
+  originalConfiguration: EmailConfiguration | null,
+): boolean {
+  if (!configuration) {
+    return false;
+  }
+
+  const isEditing = originalConfiguration !== null;
+  const isOAuth = configuration.authMethod === AuthMethod.OAuth;
+
   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,
+  if (isOAuth) {
+    const oauthClientIdChanged =
+      configuration.isOauthClientIdSet === true &&
+      checkRequiredPropsAreValid(configuration as EmailConfigurationOAuth, ['oauthClientId']);
+
+    const privatePropsToCheck: (keyof EmailConfigurationOAuth)[] = ['oauthClientSecret'];
+    if (!isEditing || oauthClientIdChanged || !configuration.isOauthClientIdSet) {
+      privatePropsToCheck.push('oauthClientId');
+    }
+
+    isValid = checkRequiredPropsAreValid(configuration as EmailConfigurationOAuth, [
+      ...COMMON_EMAIL_PROPS,
       'oauthAuthenticationHost',
-      'oauthClientId',
-      'oauthClientSecret',
       'oauthTenant',
+      ...privatePropsToCheck,
+    ]);
+  } else {
+    isValid = checkRequiredPropsAreValid(configuration as EmailConfigurationBasicAuth, [
+      ...COMMON_EMAIL_PROPS,
+      'basicPassword',
     ]);
   }
 
   return isValid;
 }
 
+// Check if required props are present and contain a value that is not an empty string.
 function checkRequiredPropsAreValid<T>(obj: T, props: (keyof T)[]): boolean {
-  return props.every(
-    (prop) => !isUndefined(obj[prop]) && typeof obj[prop] === 'string' && obj[prop].length > 0,
-  );
+  return props.every((prop) => typeof obj[prop] === 'string' && obj[prop]);
 }
index 137d8fa3a837b49ac271806f9b468551dc73985f..818297fbfaf22a9d93816cdb2eba09978da2f413 100644 (file)
@@ -59,23 +59,10 @@ export enum AuthMethod {
   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;
+export type EmailConfiguration = EmailConfigurationAuth & EmailConfigurationCommon;
+export type EmailConfigurationAuth = EmailNotificationBasicAuth | EmailNotificationOAuth;
+export type EmailConfigurationBasicAuth = EmailNotificationBasicAuth & EmailConfigurationCommon;
+export type EmailConfigurationOAuth = EmailNotificationOAuth & EmailConfigurationCommon;
 
 interface EmailConfigurationCommon {
   fromAddress: string;
@@ -87,3 +74,19 @@ interface EmailConfigurationCommon {
   subjectPrefix: string;
   username: string;
 }
+
+interface EmailNotificationBasicAuth {
+  authMethod: AuthMethod.Basic;
+  basicPassword: string;
+  readonly isBasicPasswordSet?: boolean;
+}
+
+interface EmailNotificationOAuth {
+  authMethod: AuthMethod.OAuth;
+  readonly isOauthClientIdSet?: boolean;
+  readonly isOauthClientSecretSet?: boolean;
+  oauthAuthenticationHost: string;
+  oauthClientId: string;
+  oauthClientSecret: string;
+  oauthTenant: string;
+}
index f039cd6df6e2fa6c03e0aac68bf8fa455d426e91..82ce90dac17aa721fce72cf71ebbc91f1d3aa3ef 100644 (file)
@@ -2696,7 +2696,8 @@ email_notification.form.username.description=Username used to authenticate to th
 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.description=Authenticate with OAuth
+email_notification.form.oauth_auth.supported=Supported: 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.
@@ -2723,6 +2724,11 @@ email_notification.form.save_configuration.create_success=Email configuration sa
 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_notification.overview.heading=SMTP configuration settings
+email_notification.overview.authentication_type=Authentication type
+email_notification.overview.private=Hidden for security reasons
+email_notification.form.private=**************
+email_notification.overview.value={0} value
 
 email_configuration.test.title=Test Configuration
 email_configuration.test.to_address=To