fix(ui): harden SMTP setting controls

Persist cleared optional credentials so SMTP authentication can be disabled. Constrain TLS and SSL settings to the runtime-supported boolean values and update the related UI regression coverage.
This commit is contained in:
Milan Cizek 2026-08-02 23:51:14 +02:00
parent 40805ba4cc
commit 206614bc26
2 changed files with 71 additions and 37 deletions

View file

@ -34,7 +34,8 @@ const alerts = [
{ name: "slack", variables: { SLACK_WEBHOOK_URL: "https://hooks.example.com" } },
];
const inputNamed = (name: string) => document.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
const fieldNamed = (name: string) =>
document.querySelector<HTMLInputElement | HTMLSelectElement>(`[name="${name}"]`)!;
describe("EmailSettings", () => {
beforeEach(() => {
@ -56,17 +57,19 @@ describe("EmailSettings", () => {
it("renders one named input per email variable and none for other alert types", () => {
renderWithProviders(<EmailSettings accessToken="sk-test" premiumUser alerts={alerts} />);
expect(inputNamed("SMTP_HOST")).toHaveValue("smtp.example.com");
expect(inputNamed("SMTP_PORT")).toHaveValue("587");
expect(inputNamed("SMTP_PORT")).toHaveAttribute("type", "number");
expect(inputNamed("SMTP_HOST")).toHaveAttribute("type", "text");
expect(inputNamed("SMTP_TLS")).toHaveAttribute("type", "text");
expect(inputNamed("SMTP_USE_SSL")).toHaveAttribute("type", "text");
expect(inputNamed("SMTP_USERNAME")).toHaveAttribute("type", "text");
expect(inputNamed("SMTP_PASSWORD")).toHaveValue("********");
expect(inputNamed("SMTP_SENDER_EMAIL")).toHaveAttribute("type", "text");
expect(inputNamed("TEST_EMAIL_ADDRESS")).toHaveAttribute("type", "text");
expect(inputNamed("EMAIL_LOGO_URL")).toHaveAttribute("type", "text");
expect(fieldNamed("SMTP_HOST")).toHaveValue("smtp.example.com");
expect(fieldNamed("SMTP_PORT")).toHaveValue(587);
expect(fieldNamed("SMTP_PORT")).toHaveAttribute("type", "number");
expect(fieldNamed("SMTP_HOST")).toHaveAttribute("type", "text");
expect(fieldNamed("SMTP_TLS")).toHaveValue("True");
expect(fieldNamed("SMTP_TLS").tagName).toBe("SELECT");
expect(fieldNamed("SMTP_USE_SSL")).toHaveValue("False");
expect(fieldNamed("SMTP_USE_SSL").tagName).toBe("SELECT");
expect(fieldNamed("SMTP_USERNAME")).toHaveAttribute("type", "text");
expect(fieldNamed("SMTP_PASSWORD")).toHaveValue("********");
expect(fieldNamed("SMTP_SENDER_EMAIL")).toHaveAttribute("type", "text");
expect(fieldNamed("TEST_EMAIL_ADDRESS")).toHaveAttribute("type", "text");
expect(fieldNamed("EMAIL_LOGO_URL")).toHaveAttribute("type", "text");
expect(document.querySelector('input[name="SLACK_WEBHOOK_URL"]')).toBeNull();
});
@ -83,22 +86,22 @@ describe("EmailSettings", () => {
it("only requires the SMTP host and sender email", () => {
renderWithProviders(<EmailSettings accessToken="sk-test" premiumUser alerts={alerts} />);
expect(inputNamed("SMTP_HOST")).toBeRequired();
expect(inputNamed("SMTP_SENDER_EMAIL")).toBeRequired();
expect(inputNamed("SMTP_PORT")).not.toBeRequired();
expect(inputNamed("SMTP_TLS")).not.toBeRequired();
expect(inputNamed("SMTP_USE_SSL")).not.toBeRequired();
expect(inputNamed("SMTP_USERNAME")).not.toBeRequired();
expect(inputNamed("SMTP_PASSWORD")).not.toBeRequired();
expect(inputNamed("TEST_EMAIL_ADDRESS")).not.toBeRequired();
expect(fieldNamed("SMTP_HOST")).toBeRequired();
expect(fieldNamed("SMTP_SENDER_EMAIL")).toBeRequired();
expect(fieldNamed("SMTP_PORT")).not.toBeRequired();
expect(fieldNamed("SMTP_TLS")).not.toBeRequired();
expect(fieldNamed("SMTP_USE_SSL")).not.toBeRequired();
expect(fieldNamed("SMTP_USERNAME")).not.toBeRequired();
expect(fieldNamed("SMTP_PASSWORD")).not.toBeRequired();
expect(fieldNamed("TEST_EMAIL_ADDRESS")).not.toBeRequired();
});
it("submits only the fields the admin actually edited", async () => {
const user = userEvent.setup();
renderWithProviders(<EmailSettings accessToken="sk-test" premiumUser alerts={alerts} />);
await user.clear(inputNamed("SMTP_HOST"));
await user.type(inputNamed("SMTP_HOST"), "smtp.changed.com");
await user.clear(fieldNamed("SMTP_HOST"));
await user.type(fieldNamed("SMTP_HOST"), "smtp.changed.com");
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
@ -123,17 +126,33 @@ describe("EmailSettings", () => {
});
});
it("submits empty values when optional credentials are cleared", async () => {
const user = userEvent.setup();
renderWithProviders(<EmailSettings accessToken="sk-test" premiumUser alerts={alerts} />);
await user.clear(fieldNamed("SMTP_USERNAME"));
await user.clear(fieldNamed("SMTP_PASSWORD"));
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(setCallbacksCall).toHaveBeenCalledWith("sk-test", {
general_settings: { alerting: ["email"] },
environment_variables: { SMTP_USERNAME: "", SMTP_PASSWORD: "" },
});
});
});
it("disables the premium-only fields for non-premium users", () => {
renderWithProviders(<EmailSettings accessToken="sk-test" premiumUser={false} alerts={alerts} />);
expect(inputNamed("EMAIL_LOGO_URL")).toBeDisabled();
expect(inputNamed("SMTP_HOST")).not.toBeDisabled();
expect(fieldNamed("EMAIL_LOGO_URL")).toBeDisabled();
expect(fieldNamed("SMTP_HOST")).not.toBeDisabled();
});
it("leaves the premium-only fields editable for premium users", () => {
renderWithProviders(<EmailSettings accessToken="sk-test" premiumUser alerts={alerts} />);
expect(inputNamed("EMAIL_LOGO_URL")).not.toBeDisabled();
expect(fieldNamed("EMAIL_LOGO_URL")).not.toBeDisabled();
});
it("triggers a live email health check", async () => {

View file

@ -21,6 +21,8 @@ const FIELD_TYPES: Record<string, React.HTMLInputTypeAttribute> = {
SMTP_PASSWORD: "password",
};
const BOOLEAN_FIELDS = new Set(["SMTP_TLS", "SMTP_USE_SSL"]);
const FIELD_HELP: Record<string, React.ReactNode> = {
SMTP_HOST: <>Enter the SMTP host address, e.g. `smtp.resend.com`{REQUIRED_MARKER}</>,
SMTP_PORT: <>Enter the SMTP port number, e.g. `587`</>,
@ -50,18 +52,18 @@ const EmailSettings: React.FC<EmailSettingsProps> = ({ accessToken, premiumUser,
.filter((alert) => alert.name === "email")
.forEach((alert) => {
Object.entries(alert.variables ?? {}).forEach(([key, value]) => {
const inputElement = document.querySelector(`input[name="${key}"]`) as HTMLInputElement;
if (!inputElement || !inputElement.value) {
const fieldElement = document.querySelector(`[name="${key}"]`) as HTMLInputElement | HTMLSelectElement;
if (!fieldElement) {
return;
}
// Only send fields the admin actually edited. Values rendered from the
// server are masked (SMTP_PASSWORD) or sourced from the process
// environment, so re-submitting an untouched field would persist a mask
// or copy env-managed config into the database.
if (inputElement.value === (value == null ? "" : String(value))) {
if (fieldElement.value === (value == null ? "" : String(value))) {
return;
}
updatedVariables[key] = inputElement.value;
updatedVariables[key] = fieldElement.value;
});
});
@ -122,14 +124,27 @@ const EmailSettings: React.FC<EmailSettingsProps> = ({ accessToken, premiumUser,
) : (
<p className="text-sm">{key}</p>
)}
<Input
name={key}
defaultValue={value as string | undefined}
type={FIELD_TYPES[key] ?? "text"}
required={REQUIRED_FIELDS.has(key)}
disabled={isLocked}
className="max-w-100"
/>
{BOOLEAN_FIELDS.has(key) ? (
<select
name={key}
defaultValue={value as string | undefined}
required={REQUIRED_FIELDS.has(key)}
disabled={isLocked}
className="max-w-100 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="True">True</option>
<option value="False">False</option>
</select>
) : (
<Input
name={key}
defaultValue={value as string | undefined}
type={FIELD_TYPES[key] ?? "text"}
required={REQUIRED_FIELDS.has(key)}
disabled={isLocked}
className="max-w-100"
/>
)}
<div className="text-xs text-muted-foreground italic">{FIELD_HELP[key]}</div>
</div>
);