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
4 changes: 4 additions & 0 deletions client/src/Hooks/useNotificationForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ function buildDefaults(data: Notification | null): NotificationFormData {
type: "webhook",
notificationName: data.notificationName || "",
address: data.address || "",
authType: data.authType || "none",
authUsername: data.authUsername || "",
authPassword: data.authPassword || "",
authToken: data.authToken || "",
};
}
if (data?.type === "pager_duty") {
Expand Down
106 changes: 103 additions & 3 deletions client/src/Pages/Notifications/create/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,8 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
import { useNotificationForm } from "@/Hooks/useNotificationForm";
import type { NotificationFormData } from "@/Validation/notifications";
import type { Notification } from "@/Types/Notification";
import { NotificationChannels, WebhookAuthTypes, type Notification } from "@/Types/Notification";
import { useTranslation } from "react-i18next";
import { NotificationChannels } from "@/Types/Notification";

const NotificationsCreatePage = () => {
const { t } = useTranslation();
Expand Down Expand Up @@ -46,10 +45,13 @@ const NotificationsCreatePage = () => {
}, [defaults, reset]);

const watchedType = watch("type");
const watchedValues = watch();
const watchedAuthType =
watchedType === "webhook" && "authType" in watchedValues ? watchedValues.authType : "none";

useEffect(() => {
clearErrors();
}, [watchedType, clearErrors]);
}, [watchedType, watchedAuthType, clearErrors]);

