diff --git a/enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/LicenseAndBillingSettings.unit.spec.tsx b/enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/LicenseAndBillingSettings.unit.spec.tsx index 4c4c22c173020efebc1112a10ae0d40baf715905..d3cb66aeee5b5f0d7b70bb2ce49ad47679de6cee 100644 --- a/enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/LicenseAndBillingSettings.unit.spec.tsx +++ b/enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/LicenseAndBillingSettings.unit.spec.tsx @@ -30,6 +30,7 @@ const setupState = ({ value: token, }, ], + warnings: {}, }, }); diff --git a/frontend/src/metabase-types/api/mocks/settings.ts b/frontend/src/metabase-types/api/mocks/settings.ts index 34f3a7e486a0c48fd8ad56c43ee4d20eaefb150e..fb0f47548827a6732303af08b8f5ae392322c13e 100644 --- a/frontend/src/metabase-types/api/mocks/settings.ts +++ b/frontend/src/metabase-types/api/mocks/settings.ts @@ -160,7 +160,7 @@ export const createMockSettings = ( "email-configured?": false, "email-smtp-host": null, "email-smtp-port": null, - "email-smtp-security": "None", + "email-smtp-security": "none", "email-smtp-username": null, "email-smtp-password": null, "embedding-app-origin": "", diff --git a/frontend/src/metabase-types/api/settings.ts b/frontend/src/metabase-types/api/settings.ts index 5cbf0fcbd1403fc947427a85a52c570c1632d579..3bf98cf09dad90dca450041b07e5f9cdf15949e2 100644 --- a/frontend/src/metabase-types/api/settings.ts +++ b/frontend/src/metabase-types/api/settings.ts @@ -202,7 +202,7 @@ interface InstanceSettings { "admin-email": string; "email-smtp-host": string | null; "email-smtp-port": number | null; - "email-smtp-security": "None" | "SSL" | "TLS" | "STARTTLS"; + "email-smtp-security": "none" | "ssl" | "tls" | "starttls"; "email-smtp-username": string | null; "email-smtp-password": string | null; "enable-embedding": boolean; diff --git a/frontend/src/metabase-types/store/admin.ts b/frontend/src/metabase-types/store/admin.ts index 3a6b7e15cd31918f99eb9308909157b7da25e2e8..f18f5ddc6e01963edcf1f4cc32ff833e3f295805 100644 --- a/frontend/src/metabase-types/store/admin.ts +++ b/frontend/src/metabase-types/store/admin.ts @@ -2,6 +2,7 @@ import type { CollectionPermissions, GroupsPermissions, SettingDefinition, + SettingKey, } from "metabase-types/api"; export type AdminPathKey = @@ -37,6 +38,7 @@ export interface AdminState { }; settings: { settings: SettingDefinition[]; + warnings: Partial<Record<SettingKey, unknown>>; }; } diff --git a/frontend/src/metabase-types/store/mocks/admin.ts b/frontend/src/metabase-types/store/mocks/admin.ts index d1b6dff494489e977075baac7eb6eb03f6cda25c..e193a287745b8e49b4355608ca94db87439e423b 100644 --- a/frontend/src/metabase-types/store/mocks/admin.ts +++ b/frontend/src/metabase-types/store/mocks/admin.ts @@ -5,7 +5,7 @@ export const createMockAdminState = ( ): AdminState => ({ app: createMockAdminAppState(), permissions: createMockPermissionsState(), - settings: { settings: [] }, + settings: { settings: [], warnings: {} }, ...opts, }); diff --git a/frontend/src/metabase/admin/settings/components/Email/SMTPConnectionForm.tsx b/frontend/src/metabase/admin/settings/components/Email/SMTPConnectionForm.tsx index 04f09f2110ee533a6dba8f183e817dd06994a820..ef5258762e5b092b77f6406dbf708a8f21478522 100644 --- a/frontend/src/metabase/admin/settings/components/Email/SMTPConnectionForm.tsx +++ b/frontend/src/metabase/admin/settings/components/Email/SMTPConnectionForm.tsx @@ -29,6 +29,7 @@ import { updateEmailSettings, clearEmailSettings, } from "../../settings"; +import { SetByEnvVarWrapper } from "../SettingsSetting"; const BREADCRUMBS = [[t`Email`, "/admin/settings/email"], [t`SMTP`]]; @@ -39,7 +40,7 @@ const SEND_TEST_BUTTON_STATES = { }; type ButtonStateType = keyof typeof SEND_TEST_BUTTON_STATES; -interface SMTPConnectionFormProps { +export interface SMTPConnectionFormProps { elements: SettingElement[]; settingValues: Settings; } @@ -53,17 +54,33 @@ type FormValueProps = Pick< | "email-smtp-password" >; -const FORM_VALUE_SCHEMA = Yup.object({ - "email-smtp-host": Yup.string().required(Errors.required).default(""), - "email-smtp-port": Yup.number() - .positive() - .nullable() - .required(Errors.required) - .default(null), - "email-smtp-security": Yup.string(), - "email-smtp-username": Yup.string().default(""), - "email-smtp-password": Yup.string().default(""), -}); +const anySchema = Yup.mixed().nullable().default(null); + +// we need to allow this form to be submitted even when we have removed certain inputs +// when they are set by env vars +const getFormValueSchema = (elementMap: _.Dictionary<SettingElement>) => { + return Yup.object({ + "email-smtp-host": elementMap["email-smtp-host"].is_env_setting + ? anySchema + : Yup.string().required(Errors.required).default(""), + "email-smtp-port": elementMap["email-smtp-port"].is_env_setting + ? anySchema + : Yup.number() + .positive() + .nullable() + .required(Errors.required) + .default(null), + "email-smtp-security": elementMap["email-smtp-security"].is_env_setting + ? anySchema + : Yup.string().default("none"), + "email-smtp-username": elementMap["email-smtp-username"].is_env_setting + ? anySchema + : Yup.string().default(""), + "email-smtp-password": elementMap["email-smtp-password"].is_env_setting + ? anySchema + : Yup.string().default(""), + }); +}; export const SMTPConnectionForm = ({ elements, @@ -80,11 +97,11 @@ export const SMTPConnectionForm = ({ const initialValues = useMemo<FormValueProps>( () => ({ - "email-smtp-host": settingValues["email-smtp-host"] || "", + "email-smtp-host": settingValues["email-smtp-host"], "email-smtp-port": settingValues["email-smtp-port"], - "email-smtp-security": settingValues["email-smtp-security"] || "none", - "email-smtp-username": settingValues["email-smtp-username"] || "", - "email-smtp-password": settingValues["email-smtp-password"] || "", + "email-smtp-security": settingValues["email-smtp-security"] ?? "none", + "email-smtp-username": settingValues["email-smtp-username"] ?? "", + "email-smtp-password": settingValues["email-smtp-password"] ?? "", }), [settingValues], ); @@ -136,107 +153,122 @@ export const SMTPConnectionForm = ({ } }, [dispatch, isHosted]); + const allSetByEnvVars = useMemo(() => { + return elements.every(element => element.is_env_setting); + }, [elements]); + return ( <Flex justify="space-between"> - <Stack spacing="sm" maw={400} style={{ paddingInlineStart: "0.5rem" }}> + <Stack spacing="sm" maw={600} style={{ paddingInlineStart: "0.5rem" }}> {isEmailConfigured && ( <Breadcrumbs crumbs={BREADCRUMBS} className={cx(CS.mb3)} /> )} <FormProvider initialValues={initialValues} - validationSchema={FORM_VALUE_SCHEMA} + validationSchema={getFormValueSchema(elementMap)} onSubmit={handleUpdateEmailSettings} enableReinitialize > {({ dirty, isValid, isSubmitting, values }) => ( <Form> - <FormTextInput - name="email-smtp-host" - label={elementMap["email-smtp-host"]["display_name"]} - description={elementMap["email-smtp-host"]["description"]} - placeholder={elementMap["email-smtp-host"]["placeholder"]} - mb="1.5rem" - labelProps={{ - tt: "uppercase", - mb: "0.5rem", - }} - descriptionProps={{ - fz: "0.75rem", - mb: "0.5rem", - }} - /> - <FormTextInput - name="email-smtp-port" - label={elementMap["email-smtp-port"]["display_name"]} - description={elementMap["email-smtp-port"]["description"]} - placeholder={elementMap["email-smtp-port"]["placeholder"]} - mb="1.5rem" - labelProps={{ - tt: "uppercase", - mb: "0.5rem", - }} - descriptionProps={{ - fz: "0.75rem", - mb: "0.5rem", - }} - /> - <FormRadioGroup - name="email-smtp-security" - label={elementMap["email-smtp-security"]["display_name"]} - description={elementMap["email-smtp-security"]["description"]} - mb="1.5rem" - labelProps={{ - tt: "uppercase", - fz: "0.875rem", - c: "text-medium", - mb: "0.5rem", - }} - > - <Group> - {Object.entries( - elementMap["email-smtp-security"].options || {}, - ).map(([value, setting]) => ( - <Radio - value={value} - label={setting.name} - key={value} - styles={{ - inner: { display: "none" }, - label: { - paddingLeft: 0, - color: - values["email-smtp-security"] === value - ? color("brand") - : color("text-dark"), - }, - }} - /> - ))} - </Group> - </FormRadioGroup> - <FormTextInput - name="email-smtp-username" - label={elementMap["email-smtp-username"]["display_name"]} - description={elementMap["email-smtp-username"]["description"]} - placeholder={elementMap["email-smtp-username"]["placeholder"]} - mb="1.5rem" - labelProps={{ - tt: "uppercase", - mb: "0.5rem", - }} - /> - <FormTextInput - name="email-smtp-password" - type="password" - label={elementMap["email-smtp-password"]["display_name"]} - description={elementMap["email-smtp-password"]["description"]} - placeholder={elementMap["email-smtp-password"]["placeholder"]} - mb="1.5rem" - labelProps={{ - tt: "uppercase", - mb: "0.5rem", - }} - /> + <SetByEnvVarWrapper setting={elementMap["email-smtp-host"]}> + <FormTextInput + name="email-smtp-host" + label={elementMap["email-smtp-host"]["display_name"]} + description={elementMap["email-smtp-host"]["description"]} + placeholder={elementMap["email-smtp-host"]["placeholder"]} + mb="1.5rem" + labelProps={{ + tt: "uppercase", + mb: "0.5rem", + }} + descriptionProps={{ + fz: "0.75rem", + mb: "0.5rem", + }} + /> + </SetByEnvVarWrapper> + <SetByEnvVarWrapper setting={elementMap["email-smtp-port"]}> + <FormTextInput + name="email-smtp-port" + label={elementMap["email-smtp-port"]["display_name"]} + description={elementMap["email-smtp-port"]["description"]} + placeholder={elementMap["email-smtp-port"]["placeholder"]} + mb="1.5rem" + labelProps={{ + tt: "uppercase", + mb: "0.5rem", + }} + descriptionProps={{ + fz: "0.75rem", + mb: "0.5rem", + }} + /> + </SetByEnvVarWrapper> + <SetByEnvVarWrapper setting={elementMap["email-smtp-security"]}> + <FormRadioGroup + name="email-smtp-security" + label={elementMap["email-smtp-security"]["display_name"]} + description={elementMap["email-smtp-security"]["description"]} + mb="1.5rem" + labelProps={{ + tt: "uppercase", + fz: "0.875rem", + c: "text-medium", + mb: "0.5rem", + }} + > + <Group> + {elementMap["email-smtp-security"].options?.map( + ({ value, name }) => ( + <Radio + value={value as string} + name="email-smtp-security" + label={name} + key={name} + styles={{ + inner: { display: "none" }, + label: { + paddingLeft: 0, + color: + values["email-smtp-security"] === value + ? color("brand") + : color("text-dark"), + }, + }} + /> + ), + )} + </Group> + </FormRadioGroup> + </SetByEnvVarWrapper> + <SetByEnvVarWrapper setting={elementMap["email-smtp-username"]}> + <FormTextInput + name="email-smtp-username" + label={elementMap["email-smtp-username"]["display_name"]} + description={elementMap["email-smtp-username"]["description"]} + placeholder={elementMap["email-smtp-username"]["placeholder"]} + mb="1.5rem" + labelProps={{ + tt: "uppercase", + mb: "0.5rem", + }} + /> + </SetByEnvVarWrapper> + <SetByEnvVarWrapper setting={elementMap["email-smtp-password"]}> + <FormTextInput + name="email-smtp-password" + type="password" + label={elementMap["email-smtp-password"]["display_name"]} + description={elementMap["email-smtp-password"]["description"]} + placeholder={elementMap["email-smtp-password"]["placeholder"]} + mb="1.5rem" + labelProps={{ + tt: "uppercase", + mb: "0.5rem", + }} + /> + </SetByEnvVarWrapper> {testEmailError && ( <Text role="alert" @@ -250,7 +282,7 @@ export const SMTPConnectionForm = ({ <Flex mt="1rem" gap="1.5rem"> <FormSubmitButton label={t`Save changes`} - disabled={!dirty} + disabled={!dirty || !isValid || isSubmitting} variant="filled" /> {!dirty && isValid && !isSubmitting && ( @@ -258,7 +290,12 @@ export const SMTPConnectionForm = ({ {SEND_TEST_BUTTON_STATES[sendingEmail]} </Button> )} - <Button onClick={handleClearEmailSettings}>{t`Clear`}</Button> + <Button + onClick={handleClearEmailSettings} + disabled={allSetByEnvVars} + > + {t`Clear`} + </Button> </Flex> </Form> )} diff --git a/frontend/src/metabase/admin/settings/components/Email/SMTPConnectionForm.unit.spec.tsx b/frontend/src/metabase/admin/settings/components/Email/SMTPConnectionForm.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5a588b1c8b8ad2e1607e12ca3062f6b8781bc63e --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/Email/SMTPConnectionForm.unit.spec.tsx @@ -0,0 +1,388 @@ +import userEvent from "@testing-library/user-event"; +import fetchMock from "fetch-mock"; + +import { + setupEmailEndpoints, + setupPropertiesEndpoints, + setupSettingsEndpoints, +} from "__support__/server-mocks"; +import { renderWithProviders, screen } from "__support__/ui"; +import type { SettingDefinition, Settings } from "metabase-types/api"; +import { + createMockSettingsState, + createMockState, +} from "metabase-types/store/mocks"; + +import type { SettingElement } from "../../types"; + +import type { SMTPConnectionFormProps } from "./SMTPConnectionForm"; +import { SMTPConnectionForm } from "./SMTPConnectionForm"; + +const defaultSettings = { + "email-configured": true, +} as Partial<Settings>; + +const defaultElements = [ + { + placeholder: "smtp.yourservice.com", + key: "email-smtp-host", + value: null, + is_env_setting: false, + env_name: "MB_EMAIL_SMTP_HOST", + description: "The address of the SMTP server that handles your emails.", + default: "Using value of env var $MB_EMAIL_SMTP_HOST", + originalValue: null, + display_name: "SMTP Host", + type: "string", + required: true, + autoFocus: true, + }, + { + placeholder: "587", + key: "email-smtp-port", + value: 587, + is_env_setting: false, + env_name: "MB_EMAIL_SMTP_PORT", + description: "The port your SMTP server uses for outgoing emails.", + default: null, + originalValue: 587, + display_name: "SMTP Port", + type: "number", + required: true, + validations: [["integer", "That's not a valid port number"]], + }, + { + placeholder: "none", + key: "email-smtp-security", + value: null, + is_env_setting: false, + env_name: "MB_EMAIL_SMTP_SECURITY", + description: null, + default: "none", + originalValue: null, + display_name: "SMTP Security", + type: "radio", + options: [ + { value: "none", name: "None" }, + { value: "ssl", name: "SSL" }, + { value: "tls", name: "TLS" }, + { value: "starttls", name: "STARTTLS" }, + ], + defaultValue: "none", + }, + { + placeholder: "nicetoseeyou", + key: "email-smtp-username", + value: "ash@example.com", + is_env_setting: false, + env_name: "MB_EMAIL_SMTP_USERNAME", + description: null, + default: null, + originalValue: "ash@example.com", + display_name: "SMTP Username", + type: "string", + }, + { + placeholder: "Shhh...", + key: "email-smtp-password", + value: "**********xy", + is_env_setting: false, + env_name: "MB_EMAIL_SMTP_PASSWORD", + description: null, + default: null, + originalValue: "**********xy", + display_name: "SMTP Password", + type: "password", + }, +] as SettingElement[]; + +const defaultValues = { + "email-smtp-host": null, + "email-smtp-port": null, + "email-smtp-security": "none", + "email-smtp-username": null, +} as Settings; + +const setup = ({ elements, settingValues }: SMTPConnectionFormProps) => { + setupEmailEndpoints(); + setupSettingsEndpoints(elements as SettingDefinition[]); + setupPropertiesEndpoints(settingValues); + + renderWithProviders( + <SMTPConnectionForm elements={elements} settingValues={settingValues} />, + { + storeInitialState: createMockState({ + settings: createMockSettingsState(defaultSettings), + }), + }, + ); +}; + +describe("SMTP connection form", () => { + it("should render the smtp connection form", async () => { + setup({ elements: defaultElements, settingValues: defaultValues }); + + expect(screen.getByText(/SMTP Host/i)).toBeInTheDocument(); + expect(screen.getByText(/SMTP Port/i)).toBeInTheDocument(); + expect(screen.getByText(/SMTP Host/i)).toBeInTheDocument(); + expect(screen.getByText(/SMTP Username/i)).toBeInTheDocument(); + expect(screen.getByText(/SMTP Password/i)).toBeInTheDocument(); + }); + + it("should render all security options", () => { + setup({ elements: defaultElements, settingValues: defaultValues }); + + expect(screen.getByText("None")).toBeInTheDocument(); + expect(screen.getByText("SSL")).toBeInTheDocument(); + expect(screen.getByText("TLS")).toBeInTheDocument(); + expect(screen.getByText("STARTTLS")).toBeInTheDocument(); + }); + + it("should populate the host", () => { + const values = { + ...defaultValues, + "email-smtp-host": "smtp.rotom.com", + } as Settings; + + setup({ elements: defaultElements, settingValues: values }); + + expect(screen.getByLabelText(/SMTP host/i)).toHaveDisplayValue( + "smtp.rotom.com", + ); + }); + + it("should populate the port", () => { + const values = { + ...defaultValues, + "email-smtp-port": 123, + } as Settings; + + setup({ elements: defaultElements, settingValues: values }); + + expect(screen.getByLabelText(/SMTP port/i)).toHaveDisplayValue("123"); + }); + + it("should populate the passed security value", () => { + const values = { + ...defaultValues, + "email-smtp-security": "ssl", + } as Settings; + + setup({ elements: defaultElements, settingValues: values }); + + expect(screen.getByLabelText("SSL")).toBeChecked(); + expect(screen.getByLabelText("TLS")).not.toBeChecked(); + }); + + it("should populate the username", () => { + const values = { + ...defaultValues, + "email-smtp-username": "misty@example.com", + } as Settings; + + setup({ elements: defaultElements, settingValues: values }); + + expect(screen.getByLabelText(/SMTP username/i)).toHaveDisplayValue( + "misty@example.com", + ); + }); + + it("should populate the password", () => { + const values = { + ...defaultValues, + "email-smtp-password": "*****chu", + } as Settings; + + setup({ elements: defaultElements, settingValues: values }); + + expect(screen.getByLabelText(/SMTP password/i)).toHaveDisplayValue( + "*****chu", + ); + }); + + it("should show save button as disabled when required fields are empty", () => { + setup({ elements: defaultElements, settingValues: defaultValues }); + + expect( + screen.getByRole("button", { name: /save changes/i }), + ).toBeDisabled(); + }); + + it("should show save button as enabled when required fields are filled", async () => { + setup({ elements: defaultElements, settingValues: defaultValues }); + + expect( + screen.getByRole("button", { name: /save changes/i }), + ).toBeDisabled(); + + await userEvent.type(screen.getByLabelText(/SMTP host/i), "smtp.rotom.com"); + await userEvent.type(screen.getByLabelText(/SMTP port/i), "123"); + + expect( + await screen.findByRole("button", { name: /save changes/i }), + ).toBeEnabled(); + + await userEvent.click(screen.getByLabelText("TLS")); + await userEvent.type( + screen.getByLabelText(/SMTP username/i), + "misty@example.com", + ); + await userEvent.type( + screen.getByLabelText(/SMTP password/i), + "iheartpikachu", + ); + + expect( + await screen.findByRole("button", { name: /save changes/i }), + ).toBeEnabled(); + }); + + it("should submit all settings changes via api", async () => { + setup({ elements: defaultElements, settingValues: defaultValues }); + + await userEvent.type(screen.getByLabelText(/SMTP host/i), "smtp.rotom.com"); + await userEvent.type(screen.getByLabelText(/SMTP port/i), "123"); + await userEvent.click(screen.getByLabelText("TLS")); + await userEvent.type( + screen.getByLabelText(/SMTP username/i), + "misty@example.com", + ); + await userEvent.type( + screen.getByLabelText(/SMTP password/i), + "iheartpikachu", + ); + + await userEvent.click( + screen.getByRole("button", { name: /save changes/i }), + ); + + const [emailApiCall] = fetchMock.calls(); + const body = await emailApiCall?.request?.json(); + + expect(body).toEqual({ + "email-smtp-host": "smtp.rotom.com", + "email-smtp-port": "123", + "email-smtp-security": "tls", + "email-smtp-username": "misty@example.com", + "email-smtp-password": "iheartpikachu", + }); + }); + + it("should hide setting fields that are set by an environment variable", () => { + const elements = [ + { + ...defaultElements[0], + is_env_setting: true, + }, + ...defaultElements.slice(1), + ]; + + setup({ elements, settingValues: defaultValues }); + + expect(screen.getByText(/this has been set by the/i)).toBeInTheDocument(); + expect(screen.getByText(/MB_EMAIL_SMTP_HOST/i)).toBeInTheDocument(); + expect(screen.getByText(/environment variable/i)).toBeInTheDocument(); + }); + + it("should allow form submission when some fields are set by an environment variable", async () => { + const elements = [ + { + ...defaultElements[0], + is_env_setting: true, + }, + ...defaultElements.slice(1), + ]; + + setup({ elements, settingValues: defaultValues }); + + expect(screen.getByText(/this has been set by the/i)).toBeInTheDocument(); + expect(screen.getByText(/MB_EMAIL_SMTP_HOST/i)).toBeInTheDocument(); + expect(screen.getByText(/environment variable/i)).toBeInTheDocument(); + + await userEvent.type(screen.getByLabelText(/SMTP port/i), "123"); + await userEvent.click(screen.getByLabelText("TLS")); + await userEvent.type( + screen.getByLabelText(/SMTP username/i), + "misty@example.com", + ); + await userEvent.type( + screen.getByLabelText(/SMTP password/i), + "iheartpikachu", + ); + + expect( + await screen.findByRole("button", { name: /save changes/i }), + ).toBeEnabled(); + + await userEvent.click( + screen.getByRole("button", { name: /save changes/i }), + ); + + const [emailApiCall] = fetchMock.calls(); + const body = await emailApiCall?.request?.json(); + + expect(body).toEqual({ + "email-smtp-host": null, + "email-smtp-port": "123", + "email-smtp-security": "tls", + "email-smtp-username": "misty@example.com", + "email-smtp-password": "iheartpikachu", + }); + }); + + it("should enable test email button when all required fields are populated", async () => { + const fullValues = { + "email-smtp-host": "smtp.rotom.com", + "email-smtp-port": 123, + "email-smtp-security": "tls", + "email-smtp-username": "misty@example.com", + "email-smtp-password": "iheartpikachu", + } as Settings; + + setup({ elements: defaultElements, settingValues: fullValues }); + + expect( + await screen.findByRole("button", { name: /send test email/i }), + ).toBeEnabled(); + }); + + it("should hide test email button when fields are missing", async () => { + setup({ elements: defaultElements, settingValues: defaultValues }); + + expect( + screen.queryByRole("button", { name: /send test email/i }), + ).not.toBeInTheDocument(); + }); + + it("should hide test email button when form is dirty", async () => { + const fullValues = { + "email-smtp-host": "smtp.rotom.com", + "email-smtp-port": 123, + "email-smtp-security": "tls", + "email-smtp-username": "misty@example.com", + "email-smtp-password": "iheartpikachu", + } as Settings; + + setup({ elements: defaultElements, settingValues: fullValues }); + + expect( + await screen.findByRole("button", { name: /send test email/i }), + ).toBeEnabled(); + await userEvent.type(screen.getByLabelText(/SMTP host/i), "smtp.rotom.com"); + expect( + screen.queryByRole("button", { name: /send test email/i }), + ).not.toBeInTheDocument(); + }); + + it("should enable test email button when all fields are set by environment variables (metabase#45445)", async () => { + const elements = defaultElements.map(el => ({ + ...el, + is_env_setting: true, + })); + + setup({ elements, settingValues: defaultValues }); + expect( + await screen.findByRole("button", { name: /send test email/i }), + ).toBeEnabled(); + }); +}); diff --git a/frontend/src/metabase/admin/settings/components/SettingsSetting.jsx b/frontend/src/metabase/admin/settings/components/SettingsSetting.jsx index 5202789b209b2fc25cdccc7b31a8c52e1321b7f1..97909ffa638052fe42250b2b9f0e1eb2d868b329 100644 --- a/frontend/src/metabase/admin/settings/components/SettingsSetting.jsx +++ b/frontend/src/metabase/admin/settings/components/SettingsSetting.jsx @@ -6,6 +6,7 @@ import { jt } from "ttag"; import ExternalLink from "metabase/core/components/ExternalLink"; import { alpha } from "metabase/lib/colors"; +import { Box } from "metabase/ui"; import { settingToFormFieldId, getEnvVarDocsUrl } from "../utils"; @@ -95,13 +96,7 @@ export const SettingsSetting = props => { {!setting.noHeader && <SettingHeader id={settingId} setting={setting} />} <SettingContent> {setting.is_env_setting && !setting.forceRenderWidget ? ( - <SettingEnvVarMessage> - {jt`This has been set by the ${( - <ExternalLink href={getEnvVarDocsUrl(setting.env_name)}> - {setting.env_name} - </ExternalLink> - )} environment variable.`} - </SettingEnvVarMessage> + <SetByEnvVar setting={setting} /> ) : ( <Widget id={settingId} {...widgetProps} /> )} @@ -115,3 +110,25 @@ export const SettingsSetting = props => { </SettingRoot> ); }; + +export const SetByEnvVar = ({ setting }) => ( + <SettingEnvVarMessage> + {jt`This has been set by the ${( + <ExternalLink href={getEnvVarDocsUrl(setting.env_name)}> + {setting.env_name} + </ExternalLink> + )} environment variable.`} + </SettingEnvVarMessage> +); + +export const SetByEnvVarWrapper = ({ setting, children }) => { + if (setting.is_env_setting) { + return ( + <Box mb="lg"> + <SettingHeader id={setting.key} setting={setting} /> + <SetByEnvVar setting={setting} /> + </Box> + ); + } + return children; +}; diff --git a/frontend/src/metabase/admin/settings/selectors.js b/frontend/src/metabase/admin/settings/selectors.js index 7b4fc5e7c88aeebd07bd6533c14c8a5fa73407e7..5566f8d5a049c3c6d84e925df71b58f6871dcfd8 100644 --- a/frontend/src/metabase/admin/settings/selectors.js +++ b/frontend/src/metabase/admin/settings/selectors.js @@ -252,7 +252,12 @@ export const ADMIN_SETTINGS_SECTIONS = { display_name: t`SMTP Security`, description: null, type: "radio", - options: { none: "None", ssl: "SSL", tls: "TLS", starttls: "STARTTLS" }, + options: [ + { value: "none", name: "None" }, + { value: "ssl", name: "SSL" }, + { value: "tls", name: "TLS" }, + { value: "starttls", name: "STARTTLS" }, + ], defaultValue: "none", }, { diff --git a/frontend/src/metabase/admin/settings/types.ts b/frontend/src/metabase/admin/settings/types.ts index e0d6d370bf9d761720f4d56e6b88528a18b36119..2c9828223da1d66db5486df96155db84cfbd6135 100644 --- a/frontend/src/metabase/admin/settings/types.ts +++ b/frontend/src/metabase/admin/settings/types.ts @@ -11,6 +11,8 @@ export type SettingElement = { key?: SettingKey; tab?: string; display_name?: string; + env_name?: string; + is_env_setting?: boolean; type?: string; description?: string; note?: string; @@ -18,6 +20,8 @@ export type SettingElement = { placeholder?: string; options?: { value: SettingValue; name: string }[]; value?: SettingValue; + default?: SettingValue; + originalValue?: SettingValue; defaultValue?: SettingValue; required?: boolean; autoFocus?: boolean; diff --git a/frontend/src/metabase/admin/upsells/UpsellHosting.tsx b/frontend/src/metabase/admin/upsells/UpsellHosting.tsx index 25b407e2901e5d9e3602658970fa10881d1baa0a..aedaad65c898c16a603641ef1dd9b5c63f6d668d 100644 --- a/frontend/src/metabase/admin/upsells/UpsellHosting.tsx +++ b/frontend/src/metabase/admin/upsells/UpsellHosting.tsx @@ -27,7 +27,7 @@ export const UpsellHosting = ({ source }: { source: string }) => { maxWidth={UPSELL_CARD_WIDTH} > {jt`${( - <strong>{t`Migrate to Metabase Cloud`}</strong> + <strong key="migrate">{t`Migrate to Metabase Cloud`}</strong> )} for fast, reliable, and secure deployment.`} </UpsellCard> ); @@ -51,7 +51,7 @@ export const UpsellHostingUpdates = ({ source }: { source: string }) => { maxWidth={UPSELL_CARD_WIDTH} > {jt`${( - <strong>{t`Migrate to Metabase Cloud`}</strong> + <strong key="migrate">{t`Migrate to Metabase Cloud`}</strong> )} for fast, reliable, and secure deployment.`} </UpsellCard> ); diff --git a/frontend/test/__support__/server-mocks/email.ts b/frontend/test/__support__/server-mocks/email.ts new file mode 100644 index 0000000000000000000000000000000000000000..a34edf3637e41a697ceb0a4d4c1aad6380e69166 --- /dev/null +++ b/frontend/test/__support__/server-mocks/email.ts @@ -0,0 +1,15 @@ +// email settings, why aren't they in the settings endpoint? who knows? ¯\_(ツ)_/¯ +import fetchMock from "fetch-mock"; + +const defaultSettings = { + "email-smtp-host": "smtp.rotom.test", + "email-smtp-port": 587, + "email-smtp-security": "tls", + "email-smtp-username": "misty@rotom.test", + "email-smtp-password": "iheartpikachu", +}; + +export const setupEmailEndpoints = (settings = defaultSettings) => { + fetchMock.put("path:/api/email", settings); + fetchMock.delete("path:/api/email", 204); +}; diff --git a/frontend/test/__support__/server-mocks/index.ts b/frontend/test/__support__/server-mocks/index.ts index d0510d3db92d12844d34619d9a66337ce940cd0d..25a96caf9a04bc554acb21a6a1709393efdbb90b 100644 --- a/frontend/test/__support__/server-mocks/index.ts +++ b/frontend/test/__support__/server-mocks/index.ts @@ -10,6 +10,7 @@ export * from "./constants"; export * from "./dashboard"; export * from "./database"; export * from "./dataset"; +export * from "./email"; export * from "./field"; export * from "./group"; export * from "./impersonation";