feat: implement webhooks

This commit is contained in:
Catalin Pit
2024-02-06 16:00:28 +02:00
parent fe4345eeb9
commit edeeaa5651
8 changed files with 306 additions and 2 deletions

View File

@ -0,0 +1,71 @@
'use client';
import { Zap } from 'lucide-react';
import { Button } from '@documenso/ui/primitives/button';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { DeleteWebhookDialog } from '~/components/(dashboard)/settings/webhooks/delete-webhook-dialog';
export default function WebhookPage() {
// TODO: Fetch webhooks from the DB after implementing the backend
const webhooks = [
{
id: 1,
secret: 'my-secret',
webhookUrl: 'https://example.com/webhook',
eventTriggers: ['document.created', 'document.signed'],
enabled: true,
userID: 1,
},
];
return (
<div>
<SettingsHeader
title="Webhooks"
subtitle="On this page, you can create new Webhooks and manage the existing ones."
>
<Button variant="default">Create Webhook</Button>
</SettingsHeader>
{webhooks.length === 0 && (
// TODO: Perhaps add some illustrations here to make the page more engaging
<div className="mb-4">
<p className="text-muted-foreground mt-2 text-sm italic">
You have no webhooks yet. Your webhooks will be shown here once you create them.
</p>
</div>
)}
{webhooks.length > 0 && (
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
{webhooks.map((webhook) => (
<div key={webhook.id} className="border-border rounded-lg border p-4">
<div className="flex items-center justify-between gap-x-4">
<div>
<h4 className="text-lg font-semibold">Webhook URL</h4>
<p className="text-muted-foreground">{webhook.webhookUrl}</p>
<h4 className="mt-4 text-lg font-semibold">Event triggers</h4>
{webhook.eventTriggers.map((trigger, index) => (
<p key={index} className="text-muted-foreground flex flex-row items-center">
<Zap className="mr-1 h-4 w-4 fill-yellow-400 stroke-yellow-600" /> {trigger}
</p>
))}
</div>
</div>
<div className="flex flex-col-reverse space-y-2 space-y-reverse sm:flex-row sm:justify-end sm:space-x-2 sm:space-y-0">
<Button variant="secondary" className="">
Edit
</Button>
<DeleteWebhookDialog webhook={webhook}>
<Button variant="destructive">Delete</Button>
</DeleteWebhookDialog>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { CreditCard, Lock, User, Users } from 'lucide-react';
import { 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';
@ -48,6 +48,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button>
</Link>
<Link href="/settings/webhooks">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
)}
>
<Webhook className="mr-2 h-5 w-5" />
Webhooks
</Button>
</Link>
<Link href="/settings/security">
<Button
variant="ghost"

View File

@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { CreditCard, Lock, User, Users } from 'lucide-react';
import { 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';
@ -51,6 +51,19 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button>
</Link>
<Link href="/settings/webhooks">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
)}
>
<Webhook className="mr-2 h-5 w-5" />
Webhooks
</Button>
</Link>
<Link href="/settings/security">
<Button
variant="ghost"

View File

@ -0,0 +1,3 @@
export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogProps) => {
return <h1>test</h1>;
};

View File

@ -0,0 +1,167 @@
'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';
export type DeleteWebhookDialogProps = {
webhook: Pick<Webhook, 'id' | 'webhookUrl'>;
onDelete?: () => void;
children: React.ReactNode;
};
export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogProps) => {
const router = useRouter();
const { toast } = useToast();
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<typeof ZDeleteWebhookFormSchema>;
const { mutateAsync: deleteWebhook } = trpc.webhook.deleteWebhookById.useMutation();
const form = useForm<TDeleteWebhookFormSchema>({
resolver: zodResolver(ZDeleteWebhookFormSchema),
values: {
webhookUrl: '',
},
});
const onSubmit = async () => {
try {
await deleteWebhook({ id: webhook.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 (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild>
{children ?? (
<Button className="mr-4" variant="destructive">
Delete
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Webhook</DialogTitle>
<DialogDescription>
Please note that this action is irreversible. Once confirmed, your webhook will be
permanently deleted.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
Confirm by typing:{' '}
<span className="font-sm text-destructive font-semibold">
{deleteMessage}
</span>
</FormLabel>
<FormControl>
<Input className="bg-background" type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<div className="flex w-full flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
variant="destructive"
className="flex-1"
disabled={!form.formState.isValid}
loading={form.formState.isSubmitting}
>
I'm sure! Delete it
</Button>
</div>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,19 @@
-- CreateEnum
CREATE TYPE "WebhookTriggerEvents" AS ENUM ('DOCUMENT_CREATED', 'DOCUMENT_SIGNED');
-- CreateTable
CREATE TABLE "Webhook" (
"id" SERIAL NOT NULL,
"webhookUrl" TEXT NOT NULL,
"eventTriggers" "WebhookTriggerEvents"[],
"secret" TEXT,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" INTEGER NOT NULL,
CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -47,6 +47,7 @@ model User {
VerificationToken VerificationToken[]
Template Template[]
securityAuditLogs UserSecurityAuditLog[]
Webhooks Webhook[]
@@index([email])
}
@ -94,6 +95,23 @@ model VerificationToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
enum WebhookTriggerEvents {
DOCUMENT_CREATED
DOCUMENT_SIGNED
}
model Webhook {
id Int @id @default(autoincrement())
webhookUrl String
eventTriggers WebhookTriggerEvents[]
secret String?
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
userId Int
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
enum SubscriptionStatus {
ACTIVE
PAST_DUE