Webhook polishing (#2987)
This commit is contained in:
parent
c5a40f6c37
commit
6253216484
|
@ -1,8 +1,9 @@
|
|||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { Tooltip } from "@calcom/ui";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { DialogFooter } from "@calcom/ui/Dialog";
|
||||
import Switch from "@calcom/ui/Switch";
|
||||
|
@ -29,20 +30,38 @@ export default function WebhookDialogForm(props: {
|
|||
subscriberUrl: "",
|
||||
active: true,
|
||||
payloadTemplate: null,
|
||||
secret: null,
|
||||
} as Omit<TWebhook, "userId" | "createdAt" | "eventTypeId" | "appId">,
|
||||
} = props;
|
||||
|
||||
const [useCustomPayloadTemplate, setUseCustomPayloadTemplate] = useState(!!defaultValues.payloadTemplate);
|
||||
const [changeSecret, setChangeSecret] = useState(false);
|
||||
const [newSecret, setNewSecret] = useState("");
|
||||
const hasSecretKey = !!defaultValues.secret;
|
||||
const currentSecret = defaultValues.secret;
|
||||
|
||||
const form = useForm({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const handleInput = (event: React.FormEvent<HTMLInputElement>) => {
|
||||
setNewSecret(event.currentTarget.value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (changeSecret) {
|
||||
form.unregister("secret", { keepDefaultValue: false });
|
||||
}
|
||||
}, [changeSecret]);
|
||||
|
||||
return (
|
||||
<Form
|
||||
data-testid="WebhookDialogForm"
|
||||
form={form}
|
||||
handleSubmit={async (event) => {
|
||||
const e = { ...event, eventTypeId: props.eventTypeId };
|
||||
const e = changeSecret
|
||||
? { ...event, eventTypeId: props.eventTypeId }
|
||||
: { ...event, secret: currentSecret, eventTypeId: props.eventTypeId };
|
||||
if (!useCustomPayloadTemplate && event.payloadTemplate) {
|
||||
event.payloadTemplate = null;
|
||||
}
|
||||
|
@ -119,6 +138,50 @@ export default function WebhookDialogForm(props: {
|
|||
))}
|
||||
</InputGroupBox>
|
||||
</fieldset>
|
||||
<fieldset className="space-y-2">
|
||||
{!!hasSecretKey && !changeSecret && (
|
||||
<>
|
||||
<FieldsetLegend>{t("secret")}</FieldsetLegend>
|
||||
<div className="rounded-sm bg-gray-50 p-2 text-xs text-neutral-900">
|
||||
{t("forgotten_secret_description")}
|
||||
</div>
|
||||
<Button
|
||||
color="secondary"
|
||||
type="button"
|
||||
className="py-1 text-xs"
|
||||
onClick={() => {
|
||||
setChangeSecret(true);
|
||||
}}>
|
||||
{t("change_secret")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!!hasSecretKey && changeSecret && (
|
||||
<>
|
||||
<TextField
|
||||
autoComplete="off"
|
||||
label={t("secret")}
|
||||
{...form.register("secret")}
|
||||
value={newSecret}
|
||||
onChange={handleInput}
|
||||
type="text"
|
||||
placeholder={t("leave_blank_to_remove_secret")}
|
||||
/>
|
||||
<Button
|
||||
color="secondary"
|
||||
type="button"
|
||||
className="py-1 text-xs"
|
||||
onClick={() => {
|
||||
setChangeSecret(false);
|
||||
}}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!hasSecretKey && (
|
||||
<TextField autoComplete="off" label={t("secret")} {...form.register("secret")} type="text" />
|
||||
)}
|
||||
</fieldset>
|
||||
<fieldset className="space-y-2">
|
||||
<FieldsetLegend>{t("payload_template")}</FieldsetLegend>
|
||||
<div className="space-x-3 text-sm rtl:space-x-reverse">
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Webhook } from "@prisma/client";
|
||||
import { createHmac } from "crypto";
|
||||
import { compile } from "handlebars";
|
||||
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
@ -23,12 +24,14 @@ function jsonParse(jsonString: string) {
|
|||
}
|
||||
|
||||
const sendPayload = async (
|
||||
secretKey: string | null,
|
||||
triggerEvent: string,
|
||||
createdAt: string,
|
||||
webhook: Pick<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">,
|
||||
data: CalendarEvent & {
|
||||
metadata?: { [key: string]: string };
|
||||
rescheduleUid?: string;
|
||||
bookingId?: number;
|
||||
}
|
||||
) => {
|
||||
const { subscriberUrl, appId, payloadTemplate: template } = webhook;
|
||||
|
@ -56,10 +59,15 @@ const sendPayload = async (
|
|||
});
|
||||
}
|
||||
|
||||
const secretSignature = secretKey
|
||||
? createHmac("sha256", secretKey).update(`${body}`).digest("hex")
|
||||
: "no-secret-provided";
|
||||
|
||||
const response = await fetch(subscriberUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"X-Cal-Signature-256": secretSignature,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
|
|
@ -33,6 +33,7 @@ const getWebhooks = async (options: GetSubscriberOptions) => {
|
|||
subscriberUrl: true,
|
||||
payloadTemplate: true,
|
||||
appId: true,
|
||||
secret: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -795,9 +795,11 @@ async function handler(req: NextApiRequest) {
|
|||
...evt,
|
||||
metadata: reqBody.metadata,
|
||||
});
|
||||
const bookingId = booking?.id;
|
||||
const promises = subscribers.map((sub) =>
|
||||
sendPayload(eventTrigger, new Date().toISOString(), sub, {
|
||||
sendPayload(sub.secret, eventTrigger, new Date().toISOString(), sub, {
|
||||
...evt,
|
||||
bookingId,
|
||||
rescheduleUid,
|
||||
metadata: reqBody.metadata,
|
||||
}).catch((e) => {
|
||||
|
|
|
@ -1,4 +1,12 @@
|
|||
import { BookingStatus, User, Booking, Attendee, BookingReference, EventType } from "@prisma/client";
|
||||
import {
|
||||
BookingStatus,
|
||||
User,
|
||||
Booking,
|
||||
Attendee,
|
||||
BookingReference,
|
||||
EventType,
|
||||
WebhookTriggerEvents,
|
||||
} from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getSession } from "next-auth/react";
|
||||
|
@ -10,10 +18,13 @@ import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builde
|
|||
import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director";
|
||||
import { deleteMeeting } from "@calcom/core/videoClient";
|
||||
import { sendRequestRescheduleEmail } from "@calcom/emails";
|
||||
import { isPrismaObjOrUndefined } from "@calcom/lib";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { Person } from "@calcom/types/Calendar";
|
||||
import { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
|
||||
import prisma from "@lib/prisma";
|
||||
import sendPayload from "@lib/webhooks/sendPayload";
|
||||
import getWebhooks from "@lib/webhooks/subscriptions";
|
||||
|
||||
export type RescheduleResponse = Booking & {
|
||||
attendees: Attendee[];
|
||||
|
@ -69,6 +80,7 @@ const handler = async (
|
|||
id: true,
|
||||
uid: true,
|
||||
title: true,
|
||||
description: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
eventTypeId: true,
|
||||
|
@ -76,6 +88,7 @@ const handler = async (
|
|||
attendees: true,
|
||||
references: true,
|
||||
userId: true,
|
||||
customInputs: true,
|
||||
dynamicEventSlugRef: true,
|
||||
dynamicGroupSlugRef: true,
|
||||
destinationCalendar: true,
|
||||
|
@ -216,6 +229,45 @@ const handler = async (
|
|||
await sendRequestRescheduleEmail(builder.calendarEvent, {
|
||||
rescheduleLink: builder.rescheduleLink,
|
||||
});
|
||||
|
||||
const evt: CalendarEvent = {
|
||||
title: bookingToReschedule?.title,
|
||||
type: event && event.title ? event.title : bookingToReschedule.title,
|
||||
description: bookingToReschedule?.description || "",
|
||||
customInputs: isPrismaObjOrUndefined(bookingToReschedule.customInputs),
|
||||
startTime: bookingToReschedule?.startTime ? dayjs(bookingToReschedule.startTime).format() : "",
|
||||
endTime: bookingToReschedule?.endTime ? dayjs(bookingToReschedule.endTime).format() : "",
|
||||
organizer: userOwnerAsPeopleType,
|
||||
attendees: usersToPeopleType(
|
||||
// username field doesn't exists on attendee but could be in the future
|
||||
bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[],
|
||||
tAttendees
|
||||
),
|
||||
uid: bookingToReschedule?.uid,
|
||||
location: bookingToReschedule?.location,
|
||||
destinationCalendar:
|
||||
bookingToReschedule?.destinationCalendar || bookingToReschedule?.destinationCalendar,
|
||||
cancellationReason: `Please reschedule. ${cancellationReason}`, // TODO::Add i18-next for this
|
||||
};
|
||||
|
||||
// Send webhook
|
||||
const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED";
|
||||
// Send Webhook call if hooked to BOOKING.CANCELLED
|
||||
const subscriberOptions = {
|
||||
userId: bookingToReschedule.userId,
|
||||
eventTypeId: (bookingToReschedule.eventTypeId as number) || 0,
|
||||
triggerEvent: eventTrigger,
|
||||
};
|
||||
const webhooks = await getWebhooks(subscriberOptions);
|
||||
const promises = webhooks.map((webhook) =>
|
||||
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, evt).catch((e) => {
|
||||
console.error(
|
||||
`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`,
|
||||
e
|
||||
);
|
||||
})
|
||||
);
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
return res.status(200).json(bookingToReschedule);
|
||||
|
|
|
@ -139,7 +139,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
};
|
||||
const webhooks = await getWebhooks(subscriberOptions);
|
||||
const promises = webhooks.map((webhook) =>
|
||||
sendPayload(eventTrigger, new Date().toISOString(), webhook, evt).catch((e) => {
|
||||
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, evt).catch((e) => {
|
||||
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`, e);
|
||||
})
|
||||
);
|
||||
|
|
|
@ -33,6 +33,8 @@ test.describe("Integrations", () => {
|
|||
|
||||
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
|
||||
|
||||
await page.fill('[name="secret"]', "secret");
|
||||
|
||||
await page.click("[type=submit]");
|
||||
|
||||
// dialog is closed
|
||||
|
@ -71,6 +73,7 @@ test.describe("Integrations", () => {
|
|||
body.payload.organizer.timeZone = dynamic;
|
||||
body.payload.organizer.language = dynamic;
|
||||
body.payload.uid = dynamic;
|
||||
body.payload.bookingId = dynamic;
|
||||
body.payload.additionalInformation = dynamic;
|
||||
|
||||
// if we change the shape of our webhooks, we can simply update this by clicking `u`
|
||||
|
|
|
@ -1 +1 @@
|
|||
{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30 min","title":"30 min between PRO and Test Testson","description":"","additionalNotes":"","customInputs":{},"startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"PRO","email":"[redacted/dynamic]","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"hideCalendarNotes":false,"uid":"[redacted/dynamic]","metadata":{},"additionalInformation":"[redacted/dynamic]"}}
|
||||
{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30 min","title":"30 min between PRO and Test Testson","description":"","additionalNotes":"","customInputs":{},"startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"PRO","email":"[redacted/dynamic]","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"hideCalendarNotes":false,"uid":"[redacted/dynamic]","bookingId":"[redacted/dynamic]","metadata":{},"additionalInformation":"[redacted/dynamic]"}}
|
|
@ -184,6 +184,9 @@
|
|||
"getting_started": "Getting Started",
|
||||
"15min_meeting": "15 Min Meeting",
|
||||
"30min_meeting": "30 Min Meeting",
|
||||
"secret":"Secret",
|
||||
"leave_blank_to_remove_secret":"Leave blank to remove secret",
|
||||
"webhook_secret_key_description":"Ensure your server is only receiving the expected Cal.com requests for security reasons",
|
||||
"secret_meeting": "Secret Meeting",
|
||||
"login_instead": "Login instead",
|
||||
"already_have_an_account": "Already have an account?",
|
||||
|
@ -393,7 +396,9 @@
|
|||
"your_old_password": "Your old password",
|
||||
"current_password": "Current Password",
|
||||
"change_password": "Change Password",
|
||||
"change_secret": "Change Secret",
|
||||
"new_password_matches_old_password": "New password matches your old password. Please choose a different password.",
|
||||
"forgotten_secret_description":"If you have lost or forgotten this secret, you can change it, but be aware that all integrations using this secret will need to be updated",
|
||||
"current_incorrect_password": "Current password is incorrect",
|
||||
"incorrect_password": "Password is incorrect.",
|
||||
"1_on_1": "1-on-1",
|
||||
|
|
|
@ -97,6 +97,7 @@ export const webhookRouter = createProtectedRouter()
|
|||
payloadTemplate: z.string().nullable(),
|
||||
eventTypeId: z.number().optional(),
|
||||
appId: z.string().optional().nullable(),
|
||||
secret: z.string().optional().nullable(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
if (input.eventTypeId) {
|
||||
|
@ -125,6 +126,7 @@ export const webhookRouter = createProtectedRouter()
|
|||
payloadTemplate: z.string().nullable(),
|
||||
eventTypeId: z.number().optional(),
|
||||
appId: z.string().optional().nullable(),
|
||||
secret: z.string().optional().nullable(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const { id, ...data } = input;
|
||||
|
@ -161,7 +163,6 @@ export const webhookRouter = createProtectedRouter()
|
|||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const { id } = input;
|
||||
|
||||
input.eventTypeId
|
||||
? await ctx.prisma.eventType.update({
|
||||
where: {
|
||||
|
@ -207,7 +208,6 @@ export const webhookRouter = createProtectedRouter()
|
|||
};
|
||||
|
||||
const data = {
|
||||
triggerEvent: "PING",
|
||||
type: "Test",
|
||||
title: "Test trigger event",
|
||||
description: "",
|
||||
|
@ -230,8 +230,8 @@ export const webhookRouter = createProtectedRouter()
|
|||
};
|
||||
|
||||
try {
|
||||
const webhook = { subscriberUrl: url, payloadTemplate, appId: null };
|
||||
return await sendPayload(type, new Date().toISOString(), webhook, data);
|
||||
const webhook = { subscriberUrl: url, payloadTemplate, appId: null, secret: null };
|
||||
return await sendPayload(null, type, new Date().toISOString(), webhook, data);
|
||||
} catch (_err) {
|
||||
const error = getErrorFromUnknown(_err);
|
||||
return {
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Webhook" ADD COLUMN "secret" TEXT;
|
|
@ -397,6 +397,7 @@ model Webhook {
|
|||
eventType EventType? @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
|
||||
app App? @relation(fields: [appId], references: [slug], onDelete: Cascade)
|
||||
appId String?
|
||||
secret String?
|
||||
}
|
||||
|
||||
model Impersonations {
|
||||
|
|
|
@ -13,6 +13,7 @@ export const _WebhookModel = z.object({
|
|||
active: z.boolean(),
|
||||
eventTriggers: z.nativeEnum(WebhookTriggerEvents).array(),
|
||||
appId: z.string().nullish(),
|
||||
secret: z.string().nullish(),
|
||||
})
|
||||
|
||||
export interface CompleteWebhook extends z.infer<typeof _WebhookModel> {
|
||||
|
|
Loading…
Reference in New Issue
Block a user