diff options
18 files changed, 1291 insertions, 31 deletions
diff --git a/server/sonar-web/design-system/src/components/SelectionCard.tsx b/server/sonar-web/design-system/src/components/SelectionCard.tsx index 6c39d0d9200..82317246792 100644 --- a/server/sonar-web/design-system/src/components/SelectionCard.tsx +++ b/server/sonar-web/design-system/src/components/SelectionCard.tsx @@ -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')}; diff --git a/server/sonar-web/package.json b/server/sonar-web/package.json index c0d1ac4c33a..5dfb4282fc8 100644 --- a/server/sonar-web/package.json +++ b/server/sonar-web/package.json @@ -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", diff --git a/server/sonar-web/src/main/js/api/mocks/SystemServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/SystemServiceMock.ts index 34e832b5b3f..23da678af86 100644 --- a/server/sonar-web/src/main/js/api/mocks/SystemServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/SystemServiceMock.ts @@ -17,12 +17,26 @@ * 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> { diff --git a/server/sonar-web/src/main/js/api/system.ts b/server/sonar-web/src/main/js/api/system.ts index 6640209dc60..40cfd9a7de3 100644 --- a/server/sonar-web/src/main/js/api/system.ts +++ b/server/sonar-web/src/main/js/api/system.ts @@ -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, + ); +} diff --git a/server/sonar-web/src/main/js/apps/settings/components/AdditionalCategories.tsx b/server/sonar-web/src/main/js/apps/settings/components/AdditionalCategories.tsx index dc913bcdcee..0411fd4065d 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/AdditionalCategories.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/AdditionalCategories.tsx @@ -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 index 00000000000..05b65957c40 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/email-notification/AuthenticationSelector.tsx @@ -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 index 00000000000..31266135e01 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/email-notification/CommonSMTP.tsx @@ -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> + ); +} diff --git a/server/sonar-web/src/main/js/apps/settings/components/email-notification/EmailNotification.tsx b/server/sonar-web/src/main/js/apps/settings/components/email-notification/EmailNotification.tsx index b4bd95e965d..2895abd0f8f 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/email-notification/EmailNotification.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/email-notification/EmailNotification.tsx @@ -17,30 +17,25 @@ * 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 index 00000000000..48f98ff7085 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/email-notification/EmailNotificationConfiguration.tsx @@ -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 index 00000000000..d881be0d2d8 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/email-notification/EmailNotificationFormField.tsx @@ -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 index 00000000000..3d629d33ed1 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/email-notification/SenderInformation.tsx @@ -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 index 00000000000..28f49b63b67 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/email-notification/__tests__/EmailNotification-it.tsx @@ -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 index 00000000000..4db59fac05f --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/email-notification/utils.ts @@ -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 index 00000000000..df2ee9abf55 --- /dev/null +++ b/server/sonar-web/src/main/js/helpers/mocks/system.ts @@ -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; +} diff --git a/server/sonar-web/src/main/js/queries/system.ts b/server/sonar-web/src/main/js/queries/system.ts index d5de4ccdf54..797d4e5c39d 100644 --- a/server/sonar-web/src/main/js/queries/system.ts +++ b/server/sonar-web/src/main/js/queries/system.ts @@ -17,8 +17,16 @@ * 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'), + ); + }, + }); +} diff --git a/server/sonar-web/src/main/js/types/system.ts b/server/sonar-web/src/main/js/types/system.ts index 12ec0ea7bc8..137d8fa3a83 100644 --- a/server/sonar-web/src/main/js/types/system.ts +++ b/server/sonar-web/src/main/js/types/system.ts @@ -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; +} diff --git a/server/sonar-web/yarn.lock b/server/sonar-web/yarn.lock index 9cf64a7db50..ca0baf45222 100644 --- a/server/sonar-web/yarn.lock +++ b/server/sonar-web/yarn.lock @@ -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" diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 780b6ac602d..f039cd6df6e 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -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 |