admin settings

This commit is contained in:
Alex Holliday committed 2025-11-28 19:48:57 +00:00
1 parent 08887bbc58
commit 86ce30b5f4
8 files changed
+467 -314

No files matched your search

@@ -47,6 +47,8 @@ export const SettingsSwitch = () => {
orgPermissions.includes("monitors.read") ||
orgPermissions.includes("*");
const hasMaster = orgPermissions.includes("master");
return (
<>
<Tooltip title="Settings" placement="top">
@@ -85,6 +87,11 @@ export const SettingsSwitch = () => {
<MenuItem onClick={() => handleClick("settings")}>
<Typography>General settings</Typography>
</MenuItem>
{hasMaster && (
<MenuItem onClick={() => handleClick("settings/admin")}>
<Typography>Admin settings</Typography>
</MenuItem>
)}
{hasTeamEdit && (
<MenuItem onClick={() => handleClick("teams")}>
<Typography>Teams</Typography>
@@ -28,6 +28,7 @@ export const useInitForm = ({
systemEmailRequireTLS: initialData?.systemEmailRequireTLS ?? false,
systemEmailRejectUnauthorized:
initialData?.systemEmailRejectUnauthorized ?? true,
checksRetentionDays: initialData?.checksRetentionDays ?? 90,
};
return { defaults };
}, [initialData]);
+115
View File
@@ -0,0 +1,115 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import { BasePage, ConfigBox } from "@/components/design-elements";
import {
AutoComplete,
Select,
LanguageSelector,
Button,
RadioWithDescription,
Dialog,
} from "@/components/inputs";
import MenuItem from "@mui/material/MenuItem";
import Typography from "@mui/material/Typography";
import { SettingsForm } from "@/pages/settings/SettingsForm";
import DummyChart from "@/pages/settings/DummyChart";
import { useTheme } from "@mui/material/styles";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { setTimezone, setMode, setChartType } from "@/features/uiSlice";
import { useAppSelector, useAppDispatch } from "@/hooks/AppHooks";
import timezones from "@/utils/timezones.json";
import type { ITimeZone } from "@/types/timezone";
import { useGet, usePatch, usePost } from "@/hooks/UseApi";
import type { ApiResponse } from "@/hooks/UseApi";
import { z } from "zod";
import { systemSettingsSchema } from "@/validation/zod";
const AdminSettingsPage = () => {
type FormValues = z.infer<typeof systemSettingsSchema>;
const user = useAppSelector((state) => state.auth.user);
const orgPermissions = user?.org?.permissions || [];
const hasMaster = orgPermissions.includes("master");
const theme = useTheme();
const { t } = useTranslation();
const dispatch = useAppDispatch();
const timezone = useAppSelector((state) => state.ui.timezone);
const mode = useAppSelector((state) => state.ui.mode);
const chartType = useAppSelector((state) => state.ui.chartType);
const [selectedTimezone, setSelectedTimezone] = useState<ITimeZone | null>(
null
);
useEffect(() => {
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>>(
hasMaster ? "/settings" : null
);
const settings = response?.data || {};
const initialSettings = useMemo(() => settings, [settings]);
const onSubmitEmail = async (data: Partial<FormValues>) => {
const emailSettings = {
systemEmailHost: data.systemEmailHost,
systemEmailPort: data.systemEmailPort,
systemEmailAddress: data.systemEmailAddress,
systemEmailPassword: data.systemEmailPassword,
systemEmailUser: data.systemEmailUser,
systemEmailConnectionHost: data.systemEmailConnectionHost,
systemEmailTLSServername: data.systemEmailTLSServername,
systemEmailSecure: data.systemEmailSecure,
systemEmailPool: data.systemEmailPool,
systemEmailIgnoreTLS: data.systemEmailIgnoreTLS,
systemEmailRequireTLS: data.systemEmailRequireTLS,
systemEmailRejectUnauthorized: data.systemEmailRejectUnauthorized,
};
await patch("/settings/email", emailSettings);
};
const onSubmitRetention = async (data: Partial<FormValues>) => {
const { checksRetentionDays } = data;
await patch("/settings/retention", { checksRetentionDays });
};
const onTest = async (data: Partial<FormValues>) => {
const emailSettings = {
systemEmailHost: data.systemEmailHost,
systemEmailPort: data.systemEmailPort,
systemEmailAddress: data.systemEmailAddress,
systemEmailPassword: data.systemEmailPassword,
systemEmailUser: data.systemEmailUser,
systemEmailConnectionHost: data.systemEmailConnectionHost,
systemEmailTLSServername: data.systemEmailTLSServername,
systemEmailSecure: data.systemEmailSecure,
systemEmailPool: data.systemEmailPool,
systemEmailIgnoreTLS: data.systemEmailIgnoreTLS,
systemEmailRequireTLS: data.systemEmailRequireTLS,
systemEmailRejectUnauthorized: data.systemEmailRejectUnauthorized,
};
await post("/settings/test-transport", emailSettings);
};
return (
<BasePage>
<SettingsForm
onSubmitEmail={onSubmitEmail}
onSubmitRetention={onSubmitRetention}
onTest={onTest}
initialData={initialSettings}
loading={posting || loading || patching}
/>
</BasePage>
);
};
export default AdminSettingsPage;
+10 -42
View File
@@ -11,25 +11,19 @@ import {
} from "@/components/inputs";
import MenuItem from "@mui/material/MenuItem";
import Typography from "@mui/material/Typography";
import { SettingsForm } from "@/pages/settings/SettingsForm";
import DummyChart from "@/pages/settings/DummyChart";
import { useTheme } from "@mui/material/styles";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { setTimezone, setMode, setChartType } 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, useDelete } from "@/hooks/UseApi";
import type { ApiResponse } from "@/hooks/UseApi";
import { z } from "zod";
import { systemSettingsSchema } from "@/validation/zod";
import { useDelete } from "@/hooks/UseApi";
const SettingsPage = () => {
type FormValues = z.infer<typeof systemSettingsSchema>;
const user = useAppSelector((state) => state.auth.user);
const orgPermissions = user?.org?.permissions || [];
@@ -53,23 +47,8 @@ const SettingsPage = () => {
setSelectedTimezone(tz || null);
}, [timezone]);
const { post, loading: posting } = usePost<Partial<FormValues>, any>();
const { patch, loading: patching } = usePatch<Partial<FormValues>, any>();
const { deleteFn, loading: isDeleting } = useDelete();
const { response, loading } = useGet<ApiResponse<any>>(
hasMaster ? "/settings" : null
);
const settings = response?.data || {};
const initialSettings = useMemo(() => settings, [settings]);
const onSubmit = async (data: Partial<FormValues>) => {
await patch("/settings/email", data);
};
const onTest = async (data: Partial<FormValues>) => {
await post("/settings/test-transport", data);
};
const [isDialogOpen, setIsDialogOpen] = useState(false);
const handleOpen = () => setIsDialogOpen(true);
@@ -152,30 +131,19 @@ const SettingsPage = () => {
subtitle="Monitor related settings"
rightContent={
<Stack gap={theme.spacing(4)}>
<Stack gap={theme.spacing(4)}>
<Box>
<Button
variant="contained"
color="error"
onClick={handleOpen}
>
Remove all monitors
</Button>
</Box>
</Stack>
<Box>
<Button variant="contained" color="error" onClick={handleOpen}>
Remove all monitors
</Button>
</Box>
<Typography>
Removes all monitors from your organization.
</Typography>
</Stack>
}
/>
)}
{hasMaster && (
<SettingsForm
onSubmit={onSubmit}
onTest={onTest}
initialData={initialSettings}
loading={posting || loading || patching}
/>
)}
<Dialog
open={isDialogOpen}
title={t("settings.removeAllMonitors.title", "Delete all monitors?")}
+326 -270
View File
@@ -17,12 +17,14 @@ type FormValues = z.infer<typeof systemSettingsSchema>;
export const SettingsForm = ({
initialData,
onSubmit,
onSubmitEmail,
onSubmitRetention,
onTest,
loading,
}: {
initialData?: Partial<FormValues>;
onSubmit: SubmitHandler<FormValues>;
onSubmitEmail: SubmitHandler<FormValues>;
onSubmitRetention: SubmitHandler<FormValues>;
onTest: (data: Partial<FormValues>) => Promise<void>;
loading?: boolean;
}) => {
@@ -48,277 +50,331 @@ export const SettingsForm = ({
}, [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
gap={theme.spacing(4)}
direction="row"
justifyContent="flex-end"
>
<Button
loading={loading}
variant="contained"
color="accent"
onClick={() => {
onTest(formValues);
<Stack spacing={theme.spacing(10)}>
<Stack
spacing={theme.spacing(10)}
component="form"
onSubmit={handleSubmit(onSubmitEmail)}
>
<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",
}}
>
Test transport config
</Button>
<Button
loading={loading}
type="submit"
variant="contained"
color="accent"
>
Save e-mail settings
</Button>
<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>
</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
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"
>
Save e-mail settings
</Button>
</Stack>
</Stack>
}
/>
</Stack>
<Stack
component="form"
onSubmit={handleSubmit(onSubmitRetention)}
spacing={theme.spacing(10)}
>
<ConfigBox
title="Global Data retention"
subtitle="Set data retention policy. This affects all organizations and teams"
rightContent={
<Stack spacing={theme.spacing(10)}>
<Controller
name="checksRetentionDays"
control={control}
render={({ field }) => (
<TextInput
{...field}
type="number"
fieldLabel="Retention days"
fullWidth
error={!!errors.checksRetentionDays}
helperText={
errors.checksRetentionDays
? errors.checksRetentionDays.message
: ""
}
/>
)}
/>
<Stack
gap={theme.spacing(4)}
direction="row"
justifyContent="flex-end"
>
<Button
variant="contained"
color="accent"
type="submit"
loading={loading}
>
Save retention settings
</Button>
</Stack>
</Stack>
}
/>
</Stack>
</Stack>
);
};
+2
View File
@@ -7,6 +7,7 @@ import UptimeCreatePage from "@/pages/uptime/UptimeCreate";
import UptimeDetailsPage from "@/pages/uptime/UptimeDetails";
import UptimeConfigurePage from "@/pages/uptime/UptimeConfigure";
import SettingsPage from "@/pages/settings/Settings";
import AdminSettingsPage from "@/pages/settings/AdminSettings";
import TeamsPage from "@/pages/teams/Teams";
import TeamsCreatePage from "@/pages/teams/TeamsCreate";
import TeamsConfigPage from "@/pages/teams/TeamsConfig";
@@ -78,6 +79,7 @@ const Router = () => {
<Route path="uptime/:id" element={<UptimeDetailsPage />} />
<Route path="uptime" element={<UptimeMonitorsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="settings/admin" element={<AdminSettingsPage />} />
<Route path="teams/create" element={<TeamsCreatePage />} />
<Route
path="teams/:id/member/:memberId/configure"
+4
View File
@@ -291,6 +291,10 @@ export const systemSettingsSchema = z.object({
systemEmailIgnoreTLS: z.boolean().optional(),
systemEmailRequireTLS: z.boolean().optional(),
systemEmailRejectUnauthorized: z.boolean().optional(),
checksRetentionDays: z.coerce
.number({ message: "Number required" })
.min(1, "Retention days must be at least 1")
.max(365, "Retention days cannot exceed 365 days"),
});
const statusPageUrlRegex = /^[A-Za-z0-9]+$/;
@@ -13,7 +13,7 @@ export interface ISystemSettings extends Document {
systemEmailIgnoreTLS: boolean;
systemEmailRequireTLS: boolean;
systemEmailRejectUnauthorized: boolean;
dataRetentionDays: number;
checksRetentionDays: number;
}
const SystemSettingsSchema = new Schema<ISystemSettings>(
@@ -31,7 +31,7 @@ const SystemSettingsSchema = new Schema<ISystemSettings>(
systemEmailIgnoreTLS: { type: Boolean, default: false },
systemEmailRequireTLS: { type: Boolean, default: false },
systemEmailRejectUnauthorized: { type: Boolean, default: true },
dataRetentionDays: { type: Number, default: 90 },
checksRetentionDays: { type: Number, default: 90 },
},
{
timestamps: true,