email settings
This commit is contained in:
1 parent
819c0ca05a
commit
3faff5dc54
16 files changed
+665
-33
No files matched your search
@@ -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 (
|
||||
<SplitBox
|
||||
left={
|
||||
<Fragment>
|
||||
<Stack spacing={theme.spacing(4)}>
|
||||
<Typography textTransform={"capitalize"} component="h2" variant="h2">
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography component="p">{subtitle}</Typography>
|
||||
</Fragment>
|
||||
{leftContent}
|
||||
</Stack>
|
||||
}
|
||||
right={rightContent}
|
||||
/>
|
||||
|
||||
@@ -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<HTMLInputElement, SwitchProps>(
|
||||
function SwitchComponent({ sx, ...props }, ref) {
|
||||
const theme = useTheme();
|
||||
const additionalSx = Array.isArray(sx) ? sx : sx ? [sx] : [];
|
||||
|
||||
return (
|
||||
<Switch
|
||||
{...props}
|
||||
slotProps={{
|
||||
input: {
|
||||
ref: ref,
|
||||
},
|
||||
}}
|
||||
sx={[
|
||||
{
|
||||
"& .MuiSwitch-switchBase": {
|
||||
"&.Mui-checked": {
|
||||
color: "#E0E0E0",
|
||||
"& + .MuiSwitch-track": {
|
||||
backgroundColor: theme.palette.accent.main,
|
||||
opacity: 1,
|
||||
border: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
...additionalSx,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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";
|
||||
@@ -70,7 +70,7 @@ export const usePost = <B = any, R = any>() => {
|
||||
const currentTeamId = useAppSelector((state) => state.auth.selectedTeamId);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { toastError } = useToast();
|
||||
const { toastError, toastSuccess } = useToast();
|
||||
|
||||
const postFn = async (
|
||||
endpoint: string,
|
||||
@@ -88,10 +88,13 @@ export const usePost = <B = any, R = any>() => {
|
||||
"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 = <B = any, R = any>() => {
|
||||
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 = <R = any,>() => {
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { systemSettingsSchema } from "@/validation/zod";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { z } from "zod";
|
||||
export const useInitForm = ({
|
||||
initialData,
|
||||
}: {
|
||||
initialData: Partial<z.infer<typeof systemSettingsSchema>> | undefined;
|
||||
}) => {
|
||||
return useMemo(() => {
|
||||
const defaults: z.infer<typeof systemSettingsSchema> = {
|
||||
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]);
|
||||
};
|
||||
@@ -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<typeof systemSettingsSchema>;
|
||||
|
||||
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<Partial<FormValues>, any>();
|
||||
const { patch, loading: patching } = usePatch<Partial<FormValues>, any>();
|
||||
|
||||
const { response, loading } = useGet<ApiResponse<any>>("/settings");
|
||||
const settings = response?.data || {};
|
||||
const initialSettings = useMemo(() => settings, [settings]);
|
||||
|
||||
const onSubmit = async (data: Partial<FormValues>) => {
|
||||
const res = await patch("/settings", data);
|
||||
console.log(res?.data);
|
||||
};
|
||||
|
||||
const onTest = async (data: Partial<FormValues>) => {
|
||||
const res = await post("/settings/test-transport", data);
|
||||
};
|
||||
return (
|
||||
<BasePage>
|
||||
<ConfigBox
|
||||
title="UI Settings"
|
||||
subtitle="Configure time zone, UI mode, and language preferences"
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<Stack spacing={theme.spacing(10)}>
|
||||
<Stack spacing={theme.spacing(2)}>
|
||||
<Typography>Time zone</Typography>
|
||||
|
||||
@@ -62,6 +85,12 @@ const SettingsPage = () => {
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<SettingsForm
|
||||
onSubmit={onSubmit}
|
||||
onTest={onTest}
|
||||
initialData={initialSettings}
|
||||
loading={posting || loading || patching}
|
||||
/>
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<typeof systemSettingsSchema>;
|
||||
|
||||
export const SettingsForm = ({
|
||||
initialData,
|
||||
onSubmit,
|
||||
onTest,
|
||||
loading,
|
||||
}: {
|
||||
initialData?: Partial<FormValues>;
|
||||
onSubmit: SubmitHandler<FormValues>;
|
||||
onTest: (data: Partial<FormValues>) => Promise<void>;
|
||||
loading?: boolean;
|
||||
}) => {
|
||||
const { defaults } = useInitForm({ initialData: initialData });
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(systemSettingsSchema) as any,
|
||||
defaultValues: defaults,
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const formValues = watch();
|
||||
|
||||
useEffect(() => {
|
||||
reset(defaults);
|
||||
}, [initialData, reset, defaults]);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
spacing={theme.spacing(10)}
|
||||
component="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<ConfigBox
|
||||
title={t("settingsPage.emailSettings.title")}
|
||||
subtitle={t("settingsPage.emailSettings.description")}
|
||||
leftContent={
|
||||
<Stack>
|
||||
<TextLink
|
||||
text={t("settingsPage.emailSettings.descriptionTransport")}
|
||||
linkText={t("settingsPage.emailSettings.linkTransport")}
|
||||
href="https://nodemailer.com/smtp"
|
||||
target="_blank"
|
||||
/>
|
||||
<Box
|
||||
component={"pre"}
|
||||
sx={{
|
||||
fontFamily: "monospace",
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<code>
|
||||
{JSON.stringify(
|
||||
{
|
||||
host: formValues.systemEmailHost,
|
||||
port: formValues.systemEmailPort,
|
||||
secure: formValues.systemEmailSecure,
|
||||
auth: {
|
||||
user:
|
||||
formValues.systemEmailUser ||
|
||||
formValues.systemEmailAddress,
|
||||
pass: "<your_password>",
|
||||
},
|
||||
name: formValues.systemEmailConnectionHost || "localhost",
|
||||
pool: formValues.systemEmailPool,
|
||||
tls: {
|
||||
rejectUnauthorized:
|
||||
formValues.systemEmailRejectUnauthorized,
|
||||
ignoreTLS: formValues.systemEmailIgnoreTLS,
|
||||
requireTLS: formValues.systemEmailRequireTLS,
|
||||
servername: formValues.systemEmailTLSServername,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</code>
|
||||
</Box>
|
||||
</Stack>
|
||||
}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(10)}>
|
||||
<Controller
|
||||
name="systemEmailHost"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("settingsPage.emailSettings.labelHost")}
|
||||
fullWidth
|
||||
error={!!errors.systemEmailHost}
|
||||
helperText={
|
||||
errors.systemEmailHost ? errors.systemEmailHost.message : ""
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="systemEmailPort"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
fieldLabel={t("settingsPage.emailSettings.labelPort")}
|
||||
fullWidth
|
||||
error={!!errors.systemEmailPort}
|
||||
helperText={
|
||||
errors.systemEmailPort ? errors.systemEmailPort.message : ""
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="systemEmailUser"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("settingsPage.emailSettings.labelUser")}
|
||||
fullWidth
|
||||
error={!!errors.systemEmailUser}
|
||||
helperText={
|
||||
errors.systemEmailUser ? errors.systemEmailUser.message : ""
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="systemEmailAddress"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("settingsPage.emailSettings.labelAddress")}
|
||||
fullWidth
|
||||
error={!!errors.systemEmailAddress}
|
||||
helperText={
|
||||
errors.systemEmailAddress
|
||||
? errors.systemEmailAddress.message
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="systemEmailPassword"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
type="password"
|
||||
fieldLabel={t("settingsPage.emailSettings.labelPassword")}
|
||||
fullWidth
|
||||
error={!!errors.systemEmailPassword}
|
||||
helperText={
|
||||
errors.systemEmailPassword
|
||||
? errors.systemEmailPassword.message
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="systemEmailTLSServername"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t(
|
||||
"settingsPage.emailSettings.labelTLSServername"
|
||||
)}
|
||||
fullWidth
|
||||
error={!!errors.systemEmailTLSServername}
|
||||
helperText={
|
||||
errors.systemEmailTLSServername
|
||||
? errors.systemEmailTLSServername.message
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="systemEmailConnectionHost"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t(
|
||||
"settingsPage.emailSettings.labelConnectionHost"
|
||||
)}
|
||||
fullWidth
|
||||
error={!!errors.systemEmailConnectionHost}
|
||||
helperText={
|
||||
errors.systemEmailConnectionHost
|
||||
? errors.systemEmailConnectionHost.message
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="systemEmailSecure"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Stack direction="row" alignItems={"center"}>
|
||||
<Switch {...field} checked={field.value} />
|
||||
<Typography>
|
||||
{t("settingsPage.emailSettings.labelSecure")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="systemEmailPool"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Stack direction="row" alignItems={"center"}>
|
||||
<Switch {...field} checked={field.value} />
|
||||
|
||||
<Typography>
|
||||
{t("settingsPage.emailSettings.labelPool")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="systemEmailIgnoreTLS"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Stack direction="row" alignItems={"center"}>
|
||||
<Switch {...field} checked={field.value} />
|
||||
|
||||
<Typography>
|
||||
{t("settingsPage.emailSettings.labelIgnoreTLS")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="systemEmailRequireTLS"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Stack direction="row" alignItems={"center"}>
|
||||
<Switch {...field} checked={field.value} />
|
||||
|
||||
<Typography>
|
||||
{t("settingsPage.emailSettings.labelRequireTLS")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="systemEmailRejectUnauthorized"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Stack direction="row" alignItems={"center"}>
|
||||
<Switch {...field} checked={field.value} />
|
||||
|
||||
<Typography>
|
||||
{t("settingsPage.emailSettings.labelRejectUnauthorized")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Stack gap={theme.spacing(4)} direction="row" justifyContent="flex-end">
|
||||
<Button
|
||||
loading={loading}
|
||||
variant="contained"
|
||||
color="accent"
|
||||
onClick={() => {
|
||||
onTest(formValues);
|
||||
}}
|
||||
>
|
||||
Test transport config
|
||||
</Button>
|
||||
<Button
|
||||
loading={loading}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="accent"
|
||||
>
|
||||
{t("settingsSave")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -81,6 +81,12 @@ const durationSchemaInfra = z
|
||||
}
|
||||
});
|
||||
|
||||
const optionalString = <T extends z.ZodTypeAny>(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({
|
||||
|
||||
@@ -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;
|
||||
@@ -67,7 +67,8 @@ export const initControllers = (services: any) => {
|
||||
);
|
||||
|
||||
controllers.settingsController = new SettingsController(
|
||||
services.settingsService
|
||||
services.settingsService,
|
||||
services.notificationService
|
||||
);
|
||||
|
||||
return controllers;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<Boolean>;
|
||||
testTransport: (transport: IEmailTransport) => Promise<boolean>;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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<boolean>;
|
||||
testTransport: (transport: IEmailTransport) => Promise<boolean>;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -15,12 +15,18 @@ class SettingsService implements ISettingsService {
|
||||
}
|
||||
|
||||
get = async (): Promise<ISystemSettings> => {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user