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 0a49cbdb12..4d9b8cf310 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, @@ -12,6 +13,21 @@ 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 sanitizeNotification = (notification: Notification) => { + const sanitized: Record = { ...notification }; + const sentinels: Record = {}; + + for (const field of SENSITIVE_FIELDS) { + sentinels[`${field}Set`] = typeof sanitized[field] !== "undefined" && sanitized[field] !== null && sanitized[field] !== ""; + delete sanitized[field]; + } + + return { ...sanitized, ...sentinels }; +}; const SERVICE_NAME = "NotificationController"; @@ -62,7 +78,7 @@ class NotificationController implements INotificationController { return res.status(200).json({ success: true, msg: "Notification created successfully", - data: notification, + data: sanitizeNotification(notification), }); } catch (error) { next(error); @@ -77,7 +93,7 @@ class NotificationController implements INotificationController { return res.status(200).json({ success: true, msg: "Notifications fetched successfully", - data: notifications, + data: notifications.map(sanitizeNotification), }); } catch (error) { next(error); @@ -109,7 +125,7 @@ class NotificationController implements INotificationController { return res.status(200).json({ success: true, msg: "Notification fetched successfully", - data: notification, + data: sanitizeNotification(notification), }); } catch (error) { next(error); @@ -118,17 +134,25 @@ 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); 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", - data: editedNotification, + data: sanitizeNotification(editedNotification), }); } catch (error) { next(error); diff --git a/server/src/validation/notificationValidation.ts b/server/src/validation/notificationValidation.ts index cf74786101..1ca56fda4a 100644 --- a/server/src/validation/notificationValidation.ts +++ b/server/src/validation/notificationValidation.ts @@ -4,90 +4,116 @@ 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, +]); + +// Edit validation — sensitive fields optional (already stored in DB) +export const editNotificationBodyValidation = z.discriminatedUnion("type", [ + 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;