diff --git a/client/src/components/design-elements/SplitBox.tsx b/client/src/components/design-elements/SplitBox.tsx index 044c1d137..301c1e080 100644 --- a/client/src/components/design-elements/SplitBox.tsx +++ b/client/src/components/design-elements/SplitBox.tsx @@ -1,4 +1,3 @@ -import { Fragment } from "react"; import Stack from "@mui/material/Stack"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; @@ -46,21 +45,25 @@ export const SplitBox = ({ export const ConfigBox = ({ title, subtitle, + leftContent, rightContent, }: { title: string; subtitle: string; + leftContent?: React.ReactNode; rightContent: React.ReactNode; }) => { + const theme = useTheme(); return ( + {title} {subtitle} - + {leftContent} + } right={rightContent} /> diff --git a/client/src/components/inputs/Switch.tsx b/client/src/components/inputs/Switch.tsx new file mode 100644 index 000000000..b0b91a3fc --- /dev/null +++ b/client/src/components/inputs/Switch.tsx @@ -0,0 +1,37 @@ +import { forwardRef } from "react"; +import Switch from "@mui/material/Switch"; +import type { SwitchProps } from "@mui/material/Switch"; +import { useTheme } from "@mui/material/styles"; + +export const SwitchComponent = forwardRef( + function SwitchComponent({ sx, ...props }, ref) { + const theme = useTheme(); + const additionalSx = Array.isArray(sx) ? sx : sx ? [sx] : []; + + return ( + + ); + } +); diff --git a/client/src/components/inputs/index.tsx b/client/src/components/inputs/index.tsx index d6cb384ac..468b76947 100644 --- a/client/src/components/inputs/index.tsx +++ b/client/src/components/inputs/index.tsx @@ -16,3 +16,4 @@ export { DateTimePicker } from "./DateTimePicker"; export { FormControlLabel } from "./FormControlLabel"; export { FieldLabel } from "./FieldLabel"; export { FormField } from "./FormField"; +export { SwitchComponent as Switch } from "./Switch"; diff --git a/client/src/hooks/UseApi.tsx b/client/src/hooks/UseApi.tsx index 04353e25d..d62386cb7 100644 --- a/client/src/hooks/UseApi.tsx +++ b/client/src/hooks/UseApi.tsx @@ -70,7 +70,7 @@ export const usePost = () => { const currentTeamId = useAppSelector((state) => state.auth.selectedTeamId); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const { toastError } = useToast(); + const { toastError, toastSuccess } = useToast(); const postFn = async ( endpoint: string, @@ -88,10 +88,13 @@ export const usePost = () => { "x-team-id": currentTeamId, }, }); + toastSuccess(res.data?.message || "Operation successful"); + return res.data; } catch (err: any) { const errMsg = - err?.response?.data?.msg || err.message || "An error occurred"; + err?.response?.data?.message || err.message || "An error occurred"; + console.error(err); toastError(errMsg); setError(errMsg); return null; @@ -128,8 +131,9 @@ export const usePatch = () => { toastSuccess(res.data?.message || "Operation successful"); return res.data; } catch (err: any) { + console.error(err); const errMsg = - err?.response?.data?.msg || err.message || "An error occurred"; + err?.response?.data?.message || err.message || "An error occurred"; toastError(errMsg); setError(errMsg); return null; @@ -165,8 +169,9 @@ export const useDelete = () => { toastSuccess(res.data?.message || "Operation successful"); return res.data; } catch (err: any) { + console.error(err); const errMsg = - err?.response?.data?.msg || err.message || "An error occurred"; + err?.response?.data?.message || err.message || "An error occurred"; toastError(errMsg); setError(errMsg); return null; diff --git a/client/src/hooks/forms/UseInitSettingsForm.tsx b/client/src/hooks/forms/UseInitSettingsForm.tsx new file mode 100644 index 000000000..ad32a57ea --- /dev/null +++ b/client/src/hooks/forms/UseInitSettingsForm.tsx @@ -0,0 +1,29 @@ +import { systemSettingsSchema } from "@/validation/zod"; +import { useMemo } from "react"; + +import { z } from "zod"; +export const useInitForm = ({ + initialData, +}: { + initialData: Partial> | undefined; +}) => { + return useMemo(() => { + const defaults: z.infer = { + systemEmailHost: initialData?.systemEmailHost || "", + systemEmailPort: initialData?.systemEmailPort ?? undefined, + systemEmailAddress: initialData?.systemEmailAddress || "", + systemEmailPassword: initialData?.systemEmailPassword || "", + systemEmailUser: initialData?.systemEmailUser || "", + systemEmailConnectionHost: + initialData?.systemEmailConnectionHost || "localhost", + systemEmailTLSServername: initialData?.systemEmailTLSServername || "", + systemEmailSecure: initialData?.systemEmailSecure ?? false, + systemEmailPool: initialData?.systemEmailPool ?? false, + systemEmailIgnoreTLS: initialData?.systemEmailIgnoreTLS ?? false, + systemEmailRequireTLS: initialData?.systemEmailRequireTLS ?? false, + systemEmailRejectUnauthorized: + initialData?.systemEmailRejectUnauthorized ?? true, + }; + return { defaults }; + }, [initialData]); +}; diff --git a/client/src/pages/settings/Settings.tsx b/client/src/pages/settings/Settings.tsx index 6e89e28d0..dcebaac75 100644 --- a/client/src/pages/settings/Settings.tsx +++ b/client/src/pages/settings/Settings.tsx @@ -3,16 +3,23 @@ import { BasePage, ConfigBox } from "@/components/design-elements"; import { AutoComplete, Select, LanguageSelector } from "@/components/inputs"; import MenuItem from "@mui/material/MenuItem"; import Typography from "@mui/material/Typography"; +import { SettingsForm } from "@/pages/settings/SettingsForm"; import { useTheme } from "@mui/material/styles"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { setTimezone, setMode } from "@/features/uiSlice"; import { useAppSelector, useAppDispatch } from "@/hooks/AppHooks"; import timezones from "@/utils/timezones.json"; import type { ITimeZone } from "@/types/timezone"; import type { SelectChangeEvent } from "@mui/material/Select"; +import { useGet, usePatch, usePost } from "@/hooks/UseApi"; +import type { ApiResponse } from "@/hooks/UseApi"; +import { z } from "zod"; +import { systemSettingsSchema } from "@/validation/zod"; const SettingsPage = () => { + type FormValues = z.infer; + const theme = useTheme(); const dispatch = useAppDispatch(); const timezone = useAppSelector((state) => state.ui.timezone); @@ -24,13 +31,29 @@ const SettingsPage = () => { const tz = timezones.find((tz) => tz._id === timezone); setSelectedTimezone(tz || null); }, [timezone]); + + const { post, loading: posting } = usePost, any>(); + const { patch, loading: patching } = usePatch, any>(); + + const { response, loading } = useGet>("/settings"); + const settings = response?.data || {}; + const initialSettings = useMemo(() => settings, [settings]); + + const onSubmit = async (data: Partial) => { + const res = await patch("/settings", data); + console.log(res?.data); + }; + + const onTest = async (data: Partial) => { + const res = await post("/settings/test-transport", data); + }; return ( + Time zone @@ -62,6 +85,12 @@ const SettingsPage = () => { } /> + ); }; diff --git a/client/src/pages/settings/SettingsForm.tsx b/client/src/pages/settings/SettingsForm.tsx index e69de29bb..1cec295e8 100644 --- a/client/src/pages/settings/SettingsForm.tsx +++ b/client/src/pages/settings/SettingsForm.tsx @@ -0,0 +1,319 @@ +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import { Button, TextInput, Switch, TextLink } from "@/components/inputs"; +import { ConfigBox } from "@/components/design-elements"; + +import { useTheme } from "@mui/material/styles"; +import { useTranslation } from "react-i18next"; +import { systemSettingsSchema } from "@/validation/zod"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm, Controller, type SubmitHandler } from "react-hook-form"; +import { useInitForm } from "@/hooks/forms/UseInitSettingsForm"; +import { useEffect } from "react"; +import { Typography } from "@mui/material"; +type FormValues = z.infer; + +export const SettingsForm = ({ + initialData, + onSubmit, + onTest, + loading, +}: { + initialData?: Partial; + onSubmit: SubmitHandler; + onTest: (data: Partial) => Promise; + loading?: boolean; +}) => { + const { defaults } = useInitForm({ initialData: initialData }); + const { t } = useTranslation(); + const theme = useTheme(); + const { + handleSubmit, + control, + reset, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(systemSettingsSchema) as any, + defaultValues: defaults, + mode: "onChange", + }); + + const formValues = watch(); + + useEffect(() => { + reset(defaults); + }, [initialData, reset, defaults]); + + return ( + + + + + + {JSON.stringify( + { + host: formValues.systemEmailHost, + port: formValues.systemEmailPort, + secure: formValues.systemEmailSecure, + auth: { + user: + formValues.systemEmailUser || + formValues.systemEmailAddress, + pass: "", + }, + name: formValues.systemEmailConnectionHost || "localhost", + pool: formValues.systemEmailPool, + tls: { + rejectUnauthorized: + formValues.systemEmailRejectUnauthorized, + ignoreTLS: formValues.systemEmailIgnoreTLS, + requireTLS: formValues.systemEmailRequireTLS, + servername: formValues.systemEmailTLSServername, + }, + }, + null, + 2 + )} + + + + } + rightContent={ + + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + + + {t("settingsPage.emailSettings.labelSecure")} + + + )} + /> + ( + + + + + {t("settingsPage.emailSettings.labelPool")} + + + )} + /> + ( + + + + + {t("settingsPage.emailSettings.labelIgnoreTLS")} + + + )} + /> + ( + + + + + {t("settingsPage.emailSettings.labelRequireTLS")} + + + )} + /> + ( + + + + + {t("settingsPage.emailSettings.labelRejectUnauthorized")} + + + )} + /> + + } + /> + + + + + + ); +}; diff --git a/client/src/types/system-settings.ts b/client/src/types/system-settings.ts new file mode 100644 index 000000000..51f7062f1 --- /dev/null +++ b/client/src/types/system-settings.ts @@ -0,0 +1,14 @@ +export interface ISystemSettings extends Document { + systemEmailHost?: string; + systemEmailPort?: number; + systemEmailAddress?: string; + systemEmailPassword?: string; + systemEmailUser?: string; + systemEmailConnectionHost?: string; + systemEmailTLSServername?: string; + systemEmailSecure: boolean; + systemEmailPool: boolean; + systemEmailIgnoreTLS: boolean; + systemEmailRequireTLS: boolean; + systemEmailRejectUnauthorized: boolean; +} diff --git a/client/src/validation/zod.ts b/client/src/validation/zod.ts index 6981eb498..53515ae19 100644 --- a/client/src/validation/zod.ts +++ b/client/src/validation/zod.ts @@ -81,6 +81,12 @@ const durationSchemaInfra = z } }); +const optionalString = (schema: T) => + z.preprocess( + (val) => (val === "" || val === null ? undefined : val), + schema.optional() + ); + export const monitorSchema = z.object({ type: z.string().min(1, "You must select an option"), url: z.string().min(1, "URL is required").regex(urlRegex, "Invalid URL"), @@ -203,6 +209,70 @@ export const maintenanceSchema = z } }); +export const systemSettingsSchema = z.object({ + systemEmailHost: optionalString( + z + .string() + .trim() + .min(1, { message: "SMTP host is required" }) + .max(255, { + message: "SMTP host must be 255 characters or fewer", + }) + ), + systemEmailPort: optionalString( + z + .union([z.number().int(), z.string().regex(/^\d+$/)]) + .transform((val) => (typeof val === "string" ? Number(val) : val)) + .refine( + (val) => + val === undefined || + (Number.isInteger(val) && val > 0 && val < 65536), + { + message: "Port must be a number between 1 and 65535", + } + ) + ), + systemEmailAddress: optionalString( + z.string().email({ message: "SMTP email must be valid" }) + ), + systemEmailPassword: optionalString( + z + .string() + .max(255, { + message: "SMTP password must be 255 characters or fewer", + }) + ), + systemEmailUser: optionalString( + z + .string() + .trim() + .max(255, { + message: "SMTP username must be 255 characters or fewer", + }) + ), + systemEmailConnectionHost: optionalString( + z + .string() + .trim() + .max(255, { + message: "Connection host must be 255 characters or fewer", + }) + ), + systemEmailTLSServername: optionalString( + z + .string() + .trim() + .max(255, { + message: "TLS server name must be 255 characters or fewer", + }) + ), + systemEmailSecure: z.boolean().optional(), + systemEmailPool: z.boolean().optional(), + systemEmailIgnoreTLS: z.boolean().optional(), + systemEmailRequireTLS: z.boolean().optional(), + systemEmailRejectUnauthorized: z.boolean().optional(), +}); + const statusPageUrlRegex = /^[A-Za-z0-9]+$/; export const statusPageSchema = z.object({ diff --git a/server/src/controllers/SettingsController.ts b/server/src/controllers/SettingsController.ts index 84ddc4a7f..500a60ff4 100644 --- a/server/src/controllers/SettingsController.ts +++ b/server/src/controllers/SettingsController.ts @@ -1,16 +1,22 @@ import { Request, Response, NextFunction } from "express"; import ApiError from "@/utils/ApiError.js"; -import SettingsService from "@/services/system/SettingsService.js"; +import { NotificationService, SettingsService } from "@/services/index.js"; export interface ISettingsController { get: (req: Request, res: Response, next: NextFunction) => void; update: (req: Request, res: Response, next: NextFunction) => void; + testTransport: (req: Request, res: Response, next: NextFunction) => void; } class SettingsController implements ISettingsController { private settingsService: SettingsService; - constructor(settingsService: SettingsService) { + private notificationService: NotificationService; + constructor( + settingsService: SettingsService, + notificationService: NotificationService + ) { this.settingsService = settingsService; + this.notificationService = notificationService; } get = async (req: Request, res: Response, next: NextFunction) => { @@ -36,6 +42,21 @@ class SettingsController implements ISettingsController { next(error); } }; + + testTransport = async (req: Request, res: Response, next: NextFunction) => { + try { + const testResult = await this.notificationService.testEmailTransport( + req.body + ); + + res.status(200).json({ + message: "Test transport successful", + data: testResult, + }); + } catch (error) { + next(error); + } + }; } export default SettingsController; diff --git a/server/src/init/controllers.ts b/server/src/init/controllers.ts index 31eff3c3b..544f76586 100644 --- a/server/src/init/controllers.ts +++ b/server/src/init/controllers.ts @@ -67,7 +67,8 @@ export const initControllers = (services: any) => { ); controllers.settingsController = new SettingsController( - services.settingsService + services.settingsService, + services.notificationService ); return controllers; diff --git a/server/src/routes/settings.ts b/server/src/routes/settings.ts index 7ed4485a8..a543762c4 100644 --- a/server/src/routes/settings.ts +++ b/server/src/routes/settings.ts @@ -34,6 +34,14 @@ class SettingsRoutes { validateBody(systemSettingsSchema), this.controller.update ); + this.router.post( + "/test-transport", + verifyToken, + addUserContext, + verifyTeamPermission([PERMISSIONS.master]), + validateBody(systemSettingsSchema), + this.controller.testTransport + ); }; getRouter() { diff --git a/server/src/services/infrastructure/NotificationService.ts b/server/src/services/infrastructure/NotificationService.ts index 58842a1d7..67239eceb 100644 --- a/server/src/services/infrastructure/NotificationService.ts +++ b/server/src/services/infrastructure/NotificationService.ts @@ -14,6 +14,7 @@ import { } from "./NotificationServices/index.js"; import ApiError from "@/utils/ApiError.js"; import { getChildLogger } from "@/logger/Logger.js"; +import type { IEmailTransport } from "./NotificationServices/Email.js"; const SERVICE_NAME = "NotificationService"; const logger = getChildLogger(SERVICE_NAME); @@ -36,6 +37,7 @@ export interface INotificationService { testNotificationChannel: ( notificationChannel: INotificationChannel ) => Promise; + testTransport: (transport: IEmailTransport) => Promise; } class NotificationService implements INotificationService { @@ -181,6 +183,10 @@ class NotificationService implements INotificationService { } return false; }; + + testEmailTransport = async (transport: IEmailTransport) => { + return this.emailService.testTransport(transport); + }; } export default NotificationService; diff --git a/server/src/services/infrastructure/NotificationServices/Email.ts b/server/src/services/infrastructure/NotificationServices/Email.ts index b4ded0a0c..fc1b00d3a 100644 --- a/server/src/services/infrastructure/NotificationServices/Email.ts +++ b/server/src/services/infrastructure/NotificationServices/Email.ts @@ -8,8 +8,24 @@ import nodemailer, { Transporter } from "nodemailer"; import { config } from "@/config/index.js"; import UserService from "../../business/UserService.js"; import ApiError from "@/utils/ApiError.js"; - +import { getChildLogger } from "@/logger/Logger.js"; const SERVICE_NAME = "EmailService"; +const logger = getChildLogger(SERVICE_NAME); + +export interface IEmailTransport { + systemEmailHost?: string; + systemEmailPort?: number; + systemEmailAddress?: string; + systemEmailPassword?: string; + systemEmailUser?: string; + systemEmailConnectionHost?: string; + systemEmailTLSServername?: string; + systemEmailSecure: boolean; + systemEmailPool: boolean; + systemEmailIgnoreTLS: boolean; + systemEmailRequireTLS: boolean; + systemEmailRejectUnauthorized: boolean; +} export interface IEmailService extends IMessageService { sendGeneric: ( @@ -17,6 +33,7 @@ export interface IEmailService extends IMessageService { subject: string, content: string ) => Promise; + testTransport: (transport: IEmailTransport) => Promise; } class EmailService implements IEmailService { @@ -108,6 +125,51 @@ class EmailService implements IEmailService { throw error; } }; + + testTransport = async (transport: IEmailTransport) => { + const host = (transport.systemEmailHost ?? "").trim(); + const baseOptions = { + port: Number(transport.systemEmailPort), + secure: transport.systemEmailSecure, + auth: { + user: transport.systemEmailUser || transport.systemEmailAddress, + pass: transport.systemEmailPassword, + }, + name: transport.systemEmailConnectionHost || undefined, + connectionTimeout: 5000, + tls: { + rejectUnauthorized: transport.systemEmailRejectUnauthorized, + ignoreTLS: transport.systemEmailIgnoreTLS, + requireTLS: transport.systemEmailRequireTLS, + servername: transport.systemEmailTLSServername, + }, + }; + + const transportOptions = transport.systemEmailPool + ? { + ...baseOptions, + pool: true, + host, + } + : { + ...baseOptions, + host: host || undefined, + }; + + const testTransport = nodemailer.createTransport(transportOptions); + try { + await testTransport.sendMail({ + from: `"Checkmate Test" <${transport.systemEmailAddress}>`, + to: transport.systemEmailUser || transport.systemEmailAddress, + subject: "SMTP transport test", + text: "This is a transport verification message.", + }); + return true; + } catch (error: any) { + logger.error(error); + throw new ApiError(`Email transport test failed`, 400); + } + }; } export default EmailService; diff --git a/server/src/services/system/SettingsService.ts b/server/src/services/system/SettingsService.ts index f365ed468..50d571660 100644 --- a/server/src/services/system/SettingsService.ts +++ b/server/src/services/system/SettingsService.ts @@ -15,12 +15,18 @@ class SettingsService implements ISettingsService { } get = async (): Promise => { - let settings = await SystemSettings.findById("global"); + let settings = await SystemSettings.findById("global").select( + "-_id -__v -createdAt -updatedAt" + ); if (!settings) { - settings = await SystemSettings.create({ _id: "global" }); + await SystemSettings.create({ _id: "global" }); } + settings = await SystemSettings.findById("global").select( + "-_id -__v -createdAt -updatedAt" + ); + if (!settings) { throw new ApiError("Unable to load system settings", 500); } diff --git a/server/src/validation/index.ts b/server/src/validation/index.ts index b0d229205..e6ec9182c 100644 --- a/server/src/validation/index.ts +++ b/server/src/validation/index.ts @@ -537,24 +537,45 @@ export const patchIncidentsBodySchema = z.object({ export const systemSettingsSchema = z .object({ - systemEmailHost: z.string().trim().min(1).max(255).optional(), - systemEmailPort: z - .union([z.number().int(), z.string().regex(/^\d+$/)]) - .optional() - .transform((val) => (typeof val === "string" ? Number(val) : val)) - .refine( - (val) => - val === undefined || - (Number.isInteger(val) && val > 0 && val < 65536), - { - message: "Port must be between 1 and 65535", - } - ), - systemEmailAddress: z.email().optional(), - systemEmailPassword: z.string().optional(), - systemEmailUser: z.string().trim().max(255).optional(), - systemEmailConnectionHost: z.string().trim().max(255).optional(), - systemEmailTLSServername: z.string().trim().max(255).optional(), + systemEmailHost: z.preprocess( + (val) => (val === "" || val === null ? undefined : val), + z.string().trim().min(1).max(255).optional() + ), + systemEmailPort: z.preprocess( + (val) => (val === "" || val === null ? undefined : val), + z + .union([z.number().int(), z.string().regex(/^\d+$/)]) + .transform((port) => (typeof port === "string" ? Number(port) : port)) + .refine( + (port) => + port === undefined || + (Number.isInteger(port) && port > 0 && port < 65536), + { + message: "Port must be between 1 and 65535", + } + ) + .optional() + ), + systemEmailAddress: z.preprocess( + (val) => (val === "" || val === null ? undefined : val), + z.string().email().optional() + ), + systemEmailPassword: z.preprocess( + (val) => (val === "" || val === null ? undefined : val), + z.string().trim().max(255).optional() + ), + systemEmailUser: z.preprocess( + (val) => (val === "" || val === null ? undefined : val), + z.string().trim().max(255).optional() + ), + systemEmailConnectionHost: z.preprocess( + (val) => (val === "" || val === null ? undefined : val), + z.string().trim().max(255).optional() + ), + systemEmailTLSServername: z.preprocess( + (val) => (val === "" || val === null ? undefined : val), + z.string().trim().max(255).optional() + ), systemEmailSecure: z .union([z.boolean(), z.string()]) .optional()