mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
Merge branch 'main' into feat/public-profile-1
This commit is contained in:
76
apps/marketing/content/blog/launch-week-2-day-2.mdx
Normal file
76
apps/marketing/content/blog/launch-week-2-day-2.mdx
Normal file
@ -0,0 +1,76 @@
|
||||
---
|
||||
title: Launch Week II - Day 2 - Templates
|
||||
description: Templates help you prepare regular documents faster. And you can share them with your team!
|
||||
authorName: 'Timur Ercan'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
date: 2024-02-27
|
||||
tags:
|
||||
- Launch Week
|
||||
- Templates
|
||||
---
|
||||
|
||||
<video
|
||||
id="vid"
|
||||
width="100%"
|
||||
src="https://github.com/documenso/design/assets/1309312/c9504db1-26b7-4033-88ed-a95cabd02e92"
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
></video>
|
||||
|
||||
> TLDR; You can now reuse documents via templates. More field types coming soon as well.
|
||||
|
||||
## Introducing Templates
|
||||
|
||||
It's day 2 of Launch Week, everybody 🙌 After introducing [Teams](https://documenso.com/blog/launch-week-2-day-1) yesterday, today we are looking at making Documenso faster for daily use:
|
||||
We are launching templates for Documenso! Templates are an easy way to reuse documents you send out often with just a few clicks. With templates, you can:
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
src="/blog/quickfill.png"
|
||||
width="1260"
|
||||
height="630"
|
||||
alt="Template recipients quick fill view"
|
||||
/>
|
||||
|
||||
<figcaption className="text-center">
|
||||
Quickly fill out recipients, when creating from a template
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
- Save often-uploaded documents for reuse
|
||||
- Pre-define fields, so you just have to send the document
|
||||
- Quickly fill out recipients and roles for new documents
|
||||
- Share templates with your team to make working together even easier
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
src="/blog/template.png"
|
||||
width="1260"
|
||||
height="630"
|
||||
alt="Create from template view"
|
||||
/>
|
||||
|
||||
<figcaption className="text-center">
|
||||
POV: You are a diligent german and create custom receipts with Documenso
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
## Pricing
|
||||
|
||||
Templates are **included in all Documenso Plans!** That includes our free tier: The limit of 5 documents per month still applies, but you are free to reach it with less friction using templates. Sharing templates with other users is only possible with the teams plan. If you want to share templates with people not in your team, we might have something coming up later this week 👀
|
||||
|
||||
## What's Next for Templates
|
||||
|
||||
We have a lot of great stuff coming up for templates as well:
|
||||
|
||||
- More Field Types are in the pipeline
|
||||
- Sharing Templates Externally 👀
|
||||
|
||||
Check out templates [here](https://documen.so/templates) and let us know what you think and what we can improve. Which field types are you missing? Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here and would love to hear from you :)
|
||||
|
||||
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 2! They just launched templates, and I'm pumped 🎉🚀🚀🚀. Check it out https://documen.so/day2"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
|
||||
|
||||
Best from Hamburg\
|
||||
Timur
|
||||
53
apps/marketing/content/blog/launch-week-2-day-3.mdx
Normal file
53
apps/marketing/content/blog/launch-week-2-day-3.mdx
Normal file
@ -0,0 +1,53 @@
|
||||
---
|
||||
title: Launch Week II - Day 3 - API
|
||||
description: Documenso's mission is to create a plattform developers all around the world can build upon. Today we are releasing the first version of our public API, included in all plans!
|
||||
authorName: 'Timur Ercan'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
date: 2024-02-28
|
||||
tags:
|
||||
- Launch Week
|
||||
- API
|
||||
---
|
||||
|
||||
<video
|
||||
id="vid"
|
||||
width="100%"
|
||||
src="https://github.com/documenso/design/assets/1309312/cb74d6cb-a127-4cac-a166-ad6b56c6140d"
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
></video>
|
||||
|
||||
> TLDR; The public API is now availible for all plans.
|
||||
|
||||
## Introducing the public Documenso API
|
||||
|
||||
Launch. Week. Day. 3 🎉 Documenso's mission is to create a platform that developers all around the world can build upon. Today we are releasing the first version of our public API, and we are pumped. Since this is the first version, we focused on the basics. With the new API you can:
|
||||
|
||||
- Get Documents (Individual or all Accessible)
|
||||
- Upload Documents
|
||||
- Delete Documents
|
||||
- Create Documents from Templates
|
||||
- Trigger Sending Documents for Singing
|
||||
|
||||
You can check out the detailed API documentation here:
|
||||
|
||||
> API DOCUMENTATION: [https://app.documenso.com/api/v1/openapi](https://app.documenso.com/api/v1/openapi)
|
||||
|
||||
## Pricing
|
||||
|
||||
We are building Documenso to be an open and extendable platform; therefore the API is included in all current plans. The API is authenticated via auth tokens, which every user can create at no extra cost, as can teams. Existing limits still apply (i.e., the number of included documents for the free plan). While we don't have all the details yet, we don't intend to price the API usage in itself (rather the accounts using it) since we want you to build on Documenso without being smothered by API costs.
|
||||
|
||||
> Try the API here for free: [https://documen.so/api](https://documen.so/api)
|
||||
|
||||
## What's next for the API
|
||||
|
||||
You tell us. This is by far the most requested feature, so we would like to hear from you. What should we add? How can we integrate even better?
|
||||
|
||||
Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here and would love to hear from you :)
|
||||
|
||||
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 3! The public API is here 👀 Check it out https://documen.so/day3"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
|
||||
|
||||
Best from Hamburg\
|
||||
Timur
|
||||
BIN
apps/marketing/public/blog/quickfill.png
Normal file
BIN
apps/marketing/public/blog/quickfill.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
BIN
apps/marketing/public/blog/template.png
Normal file
BIN
apps/marketing/public/blog/template.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
@ -1,5 +1,6 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -18,7 +19,15 @@ export default async function ApiTokensPage() {
|
||||
<h3 className="text-2xl font-semibold">API Tokens</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
On this page, you can create new API tokens and manage the existing ones.
|
||||
On this page, you can create new API tokens and manage the existing ones. <br />
|
||||
You can view our swagger docs{' '}
|
||||
<a
|
||||
className="text-primary underline"
|
||||
href={`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/openapi`}
|
||||
target="_blank"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
201
apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx
Normal file
201
apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx
Normal file
@ -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<typeof ZEditWebhookFormSchema>;
|
||||
|
||||
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<TEditWebhookFormSchema>({
|
||||
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 (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title="Edit webhook"
|
||||
subtitle="On this page, you can edit the webhook and its settings."
|
||||
/>
|
||||
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full max-w-xl flex-col gap-y-6"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<div className="flex flex-col-reverse gap-4 md:flex-row">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel required>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
The URL for Documenso to send webhook events to.
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Enabled</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
className="bg-background"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="eventTriggers"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<FormItem className="flex flex-col gap-2">
|
||||
<FormLabel required>Triggers</FormLabel>
|
||||
<FormControl>
|
||||
<TriggerMultiSelectCombobox
|
||||
listValues={value}
|
||||
onChange={(values: string[]) => {
|
||||
onChange(values);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
The events that will trigger a webhook to be sent to your URL.
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="secret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput className="bg-background" {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
A secret that will be sent to your URL so you can verify that the request has
|
||||
been sent by Documenso.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
Update webhook
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
apps/web/src/app/(dashboard)/settings/webhooks/page.tsx
Normal file
101
apps/web/src/app/(dashboard)/settings/webhooks/page.tsx
Normal file
@ -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 (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title="Webhooks"
|
||||
subtitle="On this page, you can create new Webhooks and manage the existing ones."
|
||||
>
|
||||
<CreateWebhookDialog />
|
||||
</SettingsHeader>
|
||||
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webhooks && 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 && webhooks.length > 0 && (
|
||||
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
|
||||
{webhooks?.map((webhook) => (
|
||||
<div
|
||||
key={webhook.id}
|
||||
className={cn(
|
||||
'border-border rounded-lg border p-4',
|
||||
!webhook.enabled && 'bg-muted/40',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-x-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="truncate font-mono text-xs">{webhook.id}</div>
|
||||
|
||||
<div className="mt-1.5 flex items-center gap-4">
|
||||
<h5
|
||||
className="max-w-[30rem] truncate text-sm sm:max-w-[18rem]"
|
||||
title={webhook.webhookUrl}
|
||||
>
|
||||
{webhook.webhookUrl}
|
||||
</h5>
|
||||
|
||||
<Badge variant={webhook.enabled ? 'neutral' : 'warning'} size="small">
|
||||
{webhook.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
Listening to{' '}
|
||||
{webhook.eventTriggers
|
||||
.map((trigger) => toFriendlyWebhookEventName(trigger))
|
||||
.join(', ')}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
Created on{' '}
|
||||
<LocaleDate date={webhook.createdAt} format={DateTime.DATETIME_FULL} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-shrink-0 gap-4 sm:mt-0">
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/settings/webhooks/${webhook.id}`}>Edit</Link>
|
||||
</Button>
|
||||
<DeleteWebhookDialog webhook={webhook}>
|
||||
<Button variant="destructive">Delete</Button>
|
||||
</DeleteWebhookDialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
@ -29,7 +30,15 @@ export default async function ApiTokensPage({ params }: ApiTokensPageProps) {
|
||||
<h3 className="text-2xl font-semibold">API Tokens</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
On this page, you can create new API tokens and manage the existing ones.
|
||||
On this page, you can create new API tokens and manage the existing ones. <br />
|
||||
You can view our swagger docs{' '}
|
||||
<a
|
||||
className="text-primary underline"
|
||||
href={`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/openapi`}
|
||||
target="_blank"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
@ -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<typeof ZEditWebhookFormSchema>;
|
||||
|
||||
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<TEditWebhookFormSchema>({
|
||||
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 (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title="Edit webhook"
|
||||
subtitle="On this page, you can edit the webhook and its settings."
|
||||
/>
|
||||
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full max-w-xl flex-col gap-y-6"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<div className="flex flex-col-reverse gap-4 md:flex-row">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel required>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
The URL for Documenso to send webhook events to.
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Enabled</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
className="bg-background"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="eventTriggers"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<FormItem className="flex flex-col gap-2">
|
||||
<FormLabel required>Triggers</FormLabel>
|
||||
<FormControl>
|
||||
<TriggerMultiSelectCombobox
|
||||
listValues={value}
|
||||
onChange={(values: string[]) => {
|
||||
onChange(values);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
The events that will trigger a webhook to be sent to your URL.
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="secret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput className="bg-background" {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
A secret that will be sent to your URL so you can verify that the request has
|
||||
been sent by Documenso.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
Update webhook
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/page.tsx
Normal file
106
apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/page.tsx
Normal file
@ -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 (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title="Webhooks"
|
||||
subtitle="On this page, you can create new Webhooks and manage the existing ones."
|
||||
>
|
||||
<CreateWebhookDialog />
|
||||
</SettingsHeader>
|
||||
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webhooks && 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 && webhooks.length > 0 && (
|
||||
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
|
||||
{webhooks?.map((webhook) => (
|
||||
<div
|
||||
key={webhook.id}
|
||||
className={cn(
|
||||
'border-border rounded-lg border p-4',
|
||||
!webhook.enabled && 'bg-muted/40',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-x-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="truncate font-mono text-xs">{webhook.id}</div>
|
||||
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<h5
|
||||
className="max-w-[30rem] truncate text-sm sm:max-w-[18rem]"
|
||||
title={webhook.webhookUrl}
|
||||
>
|
||||
{webhook.webhookUrl}
|
||||
</h5>
|
||||
|
||||
<Badge variant={webhook.enabled ? 'neutral' : 'warning'} size="small">
|
||||
{webhook.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
Listening to{' '}
|
||||
{webhook.eventTriggers
|
||||
.map((trigger) => toFriendlyWebhookEventName(trigger))
|
||||
.join(', ')}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
Created on{' '}
|
||||
<LocaleDate date={webhook.createdAt} format={DateTime.DATETIME_FULL} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-shrink-0 gap-4 sm:mt-0">
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/t/${team.url}/settings/webhooks/${webhook.id}`}>Edit</Link>
|
||||
</Button>
|
||||
<DeleteWebhookDialog webhook={webhook}>
|
||||
<Button variant="destructive">Delete</Button>
|
||||
</DeleteWebhookDialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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) => {
|
||||
</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>
|
||||
|
||||
{isBillingEnabled && (
|
||||
<Link href="/settings/billing">
|
||||
<Button
|
||||
|
||||
@ -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';
|
||||
@ -80,6 +80,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>
|
||||
|
||||
{isBillingEnabled && (
|
||||
<Link href="/settings/billing">
|
||||
<Button
|
||||
|
||||
@ -0,0 +1,232 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCreateWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
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 { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { TriggerMultiSelectCombobox } from './trigger-multiselect-combobox';
|
||||
|
||||
const ZCreateWebhookFormSchema = ZCreateWebhookMutationSchema.omit({ teamId: true });
|
||||
|
||||
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
|
||||
|
||||
export type CreateWebhookDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useForm<TCreateWebhookFormSchema>({
|
||||
resolver: zodResolver(ZCreateWebhookFormSchema),
|
||||
values: {
|
||||
webhookUrl: '',
|
||||
eventTriggers: [],
|
||||
secret: '',
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: createWebhook } = trpc.webhook.createWebhook.useMutation();
|
||||
|
||||
const onSubmit = async ({
|
||||
enabled,
|
||||
eventTriggers,
|
||||
secret,
|
||||
webhookUrl,
|
||||
}: TCreateWebhookFormSchema) => {
|
||||
try {
|
||||
await createWebhook({
|
||||
enabled,
|
||||
eventTriggers,
|
||||
secret,
|
||||
webhookUrl,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
|
||||
toast({
|
||||
title: 'Webhook created',
|
||||
description: 'The webhook was successfully created.',
|
||||
});
|
||||
|
||||
form.reset();
|
||||
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while creating the webhook. Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
{...props}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger ?? <Button className="flex-shrink-0">Create Webhook</Button>}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-w-lg" position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create webhook</DialogTitle>
|
||||
<DialogDescription>On this page, you can create a new webhook.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<div className="flex flex-col-reverse gap-4 md:flex-row">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel required>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
The URL for Documenso to send webhook events to.
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Enabled</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
className="bg-background"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="eventTriggers"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<FormItem className="flex flex-col gap-2">
|
||||
<FormLabel required>Triggers</FormLabel>
|
||||
<FormControl>
|
||||
<TriggerMultiSelectCombobox
|
||||
listValues={value}
|
||||
onChange={(values: string[]) => {
|
||||
onChange(values);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
The events that will trigger a webhook to be sent to your URL.
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="secret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
className="bg-background"
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
A secret that will be sent to your URL so you can verify that the request has
|
||||
been sent by Documenso.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-nowrap gap-4">
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -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<Webhook, 'id' | 'webhookUrl'>;
|
||||
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<typeof ZDeleteWebhookFormSchema>;
|
||||
|
||||
const { mutateAsync: deleteWebhook } = trpc.webhook.deleteWebhook.useMutation();
|
||||
|
||||
const form = useForm<TDeleteWebhookFormSchema>({
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@ -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<string[]>([]);
|
||||
|
||||
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 (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
className="w-[200px] justify-between"
|
||||
>
|
||||
{selectedValues.length > 0 ? selectedValues.length + ' selected...' : 'Select values...'}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="z-9999 w-full max-w-[280px] p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={truncateTitle(
|
||||
selectedValues.map((v) => toFriendlyWebhookEventName(v)).join(', '),
|
||||
15,
|
||||
)}
|
||||
/>
|
||||
<CommandEmpty>No value found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allEvents.map((value: string, i: number) => (
|
||||
<CommandItem key={i} onSelect={() => handleSelect(value)}>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedValues.includes(value) ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
{toFriendlyWebhookEventName(value)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@ -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) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={webhooksPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(webhooksPath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Webhook className="mr-2 h-5 w-5" />
|
||||
Webhooks
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{IS_BILLING_ENABLED() && (
|
||||
<Link href={billingPath}>
|
||||
<Button
|
||||
|
||||
@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams, usePathname } from 'next/navigation';
|
||||
|
||||
import { Braces, CreditCard, Key, User } from 'lucide-react';
|
||||
import { Braces, CreditCard, Key, User, 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 MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||
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 (
|
||||
@ -67,6 +68,19 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={webhooksPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(webhooksPath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Webhook className="mr-2 h-5 w-5" />
|
||||
Webhooks
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{IS_BILLING_ENABLED() && (
|
||||
<Link href={billingPath}>
|
||||
<Button
|
||||
|
||||
0
apps/web/src/components/forms/webhook.tsx
Normal file
0
apps/web/src/components/forms/webhook.tsx
Normal file
3
apps/web/src/pages/api/v1/me/index.ts
Normal file
3
apps/web/src/pages/api/v1/me/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { testCredentialsHandler } from '@documenso/lib/server-only/public-api/test-credentials';
|
||||
|
||||
export default testCredentialsHandler;
|
||||
3
apps/web/src/pages/api/v1/zapier/list-documents/index.ts
Normal file
3
apps/web/src/pages/api/v1/zapier/list-documents/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { listDocumentsHandler } from '@documenso/lib/server-only/webhooks/zapier/list-documents';
|
||||
|
||||
export default listDocumentsHandler;
|
||||
3
apps/web/src/pages/api/v1/zapier/subscribe/index.ts
Normal file
3
apps/web/src/pages/api/v1/zapier/subscribe/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { subscribeHandler } from '@documenso/lib/server-only/webhooks/zapier/subscribe';
|
||||
|
||||
export default subscribeHandler;
|
||||
3
apps/web/src/pages/api/v1/zapier/unsubscribe/index.ts
Normal file
3
apps/web/src/pages/api/v1/zapier/unsubscribe/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { unsubscribeHandler } from '@documenso/lib/server-only/webhooks/zapier/unsubscribe';
|
||||
|
||||
export default unsubscribeHandler;
|
||||
12
apps/web/src/pages/api/webhook/trigger.ts
Normal file
12
apps/web/src/pages/api/webhook/trigger.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { handlerTriggerWebhooks } from '@documenso/lib/server-only/webhooks/trigger/handler';
|
||||
|
||||
export const config = {
|
||||
maxDuration: 300,
|
||||
api: {
|
||||
bodyParser: {
|
||||
sizeLimit: '50mb',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default handlerTriggerWebhooks;
|
||||
@ -12,7 +12,7 @@ interface TeamProviderProps {
|
||||
|
||||
const TeamContext = createContext<Team | null>(null);
|
||||
|
||||
export const useCurrentTeam = (): Team | null => {
|
||||
export const useCurrentTeam = () => {
|
||||
const context = useContext(TeamContext);
|
||||
|
||||
if (!context) {
|
||||
@ -22,7 +22,7 @@ export const useCurrentTeam = (): Team | null => {
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useOptionalCurrentTeam = (): Team | null => {
|
||||
export const useOptionalCurrentTeam = () => {
|
||||
return useContext(TeamContext);
|
||||
};
|
||||
|
||||
|
||||
12
packages/lib/server-only/crypto/sign.ts
Normal file
12
packages/lib/server-only/crypto/sign.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { hashString } from '../auth/hash';
|
||||
import { encryptSecondaryData } from './encrypt';
|
||||
|
||||
export const sign = (data: unknown) => {
|
||||
const stringified = JSON.stringify(data);
|
||||
|
||||
const hashed = hashString(stringified);
|
||||
|
||||
const signature = encryptSecondaryData({ data: hashed });
|
||||
|
||||
return signature;
|
||||
};
|
||||
12
packages/lib/server-only/crypto/verify.ts
Normal file
12
packages/lib/server-only/crypto/verify.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { hashString } from '../auth/hash';
|
||||
import { decryptSecondaryData } from './decrypt';
|
||||
|
||||
export const verify = (data: unknown, signature: string) => {
|
||||
const stringified = JSON.stringify(data);
|
||||
|
||||
const hashed = hashString(stringified);
|
||||
|
||||
const decrypted = decryptSecondaryData(signature);
|
||||
|
||||
return decrypted === hashed;
|
||||
};
|
||||
@ -5,7 +5,9 @@ import type { RequestMetadata } from '@documenso/lib/universal/extract-request-m
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { sealDocument } from './seal-document';
|
||||
import { sendPendingEmail } from './send-pending-email';
|
||||
|
||||
@ -15,14 +17,8 @@ export type CompleteDocumentWithTokenOptions = {
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const completeDocumentWithToken = async ({
|
||||
token,
|
||||
documentId,
|
||||
requestMetadata,
|
||||
}: CompleteDocumentWithTokenOptions) => {
|
||||
'use server';
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => {
|
||||
return await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
Recipient: {
|
||||
@ -39,6 +35,16 @@ export const completeDocumentWithToken = async ({
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const completeDocumentWithToken = async ({
|
||||
token,
|
||||
documentId,
|
||||
requestMetadata,
|
||||
}: CompleteDocumentWithTokenOptions) => {
|
||||
'use server';
|
||||
|
||||
const document = await getDocument({ token, documentId });
|
||||
|
||||
if (document.status === DocumentStatus.COMPLETED) {
|
||||
throw new Error(`Document ${document.id} has already been completed`);
|
||||
@ -124,4 +130,13 @@ export const completeDocumentWithToken = async ({
|
||||
if (documents.count > 0) {
|
||||
await sealDocument({ documentId: document.id, requestMetadata });
|
||||
}
|
||||
|
||||
const updatedDocument = await getDocument({ token, documentId });
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_SIGNED,
|
||||
data: updatedDocument,
|
||||
userId: updatedDocument.userId,
|
||||
teamId: updatedDocument.teamId ?? undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@ -5,6 +5,9 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type CreateDocumentOptions = {
|
||||
title: string;
|
||||
@ -63,6 +66,13 @@ export const createDocument = async ({
|
||||
}),
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||
data: document,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return document;
|
||||
});
|
||||
};
|
||||
|
||||
@ -9,12 +9,14 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
import { signPdf } from '@documenso/signing';
|
||||
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
import { putFile } from '../../universal/upload/put-file';
|
||||
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { sendCompletedEmail } from './send-completed-email';
|
||||
|
||||
export type SealDocumentOptions = {
|
||||
@ -36,6 +38,7 @@ export const sealDocument = async ({
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
@ -134,4 +137,11 @@ export const sealDocument = async ({
|
||||
if (sendEmail) {
|
||||
await sendCompletedEmail({ documentId, requestMetadata });
|
||||
}
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_COMPLETED,
|
||||
data: document,
|
||||
userId: document.userId,
|
||||
teamId: document.teamId ?? undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@ -10,12 +10,14 @@ import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-
|
||||
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import {
|
||||
RECIPIENT_ROLES_DESCRIPTION,
|
||||
RECIPIENT_ROLE_TO_EMAIL_TYPE,
|
||||
} from '../../constants/recipient-roles';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type SendDocumentOptions = {
|
||||
documentId: number;
|
||||
@ -180,8 +182,18 @@ export const sendDocument = async ({
|
||||
data: {
|
||||
status: DocumentStatus.PENDING,
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_SENT,
|
||||
data: updatedDocument,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return updatedDocument;
|
||||
};
|
||||
|
||||
@ -3,6 +3,10 @@ import type { RequestMetadata } from '@documenso/lib/universal/extract-request-m
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { ReadStatus } from '@documenso/prisma/client';
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { getDocumentAndRecipientByToken } from './get-document-by-token';
|
||||
|
||||
export type ViewedDocumentOptions = {
|
||||
token: string;
|
||||
@ -51,4 +55,13 @@ export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentO
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const document = await getDocumentAndRecipientByToken({ token });
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_OPENED,
|
||||
data: document,
|
||||
userId: document.userId,
|
||||
teamId: document.teamId ?? undefined,
|
||||
});
|
||||
};
|
||||
|
||||
37
packages/lib/server-only/public-api/get-user-by-token.ts
Normal file
37
packages/lib/server-only/public-api/get-user-by-token.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { hashString } from '../auth/hash';
|
||||
|
||||
export const getUserByApiToken = async ({ token }: { token: string }) => {
|
||||
const hashedToken = hashString(token);
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
ApiToken: {
|
||||
some: {
|
||||
token: hashedToken,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
ApiToken: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
|
||||
const retrievedToken = user.ApiToken.find((apiToken) => apiToken.token === hashedToken);
|
||||
|
||||
// This should be impossible but we need to satisfy TypeScript
|
||||
if (!retrievedToken) {
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
|
||||
if (retrievedToken.expires && retrievedToken.expires < new Date()) {
|
||||
throw new Error('Expired token');
|
||||
}
|
||||
|
||||
return user;
|
||||
};
|
||||
19
packages/lib/server-only/public-api/test-credentials.ts
Normal file
19
packages/lib/server-only/public-api/test-credentials.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { validateApiToken } from '@documenso/lib/server-only/webhooks/zapier/validateApiToken';
|
||||
|
||||
export const testCredentialsHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
try {
|
||||
const { authorization } = req.headers;
|
||||
|
||||
const result = await validateApiToken({ authorization });
|
||||
|
||||
return res.status(200).json({
|
||||
name: result.team?.name ?? result.user.name,
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({
|
||||
message: 'Internal Server Error',
|
||||
});
|
||||
}
|
||||
};
|
||||
44
packages/lib/server-only/webhooks/create-webhook.ts
Normal file
44
packages/lib/server-only/webhooks/create-webhook.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
export interface CreateWebhookOptions {
|
||||
webhookUrl: string;
|
||||
eventTriggers: WebhookTriggerEvents[];
|
||||
secret: string | null;
|
||||
enabled: boolean;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
}
|
||||
|
||||
export const createWebhook = async ({
|
||||
webhookUrl,
|
||||
eventTriggers,
|
||||
secret,
|
||||
enabled,
|
||||
userId,
|
||||
teamId,
|
||||
}: CreateWebhookOptions) => {
|
||||
if (teamId) {
|
||||
await prisma.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.webhook.create({
|
||||
data: {
|
||||
webhookUrl,
|
||||
eventTriggers,
|
||||
secret,
|
||||
enabled,
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
};
|
||||
30
packages/lib/server-only/webhooks/delete-webhook-by-id.ts
Normal file
30
packages/lib/server-only/webhooks/delete-webhook-by-id.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type DeleteWebhookByIdOptions = {
|
||||
id: string;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export const deleteWebhookById = async ({ id, userId, teamId }: DeleteWebhookByIdOptions) => {
|
||||
return await prisma.webhook.delete({
|
||||
where: {
|
||||
id,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
};
|
||||
36
packages/lib/server-only/webhooks/edit-webhook.ts
Normal file
36
packages/lib/server-only/webhooks/edit-webhook.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type EditWebhookOptions = {
|
||||
id: string;
|
||||
data: Omit<Prisma.WebhookUpdateInput, 'id' | 'userId' | 'teamId'>;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export const editWebhook = async ({ id, data, userId, teamId }: EditWebhookOptions) => {
|
||||
return await prisma.webhook.update({
|
||||
where: {
|
||||
id,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
data: {
|
||||
...data,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,38 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
export type GetAllWebhooksByEventTriggerOptions = {
|
||||
event: WebhookTriggerEvents;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export const getAllWebhooksByEventTrigger = async ({
|
||||
event,
|
||||
userId,
|
||||
teamId,
|
||||
}: GetAllWebhooksByEventTriggerOptions) => {
|
||||
return prisma.webhook.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
eventTriggers: {
|
||||
has: event,
|
||||
},
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
};
|
||||
30
packages/lib/server-only/webhooks/get-webhook-by-id.ts
Normal file
30
packages/lib/server-only/webhooks/get-webhook-by-id.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetWebhookByIdOptions = {
|
||||
id: string;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export const getWebhookById = async ({ id, userId, teamId }: GetWebhookByIdOptions) => {
|
||||
return await prisma.webhook.findFirstOrThrow({
|
||||
where: {
|
||||
id,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
};
|
||||
19
packages/lib/server-only/webhooks/get-webhooks-by-team-id.ts
Normal file
19
packages/lib/server-only/webhooks/get-webhooks-by-team-id.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export const getWebhooksByTeamId = async (teamId: number, userId: number) => {
|
||||
return await prisma.webhook.findMany({
|
||||
where: {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
};
|
||||
12
packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts
Normal file
12
packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export const getWebhooksByUserId = async (userId: number) => {
|
||||
return await prisma.webhook.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
};
|
||||
58
packages/lib/server-only/webhooks/trigger/execute-webhook.ts
Normal file
58
packages/lib/server-only/webhooks/trigger/execute-webhook.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
Prisma,
|
||||
type Webhook,
|
||||
WebhookCallStatus,
|
||||
type WebhookTriggerEvents,
|
||||
} from '@documenso/prisma/client';
|
||||
|
||||
export type ExecuteWebhookOptions = {
|
||||
event: WebhookTriggerEvents;
|
||||
webhook: Webhook;
|
||||
data: unknown;
|
||||
};
|
||||
|
||||
export const executeWebhook = async ({ event, webhook, data }: ExecuteWebhookOptions) => {
|
||||
const { webhookUrl: url, secret } = webhook;
|
||||
|
||||
console.log('Executing webhook', { event, url });
|
||||
|
||||
const payload = {
|
||||
event,
|
||||
payload: data,
|
||||
createdAt: new Date().toISOString(),
|
||||
webhookEndpoint: url,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Documenso-Secret': secret ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const body = await response.text();
|
||||
|
||||
let responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput = Prisma.JsonNull;
|
||||
|
||||
try {
|
||||
responseBody = JSON.parse(body);
|
||||
} catch (err) {
|
||||
responseBody = body;
|
||||
}
|
||||
|
||||
await prisma.webhookCall.create({
|
||||
data: {
|
||||
url,
|
||||
event,
|
||||
status: response.ok ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED,
|
||||
requestBody: payload as Prisma.InputJsonValue,
|
||||
responseCode: response.status,
|
||||
responseBody,
|
||||
responseHeaders: Object.fromEntries(response.headers.entries()),
|
||||
webhookId: webhook.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
58
packages/lib/server-only/webhooks/trigger/handler.ts
Normal file
58
packages/lib/server-only/webhooks/trigger/handler.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { verify } from '../../crypto/verify';
|
||||
import { getAllWebhooksByEventTrigger } from '../get-all-webhooks-by-event-trigger';
|
||||
import { executeWebhook } from './execute-webhook';
|
||||
import { ZTriggerWebhookBodySchema } from './schema';
|
||||
|
||||
export type HandlerTriggerWebhooksResponse =
|
||||
| {
|
||||
success: true;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export const handlerTriggerWebhooks = async (
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<HandlerTriggerWebhooksResponse>,
|
||||
) => {
|
||||
const signature = req.headers['x-webhook-signature'];
|
||||
|
||||
if (typeof signature !== 'string') {
|
||||
console.log('Missing signature');
|
||||
return res.status(400).json({ success: false, error: 'Missing signature' });
|
||||
}
|
||||
|
||||
const valid = verify(req.body, signature);
|
||||
|
||||
if (!valid) {
|
||||
console.log('Invalid signature');
|
||||
return res.status(400).json({ success: false, error: 'Invalid signature' });
|
||||
}
|
||||
|
||||
const result = ZTriggerWebhookBodySchema.safeParse(req.body);
|
||||
|
||||
if (!result.success) {
|
||||
console.log('Invalid request body');
|
||||
return res.status(400).json({ success: false, error: 'Invalid request body' });
|
||||
}
|
||||
|
||||
const { event, data, userId, teamId } = result.data;
|
||||
|
||||
const allWebhooks = await getAllWebhooksByEventTrigger({ event, userId, teamId });
|
||||
|
||||
await Promise.allSettled(
|
||||
allWebhooks.map(async (webhook) =>
|
||||
executeWebhook({
|
||||
event,
|
||||
webhook,
|
||||
data,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return res.status(200).json({ success: true, message: 'Webhooks executed successfully' });
|
||||
};
|
||||
12
packages/lib/server-only/webhooks/trigger/schema.ts
Normal file
12
packages/lib/server-only/webhooks/trigger/schema.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
export const ZTriggerWebhookBodySchema = z.object({
|
||||
event: z.nativeEnum(WebhookTriggerEvents),
|
||||
data: z.unknown(),
|
||||
userId: z.number(),
|
||||
teamId: z.number().optional(),
|
||||
});
|
||||
|
||||
export type TTriggerWebhookBodySchema = z.infer<typeof ZTriggerWebhookBodySchema>;
|
||||
40
packages/lib/server-only/webhooks/trigger/trigger-webhook.ts
Normal file
40
packages/lib/server-only/webhooks/trigger/trigger-webhook.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import type { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { sign } from '../../crypto/sign';
|
||||
|
||||
export type TriggerWebhookOptions = {
|
||||
event: WebhookTriggerEvents;
|
||||
data: Record<string, unknown>;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export const triggerWebhook = async ({ event, data, userId, teamId }: TriggerWebhookOptions) => {
|
||||
try {
|
||||
const body = {
|
||||
event,
|
||||
data,
|
||||
userId,
|
||||
teamId,
|
||||
};
|
||||
|
||||
const signature = sign(body);
|
||||
|
||||
await Promise.race([
|
||||
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/webhook/trigger`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-webhook-signature': signature,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Request timeout')), 500);
|
||||
}),
|
||||
]).catch(() => null);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to trigger webhook`);
|
||||
}
|
||||
};
|
||||
67
packages/lib/server-only/webhooks/zapier/list-documents.ts
Normal file
67
packages/lib/server-only/webhooks/zapier/list-documents.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
import type { Webhook } from '@documenso/prisma/client';
|
||||
|
||||
import { getWebhooksByTeamId } from '../get-webhooks-by-team-id';
|
||||
import { getWebhooksByUserId } from '../get-webhooks-by-user-id';
|
||||
import { validateApiToken } from './validateApiToken';
|
||||
|
||||
export const listDocumentsHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
try {
|
||||
const { authorization } = req.headers;
|
||||
const { user, userId, teamId } = await validateApiToken({ authorization });
|
||||
|
||||
let allWebhooks: Webhook[] = [];
|
||||
|
||||
const documents = await findDocuments({
|
||||
userId: userId ?? user.id,
|
||||
teamId: teamId ?? undefined,
|
||||
perPage: 1,
|
||||
});
|
||||
|
||||
const recipients = await getRecipientsForDocument({
|
||||
documentId: documents.data[0].id,
|
||||
userId: userId ?? user.id,
|
||||
teamId: teamId ?? undefined,
|
||||
});
|
||||
|
||||
if (userId) {
|
||||
allWebhooks = await getWebhooksByUserId(userId);
|
||||
}
|
||||
|
||||
if (teamId) {
|
||||
allWebhooks = await getWebhooksByTeamId(teamId, user.id);
|
||||
}
|
||||
|
||||
if (documents && documents.data.length > 0 && allWebhooks.length > 0 && recipients.length > 0) {
|
||||
const testWebhook = {
|
||||
event: allWebhooks[0].eventTriggers.toString(),
|
||||
createdAt: allWebhooks[0].createdAt,
|
||||
webhookEndpoint: allWebhooks[0].webhookUrl,
|
||||
payload: {
|
||||
id: documents.data[0].id,
|
||||
userId: documents.data[0].userId,
|
||||
title: documents.data[0].title,
|
||||
status: documents.data[0].status,
|
||||
documentDataId: documents.data[0].documentDataId,
|
||||
createdAt: documents.data[0].createdAt,
|
||||
updatedAt: documents.data[0].updatedAt,
|
||||
completedAt: documents.data[0].completedAt,
|
||||
deletedAt: documents.data[0].deletedAt,
|
||||
teamId: documents.data[0].teamId,
|
||||
Recipient: recipients,
|
||||
},
|
||||
};
|
||||
|
||||
return res.status(200).json([testWebhook]);
|
||||
}
|
||||
|
||||
return res.status(200).json([]);
|
||||
} catch (err) {
|
||||
return res.status(500).json({
|
||||
message: 'Internal Server Error',
|
||||
});
|
||||
}
|
||||
};
|
||||
32
packages/lib/server-only/webhooks/zapier/subscribe.ts
Normal file
32
packages/lib/server-only/webhooks/zapier/subscribe.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { validateApiToken } from './validateApiToken';
|
||||
|
||||
export const subscribeHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
try {
|
||||
const { authorization } = req.headers;
|
||||
|
||||
const { webhookUrl, eventTrigger } = req.body;
|
||||
|
||||
const result = await validateApiToken({ authorization });
|
||||
|
||||
const createdWebhook = await prisma.webhook.create({
|
||||
data: {
|
||||
webhookUrl,
|
||||
eventTriggers: [eventTrigger],
|
||||
secret: null,
|
||||
enabled: true,
|
||||
userId: result.userId ?? result.user.id,
|
||||
teamId: result.teamId ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(200).json(createdWebhook);
|
||||
} catch (err) {
|
||||
return res.status(500).json({
|
||||
message: 'Internal Server Error',
|
||||
});
|
||||
}
|
||||
};
|
||||
29
packages/lib/server-only/webhooks/zapier/unsubscribe.ts
Normal file
29
packages/lib/server-only/webhooks/zapier/unsubscribe.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { validateApiToken } from './validateApiToken';
|
||||
|
||||
export const unsubscribeHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
try {
|
||||
const { authorization } = req.headers;
|
||||
|
||||
const { webhookId } = req.body;
|
||||
|
||||
const result = await validateApiToken({ authorization });
|
||||
|
||||
const deletedWebhook = await prisma.webhook.delete({
|
||||
where: {
|
||||
id: webhookId,
|
||||
userId: result.userId ?? result.user.id,
|
||||
teamId: result.teamId ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(200).json(deletedWebhook);
|
||||
} catch (err) {
|
||||
return res.status(500).json({
|
||||
message: 'Internal Server Error',
|
||||
});
|
||||
}
|
||||
};
|
||||
20
packages/lib/server-only/webhooks/zapier/validateApiToken.ts
Normal file
20
packages/lib/server-only/webhooks/zapier/validateApiToken.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { getApiTokenByToken } from '../../public-api/get-api-token-by-token';
|
||||
|
||||
type ValidateApiTokenOptions = {
|
||||
authorization: string | undefined;
|
||||
};
|
||||
|
||||
export const validateApiToken = async ({ authorization }: ValidateApiTokenOptions) => {
|
||||
try {
|
||||
// Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx"
|
||||
const [token] = (authorization || '').split('Bearer ').filter((s) => s.length > 0);
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Missing API token');
|
||||
}
|
||||
|
||||
return await getApiTokenByToken({ token });
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to validate API token`);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,3 @@
|
||||
export const toFriendlyWebhookEventName = (eventName: string) => {
|
||||
return eventName.replace(/_/g, '.').toLowerCase();
|
||||
};
|
||||
@ -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;
|
||||
@ -0,0 +1,11 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_SENT';
|
||||
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_OPENED';
|
||||
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_COMPLETED';
|
||||
@ -0,0 +1,12 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `Webhook` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Webhook" DROP CONSTRAINT "Webhook_pkey",
|
||||
ALTER COLUMN "id" DROP DEFAULT,
|
||||
ALTER COLUMN "id" SET DATA TYPE TEXT,
|
||||
ADD CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id");
|
||||
DROP SEQUENCE "Webhook_id_seq";
|
||||
@ -0,0 +1,20 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WebhookCallStatus" AS ENUM ('SUCCESS', 'FAILED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WebhookCall" (
|
||||
"id" TEXT NOT NULL,
|
||||
"status" "WebhookCallStatus" NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"requestBody" JSONB NOT NULL,
|
||||
"responseCode" INTEGER NOT NULL,
|
||||
"responseHeaders" JSONB,
|
||||
"responseBody" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"webhookId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "WebhookCall_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WebhookCall" ADD CONSTRAINT "WebhookCall_webhookId_fkey" FOREIGN KEY ("webhookId") REFERENCES "Webhook"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Webhook" ADD COLUMN "teamId" INTEGER;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `event` to the `WebhookCall` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "WebhookCall" ADD COLUMN "event" "WebhookTriggerEvents" NOT NULL;
|
||||
@ -50,6 +50,7 @@ model User {
|
||||
ApiToken ApiToken[]
|
||||
Template Template[]
|
||||
securityAuditLogs UserSecurityAuditLog[]
|
||||
Webhooks Webhook[]
|
||||
siteSettings SiteSettings[]
|
||||
|
||||
@@index([email])
|
||||
@ -105,6 +106,48 @@ model VerificationToken {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
enum WebhookTriggerEvents {
|
||||
DOCUMENT_CREATED
|
||||
DOCUMENT_SENT
|
||||
DOCUMENT_OPENED
|
||||
DOCUMENT_SIGNED
|
||||
DOCUMENT_COMPLETED
|
||||
}
|
||||
|
||||
model Webhook {
|
||||
id String @id @default(cuid())
|
||||
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)
|
||||
teamId Int?
|
||||
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
WebhookCall WebhookCall[]
|
||||
}
|
||||
|
||||
enum WebhookCallStatus {
|
||||
SUCCESS
|
||||
FAILED
|
||||
}
|
||||
|
||||
model WebhookCall {
|
||||
id String @id @default(cuid())
|
||||
status WebhookCallStatus
|
||||
url String
|
||||
event WebhookTriggerEvents
|
||||
requestBody Json
|
||||
responseCode Int
|
||||
responseHeaders Json?
|
||||
responseBody Json?
|
||||
createdAt DateTime @default(now())
|
||||
webhookId String
|
||||
webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
enum ApiTokenAlgorithm {
|
||||
SHA512
|
||||
}
|
||||
@ -386,6 +429,7 @@ model Team {
|
||||
document Document[]
|
||||
templates Template[]
|
||||
ApiToken ApiToken[]
|
||||
Webhook Webhook[]
|
||||
}
|
||||
|
||||
model TeamPending {
|
||||
|
||||
@ -11,6 +11,9 @@ module.exports = {
|
||||
sans: ['var(--font-sans)', ...fontFamily.sans],
|
||||
signature: ['var(--font-signature)'],
|
||||
},
|
||||
zIndex: {
|
||||
9999: '9999',
|
||||
},
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
|
||||
@ -12,6 +12,7 @@ import { teamRouter } from './team-router/router';
|
||||
import { templateRouter } from './template-router/router';
|
||||
import { router } from './trpc';
|
||||
import { twoFactorAuthenticationRouter } from './two-factor-authentication-router/router';
|
||||
import { webhookRouter } from './webhook-router/router';
|
||||
|
||||
export const appRouter = router({
|
||||
auth: authRouter,
|
||||
@ -26,6 +27,7 @@ export const appRouter = router({
|
||||
singleplayer: singleplayerRouter,
|
||||
team: teamRouter,
|
||||
template: templateRouter,
|
||||
webhook: webhookRouter,
|
||||
twoFactorAuthentication: twoFactorAuthenticationRouter,
|
||||
});
|
||||
|
||||
|
||||
125
packages/trpc/server/webhook-router/router.ts
Normal file
125
packages/trpc/server/webhook-router/router.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { createWebhook } from '@documenso/lib/server-only/webhooks/create-webhook';
|
||||
import { deleteWebhookById } from '@documenso/lib/server-only/webhooks/delete-webhook-by-id';
|
||||
import { editWebhook } from '@documenso/lib/server-only/webhooks/edit-webhook';
|
||||
import { getWebhookById } from '@documenso/lib/server-only/webhooks/get-webhook-by-id';
|
||||
import { getWebhooksByTeamId } from '@documenso/lib/server-only/webhooks/get-webhooks-by-team-id';
|
||||
import { getWebhooksByUserId } from '@documenso/lib/server-only/webhooks/get-webhooks-by-user-id';
|
||||
|
||||
import { authenticatedProcedure, router } from '../trpc';
|
||||
import {
|
||||
ZCreateWebhookMutationSchema,
|
||||
ZDeleteWebhookMutationSchema,
|
||||
ZEditWebhookMutationSchema,
|
||||
ZGetTeamWebhooksQuerySchema,
|
||||
ZGetWebhookByIdQuerySchema,
|
||||
} from './schema';
|
||||
|
||||
export const webhookRouter = router({
|
||||
getWebhooks: authenticatedProcedure.query(async ({ ctx }) => {
|
||||
try {
|
||||
return await getWebhooksByUserId(ctx.user.id);
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to fetch your webhooks. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
getTeamWebhooks: authenticatedProcedure
|
||||
.input(ZGetTeamWebhooksQuerySchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { teamId } = input;
|
||||
|
||||
try {
|
||||
return await getWebhooksByTeamId(teamId, ctx.user.id);
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to fetch your webhooks. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
getWebhookById: authenticatedProcedure
|
||||
.input(ZGetWebhookByIdQuerySchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { id, teamId } = input;
|
||||
|
||||
return await getWebhookById({
|
||||
id,
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to fetch your webhook. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
createWebhook: authenticatedProcedure
|
||||
.input(ZCreateWebhookMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { enabled, eventTriggers, secret, webhookUrl, teamId } = input;
|
||||
|
||||
try {
|
||||
return await createWebhook({
|
||||
enabled,
|
||||
secret,
|
||||
webhookUrl,
|
||||
eventTriggers,
|
||||
teamId,
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to create this webhook. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
deleteWebhook: authenticatedProcedure
|
||||
.input(ZDeleteWebhookMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { id, teamId } = input;
|
||||
|
||||
return await deleteWebhookById({
|
||||
id,
|
||||
teamId,
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to create this webhook. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
editWebhook: authenticatedProcedure
|
||||
.input(ZEditWebhookMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { id, teamId, ...data } = input;
|
||||
|
||||
return await editWebhook({
|
||||
id,
|
||||
data,
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to create this webhook. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
41
packages/trpc/server/webhook-router/schema.ts
Normal file
41
packages/trpc/server/webhook-router/schema.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
export const ZGetTeamWebhooksQuerySchema = z.object({
|
||||
teamId: z.number(),
|
||||
});
|
||||
|
||||
export type TGetTeamWebhooksQuerySchema = z.infer<typeof ZGetTeamWebhooksQuerySchema>;
|
||||
|
||||
export const ZCreateWebhookMutationSchema = z.object({
|
||||
webhookUrl: z.string().url(),
|
||||
eventTriggers: z
|
||||
.array(z.nativeEnum(WebhookTriggerEvents))
|
||||
.min(1, { message: 'At least one event trigger is required' }),
|
||||
secret: z.string().nullable(),
|
||||
enabled: z.boolean(),
|
||||
teamId: z.number().optional(),
|
||||
});
|
||||
|
||||
export type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookMutationSchema>;
|
||||
|
||||
export const ZGetWebhookByIdQuerySchema = z.object({
|
||||
id: z.string(),
|
||||
teamId: z.number().optional(),
|
||||
});
|
||||
|
||||
export type TGetWebhookByIdQuerySchema = z.infer<typeof ZGetWebhookByIdQuerySchema>;
|
||||
|
||||
export const ZEditWebhookMutationSchema = ZCreateWebhookMutationSchema.extend({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export type TEditWebhookMutationSchema = z.infer<typeof ZEditWebhookMutationSchema>;
|
||||
|
||||
export const ZDeleteWebhookMutationSchema = z.object({
|
||||
id: z.string(),
|
||||
teamId: z.number().optional(),
|
||||
});
|
||||
|
||||
export type TDeleteWebhookMutationSchema = z.infer<typeof ZDeleteWebhookMutationSchema>;
|
||||
@ -6,7 +6,7 @@ import { cva } from 'class-variance-authority';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-md px-2 py-1.5 text-xs font-medium ring-1 ring-inset w-fit',
|
||||
'inline-flex items-center rounded-md text-xs font-medium ring-1 ring-inset w-fit',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@ -21,9 +21,15 @@ const badgeVariants = cva(
|
||||
secondary:
|
||||
'bg-blue-50 text-blue-700 ring-blue-700/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/30',
|
||||
},
|
||||
size: {
|
||||
small: 'px-1.5 py-0.5 text-xs',
|
||||
default: 'px-2 py-1.5 text-xs',
|
||||
large: 'px-3 py-2 text-sm',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
@ -32,8 +38,8 @@ export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
function Badge({ className, variant, size, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant, size }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
Reference in New Issue
Block a user