diff --git a/client/src/Components/Inputs/Select/index.jsx b/client/src/Components/Inputs/Select/index.jsx index 2aa979434..927247d77 100644 --- a/client/src/Components/Inputs/Select/index.jsx +++ b/client/src/Components/Inputs/Select/index.jsx @@ -156,7 +156,7 @@ const Select = ({ }; Select.propTypes = { - id: PropTypes.string.isRequired, + id: PropTypes.string, name: PropTypes.string, label: PropTypes.string, placeholder: PropTypes.string, diff --git a/client/src/Components/Inputs/TextInput/index.jsx b/client/src/Components/Inputs/TextInput/index.jsx index 58e0e17eb..bb2df5d21 100644 --- a/client/src/Components/Inputs/TextInput/index.jsx +++ b/client/src/Components/Inputs/TextInput/index.jsx @@ -141,9 +141,9 @@ TextInput.displayName = "TextInput"; TextInput.propTypes = { type: PropTypes.string, - id: PropTypes.string.isRequired, + id: PropTypes.string, name: PropTypes.string, - value: PropTypes.string, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), placeholder: PropTypes.string, isRequired: PropTypes.bool, isOptional: PropTypes.bool, diff --git a/client/src/Hooks/useDeleteMonitorStats.js b/client/src/Hooks/useDeleteMonitorStats.js new file mode 100644 index 000000000..64138929f --- /dev/null +++ b/client/src/Hooks/useDeleteMonitorStats.js @@ -0,0 +1,24 @@ +import { useState } from "react"; +import { networkService } from "../main"; +import { createToast } from "../Utils/toastUtils"; +import { useTranslation } from "react-i18next"; + +const UseDeleteMonitorStats = () => { + const { t } = useTranslation(); + const [isLoading, setIsLoading] = useState(false); + const deleteMonitorStats = async ({ teamId }) => { + setIsLoading(true); + try { + const res = await networkService.deleteChecksByTeamId({ teamId }); + createToast({ body: t("settingsStatsCleared") }); + } catch (error) { + createToast({ body: t("settingsFailedToClearStats") }); + } finally { + setIsLoading(false); + } + }; + + return [deleteMonitorStats, isLoading]; +}; + +export { UseDeleteMonitorStats }; diff --git a/client/src/Hooks/useFetchSettings.js b/client/src/Hooks/useFetchSettings.js new file mode 100644 index 000000000..f7651e789 --- /dev/null +++ b/client/src/Hooks/useFetchSettings.js @@ -0,0 +1,55 @@ +import { useState, useEffect } from "react"; +import { networkService } from "../main"; +import { createToast } from "../Utils/toastUtils"; +import { useTranslation } from "react-i18next"; + +const useFetchSettings = ({ setSettingsData }) => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(undefined); + useEffect(() => { + const fetchSettings = async () => { + setIsLoading(true); + try { + const response = await networkService.getAppSettings(); + setSettingsData(response?.data?.data); + } catch (error) { + createToast({ body: "Failed to fetch settings" }); + setError(error); + } finally { + setIsLoading(false); + } + }; + fetchSettings(); + }, []); + + return [isLoading, error]; +}; + +const useSaveSettings = () => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(undefined); + const { t } = useTranslation(); + + const saveSettings = async (settings) => { + setIsLoading(true); + try { + await networkService.updateAppSettings({ settings }); + if (settings.checkTTL) { + await networkService.updateChecksTTL({ + ttl: settings.checkTTL, + }); + } + createToast({ body: t("settingsSuccessSaved") }); + } catch (error) { + createToast({ body: t("settingsFailedToSave") }); + + setError(error); + } finally { + setIsLoading(false); + } + }; + + return [isLoading, error, saveSettings]; +}; + +export { useFetchSettings, useSaveSettings }; diff --git a/client/src/Pages/Settings/SettingsAbout.jsx b/client/src/Pages/Settings/SettingsAbout.jsx new file mode 100644 index 000000000..b2b5df308 --- /dev/null +++ b/client/src/Pages/Settings/SettingsAbout.jsx @@ -0,0 +1,32 @@ +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import ConfigBox from "../../Components/ConfigBox"; +// Utils +import { useTheme } from "@emotion/react"; +import { useTranslation } from "react-i18next"; +import Link from "../../Components/Link"; + +const SettingsAbout = () => { + const theme = useTheme(); + const { t } = useTranslation(); + return ( + + + {t("settingsAbout")} + + + Checkmate {2.0} + + {t("settingsDevelopedBy")} + + + + + ); +}; + +export default SettingsAbout; diff --git a/client/src/Pages/Settings/SettingsDemoMonitors.jsx b/client/src/Pages/Settings/SettingsDemoMonitors.jsx new file mode 100644 index 000000000..1a592e776 --- /dev/null +++ b/client/src/Pages/Settings/SettingsDemoMonitors.jsx @@ -0,0 +1,90 @@ +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import Button from "@mui/material/Button"; +import ConfigBox from "../../Components/ConfigBox"; +// Utils +import { useTheme } from "@emotion/react"; +import { PropTypes } from "prop-types"; +import { useTranslation } from "react-i18next"; +import Dialog from "../../Components/Dialog"; +import { useState } from "react"; + +const SettingsDemoMonitors = ({ HEADER_SX, handleChange, isLoading }) => { + const { t } = useTranslation(); + const theme = useTheme(); + // Local state + const [isOpen, setIsOpen] = useState(false); + return ( + <> + + + {t("settingsDemoMonitors")} + {t("settingsDemoMonitorsDescription")} + + + {t("settingsAddDemoMonitors")} + + + + + + {t("settingsSystemReset")} + + {t("settingsSystemResetDescription")} + + + + {t("settingsRemoveAllMonitors")} + + + setIsOpen(false)} + confirmationButtonLabel={t("settingsRemoveAllMonitorsDialogConfirm")} + onConfirm={() => { + const syntheticEvent = { + target: { + name: "deleteMonitors", + }, + }; + handleChange(syntheticEvent); + setIsOpen(false); + }} + isLoading={isLoading} + /> + + + ); +}; + +SettingsDemoMonitors.propTypes = { + handleChange: PropTypes.func, + HEADER_SX: PropTypes.object, +}; + +export default SettingsDemoMonitors; diff --git a/client/src/Pages/Settings/SettingsEmail.jsx b/client/src/Pages/Settings/SettingsEmail.jsx new file mode 100644 index 000000000..ecc2a51f2 --- /dev/null +++ b/client/src/Pages/Settings/SettingsEmail.jsx @@ -0,0 +1,126 @@ +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import ConfigBox from "../../Components/ConfigBox"; +import TextInput from "../../Components/Inputs/TextInput"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +// Utils +import { useTheme } from "@emotion/react"; +import { PropTypes } from "prop-types"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { PasswordEndAdornment } from "../../Components/Inputs/TextInput/Adornments"; +const SettingsEmail = ({ + HEADER_SX, + handleChange, + settingsData, + setSettingsData, + isPasswordSet, +}) => { + const { t } = useTranslation(); + const theme = useTheme(); + + const [password, setPassword] = useState(""); + const [hasBeenReset, setHasBeenReset] = useState(false); + + const handlePasswordChange = (e) => { + setPassword(e.target.value); + setSettingsData({ + ...settingsData, + settings: { ...settingsData.settings, systemEmailPassword: e.target.value }, + }); + }; + + return ( + + + {t("settingsEmail")} + {t("settingsEmailDescription")} + + + + + {t("settingsEmailHost")} + + + + {t("settingsEmailPort")} + + + + {t("settingsEmailUser")} + + + + {t("settingsEmailAddress")} + + + {(isPasswordSet === false || hasBeenReset === true) && ( + + {t("settingsEmailPassword")} + } + /> + + )} + {isPasswordSet === true && hasBeenReset === false && ( + + {t("settingsEmailFieldResetLabel")} + + + )} + + + + ); +}; + +SettingsEmail.propTypes = { + settingsData: PropTypes.object, + setSettingsData: PropTypes.func, + handleChange: PropTypes.func, + HEADER_SX: PropTypes.object, + isPasswordSet: PropTypes.bool, +}; + +export default SettingsEmail; diff --git a/client/src/Pages/Settings/SettingsPagespeed.jsx b/client/src/Pages/Settings/SettingsPagespeed.jsx new file mode 100644 index 000000000..95b159fa5 --- /dev/null +++ b/client/src/Pages/Settings/SettingsPagespeed.jsx @@ -0,0 +1,87 @@ +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import Button from "@mui/material/Button"; +import ConfigBox from "../../Components/ConfigBox"; +import TextInput from "../../Components/Inputs/TextInput"; +import { PasswordEndAdornment } from "../../Components/Inputs/TextInput/Adornments"; +// Utils +import { useTheme } from "@emotion/react"; +import { PropTypes } from "prop-types"; +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; + +const SettingsPagespeed = ({ + HEADING_SX, + settingsData, + setSettingsData, + isApiKeySet, +}) => { + const { t } = useTranslation(); + const theme = useTheme(); + + // Local state + const [apiKey, setApiKey] = useState(""); + const [hasBeenReset, setHasBeenReset] = useState(false); + + // Handler + const handleChange = (e) => { + setApiKey(e.target.value); + setSettingsData({ + ...settingsData, + settings: { ...settingsData.settings, pagespeedApiKey: e.target.value }, + }); + }; + + return ( + + + {t("pageSpeedApiKeyFieldTitle")} + {t("pageSpeedApiKeyFieldDescription")} + + + {(isApiKeySet === false || hasBeenReset === true) && ( + } + /> + )} + + {isApiKeySet === true && hasBeenReset === false && ( + + {t("pageSpeedApiKeyFieldResetLabel")} + + + )} + + + ); +}; + +SettingsPagespeed.propTypes = { + HEADING_SX: PropTypes.object, + settingsData: PropTypes.object, + setSettingsData: PropTypes.func, + isApiKeySet: PropTypes.bool, +}; + +export default SettingsPagespeed; diff --git a/client/src/Pages/Settings/SettingsStats.jsx b/client/src/Pages/Settings/SettingsStats.jsx new file mode 100644 index 000000000..961f13665 --- /dev/null +++ b/client/src/Pages/Settings/SettingsStats.jsx @@ -0,0 +1,84 @@ +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import ConfigBox from "../../Components/ConfigBox"; +import TextInput from "../../Components/Inputs/TextInput"; +import Dialog from "../../Components/Dialog"; + +// Utils +import { useTheme } from "@emotion/react"; +import { PropTypes } from "prop-types"; +import { useTranslation } from "react-i18next"; +import { useState } from "react"; + +const SettingsStats = ({ HEADING_SX, handleChange, settingsData, errors }) => { + const theme = useTheme(); + const { t } = useTranslation(); + const [isOpen, setIsOpen] = useState(false); + return ( + + + + {t("settingsHistoryAndMonitoring")} + + + {t("settingsHistoryAndMonitoringDescription")} + + + + + + {t("settingsClearAllStats")} + + + + setIsOpen(false)} + confirmationButtonLabel={t("settingsClearAllStatsDialogConfirm")} + onConfirm={() => { + const syntheticEvent = { + target: { + name: "deleteStats", + }, + }; + handleChange(syntheticEvent); + setIsOpen(false); + }} + isLoading={false} + /> + + ); +}; + +SettingsStats.propTypes = { + HEADING_SX: PropTypes.object, + handleChange: PropTypes.func, + settingsData: PropTypes.object, + errors: PropTypes.object, +}; + +export default SettingsStats; diff --git a/client/src/Pages/Settings/SettingsTimeZone.jsx b/client/src/Pages/Settings/SettingsTimeZone.jsx new file mode 100644 index 000000000..e6ba1a77b --- /dev/null +++ b/client/src/Pages/Settings/SettingsTimeZone.jsx @@ -0,0 +1,43 @@ +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import ConfigBox from "../../Components/ConfigBox"; +import Select from "../../Components/Inputs/Select"; +import timezones from "../../Utils/timezones.json"; + +// Utils +import { useTheme } from "@emotion/react"; +import { PropTypes } from "prop-types"; +import { useTranslation } from "react-i18next"; +const SettingsTimeZone = ({ HEADING_SX, handleChange, timezone }) => { + const theme = useTheme(); + const { t } = useTranslation(); + return ( + + + {t("settingsGeneralSettings")} + + {t("settingsDisplayTimezone")}-{" "} + {t("settingsDisplayTimezoneDescription")} + + + + + + + + ); +}; + +SettingsUI.propTypes = { + HEADING_SX: PropTypes.object, + handleChange: PropTypes.func, + mode: PropTypes.string, + language: PropTypes.string, +}; + +export default SettingsUI; diff --git a/client/src/Pages/Settings/index.jsx b/client/src/Pages/Settings/index.jsx index 79e5ea657..eb516ba01 100644 --- a/client/src/Pages/Settings/index.jsx +++ b/client/src/Pages/Settings/index.jsx @@ -1,116 +1,69 @@ -// Components -import { Box, Stack, Typography, Button } from "@mui/material"; -import TextInput from "../../Components/Inputs/TextInput"; -import Link from "../../Components/Link"; -import Select from "../../Components/Inputs/Select"; -import { useIsAdmin } from "../../Hooks/useIsAdmin"; -import Dialog from "../../Components/Dialog"; -import ConfigBox from "../../Components/ConfigBox"; -import { PasswordEndAdornment } from "../../Components/Inputs/TextInput/Adornments"; -import { getAppSettings } from "../../Features/Settings/settingsSlice"; -// import { -// WalletMultiButton, -// WalletDisconnectButton, -// } from "@solana/wallet-adapter-react-ui"; - -//Utils -import { useTheme } from "@emotion/react"; -import { logger } from "../../Utils/Logger"; -import { useDispatch, useSelector } from "react-redux"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import Breadcrumbs from "../../Components/Breadcrumbs"; +import SettingsTimeZone from "./SettingsTimeZone"; +import SettingsUI from "./SettingsUI"; +import SettingsPagespeed from "./SettingsPagespeed"; +import SettingsDemoMonitors from "./SettingsDemoMonitors"; +import SettingsAbout from "./SettingsAbout"; +import SettingsEmail from "./SettingsEmail"; +import Button from "@mui/material/Button"; +// Utils +import { settingsValidation } from "../../Validation/validation"; import { createToast } from "../../Utils/toastUtils"; +import { useState } from "react"; +import { useTheme } from "@emotion/react"; +import { useTranslation } from "react-i18next"; +import { useSelector, useDispatch } from "react-redux"; +import { setTimezone, setMode, setLanguage } from "../../Features/UI/uiSlice"; +import SettingsStats from "./SettingsStats"; import { deleteMonitorChecksByTeamId, addDemoMonitors, deleteAllMonitors, } from "../../Features/UptimeMonitors/uptimeMonitorsSlice"; -import { update } from "../../Features/Auth/authSlice"; -import PropTypes from "prop-types"; -import { setTimezone, setMode, setLanguage } from "../../Features/UI/uiSlice"; -import timezones from "../../Utils/timezones.json"; -import { useState, useEffect } from "react"; -import { networkService } from "../../main"; -import { settingsValidation } from "../../Validation/validation"; -import { updateAppSettings } from "../../Features/Settings/settingsSlice"; -import { useTranslation } from "react-i18next"; +import { useFetchSettings, useSaveSettings } from "../../Hooks/useFetchSettings"; +import { UseDeleteMonitorStats } from "../../Hooks/useDeleteMonitorStats"; // Constants -const SECONDS_PER_DAY = 86400; +const BREADCRUMBS = [{ name: `Settings`, path: "/settings" }]; const Settings = () => { - const theme = useTheme(); - const { t, i18n } = useTranslation(); - const isAdmin = useIsAdmin(); + // Redux state + const { mode, language, timezone } = useSelector((state) => state.ui); const { user } = useSelector((state) => state.auth); - const { language } = useSelector((state) => state.ui); - const { checkTTL } = user; - const { isLoading } = useSelector((state) => state.uptimeMonitors); - const { isLoading: authIsLoading } = useSelector((state) => state.auth); - const { timezone, distributedUptimeEnabled } = useSelector((state) => state.ui); - const { mode } = useSelector((state) => state.ui); - const { pagespeedApiKey } = useSelector((state) => state.settings); - const [checksIsLoading, setChecksIsLoading] = useState(false); - const [form, setForm] = useState({ - enableDistributedUptime: distributedUptimeEnabled, - ttl: checkTTL ? (checkTTL / SECONDS_PER_DAY).toString() : 0, - pagespeedApiKey: pagespeedApiKey, + + // Local state + const [settingsData, setSettingsData] = useState({}); + const [errors, setErrors] = useState({}); + // Network + const [isSettingsLoading, settingsError] = useFetchSettings({ + setSettingsData, }); - const [version, setVersion] = useState("unknown"); - const [apiKeyFieldType, setApiKeyFieldType] = useState("password"); - const [isApiKeySet, setIsApiKeySet] = useState(pagespeedApiKey ? true : false); - const [tempPSKey, setTempPSKey] = useState(""); - const [errors, setErrors] = useState({}); - const deleteStatsMonitorsInitState = { deleteMonitors: false, deleteStats: false }; - const [isOpen, setIsOpen] = useState(deleteStatsMonitorsInitState); + const [isSaving, saveError, saveSettings] = useSaveSettings(); + const [deleteMonitorStats, isDeletingMonitorStats] = UseDeleteMonitorStats(); + + // Setup + const theme = useTheme(); + const HEADING_SX = { mt: theme.spacing(2), mb: theme.spacing(2) }; + const { t, i18n } = useTranslation(); const dispatch = useDispatch(); - //Fetching latest release version from github - useEffect(() => { - const fetchLatestVersion = async () => { - let version = "unknown"; - try { - const response = await networkService.fetchGithubLatestRelease(); - if (!response.status === 200) { - throw new Error("Failed to fetch latest version"); - } - version = response.data.tag_name; - } catch (error) { - createToast({ body: error.message || "Error fetching latest version" }); // Set error message - } finally { - setVersion(version); - } + // Handlers + const handleChange = async (e) => { + const { name, value } = e.target; + + // Build next state early + const newSettingsData = { + ...settingsData, + settings: { ...settingsData.settings, [name]: value }, }; - fetchLatestVersion(); - }, []); - - useEffect(() => { - dispatch(getAppSettings()); - }, [dispatch]); - - const handleChange = (event) => { - const { type, checked, value, id } = event.target; - - if (type === "checkbox") { - setForm((prev) => ({ - ...prev, - [id]: checked, - })); - return; - } - - if (id === "pagespeedApiKey") { - setTempPSKey(value); - return; - } - - let inputValue = value; - if (id === "ttl") { - inputValue = value.replace(/[^0-9]/g, ""); - } - - const updatedForm = { ...form, [id]: inputValue }; - const { error } = settingsValidation.validate(updatedForm, { abortEarly: false }); + // Validate + const { error } = settingsValidation.validate(newSettingsData.settings, { + abortEarly: false, + }); if (!error || error.details.length === 0) { setErrors({}); } else { @@ -119,397 +72,130 @@ const Settings = () => { newErrors[err.path[0]] = err.message; }); setErrors(newErrors); - logger.error("Validation errors:", error.details); } - setForm(updatedForm); - }; - - // TODO Handle saving - const handleSave = async () => { - try { - setChecksIsLoading(true); - await networkService.updateChecksTTL({ - ttl: form.ttl, - }); - const updatedUser = { ...user, checkTTL: form.ttl }; - const [userAction, settingsAction] = await Promise.all([ - dispatch(update({ localData: updatedUser })), - dispatch( - updateAppSettings({ - settings: { - language: language, - pagespeedApiKey: tempPSKey ? tempPSKey : form.pagespeedApiKey, - }, - }) - ), - ]); - - if (userAction.payload.success && settingsAction.payload.success) { - createToast({ body: t("settingsSuccessSaved") }); - } else { - throw new Error("Failed to save settings"); - } - } catch (error) { - createToast({ body: t("settingsFailedToSave") }); - } finally { - setChecksIsLoading(false); + if (name === "timezone") { + dispatch(setTimezone({ timezone: value })); } - }; - const handleClearStats = async () => { - try { - const action = await dispatch(deleteMonitorChecksByTeamId({ teamId: user.teamId })); - - if (deleteMonitorChecksByTeamId.fulfilled.match(action)) { - createToast({ body: t("settingsStatsCleared") }); - } else { - createToast({ body: t("settingsFailedToClearStats") }); - } - } catch (error) { - logger.error(error); - createToast({ body: t("settingsFailedToClearStats") }); - } finally { - setIsOpen(deleteStatsMonitorsInitState); + if (name === "mode") { + dispatch(setMode(value)); } - }; - const handleInsertDemoMonitors = async () => { - try { - const action = await dispatch(addDemoMonitors()); - if (addDemoMonitors.fulfilled.match(action)) { - createToast({ body: t("settingsDemoMonitorsAdded") }); - } else { + if (name === "language") { + dispatch(setLanguage(value)); + i18n.changeLanguage(value); + } + + if (name === "deleteStats") { + await deleteMonitorStats({ teamId: user.teamId }); + return; + } + + if (name === "demo") { + try { + const action = await dispatch(addDemoMonitors()); + if (addDemoMonitors.fulfilled.match(action)) { + createToast({ body: t("settingsDemoMonitorsAdded") }); + } else { + createToast({ body: t("settingsFailedToAddDemoMonitors") }); + } + } catch (error) { createToast({ body: t("settingsFailedToAddDemoMonitors") }); } - } catch (error) { - logger.error(error); - createToast({ Body: t("settingsFailedToAddDemoMonitors") }); + return; } - }; - const handleDeleteAllMonitors = async () => { - try { - const action = await dispatch(deleteAllMonitors()); - if (deleteAllMonitors.fulfilled.match(action)) { - createToast({ body: t("settingsMonitorsDeleted") }); - } else { + if (name === "deleteMonitors") { + try { + const action = await dispatch(deleteAllMonitors()); + if (deleteAllMonitors.fulfilled.match(action)) { + createToast({ body: t("settingsMonitorsDeleted") }); + } else { + createToast({ body: t("settingsFailedToDeleteMonitors") }); + } + } catch (error) { createToast({ body: t("settingsFailedToDeleteMonitors") }); } - } catch (error) { - logger.error(error); - createToast({ Body: t("settingsFailedToDeleteMonitors") }); - } finally { - setIsOpen(deleteStatsMonitorsInitState); + return; } + + setSettingsData(newSettingsData); }; - const handleResetApiKey = () => { - setIsApiKeySet(false); - setForm((prev) => ({ - ...prev, - pagespeedApiKey: "", - })); + const handleSave = () => { + const { error } = settingsValidation.validate(settingsData.settings, { + abortEarly: false, + }); + if (!error || error.details.length === 0) { + setErrors({}); + } else { + const newErrors = {}; + error.details.forEach((err) => { + newErrors[err.path[0]] = err.message; + }); + setErrors(newErrors); + } + saveSettings(settingsData?.settings); }; - const languages = Object.keys(i18n.options.resources || {}); - return ( - + + + Settings + + + + + + + - - - {t("settingsGeneralSettings")} - - {t("settingsDisplayTimezone")}-{" "} - {t("settingsDisplayTimezoneDescription")} - - - - { - dispatch(setMode(e.target.value)); - }} - items={[ - { _id: "light", name: "Light" }, - { _id: "dark", name: "Dark" }, - ]} - > - - - - {/* {isAdmin && ( - - - {t("settingsDistributedUptime")} - - {t("settingsDistributedUptimeDescription")} - - - - { - dispatch(setDistributedUptimeEnabled(e.target.checked)); - }} - /> - {distributedUptimeEnabled === true - ? t("settingsEnabled") - : t("settingsDisabled")} - - - )} */} - - - {t("pageSpeedApiKeyFieldTitle")} - - {t("pageSpeedApiKeyFieldDescription")} - - - - - ) - } - /> - {isApiKeySet && ( - - {t("pageSpeedApiKeyFieldResetLabel")} - - - )} - - - {/* {isAdmin && ( - - - {t("settingsWallet")} - - {t("settingsWalletDescription")} - - - - - - - - - - )} */} - {isAdmin && ( - - - {t("settingsHistoryAndMonitoring")} - - {t("settingsHistoryAndMonitoringDescription")} - - - - - - {t("settingsClearAllStats")} - - - - setIsOpen(deleteStatsMonitorsInitState)} - confirmationButtonLabel={t("settingsClearAllStatsDialogConfirm")} - onConfirm={handleClearStats} - isLoading={isLoading || authIsLoading || checksIsLoading} - /> - - )} - {isAdmin && ( - <> - {/* Demo Monitors Section */} - - - {t("settingsDemoMonitors")} - - {t("settingsDemoMonitorsDescription")} - - - - {t("settingsAddDemoMonitors")} - - - - - {/* System Reset Section */} - - - {t("settingsSystemReset")} - - {t("settingsSystemResetDescription")} - - - - {t("settingsRemoveAllMonitors")} - - - setIsOpen(deleteStatsMonitorsInitState)} - confirmationButtonLabel={t("settingsRemoveAllMonitorsDialogConfirm")} - onConfirm={handleDeleteAllMonitors} - isLoading={isLoading || authIsLoading || checksIsLoading} - /> - - - )} - - - - {t("settingsAbout")} - - - Checkmate {version} - - {t("settingsDevelopedBy")} - - - - - 0} + variant="contained" + color="accent" + sx={{ px: theme.spacing(12), mt: theme.spacing(20) }} + onClick={handleSave} > - - + {t("settingsSave")} + - + ); }; -Settings.propTypes = { - isAdmin: PropTypes.bool, -}; export default Settings; diff --git a/client/src/Validation/validation.js b/client/src/Validation/validation.js index 0ace7b02c..81359d903 100644 --- a/client/src/Validation/validation.js +++ b/client/src/Validation/validation.js @@ -11,7 +11,8 @@ const nameSchema = joi .messages({ "string.empty": "Name is required", "string.max": "Name must be less than 50 characters", - "string.pattern.base": "Name must contain only letters, spaces, apostrophes, or hyphens" + "string.pattern.base": + "Name must contain only letters, spaces, apostrophes, or hyphens", }); const passwordSchema = joi @@ -152,20 +153,21 @@ const monitorValidation = joi.object({ "string.invalidUrl": "Please enter a valid URL with optional port", "string.pattern.base": "Please enter a valid container ID.", }), - port: joi.number() - .integer() - .min(1) - .max(65535) - .when("type", { - is: "port", - then: joi.required().messages({ - "number.base": "Port must be a number.", - "number.min": "Port must be at least 1.", - "number.max": "Port must be at most 65535.", - "any.required": "Port is required for port monitors.", + port: joi + .number() + .integer() + .min(1) + .max(65535) + .when("type", { + is: "port", + then: joi.required().messages({ + "number.base": "Port must be a number.", + "number.min": "Port must be at least 1.", + "number.max": "Port must be at most 65535.", + "any.required": "Port is required for port monitors.", + }), + otherwise: joi.optional(), }), - otherwise: joi.optional(), - }), name: joi.string().trim().max(50).allow("").messages({ "string.max": "This field should not exceed the 50 characters limit.", }), @@ -253,14 +255,19 @@ const statusPageValidation = joi.object({ showCharts: joi.boolean(), }); const settingsValidation = joi.object({ - ttl: joi.number().required().messages({ + checkTTL: joi.number().required().messages({ "string.empty": "Please enter a value", "number.base": "Please enter a valid number", - "any.required": "Please enter a value" + "any.required": "Please enter a value", }), pagespeedApiKey: joi.string().allow("").optional(), -}) -.unknown(true); + language: joi.string().required(), + systemEmailHost: joi.string().allow(""), + systemEmailPort: joi.number().allow(null, ""), + systemEmailAddress: joi.string().allow(""), + systemEmailPassword: joi.string().allow(""), + systemEmailUser: joi.string().allow(""), +}); const dayjsValidator = (value, helpers) => { if (!dayjs(value).isValid()) { @@ -363,9 +370,12 @@ const infrastructureMonitorValidation = joi.object({ notifications: joi.array().items( joi.object({ type: joi.string().valid("email").required(), - address: joi.string().email({ tlds: { allow: false } }).required(), + address: joi + .string() + .email({ tlds: { allow: false } }) + .required(), }) - ) + ), }); export { @@ -377,5 +387,5 @@ export { advancedSettingsValidation, infrastructureMonitorValidation, statusPageValidation, - logoImageValidation + logoImageValidation, }; diff --git a/client/src/locales/gb.json b/client/src/locales/gb.json index 873229835..222d68a7f 100644 --- a/client/src/locales/gb.json +++ b/client/src/locales/gb.json @@ -108,6 +108,14 @@ "settingsFailedToAddDemoMonitors": "Failed to add demo monitors", "settingsMonitorsDeleted": "Successfully deleted all monitors", "settingsFailedToDeleteMonitors": "Failed to delete all monitors", + "settingsEmail": "Email settings", + "settingsEmailDescription": "Configure email settings", + "settingsEmailHost": "Email host", + "settingsEmailPort": "Email port", + "settingsEmailAddress": "Email address", + "settingsEmailPassword": "Email password", + "settingsEmailUser": "Email user", + "settingsEmailFieldResetLabel": "Password is set. Click Reset to change it.", "backendUnreachable": "Server Connection Error", "backendUnreachableMessage": "We're unable to connect to the server. Please check your internet connection or verify your deployment configuration if the problem persists.", "backendUnreachableError": "Cannot connect to the server. Please try again later.", @@ -439,6 +447,7 @@ "pageSpeedApiKeyFieldLabel": "PageSpeed API key", "pageSpeedApiKeyFieldDescription": "Enter your Google PageSpeed API key to enable pagespeed monitoring. Click Reset to update the key.", "pageSpeedApiKeyFieldResetLabel": "API key is set. Click Reset to change it.", + "reset": "Reset", "createNew": "Create new", "greeting": { @@ -502,5 +511,4 @@ "profile": "Profile", "password": "Password", "team": "Team" - } } diff --git a/server/controllers/monitorController.js b/server/controllers/monitorController.js index 5ab654375..9a42ea4e5 100755 --- a/server/controllers/monitorController.js +++ b/server/controllers/monitorController.js @@ -649,9 +649,8 @@ class MonitorController { sendTestEmail = async (req, res, next) => { try { const { to } = req.body; - if (!to || typeof to !== "string") { - return res.error({ msg: this.stringService.errorForValidEmailAddress }); + throw new Error(this.stringService.errorForValidEmailAddress); } const subject = this.stringService.testEmailSubject; diff --git a/server/controllers/settingsController.js b/server/controllers/settingsController.js index 4d561216e..b557e8dd6 100755 --- a/server/controllers/settingsController.js +++ b/server/controllers/settingsController.js @@ -13,12 +13,25 @@ class SettingsController { getAppSettings = async (req, res, next) => { const dbSettings = await this.settingsService.getDBSettings(); const sanitizedSettings = { ...dbSettings }; + + const returnSettings = { + pagespeedKeySet: false, + emailPasswordSet: false, + }; + if (typeof sanitizedSettings.pagespeedApiKey !== "undefined") { - sanitizedSettings.pagespeedApiKey = "********"; + returnSettings.pagespeedKeySet = true; + delete sanitizedSettings.pagespeedApiKey; } + if (typeof sanitizedSettings.systemEmailPassword !== "undefined") { + returnSettings.emailPasswordSet = true; + delete sanitizedSettings.systemEmailPassword; + } + + returnSettings.settings = sanitizedSettings; return res.success({ msg: this.stringService.getAppSettings, - data: sanitizedSettings, + data: returnSettings, }); }; diff --git a/server/db/models/AppSettings.js b/server/db/models/AppSettings.js index 3942305e8..06ba8c1df 100755 --- a/server/db/models/AppSettings.js +++ b/server/db/models/AppSettings.js @@ -2,13 +2,16 @@ import mongoose from "mongoose"; const AppSettingsSchema = mongoose.Schema( { + checkTTL: { + type: Number, + default: 30, + }, language: { type: String, default: "gb", }, pagespeedApiKey: { type: String, - default: "", }, systemEmailHost: { type: String, @@ -22,6 +25,9 @@ const AppSettingsSchema = mongoose.Schema( systemEmailPassword: { type: String, }, + systemEmailUser: { + type: String, + }, singleton: { type: Boolean, required: true, diff --git a/server/db/mongo/modules/settingsModule.js b/server/db/mongo/modules/settingsModule.js index b817d2a32..d3568ad32 100755 --- a/server/db/mongo/modules/settingsModule.js +++ b/server/db/mongo/modules/settingsModule.js @@ -14,12 +14,22 @@ const getAppSettings = async () => { const updateAppSettings = async (newSettings) => { try { - console.log(newSettings); - const settings = await AppSettings.findOneAndUpdate( - {}, - { $set: newSettings }, - { new: true, upsert: true } - ); + const update = { $set: { ...newSettings } }; + + if (newSettings.pagespeedApiKey === "") { + update.$unset = { pagespeedApiKey: "" }; + delete update.$set.pagespeedApiKey; + } + + if (newSettings.systemEmailPassword === "") { + update.$unset = { systemEmailPassword: "" }; + delete update.$set.systemEmailPassword; + } + + const settings = await AppSettings.findOneAndUpdate({}, update, { + new: true, + upsert: true, + }); return settings; } catch (error) { error.service = SERVICE_NAME; diff --git a/server/service/emailService.js b/server/service/emailService.js index c9e34eeda..57f8c78e6 100755 --- a/server/service/emailService.js +++ b/server/service/emailService.js @@ -29,7 +29,10 @@ class EmailService { this.mjml2html = mjml2html; this.nodemailer = nodemailer; this.logger = logger; + this.init(); + } + init = async () => { /** * Loads an email template from the filesystem. * @@ -67,34 +70,14 @@ class EmailService { serverIsUpTemplate: this.loadTemplate("serverIsUp"), passwordResetTemplate: this.loadTemplate("passwordReset"), hardwareIncidentTemplate: this.loadTemplate("hardwareIncident"), - testEmailTemplate: this.loadTemplate("testEmailTemplate") + testEmailTemplate: this.loadTemplate("testEmailTemplate"), }; /** * The email transporter used to send emails. * @type {Object} */ - - const { - systemEmailHost, - systemEmailPort, - systemEmailUser, - systemEmailAddress, - systemEmailPassword, - } = this.settingsService.getSettings(); - - const emailConfig = { - host: systemEmailHost, - port: systemEmailPort, - secure: true, - auth: { - user: systemEmailUser || systemEmailAddress, - pass: systemEmailPassword, - }, - }; - - this.transporter = this.nodemailer.createTransport(emailConfig); - } + }; /** * Asynchronously builds and sends an email using a specified template and context. @@ -106,6 +89,28 @@ class EmailService { * @returns {Promise} A promise that resolves to the messageId of the sent email. */ buildAndSendEmail = async (template, context, to, subject) => { + // TODO - Consider an update transporter method so this only needs to be recreated when smtp settings change + const { + systemEmailHost, + systemEmailPort, + systemEmailUser, + systemEmailAddress, + systemEmailPassword, + } = await this.settingsService.getDBSettings(); + + const emailConfig = { + host: systemEmailHost, + port: systemEmailPort, + secure: true, + auth: { + user: systemEmailUser || systemEmailAddress, + pass: systemEmailPassword, + }, + connectionTimeout: 5000, + }; + + this.transporter = this.nodemailer.createTransport(emailConfig); + const buildHtml = async (template, context) => { try { const mjml = this.templateLookup[template](context); diff --git a/server/service/settingsService.js b/server/service/settingsService.js index 548d76eee..290e645e9 100755 --- a/server/service/settingsService.js +++ b/server/service/settingsService.js @@ -59,7 +59,17 @@ class SettingsService { } async getDBSettings() { - const settings = await this.appSettings.findOne({ singleton: true }).lean(); + let settings = await this.appSettings + .findOne({ singleton: true }) + .select("-__v -_id -createdAt -updatedAt -singleton") + .lean(); + if (settings === null) { + await this.appSettings.create({}); + settings = await this.appSettings + .findOne({ singleton: true }) + .select("-__v -_id -createdAt -updatedAt -singleton") + .lean(); + } return settings; } } diff --git a/server/validation/joi.js b/server/validation/joi.js index bd402fb8a..997d35d65 100755 --- a/server/validation/joi.js +++ b/server/validation/joi.js @@ -424,21 +424,14 @@ const editMaintenanceByIdWindowBodyValidation = joi.object({ // SettingsValidation //**************************************** const updateAppSettingsBodyValidation = joi.object({ - apiBaseUrl: joi.string().allow(""), - logLevel: joi.string().valid("debug", "none", "error", "warn").allow(""), - clientHost: joi.string().allow(""), - dbType: joi.string().allow(""), - dbConnectionString: joi.string().allow(""), - redisHost: joi.string().allow(""), - redisPort: joi.number().allow(null, ""), - redisUrl: joi.string().allow(""), - jwtTTL: joi.string().allow(""), + checkTTL: joi.number().allow(""), pagespeedApiKey: joi.string().allow(""), language: joi.string().allow(""), systemEmailHost: joi.string().allow(""), systemEmailPort: joi.number().allow(""), systemEmailAddress: joi.string().allow(""), systemEmailPassword: joi.string().allow(""), + systemEmailUser: joi.string().allow(""), }); //****************************************