diff --git a/client/src/Hooks/useSettingsForm.ts b/client/src/Hooks/useSettingsForm.ts
index 8f91db451..ee4227970 100644
--- a/client/src/Hooks/useSettingsForm.ts
+++ b/client/src/Hooks/useSettingsForm.ts
@@ -20,14 +20,14 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
systemEmailAddress: data?.systemEmailAddress || "",
systemEmailConnectionHost: data?.systemEmailConnectionHost || "localhost",
systemEmailTLSServername: data?.systemEmailTLSServername || "",
- systemEmailPort: data?.systemEmailPort || "",
+ systemEmailPort: data?.systemEmailPort,
globalThresholds: {
- cpu: data?.globalThresholds?.cpu || "",
- memory: data?.globalThresholds?.memory || "",
- disk: data?.globalThresholds?.disk || "",
- temperature: data?.globalThresholds?.temperature || "",
+ cpu: data?.globalThresholds?.cpu,
+ memory: data?.globalThresholds?.memory,
+ disk: data?.globalThresholds?.disk,
+ temperature: data?.globalThresholds?.temperature,
},
- checkTTL: data?.checkTTL || 30,
+ checkTTL: data?.checkTTL ?? 30,
pagespeedApiKey: "",
systemEmailPassword: "",
};
diff --git a/client/src/Pages/Settings/SettingsStats.jsx b/client/src/Pages/Settings/SettingsStats.jsx
index 8424019c8..45735ee4d 100644
--- a/client/src/Pages/Settings/SettingsStats.jsx
+++ b/client/src/Pages/Settings/SettingsStats.jsx
@@ -75,7 +75,6 @@ const SettingsStats = ({ isAdmin, HEADING_SX, errors }) => {
SettingsStats.propTypes = {
isAdmin: PropTypes.bool,
HEADING_SX: PropTypes.object,
- errors: PropTypes.object,
};
export default SettingsStats;
diff --git a/client/src/Pages/Settings/index.jsx b/client/src/Pages/Settings/index.jsx
index 56ab44cf5..a600e7cec 100644
--- a/client/src/Pages/Settings/index.jsx
+++ b/client/src/Pages/Settings/index.jsx
@@ -12,7 +12,6 @@ import SettingsGlobalThresholds from "./SettingsGlobalThresholds.jsx";
import SettingsExport from "./SettingsExport.jsx";
import Button from "@mui/material/Button";
// Utils
-import { settingsValidation } from "@/Validation/validation.js";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useSettingsForm } from "@/Hooks/useSettingsForm.js";
@@ -31,7 +30,6 @@ const BREADCRUMBS = [{ name: `Settings`, path: "/settings" }];
const Settings = () => {
const { data: fetchedSettings, isLoading: isSettingsLoading } = useGet("/settings");
// Local state
- const [errors, setErrors] = useState({});
const [isApiKeySet, setIsApiKeySet] = useState(
fetchedSettings?.pagespeedKeySet ?? false
);
@@ -57,10 +55,9 @@ const Settings = () => {
trigger,
getValues,
setValue,
- formState: { dirtyFields },
} = form;
useEffect(() => {
- reset(defaults);
+ reset(defaults, { keepDirtyValues: false });
}, [defaults, reset]);
useEffect(() => {
@@ -88,13 +85,23 @@ const Settings = () => {
};
const onSubmit = async (data) => {
- const toSubmit = { ...data };
- if (!form.formState.dirtyFields.systemEmailPassword) {
- delete toSubmit.systemEmailPassword;
- }
- if (!form.formState.dirtyFields.pagespeedApiKey) {
- delete toSubmit.pagespeedApiKey;
- }
+ // Convert undefined to empty strings for proper unsetting on backend
+ const toSubmit = Object.fromEntries(
+ Object.entries(data).map(([key, value]) => {
+ if (value === undefined) return [key, ""];
+ if (typeof value === "object" && value !== null) {
+ // Handle nested objects like globalThresholds
+ return [
+ key,
+ Object.fromEntries(
+ Object.entries(value).map(([k, v]) => [k, v === undefined ? "" : v])
+ ),
+ ];
+ }
+ return [key, value];
+ })
+ );
+
saveSettings(toSubmit);
};
@@ -135,7 +142,6 @@ const Settings = () => {
{
loading={
isSaving || isDeletingMonitorStats || isSettingsLoading || isDeletingMonitors
}
- disabled={Object.keys(errors).length > 0}
+ disabled={!form.formState.isValid}
variant="contained"
color="accent"
sx={{ px: theme.spacing(12), py: theme.spacing(8) }}
diff --git a/client/src/Validation/settings.ts b/client/src/Validation/settings.ts
index 84c6b6ee6..7fb9ddcd8 100644
--- a/client/src/Validation/settings.ts
+++ b/client/src/Validation/settings.ts
@@ -1,56 +1,69 @@
import { z } from "zod";
+// Helper to normalize empty strings to undefined
+const optionalString = () =>
+ z.preprocess(
+ (val) =>
+ val === "" || val === null || val === undefined ? undefined : String(val).trim(),
+ z.string().optional()
+ );
+
+// Helper for threshold fields
+const thresholdField = (min: number, max: number, unit: string) =>
+ z.preprocess(
+ (val) => (val === "" || val === null || val === undefined ? undefined : val),
+ z.coerce
+ .number()
+ .int()
+ .min(min, `Min ${min}${unit}`)
+ .max(max, `Max ${max}${unit}`)
+ .optional()
+ );
+
export const settingsSchema = z.object({
systemEmailIgnoreTLS: z.boolean(),
systemEmailRequireTLS: z.boolean(),
systemEmailRejectUnauthorized: z.boolean(),
- systemEmailConnectionHost: z.string().optional(),
+ systemEmailConnectionHost: optionalString(),
systemEmailSecure: z.boolean().optional(),
systemEmailPool: z.boolean().optional(),
showURL: z.boolean().optional(),
- checkTTL: z.coerce.number().int().min(1, "Please enter a value"),
- pagespeedApiKey: z.string().optional(),
- systemEmailHost: z.string().optional(),
- systemEmailPort: z.coerce
+ checkTTL: z.coerce
.number()
.int()
- .min(1, "Port must be at least 1")
- .optional()
- .or(z.literal("")),
- systemEmailAddress: z
- .email("Please enter a valid email address")
- .transform((val) => val.toLowerCase().trim())
- .optional()
- .or(z.literal("")),
- systemEmailUser: z.string().optional(),
- systemEmailPassword: z.string().optional(),
- systemEmailTLSServername: z.string().optional(),
+ .min(1, "Please enter a value")
+ .max(365, "Maximum 365 days"),
+ pagespeedApiKey: optionalString(),
+ systemEmailHost: z.preprocess(
+ (val) =>
+ val === "" || val === null || val === undefined ? undefined : String(val).trim(),
+ z
+ .string()
+ .regex(/^[a-zA-Z0-9.-]+$/, "Invalid hostname or IP address")
+ .optional()
+ ),
+ systemEmailPort: z.preprocess(
+ (val) => (val === "" || val === null || val === undefined ? undefined : val),
+ z.coerce
+ .number()
+ .int()
+ .min(1, "Port must be at least 1")
+ .max(65535, "Port must be at most 65535")
+ .optional()
+ ),
+ systemEmailAddress: z.preprocess((val) => {
+ if (val === "" || val === null || val === undefined) return undefined;
+ return String(val).toLowerCase().trim();
+ }, z.string().email("Please enter a valid email address").optional()),
+ systemEmailUser: optionalString(),
+ systemEmailPassword: optionalString(),
+ systemEmailTLSServername: optionalString(),
globalThresholds: z
.object({
- cpu: z.coerce
- .number()
- .min(1, "Min 1%")
- .max(100, "Max 100%")
- .optional()
- .or(z.literal("")),
- memory: z.coerce
- .number()
- .min(1, "Min 1%")
- .max(100, "Max 100%")
- .optional()
- .or(z.literal("")),
- disk: z.coerce
- .number()
- .min(1, "Min 1%")
- .max(100, "Max 100%")
- .optional()
- .or(z.literal("")),
- temperature: z.coerce
- .number()
- .min(1, "Min 1°C")
- .max(150, "Max 150°C")
- .optional()
- .or(z.literal("")),
+ cpu: thresholdField(0, 100, "%"),
+ memory: thresholdField(0, 100, "%"),
+ disk: thresholdField(0, 100, "%"),
+ temperature: thresholdField(0, 150, "°C"),
})
.optional(),
});
diff --git a/server/src/repositories/settings/MongoSettingsRepository.ts b/server/src/repositories/settings/MongoSettingsRepository.ts
index 064993fa3..91a087423 100644
--- a/server/src/repositories/settings/MongoSettingsRepository.ts
+++ b/server/src/repositories/settings/MongoSettingsRepository.ts
@@ -2,7 +2,6 @@ import mongoose from "mongoose";
import { ISettingsRepository } from "@/repositories/settings/ISettingsRepository.js";
import type { Settings } from "@/types/index.js";
import { AppSettingsModel, type AppSettingsDocument } from "@/db/models/index.js";
-import { SERVFAIL } from "dns";
class MongoSettingsRepository implements ISettingsRepository {
private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => {
@@ -61,16 +60,25 @@ class MongoSettingsRepository implements ISettingsRepository {
};
update = async (settings: Partial) => {
- const update: Record = { $set: { ...settings } };
+ const update: Record = { $set: {}, $unset: {} };
- if (settings.pagespeedApiKey === "") {
- update.$unset = { pagespeedApiKey: "" };
- delete update.$set.pagespeedApiKey;
+ // Iterate through settings and separate into $set and $unset
+ Object.entries(settings).forEach(([key, value]) => {
+ if (value === undefined || value === "") {
+ // Unset undefined or empty string values
+ update.$unset[key] = "";
+ } else {
+ // Set defined values
+ update.$set[key] = value;
+ }
+ });
+
+ // Clean up empty operators
+ if (Object.keys(update.$set).length === 0) {
+ delete update.$set;
}
-
- if (settings.systemEmailPassword === "") {
- update.$unset = { systemEmailPassword: "" };
- delete update.$set.systemEmailPassword;
+ if (Object.keys(update.$unset).length === 0) {
+ delete update.$unset;
}
await AppSettingsModel.findOneAndUpdate({}, update, {
diff --git a/server/src/validation/joi.ts b/server/src/validation/joi.ts
index 63be09932..912767587 100755
--- a/server/src/validation/joi.ts
+++ b/server/src/validation/joi.ts
@@ -420,8 +420,8 @@ const updateAppSettingsBodyValidation = joi.object({
systemEmailAddress: joi.string().allow(""),
systemEmailPassword: joi.string().allow(""),
systemEmailUser: joi.string().allow(""),
- systemEmailConnectionHost: joi.string().allow("").optional(),
- systemEmailTLSServername: joi.string().allow("").optional(),
+ systemEmailConnectionHost: joi.string().allow(""),
+ systemEmailTLSServername: joi.string().allow(""),
systemEmailSecure: joi.boolean(),
systemEmailPool: joi.boolean(),
systemEmailIgnoreTLS: joi.boolean(),
@@ -430,10 +430,10 @@ const updateAppSettingsBodyValidation = joi.object({
globalThresholds: joi
.object({
- cpu: joi.number().min(1).max(100).allow("").optional(),
- memory: joi.number().min(1).max(100).allow("").optional(),
- disk: joi.number().min(1).max(100).allow("").optional(),
- temperature: joi.number().min(1).max(150).allow("").optional(),
+ cpu: joi.number().min(1).max(100).allow(""),
+ memory: joi.number().min(1).max(100).allow(""),
+ disk: joi.number().min(1).max(100).allow(""),
+ temperature: joi.number().min(1).max(150).allow(""),
})
.optional(),
});