From 3e49ecf1309900a5df08f06347a34ddc24a7d074 Mon Sep 17 00:00:00 2001 From: egeoztass Date: Mon, 20 Apr 2026 21:14:13 +0300 Subject: [PATCH 1/5] fix: mask sensitive fields in notification API responses Masks accessToken and accountSid in all notification API responses (create, get by ID, get by team, edit) to prevent plaintext exposure of credentials. - GET responses show masked values (e.g., "ACxx****") - Edit endpoint strips masked values to avoid overwriting real credentials when the user doesn't change them - Internal notification sending is unaffected (uses unmasked DB values) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/controllers/notificationController.ts | 40 ++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/server/src/controllers/notificationController.ts b/server/src/controllers/notificationController.ts index 0a49cbdb12..ff8384bdfb 100644 --- a/server/src/controllers/notificationController.ts +++ b/server/src/controllers/notificationController.ts @@ -12,6 +12,28 @@ import { AppError } from "@/utils/AppError.js"; import { INotificationsService } from "@/service/index.js"; import { requireTeamId, requireUserId } from "./controllerUtils.js"; import { IMonitorsRepository } from "@/repositories/index.js"; +import type { Notification } from "@/types/notification.js"; + +const SENSITIVE_FIELDS: (keyof Notification)[] = ["accessToken", "accountSid"]; + +const MASK_SUFFIX = "****"; + +const maskValue = (value: string): string => { + if (value.length <= 4) return MASK_SUFFIX; + return value.slice(0, 4) + MASK_SUFFIX; +}; + +const isMasked = (value: string): boolean => value.endsWith(MASK_SUFFIX); + +const maskSensitiveFields = (notification: Notification): Notification => { + const masked = { ...notification }; + for (const field of SENSITIVE_FIELDS) { + if (masked[field]) { + (masked as Record)[field] = maskValue(masked[field] as string); + } + } + return masked; +}; const SERVICE_NAME = "NotificationController"; @@ -62,7 +84,7 @@ class NotificationController implements INotificationController { return res.status(200).json({ success: true, msg: "Notification created successfully", - data: notification, + data: maskSensitiveFields(notification), }); } catch (error) { next(error); @@ -77,7 +99,7 @@ class NotificationController implements INotificationController { return res.status(200).json({ success: true, msg: "Notifications fetched successfully", - data: notifications, + data: notifications.map(maskSensitiveFields), }); } catch (error) { next(error); @@ -109,7 +131,7 @@ class NotificationController implements INotificationController { return res.status(200).json({ success: true, msg: "Notification fetched successfully", - data: notification, + data: maskSensitiveFields(notification), }); } catch (error) { next(error); @@ -124,11 +146,19 @@ class NotificationController implements INotificationController { const teamId = requireTeamId(req.user?.teamId); const notificationId = validatedParams.id; - const editedNotification = await this.notificationsService.updateById(notificationId, teamId, validatedBody); + // Strip masked sensitive fields so they don't overwrite real values + const updateData = { ...validatedBody }; + for (const field of SENSITIVE_FIELDS) { + if (updateData[field as keyof typeof updateData] && isMasked(updateData[field as keyof typeof updateData] as string)) { + delete updateData[field as keyof typeof updateData]; + } + } + + const editedNotification = await this.notificationsService.updateById(notificationId, teamId, updateData); return res.status(200).json({ success: true, msg: "Notification updated successfully", - data: editedNotification, + data: maskSensitiveFields(editedNotification), }); } catch (error) { next(error); From 25eb53e10c1c6412a4e62e467912bdea921b7bc2 Mon Sep 17 00:00:00 2001 From: egeoztass Date: Mon, 20 Apr 2026 21:36:10 +0300 Subject: [PATCH 2/5] fix: use sentinel values for sensitive notification fields Refactored to match the existing settings page pattern: Backend: - Removes accessToken and accountSid from API responses entirely - Adds boolean sentinels (accessTokenSet, accountSidSet) to indicate if credentials are stored Frontend: - Shows "Token is set" + Reset button when sentinel is true in edit mode - Shows input field when creating or after clicking Reset - Omits unchanged sensitive fields from edit submissions - Updated Zod schemas to make sensitive fields optional for edit mode Affects: Twilio, Telegram, Pushover, and Matrix notification channels. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/Pages/Notifications/create/index.tsx | 286 ++++++++++++------ client/src/Types/Notification.ts | 2 + client/src/Validation/notifications.ts | 20 +- client/src/locales/en.json | 4 + .../src/controllers/notificationController.ts | 39 +-- 5 files changed, 226 insertions(+), 125 deletions(-) diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 0f6d9b6525..10577e0a41 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -5,7 +5,8 @@ import Typography from "@mui/material/Typography"; import Stack from "@mui/material/Stack"; import { useTheme } from "@mui/material/styles"; -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; +import Box from "@mui/material/Box"; import { useParams } from "react-router-dom"; import { useNavigate } from "react-router-dom"; import { Controller, useForm } from "react-hook-form"; @@ -47,10 +48,27 @@ const NotificationsCreatePage = () => { const watchedType = watch("type"); + const [isAccessTokenSet, setIsAccessTokenSet] = useState(false); + const [accessTokenReset, setAccessTokenReset] = useState(false); + const [isAccountSidSet, setIsAccountSidSet] = useState(false); + const [accountSidReset, setAccountSidReset] = useState(false); + + useEffect(() => { + if (existingNotification) { + setIsAccessTokenSet(existingNotification.accessTokenSet ?? false); + setIsAccountSidSet(existingNotification.accountSidSet ?? false); + setAccessTokenReset(false); + setAccountSidReset(false); + } + }, [existingNotification]); + useEffect(() => { clearErrors(); }, [watchedType, clearErrors]); + const showAccessTokenInput = !isAccessTokenSet || accessTokenReset || !isEditMode; + const showAccountSidInput = !isAccountSidSet || accountSidReset || !isEditMode; + const addressConfig = useMemo(() => { if (watchedType === "pager_duty") { return { @@ -77,9 +95,16 @@ const NotificationsCreatePage = () => { }, [watchedType, t]); const onSubmit = async (data: NotificationFormData) => { + const dataToSend = { ...data }; + if (isEditMode && isAccessTokenSet && !accessTokenReset) { + delete (dataToSend as Record).accessToken; + } + if (isEditMode && isAccountSidSet && !accountSidReset) { + delete (dataToSend as Record).accountSid; + } const result = isEditMode - ? await patch(`/notifications/${notificationId}`, data) - : await post("/notifications", data); + ? await patch(`/notifications/${notificationId}`, dataToSend) + : await post("/notifications", dataToSend); if (result) { navigate("/notifications"); } @@ -180,24 +205,39 @@ const NotificationsCreatePage = () => { subtitle={t("pages.notifications.form.telegram.description")} rightContent={ - ( - - )} - /> + {showAccessTokenInput ? ( + ( + + )} + /> + ) : ( + + + {t("pages.notifications.form.sensitive.tokenSet")} + + + + )} { subtitle={t("pages.notifications.form.pushover.description")} rightContent={ - ( - - )} - /> + {showAccessTokenInput ? ( + ( + + )} + /> + ) : ( + + + {t("pages.notifications.form.sensitive.tokenSet")} + + + + )} { subtitle={t("pages.notifications.form.twilio.description")} rightContent={ - ( - - )} - /> - ( - - )} - /> + {showAccountSidInput ? ( + ( + + )} + /> + ) : ( + + + {t("pages.notifications.form.sensitive.accountSidSet")} + + + + )} + {showAccessTokenInput ? ( + ( + + )} + /> + ) : ( + + + {t("pages.notifications.form.sensitive.tokenSet")} + + + + )} { /> )} /> - ( - - )} - /> + {showAccessTokenInput ? ( + ( + + )} + /> + ) : ( + + + {t("pages.notifications.form.sensitive.tokenSet")} + + + + )} } /> diff --git a/client/src/Types/Notification.ts b/client/src/Types/Notification.ts index 9d5b914c02..3f3a7e3ac5 100644 --- a/client/src/Types/Notification.ts +++ b/client/src/Types/Notification.ts @@ -25,6 +25,8 @@ export interface Notification { accessToken?: string; accountSid?: string; twilioPhoneNumber?: string; + accessTokenSet?: boolean; + accountSidSet?: boolean; createdAt: string; updatedAt: string; } diff --git a/client/src/Validation/notifications.ts b/client/src/Validation/notifications.ts index 9fa2b011ac..83e5189ca9 100644 --- a/client/src/Validation/notifications.ts +++ b/client/src/Validation/notifications.ts @@ -42,7 +42,9 @@ const matrixSchema = baseSchema.extend({ .min(1, "Homeserver URL is required") .url("Please enter a valid URL"), roomId: z.string().min(1, "Room ID is required"), - accessToken: z.string().min(1, "Access token is required"), + accessToken: z + .union([z.string().min(1, "Access token is required"), z.literal("")]) + .optional(), }); const teamsSchema = baseSchema.extend({ @@ -53,19 +55,27 @@ const teamsSchema = baseSchema.extend({ const telegramSchema = baseSchema.extend({ type: z.literal("telegram"), address: z.string().min(1, "Chat ID is required"), - accessToken: z.string().min(1, "Bot token is required"), + accessToken: z + .union([z.string().min(1, "Bot token is required"), z.literal("")]) + .optional(), }); const pushoverSchema = baseSchema.extend({ type: z.literal("pushover"), address: z.string().min(1, "User key is required"), - accessToken: z.string().min(1, "App token is required"), + accessToken: z + .union([z.string().min(1, "App token is required"), z.literal("")]) + .optional(), }); const twilioSchema = baseSchema.extend({ type: z.literal("twilio"), - accountSid: z.string().min(1, "Account SID is required"), - accessToken: z.string().min(1, "Auth token is required"), + accountSid: z + .union([z.string().min(1, "Account SID is required"), z.literal("")]) + .optional(), + accessToken: z + .union([z.string().min(1, "Auth token is required"), z.literal("")]) + .optional(), phone: z.string().min(1, "Recipient phone number is required"), twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"), }); diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 9332098143..edc3079ab9 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -980,6 +980,10 @@ "placeholderFromNumber": "+15551234567", "optionToNumber": "To number (recipient)", "placeholderToNumber": "+15559876543" + }, + "sensitive": { + "tokenSet": "Token is set. Click reset to change it.", + "accountSidSet": "Account SID is set. Click reset to change it." } }, "table": { diff --git a/server/src/controllers/notificationController.ts b/server/src/controllers/notificationController.ts index ff8384bdfb..b4dabe6403 100644 --- a/server/src/controllers/notificationController.ts +++ b/server/src/controllers/notificationController.ts @@ -16,23 +16,16 @@ import type { Notification } from "@/types/notification.js"; const SENSITIVE_FIELDS: (keyof Notification)[] = ["accessToken", "accountSid"]; -const MASK_SUFFIX = "****"; +const sanitizeNotification = (notification: Notification) => { + const sanitized: Record = { ...notification }; + const sentinels: Record = {}; -const maskValue = (value: string): string => { - if (value.length <= 4) return MASK_SUFFIX; - return value.slice(0, 4) + MASK_SUFFIX; -}; - -const isMasked = (value: string): boolean => value.endsWith(MASK_SUFFIX); - -const maskSensitiveFields = (notification: Notification): Notification => { - const masked = { ...notification }; for (const field of SENSITIVE_FIELDS) { - if (masked[field]) { - (masked as Record)[field] = maskValue(masked[field] as string); - } + sentinels[`${field}Set`] = typeof sanitized[field] !== "undefined" && sanitized[field] !== null && sanitized[field] !== ""; + delete sanitized[field]; } - return masked; + + return { ...sanitized, ...sentinels }; }; const SERVICE_NAME = "NotificationController"; @@ -84,7 +77,7 @@ class NotificationController implements INotificationController { return res.status(200).json({ success: true, msg: "Notification created successfully", - data: maskSensitiveFields(notification), + data: sanitizeNotification(notification), }); } catch (error) { next(error); @@ -99,7 +92,7 @@ class NotificationController implements INotificationController { return res.status(200).json({ success: true, msg: "Notifications fetched successfully", - data: notifications.map(maskSensitiveFields), + data: notifications.map(sanitizeNotification), }); } catch (error) { next(error); @@ -131,7 +124,7 @@ class NotificationController implements INotificationController { return res.status(200).json({ success: true, msg: "Notification fetched successfully", - data: maskSensitiveFields(notification), + data: sanitizeNotification(notification), }); } catch (error) { next(error); @@ -146,19 +139,11 @@ class NotificationController implements INotificationController { const teamId = requireTeamId(req.user?.teamId); const notificationId = validatedParams.id; - // Strip masked sensitive fields so they don't overwrite real values - const updateData = { ...validatedBody }; - for (const field of SENSITIVE_FIELDS) { - if (updateData[field as keyof typeof updateData] && isMasked(updateData[field as keyof typeof updateData] as string)) { - delete updateData[field as keyof typeof updateData]; - } - } - - const editedNotification = await this.notificationsService.updateById(notificationId, teamId, updateData); + const editedNotification = await this.notificationsService.updateById(notificationId, teamId, validatedBody); return res.status(200).json({ success: true, msg: "Notification updated successfully", - data: maskSensitiveFields(editedNotification), + data: sanitizeNotification(editedNotification), }); } catch (error) { next(error); From 26621e406ce149a520883124cf8f1143befb42e1 Mon Sep 17 00:00:00 2001 From: egeoztass Date: Tue, 28 Apr 2026 23:00:37 +0200 Subject: [PATCH 3/5] fix: make accessToken and accountSid optional in backend validation When editing a notification with credentials already set, the frontend omits these fields. Backend validation was rejecting the request because they were required. Now optional on Telegram, Pushover, Twilio, and Matrix schemas to match the sentinel pattern. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/src/validation/notificationValidation.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/src/validation/notificationValidation.ts b/server/src/validation/notificationValidation.ts index cf74786101..0c807f6263 100644 --- a/server/src/validation/notificationValidation.ts +++ b/server/src/validation/notificationValidation.ts @@ -57,7 +57,7 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ address: z.union([z.string(), z.literal("")]).optional(), homeserverUrl: z.url({ message: "Please enter a valid Homeserver URL" }), roomId: z.string().min(1, "Room ID is required"), - accessToken: z.string().min(1, "Access Token is required"), + accessToken: z.string().min(1, "Access Token is required").optional(), }), // Teams notification z.object({ @@ -70,21 +70,21 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ notificationName: z.string().min(1, "Notification name is required"), type: z.literal("telegram"), address: z.string().min(1, "Chat ID is required"), - accessToken: z.string().min(1, "Bot token is required"), + accessToken: z.string().min(1, "Bot token is required").optional(), }), // Pushover notification z.object({ notificationName: z.string().min(1, "Notification name is required"), type: z.literal("pushover"), address: z.string().min(1, "User key is required"), - accessToken: z.string().min(1, "App token is required"), + accessToken: z.string().min(1, "App token is required").optional(), }), // Twilio SMS notification z.object({ notificationName: z.string().min(1, "Notification name is required"), type: z.literal("twilio"), - accountSid: z.string().min(1, "Account SID is required"), - accessToken: z.string().min(1, "Auth token is required"), + accountSid: z.string().min(1, "Account SID is required").optional(), + accessToken: z.string().min(1, "Auth token is required").optional(), phone: z.string().min(1, "Recipient phone number is required"), twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"), }), From 4b76fc1f944832d5fe2c2bc0f041ddeec5e3e224 Mon Sep 17 00:00:00 2001 From: egeoztass Date: Thu, 30 Apr 2026 07:48:19 +0200 Subject: [PATCH 4/5] fix: separate create and edit validation for sensitive fields Create validation requires accessToken/accountSid (new notifications must provide credentials). Edit validation makes them optional (existing credentials are preserved when not provided). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/controllers/notificationController.ts | 3 +- .../src/validation/notificationValidation.ts | 90 ++++++++++++++++++- 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/server/src/controllers/notificationController.ts b/server/src/controllers/notificationController.ts index b4dabe6403..bf5ee1a567 100644 --- a/server/src/controllers/notificationController.ts +++ b/server/src/controllers/notificationController.ts @@ -2,6 +2,7 @@ import { Request, Response, NextFunction } from "express"; import { createNotificationBodyValidation, + editNotificationBodyValidation, deleteNotificationParamValidation, getNotificationByIdParamValidation, testNotificationBodyValidation, @@ -133,7 +134,7 @@ class NotificationController implements INotificationController { editNotification = async (req: Request, res: Response, next: NextFunction) => { try { - const validatedBody = createNotificationBodyValidation.parse(req.body); + const validatedBody = editNotificationBodyValidation.parse(req.body); const validatedParams = editNotificationParamValidation.parse(req.params); const teamId = requireTeamId(req.user?.teamId); diff --git a/server/src/validation/notificationValidation.ts b/server/src/validation/notificationValidation.ts index 0c807f6263..7e2c7b83b1 100644 --- a/server/src/validation/notificationValidation.ts +++ b/server/src/validation/notificationValidation.ts @@ -5,6 +5,94 @@ import { z } from "zod"; //**************************************** export const createNotificationBodyValidation = z.discriminatedUnion("type", [ + // Email notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("email"), + address: z.email("Please enter a valid e-mail address"), + homeserverUrl: z.union([z.string(), z.literal("")]).optional(), + roomId: z.union([z.string(), z.literal("")]).optional(), + accessToken: z.union([z.string(), z.literal("")]).optional(), + }), + // Webhook notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("webhook"), + address: z.url({ message: "Please enter a valid Webhook URL" }), + homeserverUrl: z.union([z.string(), z.literal("")]).optional(), + roomId: z.union([z.string(), z.literal("")]).optional(), + accessToken: z.union([z.string(), z.literal("")]).optional(), + }), + // Slack notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("slack"), + address: z.url({ message: "Please enter a valid Webhook URL" }), + homeserverUrl: z.union([z.string(), z.literal("")]).optional(), + roomId: z.union([z.string(), z.literal("")]).optional(), + accessToken: z.union([z.string(), z.literal("")]).optional(), + }), + // Discord notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("discord"), + address: z.url({ message: "Please enter a valid Webhook URL" }), + homeserverUrl: z.union([z.string(), z.literal("")]).optional(), + roomId: z.union([z.string(), z.literal("")]).optional(), + accessToken: z.union([z.string(), z.literal("")]).optional(), + }), + // PagerDuty notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("pager_duty"), + address: z.string().min(1, "PagerDuty integration key is required"), + homeserverUrl: z.union([z.string(), z.literal("")]).optional(), + roomId: z.union([z.string(), z.literal("")]).optional(), + accessToken: z.union([z.string(), z.literal("")]).optional(), + }), + // Matrix notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("matrix"), + address: z.union([z.string(), z.literal("")]).optional(), + homeserverUrl: z.url({ message: "Please enter a valid Homeserver URL" }), + roomId: z.string().min(1, "Room ID is required"), + accessToken: z.string().min(1, "Access Token is required"), + }), + // Teams notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("teams"), + address: z.url({ message: "Please enter a valid Webhook URL" }), + }), + // Telegram notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("telegram"), + address: z.string().min(1, "Chat ID is required"), + accessToken: z.string().min(1, "Bot token is required"), + }), + // Pushover notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("pushover"), + address: z.string().min(1, "User key is required"), + accessToken: z.string().min(1, "App token is required"), + }), + // Twilio SMS notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("twilio"), + accountSid: z.string().min(1, "Account SID is required"), + accessToken: z.string().min(1, "Auth token is required"), + phone: z.string().min(1, "Recipient phone number is required"), + twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"), + }), +]); + +export const testNotificationBodyValidation = createNotificationBodyValidation; + +export const editNotificationBodyValidation = z.discriminatedUnion("type", [ // Email notification z.object({ notificationName: z.string().min(1, "Notification name is required"), @@ -90,8 +178,6 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ }), ]); -export const testNotificationBodyValidation = createNotificationBodyValidation; - export const deleteNotificationParamValidation = z.object({ id: z.string().min(1, "Notification ID is required"), }); From cbf7fd0a5f12288d0ded2fb7cd6a22c534a73281 Mon Sep 17 00:00:00 2001 From: egeoztass Date: Sun, 3 May 2026 08:47:44 +0200 Subject: [PATCH 5/5] fix: DRY validation schemas and add server-side stripping - Declare each notification schema once, derive edit version using .partial() for sensitive fields (accessToken, accountSid) - Removes duplicated schema definitions - Add server-side stripping of undefined sensitive fields in edit controller so we don't rely on client behavior Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/controllers/notificationController.ts | 10 +- .../src/validation/notificationValidation.ts | 276 +++++++----------- 2 files changed, 117 insertions(+), 169 deletions(-) diff --git a/server/src/controllers/notificationController.ts b/server/src/controllers/notificationController.ts index bf5ee1a567..4d9b8cf310 100644 --- a/server/src/controllers/notificationController.ts +++ b/server/src/controllers/notificationController.ts @@ -140,7 +140,15 @@ class NotificationController implements INotificationController { const teamId = requireTeamId(req.user?.teamId); const notificationId = validatedParams.id; - const editedNotification = await this.notificationsService.updateById(notificationId, teamId, validatedBody); + // Strip undefined sensitive fields so they don't overwrite stored values + const updateData = { ...validatedBody }; + for (const field of SENSITIVE_FIELDS) { + if (updateData[field as keyof typeof updateData] === undefined) { + delete updateData[field as keyof typeof updateData]; + } + } + + const editedNotification = await this.notificationsService.updateById(notificationId, teamId, updateData); return res.status(200).json({ success: true, msg: "Notification updated successfully", diff --git a/server/src/validation/notificationValidation.ts b/server/src/validation/notificationValidation.ts index 7e2c7b83b1..1ca56fda4a 100644 --- a/server/src/validation/notificationValidation.ts +++ b/server/src/validation/notificationValidation.ts @@ -4,180 +4,120 @@ import { z } from "zod"; // Notification Validations //**************************************** +// Individual notification schemas +const emailSchema = z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("email"), + address: z.email("Please enter a valid e-mail address"), + homeserverUrl: z.union([z.string(), z.literal("")]).optional(), + roomId: z.union([z.string(), z.literal("")]).optional(), + accessToken: z.union([z.string(), z.literal("")]).optional(), +}); + +const webhookSchema = z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("webhook"), + address: z.url({ message: "Please enter a valid Webhook URL" }), + homeserverUrl: z.union([z.string(), z.literal("")]).optional(), + roomId: z.union([z.string(), z.literal("")]).optional(), + accessToken: z.union([z.string(), z.literal("")]).optional(), +}); + +const slackSchema = z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("slack"), + address: z.url({ message: "Please enter a valid Webhook URL" }), + homeserverUrl: z.union([z.string(), z.literal("")]).optional(), + roomId: z.union([z.string(), z.literal("")]).optional(), + accessToken: z.union([z.string(), z.literal("")]).optional(), +}); + +const discordSchema = z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("discord"), + address: z.url({ message: "Please enter a valid Webhook URL" }), + homeserverUrl: z.union([z.string(), z.literal("")]).optional(), + roomId: z.union([z.string(), z.literal("")]).optional(), + accessToken: z.union([z.string(), z.literal("")]).optional(), +}); + +const pagerDutySchema = z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("pager_duty"), + address: z.string().min(1, "PagerDuty integration key is required"), + homeserverUrl: z.union([z.string(), z.literal("")]).optional(), + roomId: z.union([z.string(), z.literal("")]).optional(), + accessToken: z.union([z.string(), z.literal("")]).optional(), +}); + +const matrixSchema = z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("matrix"), + address: z.union([z.string(), z.literal("")]).optional(), + homeserverUrl: z.url({ message: "Please enter a valid Homeserver URL" }), + roomId: z.string().min(1, "Room ID is required"), + accessToken: z.string().min(1, "Access Token is required"), +}); + +const teamsSchema = z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("teams"), + address: z.url({ message: "Please enter a valid Webhook URL" }), +}); + +const telegramSchema = z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("telegram"), + address: z.string().min(1, "Chat ID is required"), + accessToken: z.string().min(1, "Bot token is required"), +}); + +const pushoverSchema = z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("pushover"), + address: z.string().min(1, "User key is required"), + accessToken: z.string().min(1, "App token is required"), +}); + +const twilioSchema = z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("twilio"), + accountSid: z.string().min(1, "Account SID is required"), + accessToken: z.string().min(1, "Auth token is required"), + phone: z.string().min(1, "Recipient phone number is required"), + twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"), +}); + +// Create validation — all fields required export const createNotificationBodyValidation = z.discriminatedUnion("type", [ - // Email notification - z.object({ - notificationName: z.string().min(1, "Notification name is required"), - type: z.literal("email"), - address: z.email("Please enter a valid e-mail address"), - homeserverUrl: z.union([z.string(), z.literal("")]).optional(), - roomId: z.union([z.string(), z.literal("")]).optional(), - accessToken: z.union([z.string(), z.literal("")]).optional(), - }), - // Webhook notification - z.object({ - notificationName: z.string().min(1, "Notification name is required"), - type: z.literal("webhook"), - address: z.url({ message: "Please enter a valid Webhook URL" }), - homeserverUrl: z.union([z.string(), z.literal("")]).optional(), - roomId: z.union([z.string(), z.literal("")]).optional(), - accessToken: z.union([z.string(), z.literal("")]).optional(), - }), - // Slack notification - z.object({ - notificationName: z.string().min(1, "Notification name is required"), - type: z.literal("slack"), - address: z.url({ message: "Please enter a valid Webhook URL" }), - homeserverUrl: z.union([z.string(), z.literal("")]).optional(), - roomId: z.union([z.string(), z.literal("")]).optional(), - accessToken: z.union([z.string(), z.literal("")]).optional(), - }), - // Discord notification - z.object({ - notificationName: z.string().min(1, "Notification name is required"), - type: z.literal("discord"), - address: z.url({ message: "Please enter a valid Webhook URL" }), - homeserverUrl: z.union([z.string(), z.literal("")]).optional(), - roomId: z.union([z.string(), z.literal("")]).optional(), - accessToken: z.union([z.string(), z.literal("")]).optional(), - }), - // PagerDuty notification - z.object({ - notificationName: z.string().min(1, "Notification name is required"), - type: z.literal("pager_duty"), - address: z.string().min(1, "PagerDuty integration key is required"), - homeserverUrl: z.union([z.string(), z.literal("")]).optional(), - roomId: z.union([z.string(), z.literal("")]).optional(), - accessToken: z.union([z.string(), z.literal("")]).optional(), - }), - // Matrix notification - z.object({ - notificationName: z.string().min(1, "Notification name is required"), - type: z.literal("matrix"), - address: z.union([z.string(), z.literal("")]).optional(), - homeserverUrl: z.url({ message: "Please enter a valid Homeserver URL" }), - roomId: z.string().min(1, "Room ID is required"), - accessToken: z.string().min(1, "Access Token is required"), - }), - // Teams notification - z.object({ - notificationName: z.string().min(1, "Notification name is required"), - type: z.literal("teams"), - address: z.url({ message: "Please enter a valid Webhook URL" }), - }), - // Telegram notification - z.object({ - notificationName: z.string().min(1, "Notification name is required"), - type: z.literal("telegram"), - address: z.string().min(1, "Chat ID is required"), - accessToken: z.string().min(1, "Bot token is required"), - }), - // Pushover notification - z.object({ - notificationName: z.string().min(1, "Notification name is required"), - type: z.literal("pushover"), - address: z.string().min(1, "User key is required"), - accessToken: z.string().min(1, "App token is required"), - }), - // Twilio SMS notification - z.object({ - notificationName: z.string().min(1, "Notification name is required"), - type: z.literal("twilio"), - accountSid: z.string().min(1, "Account SID is required"), - accessToken: z.string().min(1, "Auth token is required"), - phone: z.string().min(1, "Recipient phone number is required"), - twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"), - }), + emailSchema, + webhookSchema, + slackSchema, + discordSchema, + pagerDutySchema, + matrixSchema, + teamsSchema, + telegramSchema, + pushoverSchema, + twilioSchema, ]); -export const testNotificationBodyValidation = createNotificationBodyValidation; - +// Edit validation — sensitive fields optional (already stored in DB) export const editNotificationBodyValidation = z.discriminatedUnion("type", [ - // Email notification - z.object({ - notificationName: z.string().min(1, "Notification name is required"), - type: z.literal("email"), - address: z.email("Please enter a valid e-mail address"), - homeserverUrl: z.union([z.string(), z.literal("")]).optional(), - roomId: z.union([z.string(), z.literal("")]).optional(), - accessToken: z.union([z.string(), z.literal("")]).optional(), - }), - // Webhook notification - z.object({ - notificationName: z.string().min(1, "Notification name is required"), - type: z.literal("webhook"), - address: z.url({ message: "Please enter a valid Webhook URL" }), - homeserverUrl: z.union([z.string(), z.literal("")]).optional(), - roomId: z.union([z.string(), z.literal("")]).optional(), - accessToken: z.union([z.string(), z.literal("")]).optional(), - }), - // Slack notification - z.object({ - notificationName: z.string().min(1, "Notification name is required"), - type: z.literal("slack"), - address: z.url({ message: "Please enter a valid Webhook URL" }), - homeserverUrl: z.union([z.string(), z.literal("")]).optional(), - roomId: z.union([z.string(), z.literal("")]).optional(), - accessToken: z.union([z.string(), z.literal("")]).optional(), - }), - // Discord notification - z.object({ - notificationName: z.string().min(1, "Notification name is required"), - type: z.literal("discord"), - address: z.url({ message: "Please enter a valid Webhook URL" }), - homeserverUrl: z.union([z.string(), z.literal("")]).optional(), - roomId: z.union([z.string(), z.literal("")]).optional(), - accessToken: z.union([z.string(), z.literal("")]).optional(), - }), - // PagerDuty notification - z.object({ - notificationName: z.string().min(1, "Notification name is required"), - type: z.literal("pager_duty"), - address: z.string().min(1, "PagerDuty integration key is required"), - homeserverUrl: z.union([z.string(), z.literal("")]).optional(), - roomId: z.union([z.string(), z.literal("")]).optional(), - accessToken: z.union([z.string(), z.literal("")]).optional(), - }), - // Matrix notification - z.object({ - notificationName: z.string().min(1, "Notification name is required"), - type: z.literal("matrix"), - address: z.union([z.string(), z.literal("")]).optional(), - homeserverUrl: z.url({ message: "Please enter a valid Homeserver URL" }), - roomId: z.string().min(1, "Room ID is required"), - accessToken: z.string().min(1, "Access Token is required").optional(), - }), - // Teams notification - z.object({ - notificationName: z.string().min(1, "Notification name is required"), - type: z.literal("teams"), - address: z.url({ message: "Please enter a valid Webhook URL" }), - }), - // Telegram notification - z.object({ - notificationName: z.string().min(1, "Notification name is required"), - type: z.literal("telegram"), - address: z.string().min(1, "Chat ID is required"), - accessToken: z.string().min(1, "Bot token is required").optional(), - }), - // Pushover notification - z.object({ - notificationName: z.string().min(1, "Notification name is required"), - type: z.literal("pushover"), - address: z.string().min(1, "User key is required"), - accessToken: z.string().min(1, "App token is required").optional(), - }), - // Twilio SMS notification - z.object({ - notificationName: z.string().min(1, "Notification name is required"), - type: z.literal("twilio"), - accountSid: z.string().min(1, "Account SID is required").optional(), - accessToken: z.string().min(1, "Auth token is required").optional(), - phone: z.string().min(1, "Recipient phone number is required"), - twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"), - }), + emailSchema, + webhookSchema, + slackSchema, + discordSchema, + pagerDutySchema, + matrixSchema.partial({ accessToken: true }), + teamsSchema, + telegramSchema.partial({ accessToken: true }), + pushoverSchema.partial({ accessToken: true }), + twilioSchema.partial({ accessToken: true, accountSid: true }), ]); +export const testNotificationBodyValidation = createNotificationBodyValidation; + export const deleteNotificationParamValidation = z.object({ id: z.string().min(1, "Notification ID is required"), });