diff --git a/client/src/pages/settings/SettingsForm.tsx b/client/src/pages/settings/SettingsForm.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/server/src/controllers/SettingsController.ts b/server/src/controllers/SettingsController.ts new file mode 100644 index 000000000..84ddc4a7f --- /dev/null +++ b/server/src/controllers/SettingsController.ts @@ -0,0 +1,41 @@ +import { Request, Response, NextFunction } from "express"; +import ApiError from "@/utils/ApiError.js"; +import SettingsService from "@/services/system/SettingsService.js"; + +export interface ISettingsController { + get: (req: Request, res: Response, next: NextFunction) => void; + update: (req: Request, res: Response, next: NextFunction) => void; +} + +class SettingsController implements ISettingsController { + private settingsService: SettingsService; + constructor(settingsService: SettingsService) { + this.settingsService = settingsService; + } + + get = async (req: Request, res: Response, next: NextFunction) => { + try { + const settings = await this.settingsService.get(); + res.status(200).json({ + message: "Settings retrieved successfully", + data: settings, + }); + } catch (error) { + next(error); + } + }; + + update = async (req: Request, res: Response, next: NextFunction) => { + try { + const updatedSettings = await this.settingsService.update(req.body); + res.status(200).json({ + message: "Settings updated successfully", + data: updatedSettings, + }); + } catch (error) { + next(error); + } + }; +} + +export default SettingsController; diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 2b1ae2524..ba1e2f36f 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -13,3 +13,4 @@ export { default as DiagnosticController } from "./DiagnosticController.js"; export { default as RecoveryController } from "./RecoveryController.js"; export { default as ProfileController } from "./ProfileController.js"; export { default as IncidentsController } from "./IncidentsController.js"; +export { default as SettingsController } from "./SettingsController.js"; diff --git a/server/src/db/models/index.ts b/server/src/db/models/index.ts index 571c8b068..332b384c9 100644 --- a/server/src/db/models/index.ts +++ b/server/src/db/models/index.ts @@ -56,3 +56,6 @@ export type { IRecoveryToken } from "./recovery/RecoveryToken.js"; export { Incident, ResolutionTypes } from "./incidents/Incident.js"; export type { IIncident, ResolutionType } from "./incidents/Incident.js"; + +export { SystemSettings } from "./system/SystemSettings.js"; +export type { ISystemSettings } from "./system/SystemSettings.js"; diff --git a/server/src/db/models/system/SystemSettings.ts b/server/src/db/models/system/SystemSettings.ts new file mode 100644 index 000000000..12291b690 --- /dev/null +++ b/server/src/db/models/system/SystemSettings.ts @@ -0,0 +1,45 @@ +import mongoose, { Schema, Document } from "mongoose"; + +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; +} + +const SystemSettingsSchema = new Schema( + { + _id: { type: String, default: "global", immutable: true }, + systemEmailHost: { type: String }, + systemEmailPort: { type: Number }, + systemEmailAddress: { type: String }, + systemEmailPassword: { type: String }, + systemEmailUser: { type: String }, + systemEmailConnectionHost: { type: String, default: "localhost" }, + systemEmailTLSServername: { type: String }, + systemEmailSecure: { type: Boolean, default: false }, + systemEmailPool: { type: Boolean, default: false }, + systemEmailIgnoreTLS: { type: Boolean, default: false }, + systemEmailRequireTLS: { type: Boolean, default: false }, + systemEmailRejectUnauthorized: { type: Boolean, default: true }, + }, + { + timestamps: true, + collection: "system_settings", + } +); + +SystemSettingsSchema.index({ _id: 1 }, { unique: true }); + +export const SystemSettings = mongoose.model( + "SystemSettings", + SystemSettingsSchema +); diff --git a/server/src/init/controllers.ts b/server/src/init/controllers.ts index 42b010a18..31eff3c3b 100644 --- a/server/src/init/controllers.ts +++ b/server/src/init/controllers.ts @@ -14,6 +14,7 @@ import { RecoveryController, ProfileController, IncidentsController, + SettingsController, } from "@/controllers/index.js"; export const initControllers = (services: any) => { @@ -65,5 +66,9 @@ export const initControllers = (services: any) => { services.monitorService ); + controllers.settingsController = new SettingsController( + services.settingsService + ); + return controllers; }; diff --git a/server/src/init/routes.ts b/server/src/init/routes.ts index 367e49b57..ae245207b 100644 --- a/server/src/init/routes.ts +++ b/server/src/init/routes.ts @@ -15,6 +15,7 @@ import { RecoveryRoutes, ProfileRoutes, IncidentsRoutes, + SettingsRoutes, } from "@/routes/index.js"; import { errorHandler } from "@/middleware/ErrorHandler.js"; export const initRoutes = (controllers: any, app: Express) => { @@ -44,6 +45,7 @@ export const initRoutes = (controllers: any, app: Express) => { const recoveryRoutes = new RecoveryRoutes(controllers.recoveryController); const profileRoutes = new ProfileRoutes(controllers.profileController); const incidentsRoutes = new IncidentsRoutes(controllers.incidentsController); + const settingsRoutes = new SettingsRoutes(controllers.settingsController); app.use("/api/v1/auth", authRoutes.getRouter()); app.use("/api/v1/invite", inviteRoutes.getRouter()); @@ -63,5 +65,6 @@ export const initRoutes = (controllers: any, app: Express) => { app.use("/api/v1/recovery", recoveryRoutes.getRouter()); app.use("/api/v1/profile", profileRoutes.getRouter()); app.use("/api/v1/incidents", incidentsRoutes.getRouter()); + app.use("/api/v1/settings", settingsRoutes.getRouter()); app.use(errorHandler); }; diff --git a/server/src/init/services.ts b/server/src/init/services.ts index 71db4170b..2054bfd76 100644 --- a/server/src/init/services.ts +++ b/server/src/init/services.ts @@ -27,6 +27,7 @@ import { DiagnosticService, RecoveryService, IncidentService, + SettingsService, } from "@/services/index.js"; export const initServices = async () => { @@ -63,6 +64,8 @@ export const initServices = async () => { const statusPageService = new StatusPageService(); const diagnosticService = new DiagnosticService(jobQueue); const recoveryService = new RecoveryService(); + const settingsService = new SettingsService(); + const services = { checkService, inviteService, @@ -89,6 +92,7 @@ export const initServices = async () => { diagnosticService, recoveryService, incidentService, + settingsService, }; Object.values(services).forEach((service) => { diff --git a/server/src/routes/diagnostic.ts b/server/src/routes/diagnostic.ts index 09203455a..7f9f07596 100644 --- a/server/src/routes/diagnostic.ts +++ b/server/src/routes/diagnostic.ts @@ -3,7 +3,6 @@ import { DiagnosticController } from "@/controllers/index.js"; import { verifyToken } from "@/middleware/VerifyToken.js"; import { verifyOrgPermission } from "@/middleware/VerifyPermission.js"; import { addUserContext } from "@/middleware/AddUserContext.js"; -import { FeatureFlags } from "@/utils/FeatureFlags.js"; import { PERMISSIONS } from "@/services/business/AuthService.js"; class DiagnosticRoutes { diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 4598f9715..470727472 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -15,3 +15,4 @@ export { default as DiagnosticRoutes } from "./diagnostic.js"; export { default as RecoveryRoutes } from "./recovery.js"; export { default as ProfileRoutes } from "./profile.js"; export { default as IncidentsRoutes } from "./incidents.js"; +export { default as SettingsRoutes } from "./settings.js"; diff --git a/server/src/routes/settings.ts b/server/src/routes/settings.ts new file mode 100644 index 000000000..7ed4485a8 --- /dev/null +++ b/server/src/routes/settings.ts @@ -0,0 +1,44 @@ +import { Router } from "express"; +import { SettingsController } from "@/controllers/index.js"; +import { verifyToken } from "@/middleware/VerifyToken.js"; +import { verifyTeamPermission } from "@/middleware/VerifyPermission.js"; +import { addUserContext } from "@/middleware/AddUserContext.js"; +import { PERMISSIONS } from "@/services/business/AuthService.js"; +import { validateBody } from "@/middleware/validation.js"; +import { systemSettingsSchema } from "@/validation/index.js"; +import { verify } from "node:crypto"; + +class SettingsRoutes { + private router; + private controller; + constructor(settingsController: SettingsController) { + this.router = Router(); + this.controller = settingsController; + this.initRoutes(); + } + + initRoutes = () => { + this.router.get( + "/", + verifyToken, + addUserContext, + verifyTeamPermission([PERMISSIONS.master]), + this.controller.get + ); + + this.router.patch( + "/", + verifyToken, + addUserContext, + verifyTeamPermission([PERMISSIONS.master]), + validateBody(systemSettingsSchema), + this.controller.update + ); + }; + + getRouter() { + return this.router; + } +} + +export default SettingsRoutes; diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 01bc1eb08..b6edb5cbe 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -22,5 +22,6 @@ export { default as JobQueue } from "@/services/infrastructure/JobQueue.js"; export { default as JobGenerator } from "@/services/infrastructure/JobGenerator.js"; export { default as ServiceRegistry } from "@/services/system/ServiceRegistry.js"; +export { default as SettingsService } from "@/services/system/SettingsService.js"; export * from "@/services/infrastructure/NotificationServices/index.js"; diff --git a/server/src/services/system/SettingsService.ts b/server/src/services/system/SettingsService.ts new file mode 100644 index 000000000..f365ed468 --- /dev/null +++ b/server/src/services/system/SettingsService.ts @@ -0,0 +1,53 @@ +import { ISystemSettings, SystemSettings } from "@/db/models/index.js"; +import ApiError from "@/utils/ApiError.js"; + +const SERVICE_NAME = "SettingsService"; +export interface ISettingsService { + get: () => Promise; + update: (settings: Partial) => Promise; +} + +class SettingsService implements ISettingsService { + public SERVICE_NAME: string; + + constructor() { + this.SERVICE_NAME = SERVICE_NAME; + } + + get = async (): Promise => { + let settings = await SystemSettings.findById("global"); + + if (!settings) { + settings = await SystemSettings.create({ _id: "global" }); + } + + if (!settings) { + throw new ApiError("Unable to load system settings", 500); + } + + return settings; + }; + + update = async ( + payload: Partial + ): Promise => { + const { _id, createdAt, updatedAt, ...updates } = payload as Record< + string, + unknown + >; + + const settings = await SystemSettings.findOneAndUpdate( + { _id: "global" }, + { $set: updates }, + { new: true, upsert: true, setDefaultsOnInsert: true } + ); + + if (!settings) { + throw new ApiError("Unable to update system settings", 500); + } + + return settings; + }; +} + +export default SettingsService; diff --git a/server/src/validation/index.ts b/server/src/validation/index.ts index b7a6f9c64..6e95ac334 100644 --- a/server/src/validation/index.ts +++ b/server/src/validation/index.ts @@ -532,3 +532,46 @@ export const getIncidentsQuerySchema = z.object({ export const patchIncidentsBodySchema = z.object({ resolutionNote: z.string().max(200).optional(), }); + +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(), + systemEmailSecure: z + .union([z.boolean(), z.string()]) + .optional() + .transform((val) => (typeof val === "string" ? val === "true" : val)), + systemEmailPool: z + .union([z.boolean(), z.string()]) + .optional() + .transform((val) => (typeof val === "string" ? val === "true" : val)), + systemEmailIgnoreTLS: z + .union([z.boolean(), z.string()]) + .optional() + .transform((val) => (typeof val === "string" ? val === "true" : val)), + systemEmailRequireTLS: z + .union([z.boolean(), z.string()]) + .optional() + .transform((val) => (typeof val === "string" ? val === "true" : val)), + systemEmailRejectUnauthorized: z + .union([z.boolean(), z.string()]) + .optional() + .transform((val) => (typeof val === "string" ? val === "true" : val)), + }) + .strip();