diff --git a/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx b/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx new file mode 100644 index 000000000..53ec24827 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx @@ -0,0 +1,201 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Loader } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import type { z } from 'zod'; + +import { trpc } from '@documenso/trpc/react'; +import { ZEditWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { PasswordInput } from '@documenso/ui/primitives/password-input'; +import { Switch } from '@documenso/ui/primitives/switch'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; +import { TriggerMultiSelectCombobox } from '~/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox'; + +const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true }); + +type TEditWebhookFormSchema = z.infer; + +export type WebhookPageOptions = { + params: { + id: string; + }; +}; + +export default function WebhookPage({ params }: WebhookPageOptions) { + const { toast } = useToast(); + const router = useRouter(); + + const { data: webhook, isLoading } = trpc.webhook.getWebhookById.useQuery( + { + id: params.id, + }, + { enabled: !!params.id }, + ); + + const { mutateAsync: updateWebhook } = trpc.webhook.editWebhook.useMutation(); + + const form = useForm({ + resolver: zodResolver(ZEditWebhookFormSchema), + values: { + webhookUrl: webhook?.webhookUrl ?? '', + eventTriggers: webhook?.eventTriggers ?? [], + secret: webhook?.secret ?? '', + enabled: webhook?.enabled ?? true, + }, + }); + + const onSubmit = async (data: TEditWebhookFormSchema) => { + try { + await updateWebhook({ + id: params.id, + ...data, + }); + + toast({ + title: 'Webhook updated', + description: 'The webhook has been updated successfully.', + duration: 5000, + }); + + router.refresh(); + } catch (err) { + toast({ + title: 'Failed to update webhook', + description: 'We encountered an error while updating the webhook. Please try again later.', + variant: 'destructive', + }); + } + }; + + return ( +
+ + + {isLoading && ( +
+ +
+ )} + +
+ +
+
+ ( + + Webhook URL + + + + + + The URL for Documenso to send webhook events to. + + + + + )} + /> + + ( + + Enabled + +
+ + + +
+ + +
+ )} + /> +
+ + ( + + Triggers + + { + onChange(values); + }} + /> + + + + The events that will trigger a webhook to be sent to your URL. + + + + + )} + /> + + ( + + Secret + + + + + + A secret that will be sent to your URL so you can verify that the request has + been sent by Documenso. + + + + )} + /> + +
+ +
+
+
+ +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx new file mode 100644 index 000000000..01196544d --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx @@ -0,0 +1,101 @@ +'use client'; + +import Link from 'next/link'; + +import { Loader } from 'lucide-react'; +import { DateTime } from 'luxon'; + +import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name'; +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { Button } from '@documenso/ui/primitives/button'; + +import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; +import { CreateWebhookDialog } from '~/components/(dashboard)/settings/webhooks/create-webhook-dialog'; +import { DeleteWebhookDialog } from '~/components/(dashboard)/settings/webhooks/delete-webhook-dialog'; +import { LocaleDate } from '~/components/formatter/locale-date'; + +export default function WebhookPage() { + const { data: webhooks, isLoading } = trpc.webhook.getWebhooks.useQuery(); + + return ( +
+ + + + + {isLoading && ( +
+ +
+ )} + + {webhooks && webhooks.length === 0 && ( + // TODO: Perhaps add some illustrations here to make the page more engaging +
+

+ You have no webhooks yet. Your webhooks will be shown here once you create them. +

+
+ )} + + {webhooks && webhooks.length > 0 && ( +
+ {webhooks?.map((webhook) => ( +
+
+
+
{webhook.id}
+ +
+
+ {webhook.webhookUrl} +
+ + + {webhook.enabled ? 'Enabled' : 'Disabled'} + +
+ +

+ Listening to{' '} + {webhook.eventTriggers + .map((trigger) => toFriendlyWebhookEventName(trigger)) + .join(', ')} +

+ +

+ Created on{' '} + +

+
+ +
+ + + + +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/[id]/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/[id]/page.tsx new file mode 100644 index 000000000..cc7261bda --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/[id]/page.tsx @@ -0,0 +1,206 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Loader } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import type { z } from 'zod'; + +import { trpc } from '@documenso/trpc/react'; +import { ZEditWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { PasswordInput } from '@documenso/ui/primitives/password-input'; +import { Switch } from '@documenso/ui/primitives/switch'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; +import { TriggerMultiSelectCombobox } from '~/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox'; +import { useCurrentTeam } from '~/providers/team'; + +const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true }); + +type TEditWebhookFormSchema = z.infer; + +export type WebhookPageOptions = { + params: { + id: string; + }; +}; + +export default function WebhookPage({ params }: WebhookPageOptions) { + const { toast } = useToast(); + const router = useRouter(); + + const team = useCurrentTeam(); + + const { data: webhook, isLoading } = trpc.webhook.getWebhookById.useQuery( + { + id: params.id, + teamId: team.id, + }, + { enabled: !!params.id && !!team.id }, + ); + + const { mutateAsync: updateWebhook } = trpc.webhook.editWebhook.useMutation(); + + const form = useForm({ + resolver: zodResolver(ZEditWebhookFormSchema), + values: { + webhookUrl: webhook?.webhookUrl ?? '', + eventTriggers: webhook?.eventTriggers ?? [], + secret: webhook?.secret ?? '', + enabled: webhook?.enabled ?? true, + }, + }); + + const onSubmit = async (data: TEditWebhookFormSchema) => { + try { + await updateWebhook({ + id: params.id, + teamId: team.id, + ...data, + }); + + toast({ + title: 'Webhook updated', + description: 'The webhook has been updated successfully.', + duration: 5000, + }); + + router.refresh(); + } catch (err) { + toast({ + title: 'Failed to update webhook', + description: 'We encountered an error while updating the webhook. Please try again later.', + variant: 'destructive', + }); + } + }; + + return ( +
+ + + {isLoading && ( +
+ +
+ )} + +
+ +
+
+ ( + + Webhook URL + + + + + + The URL for Documenso to send webhook events to. + + + + + )} + /> + + ( + + Enabled + +
+ + + +
+ + +
+ )} + /> +
+ + ( + + Triggers + + { + onChange(values); + }} + /> + + + + The events that will trigger a webhook to be sent to your URL. + + + + + )} + /> + + ( + + Secret + + + + + + A secret that will be sent to your URL so you can verify that the request has + been sent by Documenso. + + + + )} + /> + +
+ +
+
+
+ +
+ ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/page.tsx new file mode 100644 index 000000000..054664624 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/page.tsx @@ -0,0 +1,106 @@ +'use client'; + +import Link from 'next/link'; + +import { Loader } from 'lucide-react'; +import { DateTime } from 'luxon'; + +import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name'; +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { Button } from '@documenso/ui/primitives/button'; + +import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; +import { CreateWebhookDialog } from '~/components/(dashboard)/settings/webhooks/create-webhook-dialog'; +import { DeleteWebhookDialog } from '~/components/(dashboard)/settings/webhooks/delete-webhook-dialog'; +import { LocaleDate } from '~/components/formatter/locale-date'; +import { useCurrentTeam } from '~/providers/team'; + +export default function WebhookPage() { + const team = useCurrentTeam(); + + const { data: webhooks, isLoading } = trpc.webhook.getTeamWebhooks.useQuery({ + teamId: team.id, + }); + + return ( +
+ + + + + {isLoading && ( +
+ +
+ )} + + {webhooks && webhooks.length === 0 && ( + // TODO: Perhaps add some illustrations here to make the page more engaging +
+

+ You have no webhooks yet. Your webhooks will be shown here once you create them. +

+
+ )} + + {webhooks && webhooks.length > 0 && ( +
+ {webhooks?.map((webhook) => ( +
+
+
+
{webhook.id}
+ +
+
+ {webhook.webhookUrl} +
+ + + {webhook.enabled ? 'Enabled' : 'Disabled'} + +
+ +

+ Listening to{' '} + {webhook.eventTriggers + .map((trigger) => toFriendlyWebhookEventName(trigger)) + .join(', ')} +

+ +

+ Created on{' '} + +

+
+ +
+ + + + +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx index e87c47b67..6109d1f3d 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx @@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { Braces, CreditCard, Lock, User, Users } from 'lucide-react'; +import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { cn } from '@documenso/ui/lib/utils'; @@ -77,6 +77,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { + + + + {isBillingEnabled && ( + + + + {isBillingEnabled && ( } + + + + + Create webhook + On this page, you can create a new webhook. + + +
+ +
+
+ ( + + Webhook URL + + + + + + The URL for Documenso to send webhook events to. + + + + + )} + /> + + ( + + Enabled + +
+ + + +
+ + +
+ )} + /> +
+ + ( + + Triggers + + { + onChange(values); + }} + /> + + + + The events that will trigger a webhook to be sent to your URL. + + + + + )} + /> + + ( + + Secret + + + + + + A secret that will be sent to your URL so you can verify that the request has + been sent by Documenso. + + + + )} + /> + + +
+ + +
+
+
+
+ +
+ + ); +}; diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx b/apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx new file mode 100644 index 000000000..e65ae78b8 --- /dev/null +++ b/apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx @@ -0,0 +1,172 @@ +'use effect'; + +import { useEffect, useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import type { Webhook } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useOptionalCurrentTeam } from '~/providers/team'; + +export type DeleteWebhookDialogProps = { + webhook: Pick; + onDelete?: () => void; + children: React.ReactNode; +}; + +export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogProps) => { + const router = useRouter(); + const { toast } = useToast(); + + const team = useOptionalCurrentTeam(); + + const [open, setOpen] = useState(false); + + const deleteMessage = `delete ${webhook.webhookUrl}`; + + const ZDeleteWebhookFormSchema = z.object({ + webhookUrl: z.literal(deleteMessage, { + errorMap: () => ({ message: `You must enter '${deleteMessage}' to proceed` }), + }), + }); + + type TDeleteWebhookFormSchema = z.infer; + + const { mutateAsync: deleteWebhook } = trpc.webhook.deleteWebhook.useMutation(); + + const form = useForm({ + resolver: zodResolver(ZDeleteWebhookFormSchema), + values: { + webhookUrl: '', + }, + }); + + const onSubmit = async () => { + try { + await deleteWebhook({ id: webhook.id, teamId: team?.id }); + + toast({ + title: 'Webhook deleted', + duration: 5000, + description: 'The webhook has been successfully deleted.', + }); + + setOpen(false); + + router.refresh(); + } catch (error) { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + duration: 5000, + description: + 'We encountered an unknown error while attempting to delete it. Please try again later.', + }); + } + }; + + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)}> + + {children ?? ( + + )} + + + + + Delete Webhook + + + Please note that this action is irreversible. Once confirmed, your webhook will be + permanently deleted. + + + +
+ +
+ ( + + + Confirm by typing:{' '} + + {deleteMessage} + + + + + + + + )} + /> + + +
+ + + +
+
+
+
+ +
+
+ ); +}; diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox.tsx b/apps/web/src/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox.tsx new file mode 100644 index 000000000..5636f1931 --- /dev/null +++ b/apps/web/src/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from 'react'; + +import { WebhookTriggerEvents } from '@prisma/client/'; +import { Check, ChevronsUpDown } from 'lucide-react'; + +import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from '@documenso/ui/primitives/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; + +import { truncateTitle } from '~/helpers/truncate-title'; + +type TriggerMultiSelectComboboxProps = { + listValues: string[]; + onChange: (_values: string[]) => void; +}; + +export const TriggerMultiSelectCombobox = ({ + listValues, + onChange, +}: TriggerMultiSelectComboboxProps) => { + const [isOpen, setIsOpen] = useState(false); + const [selectedValues, setSelectedValues] = useState([]); + + const triggerEvents = Object.values(WebhookTriggerEvents); + + useEffect(() => { + setSelectedValues(listValues); + }, [listValues]); + + const allEvents = [...new Set([...triggerEvents, ...selectedValues])]; + + const handleSelect = (currentValue: string) => { + let newSelectedValues; + + if (selectedValues.includes(currentValue)) { + newSelectedValues = selectedValues.filter((value) => value !== currentValue); + } else { + newSelectedValues = [...selectedValues, currentValue]; + } + + setSelectedValues(newSelectedValues); + onChange(newSelectedValues); + setIsOpen(false); + }; + + return ( + + + + + + + toFriendlyWebhookEventName(v)).join(', '), + 15, + )} + /> + No value found. + + {allEvents.map((value: string, i: number) => ( + handleSelect(value)}> + + {toFriendlyWebhookEventName(value)} + + ))} + + + + + ); +}; diff --git a/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx index 20fe8cb2e..6964b2cee 100644 --- a/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx +++ b/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx @@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react'; import Link from 'next/link'; import { useParams, usePathname } from 'next/navigation'; -import { Braces, CreditCard, Settings, Users } from 'lucide-react'; +import { Braces, CreditCard, Settings, Users, Webhook } from 'lucide-react'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { cn } from '@documenso/ui/lib/utils'; @@ -22,6 +22,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { const settingsPath = `/t/${teamUrl}/settings`; const membersPath = `/t/${teamUrl}/settings/members`; const tokensPath = `/t/${teamUrl}/settings/tokens`; + const webhooksPath = `/t/${teamUrl}/settings/webhooks`; const billingPath = `/t/${teamUrl}/settings/billing`; return ( @@ -59,6 +60,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { + + + + {IS_BILLING_ENABLED() && ( + + + + {IS_BILLING_ENABLED() && (