Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 83 additions & 7 deletions client/src/Hooks/useNotificationForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,93 @@ interface UseNotificationFormOptions {
}

function buildDefaults(data: Notification | null): NotificationFormData {
if (data?.type === "matrix") {
return {
type: "matrix",
notificationName: data.notificationName || "",
homeserverUrl: data.homeserverUrl || "",
roomId: data.roomId || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "telegram") {
return {
type: "telegram",
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "slack") {
return {
type: "slack",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "discord") {
return {
type: "discord",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "webhook") {
return {
type: "webhook",
notificationName: data.notificationName || "",
address: data.address || "",
webhookAuthType: data.webhookAuthType || "none",
webhookAuthUsername: data.webhookAuthUsername || "",
webhookAuthPassword: data.webhookAuthPassword || "",
webhookAuthToken: data.webhookAuthToken || "",
};
}
if (data?.type === "pager_duty") {
return {
type: "pager_duty",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "teams") {
return {
type: "teams",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "twilio") {
return {
type: "twilio",
notificationName: data.notificationName || "",
accountSid: data.accountSid || "",
accessToken: data.accessToken || "",
phone: data.phone || "",
twilioPhoneNumber: data.twilioPhoneNumber || "",
};
}
if (data?.type === "pushover") {
return {
type: "pushover",
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "ntfy") {
return {
type: "ntfy",
notificationName: data.notificationName || "",
address: data.address || "",
topic: data.topic || "",
};
}
// Default: email (covers both data === null and data.type === "email")
return {
type: data?.type ?? "email",
notificationName: data?.notificationName || "",
address: data?.address || "",
accessToken: data?.accessToken || "",
accountSid: data?.accountSid || "",
phone: data?.phone || "",
twilioPhoneNumber: data?.twilioPhoneNumber || "",
homeserverUrl: data?.homeserverUrl || "",
roomId: data?.roomId || "",
topic: data?.topic || "",
};
}

Expand Down
61 changes: 61 additions & 0 deletions client/src/Pages/Notifications/create/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,67 @@ const NotificationsCreatePage = () => {
}
/>
)}
{watchedType === "webhook" && (
<ConfigBox
title={t("pages.notifications.form.webhookAuth.title")}
subtitle={t("pages.notifications.form.webhookAuth.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormSelectField
name="webhookAuthType"
fieldLabel={t("pages.notifications.form.webhookAuth.optionAuthType")}
options={[
{
value: "none",
label: t("pages.notifications.form.webhookAuth.typeNone"),
},
{
value: "basic",
label: t("pages.notifications.form.webhookAuth.typeBasic"),
},
{
value: "bearer",
label: t("pages.notifications.form.webhookAuth.typeBearer"),
},
]}
/>
{watch("webhookAuthType") === "basic" && (
<>
<FormTextField
name="webhookAuthUsername"
fieldLabel={t(
"pages.notifications.form.webhookAuth.optionUsername"
)}
placeholder={t(
"pages.notifications.form.webhookAuth.placeholderUsername"
)}
/>
<FormTextField
name="webhookAuthPassword"
type="password"
fieldLabel={t(
"pages.notifications.form.webhookAuth.optionPassword"
)}
placeholder={t(
"pages.notifications.form.webhookAuth.placeholderPassword"
)}
/>
</>
)}
{watch("webhookAuthType") === "bearer" && (
<FormTextField
name="webhookAuthToken"
type="password"
fieldLabel={t("pages.notifications.form.webhookAuth.optionToken")}
placeholder={t(
"pages.notifications.form.webhookAuth.placeholderToken"
)}
/>
)}
</Stack>
}
/>
)}
<Stack
direction="row"
justifyContent="flex-end"
Expand Down
4 changes: 4 additions & 0 deletions client/src/Types/Notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ export interface Notification {
accountSid?: string;
twilioPhoneNumber?: string;
topic?: string;
webhookAuthType?: "none" | "basic" | "bearer";
webhookAuthUsername?: string;
webhookAuthPassword?: string;
webhookAuthToken?: string;
createdAt: string;
updatedAt: string;
}
4 changes: 4 additions & 0 deletions client/src/Validation/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ const discordSchema = baseSchema.extend({
const webhookSchema = baseSchema.extend({
type: z.literal("webhook"),
address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"),
webhookAuthType: z.enum(["none", "basic", "bearer"]).optional(),
webhookAuthUsername: z.union([z.string(), z.literal("")]).optional(),
webhookAuthPassword: z.union([z.string(), z.literal("")]).optional(),
webhookAuthToken: z.union([z.string(), z.literal("")]).optional(),
});

const rocketChatSchema = baseSchema.extend({
Expand Down
14 changes: 14 additions & 0 deletions client/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1208,6 +1208,20 @@
"placeholderServerUrl": "https://ntfy.sh",
"optionTopic": "Topic",
"placeholderTopic": "checkmate-alerts"
},
"webhookAuth": {
"title": "Webhook authentication",
"description": "Add authentication headers to your webhook requests.",
"optionAuthType": "Authentication type",
"typeNone": "None",
"typeBasic": "Basic",
"typeBearer": "Bearer",
"optionUsername": "Username",
"placeholderUsername": "Enter username",
"optionPassword": "Password",
"placeholderPassword": "Enter password",
"optionToken": "Token",
"placeholderToken": "Enter bearer token"
}
},
"table": {
Expand Down
48 changes: 40 additions & 8 deletions server/src/api/validation/notificationValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,46 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
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(),
}),
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(),
webhookAuthType: z.enum(["none", "basic", "bearer"]).optional(),
webhookAuthUsername: z.union([z.string(), z.literal("")]).optional(),
webhookAuthPassword: z.union([z.string(), z.literal("")]).optional(),
webhookAuthToken: z.union([z.string(), z.literal("")]).optional(),
})
.superRefine((data, ctx) => {
if (data.webhookAuthType === "basic") {
if (!data.webhookAuthUsername) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Username is required when auth type is Basic",
path: ["webhookAuthUsername"],
});
}
if (!data.webhookAuthPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Password is required when auth type is Basic",
path: ["webhookAuthPassword"],
});
}
}
if (data.webhookAuthType === "bearer") {
if (!data.webhookAuthToken) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Token is required when auth type is Bearer",
path: ["webhookAuthToken"],
});
}
}
}),
// Rocket.Chat notification
z.object({
notificationName: z.string().min(1, "Notification name is required"),
Expand Down
8 changes: 8 additions & 0 deletions server/src/domain/notifications/notification.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ const NotificationSchema = new Schema<NotificationDocument>(
accountSid: { type: String },
twilioPhoneNumber: { type: String },
topic: { type: String },
webhookAuthType: {
type: String,
enum: ["none", "basic", "bearer"],
default: "none",
},
webhookAuthUsername: { type: String },
webhookAuthPassword: { type: String },
webhookAuthToken: { type: String },
ntfyAuthType: { type: String, enum: NtfyAuthTypes },
ntfyUsername: { type: String },
},
Expand Down
14 changes: 8 additions & 6 deletions server/src/domain/notifications/notification.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,19 @@ export interface Notification {
accountSid?: string;
twilioPhoneNumber?: string;
topic?: string;
// Webhook authentication fields
webhookAuthType?: WebhookAuthType;
webhookAuthUsername?: string;
webhookAuthPassword?: string;
webhookAuthToken?: string;
ntfyAuthType?: NtfyAuthType;
ntfyUsername?: string;
createdAt: string;
updatedAt: string;
}

export type WebhookAuthType = "none" | "basic" | "bearer";

export interface AlertPagerDutyPayload {
routing_key?: string;
dedup_key?: string;
Expand All @@ -65,11 +72,6 @@ export interface AlertDiscordPayload {
timestamp: string;
}

/**
* Unified notification message types for cross-provider consistency
* Part of notification system unification effort
*/

export type NotificationType = "monitor_down" | "monitor_up" | "threshold_breach" | "threshold_resolved" | "test";

export type NotificationSeverity = "critical" | "warning" | "info" | "success";
Expand All @@ -87,7 +89,7 @@ export interface ThresholdBreach {
currentValue: number;
threshold: number;
unit: string;
formattedValue: string; // e.g., "85%" or "72°C"
formattedValue: string;
}

export interface IncidentInfo {
Expand Down
31 changes: 30 additions & 1 deletion server/src/domain/notifications/providers/webhook.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const SERVICE_NAME = "WebhookProvider";
import type { Notification } from "@/domain/notifications/notification.type.js";
import type { Notification, WebhookAuthType } from "@/domain/notifications/notification.type.js";
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
import { getTestMessage } from "@/domain/notifications/providers/utils.js";
Expand All @@ -19,6 +19,7 @@ export class WebhookProvider extends NotificationProvider {
json: payload,
headers: {
"Content-Type": "application/json",
...this.getAuthHeaders(notification),
},
...this.gotRequestOptions(),
});
Expand All @@ -40,6 +41,33 @@ export class WebhookProvider extends NotificationProvider {
}
};

/**
* Builds authorization headers based on the notification's webhookAuthType.
* Supports Basic Auth (base64-encoded username:password) and Bearer tokens.
*/
private getAuthHeaders = (notification: Notification): Record<string, string> => {
const authType: WebhookAuthType = notification.webhookAuthType || "none";

switch (authType) {
case "basic": {
if (notification.webhookAuthUsername && notification.webhookAuthPassword) {
const encoded = Buffer.from(`${notification.webhookAuthUsername}:${notification.webhookAuthPassword}`).toString("base64");
return { Authorization: `Basic ${encoded}` };
}
return {};
}
case "bearer": {
if (notification.webhookAuthToken) {
return { Authorization: `Bearer ${notification.webhookAuthToken}` };
}
return {};
}
case "none":
default:
return {};
}
};

private buildWebhookPayload(message: NotificationMessage): object {
const lines: string[] = [];

Expand Down Expand Up @@ -101,6 +129,7 @@ export class WebhookProvider extends NotificationProvider {
json: { text: getTestMessage() },
headers: {
"Content-Type": "application/json",
...this.getAuthHeaders(notification as Notification),
},
...this.gotRequestOptions(),
});
Expand Down
Loading
Loading