Merge branch 'main' into feat/public-profile-1

This commit is contained in:
Lucas Smith
2024-02-29 14:08:19 +11:00
committed by GitHub
61 changed files with 2319 additions and 19 deletions

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

View File

@ -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" />

View 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>
);
}

View 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>
);
}

View File

@ -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" />

View File

@ -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>
);
}

View 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>
);
}

View File

@ -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

View File

@ -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

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,3 @@
import { testCredentialsHandler } from '@documenso/lib/server-only/public-api/test-credentials';
export default testCredentialsHandler;

View File

@ -0,0 +1,3 @@
import { listDocumentsHandler } from '@documenso/lib/server-only/webhooks/zapier/list-documents';
export default listDocumentsHandler;

View File

@ -0,0 +1,3 @@
import { subscribeHandler } from '@documenso/lib/server-only/webhooks/zapier/subscribe';
export default subscribeHandler;

View File

@ -0,0 +1,3 @@
import { unsubscribeHandler } from '@documenso/lib/server-only/webhooks/zapier/unsubscribe';
export default unsubscribeHandler;

View 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;

View File

@ -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);
};

View 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;
};

View 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;
};

View File

@ -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,
});
};

View File

@ -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;
});
};

View File

@ -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,
});
};

View File

@ -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;
};

View File

@ -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,
});
};

View 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;
};

View 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',
});
}
};

View 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,
},
});
};

View 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,
}),
},
});
};

View 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,
},
});
};

View File

@ -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,
}),
},
});
};

View 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,
}),
},
});
};

View 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',
},
});
};

View 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',
},
});
};

View 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,
},
});
};

View 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' });
};

View 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>;

View 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`);
}
};

View 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',
});
}
};

View 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',
});
}
};

View 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',
});
}
};

View 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`);
}
};

View File

@ -0,0 +1,3 @@
export const toFriendlyWebhookEventName = (eventName: string) => {
return eventName.replace(/_/g, '.').toLowerCase();
};

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

@ -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';

View File

@ -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";

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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 {

View File

@ -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))',

View File

@ -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,
});

View 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.',
});
}
}),
});

View 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>;

View File

@ -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 };