const addressConfig = useMemo(() => {
if (watchedType === "pager_duty") {
Expand Down Expand Up @@ -174,6 +176,104 @@ 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)}>
<Controller
name="authType"
control={control}
defaultValue={"authType" in defaults ? defaults.authType : "none"}
render={({ field, fieldState }) => (
<Select
value={field.value}
fieldLabel={t("pages.notifications.form.webhookAuth.optionAuthType")}
error={!!fieldState.error}
onChange={field.onChange}
>
{WebhookAuthTypes.map((authType) => (
<MenuItem
key={authType}
value={authType}
>
<Typography textTransform="capitalize">
{t(`pages.notifications.form.webhookAuth.types.${authType}`)}
</Typography>
</MenuItem>
))}
</Select>
)}
/>
{watchedAuthType === "basic" && (
<>
<Controller
name="authUsername"
control={control}
defaultValue={"authUsername" in defaults ? defaults.authUsername : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t(
"pages.notifications.form.webhookAuth.optionUsername"
)}
placeholder={t(
"pages.notifications.form.webhookAuth.placeholderUsername"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="authPassword"
control={control}
defaultValue={"authPassword" in defaults ? defaults.authPassword : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t(
"pages.notifications.form.webhookAuth.optionPassword"
)}
placeholder={t(
"pages.notifications.form.webhookAuth.placeholderPassword"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</>
)}
{watchedAuthType === "bearer" && (
<Controller
name="authToken"
control={control}
defaultValue={"authToken" in defaults ? defaults.authToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.notifications.form.webhookAuth.optionToken")}
placeholder={t(
"pages.notifications.form.webhookAuth.placeholderToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
)}
</Stack>
}
/>
)}
{watchedType === "telegram" && (
<ConfigBox
title={t("pages.notifications.form.telegram.title")}
Expand Down
6 changes: 6 additions & 0 deletions client/src/Types/Notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const NotificationChannels = [
"twilio",
] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];
export const WebhookAuthTypes = ["none", "basic", "bearer"] as const;
export type WebhookAuthType = (typeof WebhookAuthTypes)[number];

export interface Notification {
id: string;
Expand All @@ -19,6 +21,10 @@ export interface Notification {
type: NotificationChannel;
notificationName: string;
address?: string;
authType?: WebhookAuthType;
authUsername?: string;
authPassword?: string;
authToken?: string;
phone?: string;
homeserverUrl?: string;
roomId?: string;
Expand Down
31 changes: 31 additions & 0 deletions client/src/Validation/notifications.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { z } from "zod";

const webhookAuthTypeSchema = z.enum(["none", "basic", "bearer"]);

const baseSchema = z.object({
notificationName: z
.string()
Expand Down Expand Up @@ -28,6 +30,35 @@ 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"),
authType: webhookAuthTypeSchema,
authUsername: z.string().optional(),
authPassword: z.string().optional(),
authToken: z.string().optional(),
}).superRefine((data, ctx) => {
if (data.authType === "basic") {
if (!data.authUsername?.trim()) {
ctx.addIssue({
code: "custom",
path: ["authUsername"],
message: "Username is required",
});
}
if (!data.authPassword?.trim()) {
ctx.addIssue({
code: "custom",
path: ["authPassword"],
message: "Password is required",
});
}
}

if (data.authType === "bearer" && !data.authToken?.trim()) {
ctx.addIssue({
code: "custom",
path: ["authToken"],
message: "Token is required",
});
}
});

const pagerDutySchema = baseSchema.extend({
Expand Down
16 changes: 16 additions & 0 deletions client/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1119,6 +1119,22 @@
"placeholder": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"title": "Integration key"
},
"webhookAuth": {
"title": "Webhook authentication",
"description": "Optionally attach an Authorization header to webhook requests.",
"optionAuthType": "Authentication type",
"optionUsername": "Username",
"optionPassword": "Password",
"optionToken": "Bearer token",
"placeholderUsername": "webhook-user",
"placeholderPassword": "Enter password",
"placeholderToken": "Enter bearer token",
"types": {
"none": "None",
"basic": "Basic Auth",
"bearer": "Bearer Token"
}
},
"roomId": {
"optionRoomId": "Room ID",
"placeholder": "!abcdefg:matrix.org"
Expand Down
16 changes: 16 additions & 0 deletions client/src/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1059,6 +1059,22 @@
"placeholder": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"title": "集成密钥"
},
"webhookAuth": {
"title": "Webhook 璁よ瘉",
"description": "鍙€夊湴涓?webhook 璇锋眰闄勫姞 Authorization 璇锋眰澶淬€?",
"optionAuthType": "璁よ瘉鏂瑰紡",
"optionUsername": "鐢ㄦ埛鍚?",
"optionPassword": "瀵嗙爜",
"optionToken": "Bearer Token",
"placeholderUsername": "webhook-user",
"placeholderPassword": "璇疯緭鍏ュ瘑鐮?",
"placeholderToken": "璇疯緭鍏?Bearer Token",
"types": {
"none": "鏃?",
"basic": "Basic Auth",
"bearer": "Bearer Token"
}
},
"roomId": {
"optionRoomId": "房间 ID",
"placeholder": "!abcdefg:matrix.org"
Expand Down
8 changes: 7 additions & 1 deletion server/openapi/routes/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ const notificationVariantMeta: Record<string, { component: string; example: Reco
},
webhook: {
component: "WebhookNotification",
example: { notificationName: "Custom webhook", type: "webhook", address: "https://example.com/hooks/checkmate" },
example: {
notificationName: "Custom webhook",
type: "webhook",
address: "https://example.com/hooks/checkmate",
authType: "bearer",
authToken: "secret-token",
},
},
slack: {
component: "SlackNotification",
Expand Down
4 changes: 4 additions & 0 deletions server/src/db/models/Notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ const NotificationSchema = new Schema<NotificationDocument>(
required: true,
},
address: { type: String },
authType: { type: String, enum: ["none", "basic", "bearer"] },
authUsername: { type: String },
authPassword: { type: String },
authToken: { type: String },
phone: { type: String },
homeserverUrl: { type: String },
roomId: { type: String },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ class MongoNotificationsRepository implements INotificationsRepository {
type: doc.type,
notificationName: doc.notificationName,
address: doc.address ?? undefined,
authType: doc.authType ?? undefined,
authUsername: doc.authUsername ?? undefined,
authPassword: doc.authPassword ?? undefined,
authToken: doc.authToken ?? undefined,
phone: doc.phone ?? undefined,
homeserverUrl: doc.homeserverUrl ?? undefined,
roomId: doc.roomId ?? undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,23 @@ import { getTestMessage } from "@/service/infrastructure/notificationProviders/u
import got from "got";

export class WebhookProvider extends NotificationProvider {
private buildHeaders(notification: Partial<Notification>) {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};

if (notification.authType === "basic" && notification.authUsername && notification.authPassword) {
const credentials = Buffer.from(`${notification.authUsername}:${notification.authPassword}`).toString("base64");
headers.Authorization = `Basic ${credentials}`;
}

if (notification.authType === "bearer" && notification.authToken) {
headers.Authorization = `Bearer ${notification.authToken}`;
}

return headers;
}

sendMessage = async (notification: Notification, message: NotificationMessage): Promise<boolean> => {
if (!notification.address) {
return false;
Expand All @@ -17,9 +34,7 @@ export class WebhookProvider extends NotificationProvider {
try {
await got.post(notification.address, {
json: payload,
headers: {
"Content-Type": "application/json",
},
headers: this.buildHeaders(notification),
...this.gotRequestOptions(),
});
this.logger.info({
Expand Down Expand Up @@ -99,9 +114,7 @@ export class WebhookProvider extends NotificationProvider {
try {
await got.post(notification.address, {
json: { text: getTestMessage() },
headers: {
"Content-Type": "application/json",
},
headers: this.buildHeaders(notification),
...this.gotRequestOptions(),
});
return true;
Expand Down
6 changes: 6 additions & 0 deletions server/src/types/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const NotificationChannels = [
"twilio",
] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];
export const WebhookAuthTypes = ["none", "basic", "bearer"] as const;
export type WebhookAuthType = (typeof WebhookAuthTypes)[number];

export interface Notification {
id: string;
Expand All @@ -19,6 +21,10 @@ export interface Notification {
type: NotificationChannel;
notificationName: string;
address?: string;
authType?: WebhookAuthType;
authUsername?: string;
authPassword?: string;
authToken?: string;
phone?: string;
homeserverUrl?: string;
roomId?: string;
Expand Down
Loading
Loading