diff --git a/apps/web/components/webhook/WebhookDialogForm.tsx b/apps/web/components/webhook/WebhookDialogForm.tsx index e1988a5c0a..6a803f94c4 100644 --- a/apps/web/components/webhook/WebhookDialogForm.tsx +++ b/apps/web/components/webhook/WebhookDialogForm.tsx @@ -2,6 +2,7 @@ import { Webhook } from "@prisma/client"; import { useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; +import customTemplate, { hasTemplateIntegration } from "@calcom/features/webhooks/utils/integrationTemplate"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import showToast from "@calcom/lib/notification"; import { trpc } from "@calcom/trpc/react"; @@ -11,11 +12,11 @@ import Switch from "@calcom/ui/Switch"; import { FieldsetLegend, Form, InputGroupBox, TextArea, TextField } from "@calcom/ui/form/fields"; import { WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP } from "@lib/webhooks/constants"; -import customTemplate, { hasTemplateIntegration } from "@lib/webhooks/integrationTemplate"; import { TWebhook } from "@components/webhook/WebhookListItem"; import WebhookTestDisclosure from "@components/webhook/WebhookTestDisclosure"; +/** @deprecated Moved to `packages/features/webhooks` */ export default function WebhookDialogForm(props: { eventTypeId?: number; defaultValues?: TWebhook; diff --git a/apps/web/components/webhook/WebhookListContainer.tsx b/apps/web/components/webhook/WebhookListContainer.tsx index 74f99e8b35..7d0f8a3f20 100644 --- a/apps/web/components/webhook/WebhookListContainer.tsx +++ b/apps/web/components/webhook/WebhookListContainer.tsx @@ -21,6 +21,7 @@ export type WebhookListContainerType = { appId?: string; }; +/** @deprecated Moved to `packages/features/webhooks` */ export default function WebhookListContainer(props: WebhookListContainerType) { const router = useRouter(); const query = trpc.useQuery( diff --git a/apps/web/components/webhook/WebhookListItem.tsx b/apps/web/components/webhook/WebhookListItem.tsx index 65118b7159..e23a90344b 100644 --- a/apps/web/components/webhook/WebhookListItem.tsx +++ b/apps/web/components/webhook/WebhookListItem.tsx @@ -11,6 +11,7 @@ import { useLocale } from "@lib/hooks/useLocale"; export type TWebhook = inferQueryOutput<"viewer.webhook.list">[number]; +/** @deprecated Moved to `packages/features/webhooks` */ export default function WebhookListItem(props: { webhook: TWebhook; onEditWebhook: () => void }) { const { t } = useLocale(); const utils = trpc.useContext(); diff --git a/apps/web/components/webhook/WebhookTestDisclosure.tsx b/apps/web/components/webhook/WebhookTestDisclosure.tsx index 34d2ae03d7..21a4d20a9e 100644 --- a/apps/web/components/webhook/WebhookTestDisclosure.tsx +++ b/apps/web/components/webhook/WebhookTestDisclosure.tsx @@ -11,6 +11,7 @@ import { InputGroupBox } from "@calcom/ui/form/fields"; import { useLocale } from "@lib/hooks/useLocale"; +/** @deprecated Moved to `packages/features/webhooks` */ export default function WebhookTestDisclosure() { const subscriberUrl: string = useWatch({ name: "subscriberUrl" }); const payloadTemplate = useWatch({ name: "payloadTemplate" }) || null; diff --git a/apps/web/lib/webhooks/sendPayload.tsx b/apps/web/lib/webhooks/sendPayload.tsx index be765d53ed..e396f36258 100644 --- a/apps/web/lib/webhooks/sendPayload.tsx +++ b/apps/web/lib/webhooks/sendPayload.tsx @@ -5,8 +5,6 @@ import { compile } from "handlebars"; import { getHumanReadableLocationValue } from "@calcom/app-store/locations"; import type { CalendarEvent } from "@calcom/types/Calendar"; -import { getTranslation } from "@server/lib/i18n"; - type ContentType = "application/json" | "application/x-www-form-urlencoded"; export type EventTypeInfo = { diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 8d53b468d4..b00d073d89 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -7,6 +7,8 @@ import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry const V2_WHITELIST = [ "/settings/admin", + "/settings/developer/webhooks", + "/settings/developer/api-keys", "/settings/my-account", "/settings/security", "/availability", diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts index d9a5fe0007..aa3eee55bd 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -21,6 +21,7 @@ import { sendScheduledSeatsEmails, } from "@calcom/emails"; import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; +import getWebhooks from "@calcom/features/webhooks/utils/getWebhooks"; import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents"; import { getErrorFromUnknown } from "@calcom/lib/errors"; @@ -28,7 +29,6 @@ import isOutOfBounds, { BookingDateInPastError } from "@calcom/lib/isOutOfBounds import logger from "@calcom/lib/logger"; import { defaultResponder, getLuckyUser } from "@calcom/lib/server"; import { updateWebUser as syncServicesUpdateWebUser } from "@calcom/lib/sync/SyncServiceManager"; -import getSubscribers from "@calcom/lib/webhooks/subscriptions"; import prisma, { userSelect } from "@calcom/prisma"; import { extendedBookingCreateBody, requiredCustomInputSchema } from "@calcom/prisma/zod-utils"; import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime"; @@ -827,7 +827,7 @@ async function handler(req: NextApiRequest) { triggerEvent: WebhookTriggerEvents.MEETING_ENDED, }; - const subscribersMeetingEnded = await getSubscribers(subscriberOptionsMeetingEnded); + const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded); subscribersMeetingEnded.forEach((subscriber) => { if (rescheduleUid && originalRescheduledBooking) { @@ -839,7 +839,7 @@ async function handler(req: NextApiRequest) { }); // Send Webhook call if hooked to BOOKING_CREATED & BOOKING_RESCHEDULED - const subscribers = await getSubscribers(subscriberOptions); + const subscribers = await getWebhooks(subscriberOptions); console.log("evt:", { ...evt, metadata: reqBody.metadata, diff --git a/apps/web/pages/v2/settings/developer/api-keys.tsx b/apps/web/pages/v2/settings/developer/api-keys.tsx new file mode 100644 index 0000000000..a128acb0dc --- /dev/null +++ b/apps/web/pages/v2/settings/developer/api-keys.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; + +import { TApiKeys } from "@calcom/ee/api-keys/components/ApiKeyListItem"; +import LicenseRequired from "@calcom/ee/common/components/v2/LicenseRequired"; +import ApiKeyDialogForm from "@calcom/features/ee/api-keys/components/v2/ApiKeyDialogForm"; +import ApiKeyListItem from "@calcom/features/ee/api-keys/components/v2/ApiKeyListItem"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Icon } from "@calcom/ui"; +import SkeletonLoader from "@calcom/ui/apps/SkeletonLoader"; +import { EmptyScreen, Button } from "@calcom/ui/v2"; +import { Dialog, DialogContent } from "@calcom/ui/v2/core/Dialog"; +import Meta from "@calcom/ui/v2/core/Meta"; +import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout"; + +const ApiKeysView = () => { + const { t } = useLocale(); + + const { data, isLoading } = trpc.useQuery(["viewer.apiKeys.list"]); + + const [apiKeyModal, setApiKeyModal] = useState(false); + const [apiKeyToEdit, setApiKeyToEdit] = useState<(TApiKeys & { neverExpires?: boolean }) | undefined>( + undefined + ); + + const NewApiKeyButton = () => { + return ( + + ); + }; + + return ( + <> + + + + <> + {isLoading && } +
+ {data?.length ? ( + <> +
+ {data?.map((apiKey, index) => ( + { + setApiKeyToEdit(apiKey); + setApiKeyModal(true); + }} + /> + ))} +
+ + + ) : ( + } + /> + )} +
+ +
+ + + + setApiKeyModal(false)} defaultValues={apiKeyToEdit} /> + + + + ); +}; + +ApiKeysView.getLayout = getLayout; + +export default ApiKeysView; diff --git a/apps/web/pages/v2/settings/developer/webhooks/[id]/index.tsx b/apps/web/pages/v2/settings/developer/webhooks/[id]/index.tsx new file mode 100644 index 0000000000..b8da528284 --- /dev/null +++ b/apps/web/pages/v2/settings/developer/webhooks/[id]/index.tsx @@ -0,0 +1 @@ +export { default } from "@calcom/features/webhooks/pages/webhook-edit-view"; diff --git a/apps/web/pages/v2/settings/developer/webhooks/index.tsx b/apps/web/pages/v2/settings/developer/webhooks/index.tsx new file mode 100644 index 0000000000..d06442e3d2 --- /dev/null +++ b/apps/web/pages/v2/settings/developer/webhooks/index.tsx @@ -0,0 +1 @@ +export { default } from "@calcom/features/webhooks/pages/webhooks-view"; diff --git a/apps/web/pages/v2/settings/developer/webhooks/new.tsx b/apps/web/pages/v2/settings/developer/webhooks/new.tsx new file mode 100644 index 0000000000..05e067b199 --- /dev/null +++ b/apps/web/pages/v2/settings/developer/webhooks/new.tsx @@ -0,0 +1 @@ +export { default } from "@calcom/features/webhooks/pages/webhook-new-view"; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index d0cd0a5148..1d2caa4f9a 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -352,10 +352,10 @@ "booking_cancelled": "Booking Cancelled", "booking_rescheduled": "Booking Rescheduled", "booking_created": "Booking Created", - "meeting_ended": "Meeting ended", + "meeting_ended": "Meeting Ended", "form_submitted": "Form Submitted", "event_triggers": "Event Triggers", - "subscriber_url": "Subscriber Url", + "subscriber_url": "Subscriber URL", "create_new_webhook": "Create a new webhook", "webhooks": "Webhooks", "team_webhooks": "Team Webhooks", @@ -1166,6 +1166,8 @@ "download_responses": "Download Responses", "create_your_first_form": "Create your first form", "create_your_first_form_description": "With Routing Forms you can ask qualifying questions and route to the correct person or event type.", + "create_your_first_webhook": "Create your first Webhook", + "create_your_first_webhook_description": "With Webhooks you can receive meeting data in real-time when something happens in Cal.com.", "for_a_maximum_of": "For a maximum of", "event_one": "event", "event_other": "events", @@ -1187,6 +1189,23 @@ "add_new_form": "Add new form", "form_description": "Create your form to route a booker", "copy_link_to_form": "Copy link to form", + "add_webhook_description": "Receive meeting data in real-time when something happens in Cal.com", + "triggers_when": "Triggers when", + "test_webhook": "Please ping test before creating.", + "enable_webhook": "Enable Webhook", + "add_webhook": "Add Webhook", + "webhook_edited_successfully": "Webhook saved", + "webhooks_description": "Receive meeting data in real-time when something happens in Cal.com", + "api_keys_description": "Generate API keys for accessing your own account", + "new_api_key": "New API key", + "active": "Active", + "api_key_updated": "API key name updated", + "api_key_update_failed": "Error updating API key name", + "embeds_title": "HTML iframe embed", + "embeds_description": "Embed all your event types on your website", + "pending_payment": "Pending payment", + "create_first_api_key": "Create your first API key", + "create_first_api_key_description": "API keys allow other apps to communicate with Cal.com", "back_to_signin": "Back to sign in", "reset_link_sent": "Reset link sent", "password_reset_email":"An email is on itโ€™s way to {{email}} with instructions to reset your password.", diff --git a/packages/app-store/ee/routing_forms/trpc-router.ts b/packages/app-store/ee/routing_forms/trpc-router.ts index e3fdc9f816..614c3dd67a 100644 --- a/packages/app-store/ee/routing_forms/trpc-router.ts +++ b/packages/app-store/ee/routing_forms/trpc-router.ts @@ -2,8 +2,8 @@ import { Prisma, WebhookTriggerEvents } from "@prisma/client"; import { v4 as uuidv4 } from "uuid"; import { z } from "zod"; +import getWebhooks from "@calcom/features/webhooks/utils/getWebhooks"; import { sendGenericWebhookPayload } from "@calcom/lib/webhooks/sendPayload"; -import getWebhooks from "@calcom/lib/webhooks/subscriptions"; import { TRPCError } from "@calcom/trpc/server"; import { createProtectedRouter, createRouter } from "@calcom/trpc/server/createRouter"; diff --git a/packages/features/ee/api-keys/components/v2/ApiKeyDialogForm.tsx b/packages/features/ee/api-keys/components/v2/ApiKeyDialogForm.tsx new file mode 100644 index 0000000000..0217d72cd7 --- /dev/null +++ b/packages/features/ee/api-keys/components/v2/ApiKeyDialogForm.tsx @@ -0,0 +1,182 @@ +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; + +import dayjs from "@calcom/dayjs"; +import { TApiKeys } from "@calcom/ee/api-keys/components/ApiKeyListItem"; +import LicenseRequired from "@calcom/ee/common/components/v2/LicenseRequired"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import showToast from "@calcom/lib/notification"; +import { trpc } from "@calcom/trpc/react"; +import { DialogFooter } from "@calcom/ui/Dialog"; +import { ClipboardCopyIcon } from "@calcom/ui/Icon"; +import { Tooltip } from "@calcom/ui/Tooltip"; +import { DatePicker } from "@calcom/ui/v2"; +import Button from "@calcom/ui/v2/core/Button"; +import Switch from "@calcom/ui/v2/core/Switch"; +import { Form, TextField } from "@calcom/ui/v2/core/form/fields"; + +export default function ApiKeyDialogForm({ + defaultValues, + handleClose, +}: { + defaultValues?: Omit & { neverExpires?: boolean }; + handleClose: () => void; +}) { + const { t } = useLocale(); + const utils = trpc.useContext(); + + const updateApiKeyMutation = trpc.useMutation("viewer.apiKeys.edit", { + onSuccess() { + utils.invalidateQueries("viewer.apiKeys.list"); + showToast(t("api_key_updated"), "success"); + handleClose(); + }, + onError() { + showToast(t("api_key_update_failed"), "error"); + }, + }); + + const [apiKey, setApiKey] = useState(""); + const [successfulNewApiKeyModal, setSuccessfulNewApiKeyModal] = useState(false); + const [apiKeyDetails, setApiKeyDetails] = useState({ + expiresAt: null as Date | null, + note: "" as string | null, + neverExpires: false, + }); + + const form = useForm({ + defaultValues: { + note: defaultValues?.note || "", + neverExpires: defaultValues?.neverExpires || false, + expiresAt: defaultValues?.expiresAt || dayjs().add(1, "month").toDate(), + }, + }); + const watchNeverExpires = form.watch("neverExpires"); + + return ( + + {successfulNewApiKeyModal ? ( + <> +
+

+ {t("success_api_key_created")} +

+
+ {t("success_api_key_created_bold_tagline")}{" "} + {t("you_will_only_view_it_once")} +
+
+
+
+ + {apiKey} + + + + +
+ + {apiKeyDetails.neverExpires + ? t("never_expire_key") + : `${t("expires")} ${apiKeyDetails?.expiresAt?.toLocaleDateString()}`} + +
+ + + + + ) : ( +
{ + if (defaultValues) { + console.log("Name changed"); + await updateApiKeyMutation.mutate({ id: defaultValues.id, note: event.note }); + } else { + const apiKey = await utils.client.mutation("viewer.apiKeys.create", event); + setApiKey(apiKey); + setApiKeyDetails({ ...event }); + await utils.invalidateQueries(["viewer.apiKeys.list"]); + setSuccessfulNewApiKeyModal(true); + } + }} + className="space-y-4"> +
+

+ {defaultValues ? t("edit_api_key") : t("create_api_key")} +

+

{t("api_key_modal_subtitle")}

+
+
+ ( + { + form.setValue("note", e?.target.value); + }} + type="text" + /> + )} + /> +
+ {!defaultValues && ( +
+
+ {t("expire_date")} + ( + + )} + /> +
+ ( + + )} + /> +
+ )} + + + + + +
+ )} +
+ ); +} diff --git a/packages/features/ee/api-keys/components/v2/ApiKeyListItem.tsx b/packages/features/ee/api-keys/components/v2/ApiKeyListItem.tsx new file mode 100644 index 0000000000..3157c90310 --- /dev/null +++ b/packages/features/ee/api-keys/components/v2/ApiKeyListItem.tsx @@ -0,0 +1,92 @@ +import dayjs from "@calcom/dayjs"; +import { TApiKeys } from "@calcom/ee/api-keys/components/ApiKeyListItem"; +import { classNames } from "@calcom/lib"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Icon } from "@calcom/ui"; +import Badge from "@calcom/ui/v2/core/Badge"; +import Button from "@calcom/ui/v2/core/Button"; +import Dropdown, { + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@calcom/ui/v2/core/Dropdown"; + +const ApiKeyListItem = ({ + apiKey, + lastItem, + onEditClick, +}: { + apiKey: TApiKeys; + lastItem: boolean; + onEditClick: () => void; +}) => { + const { t } = useLocale(); + const utils = trpc.useContext(); + + const isExpired = apiKey?.expiresAt ? apiKey.expiresAt < new Date() : null; + const neverExpires = apiKey?.expiresAt === null; + + const deleteApiKey = trpc.useMutation("viewer.apiKeys.delete", { + async onSuccess() { + await utils.invalidateQueries(["viewer.apiKeys.list"]); + }, + }); + + return ( +
+
+

{apiKey?.note ? apiKey.note : t("api_key_no_note")}

+
+ {!neverExpires && isExpired && ( + + {t("expired")} + + )} + {!isExpired && ( + + {t("active")} + + )} +

+ {" "} + {neverExpires ? ( +

{t("api_key_never_expires")}
+ ) : ( + `${isExpired ? t("expired") : t("expires")} ${dayjs(apiKey?.expiresAt?.toString()).fromNow()}` + )} +

+
+
+
+ + +
+
+ ); +}; + +export default ApiKeyListItem; diff --git a/packages/features/webhooks/components/WebhookForm.tsx b/packages/features/webhooks/components/WebhookForm.tsx new file mode 100644 index 0000000000..d0d101029e --- /dev/null +++ b/packages/features/webhooks/components/WebhookForm.tsx @@ -0,0 +1,266 @@ +import { WebhookTriggerEvents } from "@prisma/client"; +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; + +import { classNames } from "@calcom/lib"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { inferQueryOutput } from "@calcom/trpc/react"; +import Button from "@calcom/ui/v2/core/Button"; +import Switch from "@calcom/ui/v2/core/Switch"; +import Select from "@calcom/ui/v2/core/form/Select"; +import { Form, Label, TextArea, TextField } from "@calcom/ui/v2/core/form/fields"; + +import customTemplate, { hasTemplateIntegration } from "../utils/integrationTemplate"; +import WebhookTestDisclosure from "./WebhookTestDisclosure"; + +export type TWebhook = inferQueryOutput<"viewer.webhook.list">[number]; + +export type WebhookFormData = { + id?: string; + subscriberUrl: string; + active: boolean; + eventTriggers: WebhookTriggerEvents[]; + secret: string | null; + payloadTemplate: string | undefined | null; +}; + +export type WebhookFormSubmitData = WebhookFormData & { + changeSecret: boolean; + newSecret: string; +}; + +type WebhookTriggerEventOptions = { value: WebhookTriggerEvents; label: string }[]; + +const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record = { + core: [ + { value: WebhookTriggerEvents.BOOKING_CANCELLED, label: "booking_cancelled" }, + { value: WebhookTriggerEvents.BOOKING_CREATED, label: "booking_created" }, + { value: WebhookTriggerEvents.BOOKING_RESCHEDULED, label: "booking_rescheduled" }, + { value: WebhookTriggerEvents.MEETING_ENDED, label: "meeting_ended" }, + ], + routing_forms: [{ value: WebhookTriggerEvents.FORM_SUBMITTED, label: "form_submitted" }], +}; + +const WebhookForm = (props: { + webhook?: WebhookFormData; + apps?: (keyof typeof WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2)[]; + onSubmit: (event: WebhookFormSubmitData) => void; +}) => { + const { t } = useLocale(); + + const triggerOptions: WebhookTriggerEventOptions = WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2["core"]; + if (props.apps) { + for (const app of props.apps) { + triggerOptions.push(...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2[app]); + } + } + const translatedTriggerOptions = triggerOptions.map((option) => ({ ...option, label: t(option.label) })); + + const formMethods = useForm({ + defaultValues: { + subscriberUrl: props?.webhook?.subscriberUrl || "", + active: props?.webhook?.active || false, + eventTriggers: props?.webhook?.eventTriggers || [], + secret: props?.webhook?.secret || "", + payloadTemplate: props?.webhook?.payloadTemplate || undefined, + }, + }); + + const [useCustomTemplate, setUseCustomTemplate] = useState(false); + const [newSecret, setNewSecret] = useState(""); + const [changeSecret, setChangeSecret] = useState(false); + const hasSecretKey = !!props?.webhook?.secret; + // const currentSecret = props?.webhook?.secret; + + useEffect(() => { + if (changeSecret) { + formMethods.unregister("secret", { keepDefaultValue: false }); + } + }, [changeSecret]); + + return ( + <> +
props.onSubmit({ ...values, changeSecret, newSecret })}> + ( + <> + { + formMethods.setValue("subscriberUrl", e?.target.value); + if (hasTemplateIntegration({ url: e.target.value })) { + setUseCustomTemplate(true); + formMethods.setValue("payloadTemplate", customTemplate({ url: e.target.value })); + } + }} + /> + + )} + /> + ( +
+ { + formMethods.setValue("active", value); + }} + /> +
+ )} + /> + ( +
+ +