mirror of
https://github.com/documenso/documenso.git
synced 2025-11-25 22:21:31 +10:00
feat: add webhook logs
This commit is contained in:
@ -38,7 +38,7 @@ import { useCurrentTeam } from '~/providers/team';
|
|||||||
|
|
||||||
import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox';
|
import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox';
|
||||||
|
|
||||||
const ZCreateWebhookFormSchema = ZCreateWebhookRequestSchema.omit({ teamId: true });
|
const ZCreateWebhookFormSchema = ZCreateWebhookRequestSchema;
|
||||||
|
|
||||||
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
|
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
|
||||||
|
|
||||||
@ -78,7 +78,6 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
|
|||||||
eventTriggers,
|
eventTriggers,
|
||||||
secret,
|
secret,
|
||||||
webhookUrl,
|
webhookUrl,
|
||||||
teamId: team.id,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|||||||
@ -67,7 +67,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
|
|||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteWebhook({ id: webhook.id, teamId: team.id });
|
await deleteWebhook({ id: webhook.id });
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Webhook deleted`),
|
title: _(msg`Webhook deleted`),
|
||||||
@ -146,26 +146,18 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<div className="flex w-full flex-nowrap gap-4">
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
<Trans>Cancel</Trans>
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="flex-1"
|
|
||||||
disabled={!form.formState.isValid}
|
disabled={!form.formState.isValid}
|
||||||
loading={form.formState.isSubmitting}
|
loading={form.formState.isSubmitting}
|
||||||
>
|
>
|
||||||
<Trans>I'm sure! Delete it</Trans>
|
<Trans>Delete</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
225
apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
Normal file
225
apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { Webhook } from '@prisma/client';
|
||||||
|
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 { ZEditWebhookRequestSchema } from '@documenso/trpc/server/webhook-router/schema';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
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 { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox';
|
||||||
|
|
||||||
|
const ZEditWebhookFormSchema = ZEditWebhookRequestSchema.omit({ id: true });
|
||||||
|
|
||||||
|
type TEditWebhookFormSchema = z.infer<typeof ZEditWebhookFormSchema>;
|
||||||
|
|
||||||
|
export type WebhookEditDialogProps = {
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
webhook: Webhook;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
export const WebhookEditDialog = ({ trigger, webhook, ...props }: WebhookEditDialogProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
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: webhook.id,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Webhook updated`,
|
||||||
|
description: t`The webhook has been updated successfully.`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: t`Failed to update webhook`,
|
||||||
|
description: t`We encountered an error while updating the webhook. Please try again later.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
|
{trigger}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="max-w-lg" position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Edit webhook</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>{webhook.id}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full 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>
|
||||||
|
<Trans>The URL for Documenso to send webhook events to.</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Enabled</Trans>
|
||||||
|
</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>
|
||||||
|
<Trans>Triggers</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<WebhookMultiSelectCombobox
|
||||||
|
listValues={value}
|
||||||
|
onChange={(values: string[]) => {
|
||||||
|
onChange(values);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>The events that will trigger a webhook to be sent to your URL.</Trans>
|
||||||
|
</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>
|
||||||
|
<Trans>
|
||||||
|
A secret that will be sent to your URL so you can verify that the request
|
||||||
|
has been sent by Documenso.
|
||||||
|
</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="secondary">
|
||||||
|
<Trans>Close</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
|
<Trans>Update</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -36,8 +36,6 @@ import {
|
|||||||
} from '@documenso/ui/primitives/select';
|
} from '@documenso/ui/primitives/select';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
export type WebhookTestDialogProps = {
|
export type WebhookTestDialogProps = {
|
||||||
webhook: Pick<Webhook, 'id' | 'webhookUrl' | 'eventTriggers'>;
|
webhook: Pick<Webhook, 'id' | 'webhookUrl' | 'eventTriggers'>;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@ -53,8 +51,6 @@ export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps)
|
|||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const { mutateAsync: testWebhook } = trpc.webhook.testWebhook.useMutation();
|
const { mutateAsync: testWebhook } = trpc.webhook.testWebhook.useMutation();
|
||||||
@ -71,7 +67,6 @@ export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps)
|
|||||||
await testWebhook({
|
await testWebhook({
|
||||||
id: webhook.id,
|
id: webhook.id,
|
||||||
event,
|
event,
|
||||||
teamId: team.id,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -150,11 +145,11 @@ export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps)
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
<Trans>Cancel</Trans>
|
<Trans>Close</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
<Trans>Send Test Webhook</Trans>
|
<Trans>Send</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
198
apps/remix/app/components/general/webhook-logs-sheet.tsx
Normal file
198
apps/remix/app/components/general/webhook-logs-sheet.tsx
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { WebhookCallStatus } from '@prisma/client';
|
||||||
|
import { RotateCwIcon } from 'lucide-react';
|
||||||
|
import { createCallable } from 'react-call';
|
||||||
|
|
||||||
|
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TFindWebhookLogsResponse } from '@documenso/trpc/server/webhook-router/find-webhook-logs.types';
|
||||||
|
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Sheet, SheetContent, SheetTitle } from '@documenso/ui/primitives/sheet';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type WebhookLogsSheetProps = {
|
||||||
|
webhookCall: TFindWebhookLogsResponse['data'][number];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WebhookLogsSheet = createCallable<WebhookLogsSheetProps, string | null>(
|
||||||
|
({ call, webhookCall: initialWebhookCall }) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [webhookCall, setWebhookCall] = useState(initialWebhookCall);
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<'request' | 'response'>('request');
|
||||||
|
|
||||||
|
const { mutateAsync: resendWebhookCall, isPending: isResending } =
|
||||||
|
trpc.webhook.calls.resend.useMutation({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
toast({ title: t`Webhook successfully sent` });
|
||||||
|
|
||||||
|
setWebhookCall(result);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({ title: t`Something went wrong` });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const generalWebhookDetails = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
header: t`Status`,
|
||||||
|
value: webhookCall.status === WebhookCallStatus.SUCCESS ? t`Success` : t`Failed`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t`Event`,
|
||||||
|
value: toFriendlyWebhookEventName(webhookCall.event),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t`Sent`,
|
||||||
|
value: new Date(webhookCall.createdAt).toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t`Response Code`,
|
||||||
|
value: webhookCall.responseCode,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t`Destination`,
|
||||||
|
value: webhookCall.url,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [webhookCall]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||||
|
<SheetContent position="right" size="lg" className="max-w-2xl overflow-y-auto">
|
||||||
|
<SheetTitle>
|
||||||
|
<h2 className="text-lg font-semibold">
|
||||||
|
<Trans>Webhook Details</Trans>
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground font-mono text-xs">{webhookCall.id}</p>
|
||||||
|
</SheetTitle>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="flex items-end justify-between">
|
||||||
|
<h4 className="text-muted-foreground mb-3 text-xs font-semibold uppercase tracking-wider">
|
||||||
|
<Trans>Details</Trans>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
resendWebhookCall({
|
||||||
|
webhookId: webhookCall.webhookId,
|
||||||
|
webhookCallId: webhookCall.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
tabIndex={-1}
|
||||||
|
loading={isResending}
|
||||||
|
size="sm"
|
||||||
|
className="mb-2"
|
||||||
|
>
|
||||||
|
{!isResending && <RotateCwIcon className="mr-2 h-3.5 w-3.5" />}
|
||||||
|
<Trans>Resend</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="border-border overflow-hidden rounded-lg border">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<tbody className="divide-border bg-muted/30 divide-y">
|
||||||
|
{generalWebhookDetails.map(({ header, value }, index) => (
|
||||||
|
<tr key={index}>
|
||||||
|
<td className="text-muted-foreground border-border w-1/3 border-r px-4 py-2 font-mono text-xs">
|
||||||
|
{header}
|
||||||
|
</td>
|
||||||
|
<td className="text-foreground break-all px-4 py-2 font-mono text-xs">
|
||||||
|
{value}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payload Tabs */}
|
||||||
|
<div className="py-6">
|
||||||
|
<div className="border-border mb-4 flex items-center gap-4 border-b">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('request')}
|
||||||
|
className={cn(
|
||||||
|
'relative pb-2 text-sm font-medium transition-colors',
|
||||||
|
activeTab === 'request'
|
||||||
|
? 'text-foreground after:bg-primary after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trans>Request</Trans>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('response')}
|
||||||
|
className={cn(
|
||||||
|
'relative pb-2 text-sm font-medium transition-colors',
|
||||||
|
activeTab === 'response'
|
||||||
|
? 'text-foreground after:bg-primary after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trans>Response</Trans>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="group relative">
|
||||||
|
<div className="absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<CopyTextButton
|
||||||
|
value={JSON.stringify(
|
||||||
|
activeTab === 'request' ? webhookCall.requestBody : webhookCall.responseBody,
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}
|
||||||
|
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<pre className="bg-muted/50 border-border text-foreground overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed">
|
||||||
|
{JSON.stringify(
|
||||||
|
activeTab === 'request' ? webhookCall.requestBody : webhookCall.responseBody,
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'response' && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h4 className="text-muted-foreground mb-3 text-xs font-semibold uppercase tracking-wider">
|
||||||
|
<Trans>Response Headers</Trans>
|
||||||
|
</h4>
|
||||||
|
<div className="border-border overflow-hidden rounded-lg border">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<tbody className="divide-border bg-muted/30 divide-y">
|
||||||
|
{Object.entries(webhookCall.responseHeaders as Record<string, string>).map(
|
||||||
|
([key, value]) => (
|
||||||
|
<tr key={key}>
|
||||||
|
<td className="text-muted-foreground border-border w-1/3 border-r px-4 py-2 font-mono text-xs">
|
||||||
|
{key}
|
||||||
|
</td>
|
||||||
|
<td className="text-foreground break-all px-4 py-2 font-mono text-xs">
|
||||||
|
{value as string}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import WebhookPage, { meta } from '../../t.$teamUrl+/settings.webhooks.$id';
|
import WebhookPage, { meta } from '../../t.$teamUrl+/settings.webhooks.$id._index';
|
||||||
|
|
||||||
export { meta };
|
export { meta };
|
||||||
|
|
||||||
@ -0,0 +1,392 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
CheckCircle2Icon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
PencilIcon,
|
||||||
|
TerminalIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useLocation, useSearchParams } from 'react-router';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||||
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
|
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||||
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { WebhookEditDialog } from '~/components/dialogs/webhook-edit-dialog';
|
||||||
|
import { WebhookTestDialog } from '~/components/dialogs/webhook-test-dialog';
|
||||||
|
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||||
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
|
import { WebhookLogsSheet } from '~/components/general/webhook-logs-sheet';
|
||||||
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
|
||||||
|
import type { Route } from './+types/settings.webhooks.$id._index';
|
||||||
|
|
||||||
|
const WebhookSearchParamsSchema = ZUrlSearchParamsSchema.extend({
|
||||||
|
status: z.nativeEnum(WebhookCallStatus).optional(),
|
||||||
|
events: z.preprocess(
|
||||||
|
(value) => (typeof value === 'string' && value.length > 0 ? value.split(',') : []),
|
||||||
|
z.array(z.nativeEnum(WebhookTriggerEvents)).optional(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function meta() {
|
||||||
|
return appMetaTags('Webhooks');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WebhookPage({ params }: Route.ComponentProps) {
|
||||||
|
const { t, i18n } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
||||||
|
|
||||||
|
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
|
||||||
|
|
||||||
|
const parsedSearchParams = WebhookSearchParamsSchema.parse(
|
||||||
|
Object.fromEntries(searchParams ?? []),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: webhook, isLoading } = trpc.webhook.getWebhookById.useQuery(
|
||||||
|
{
|
||||||
|
id: params.id,
|
||||||
|
},
|
||||||
|
{ enabled: !!params.id, retry: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading: isLogsLoading,
|
||||||
|
isLoadingError: isLogsLoadingError,
|
||||||
|
} = trpc.webhook.calls.find.useQuery({
|
||||||
|
webhookId: params.id,
|
||||||
|
page: parsedSearchParams.page,
|
||||||
|
perPage: parsedSearchParams.perPage,
|
||||||
|
status: parsedSearchParams.status,
|
||||||
|
events: parsedSearchParams.events,
|
||||||
|
query: parsedSearchParams.query,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle debouncing the search query.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(searchParams?.toString());
|
||||||
|
|
||||||
|
params.set('query', debouncedSearchQuery);
|
||||||
|
|
||||||
|
if (debouncedSearchQuery === '') {
|
||||||
|
params.delete('query');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If nothing to change then do nothing.
|
||||||
|
if (params.toString() === searchParams?.toString()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchParams(params);
|
||||||
|
}, [debouncedSearchQuery, pathname, searchParams]);
|
||||||
|
|
||||||
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
|
updateSearchParams({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = data ?? {
|
||||||
|
data: [],
|
||||||
|
perPage: 10,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
header: t`Status`,
|
||||||
|
accessorKey: 'status',
|
||||||
|
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant={row.original.status === 'SUCCESS' ? 'default' : 'destructive'}>
|
||||||
|
{row.original.status === 'SUCCESS' ? (
|
||||||
|
<CheckCircle2Icon className="mr-2 h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<XCircleIcon className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{row.original.responseCode}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t`Event`,
|
||||||
|
accessorKey: 'event',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div>
|
||||||
|
<p className="text-foreground text-sm font-semibold">
|
||||||
|
{toFriendlyWebhookEventName(row.original.event)}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">{row.original.id}</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t`Sent`,
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p>
|
||||||
|
{i18n.date(row.original.createdAt, {
|
||||||
|
timeStyle: 'short',
|
||||||
|
dateStyle: 'short',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="data-state-selected:block opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<ChevronRightIcon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getTabHref = (value: string) => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
|
||||||
|
params.set('status', value);
|
||||||
|
|
||||||
|
if (value === '') {
|
||||||
|
params.delete('status');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.has('page')) {
|
||||||
|
params.delete('page');
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = pathname;
|
||||||
|
|
||||||
|
if (params.toString()) {
|
||||||
|
path += `?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <SpinnerBox className="py-32" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Todo: Update UI, currently out of place.
|
||||||
|
if (!webhook) {
|
||||||
|
return (
|
||||||
|
<GenericErrorLayout
|
||||||
|
errorCode={404}
|
||||||
|
errorCodeMap={{
|
||||||
|
404: {
|
||||||
|
heading: msg`Webhook not found`,
|
||||||
|
subHeading: msg`404 Webhook not found`,
|
||||||
|
message: msg`The webhook you are looking for may have been removed, renamed or may have never existed.`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
primaryButton={
|
||||||
|
<Button asChild>
|
||||||
|
<Link to={`/t/${team.url}/settings/webhooks`}>
|
||||||
|
<Trans>Go back</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
secondaryButton={null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SettingsHeader
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p>
|
||||||
|
<Trans>Webhook</Trans>
|
||||||
|
</p>
|
||||||
|
<Badge variant={webhook.enabled ? 'default' : 'secondary'}>
|
||||||
|
{webhook.enabled ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
subtitle={webhook.webhookUrl}
|
||||||
|
>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<WebhookTestDialog webhook={webhook}>
|
||||||
|
<Button variant="outline">
|
||||||
|
<TerminalIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Test</Trans>
|
||||||
|
</Button>
|
||||||
|
</WebhookTestDialog>
|
||||||
|
|
||||||
|
<WebhookEditDialog
|
||||||
|
webhook={webhook}
|
||||||
|
trigger={
|
||||||
|
<Button>
|
||||||
|
<PencilIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Edit</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsHeader>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="mb-4 flex flex-row items-center justify-between gap-x-4">
|
||||||
|
<Input
|
||||||
|
defaultValue={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={t`Search by ID`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WebhookEventCombobox />
|
||||||
|
|
||||||
|
<Tabs value={parsedSearchParams.status || ''} className="flex-shrink-0">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger className="hover:text-foreground min-w-[60px]" value="" asChild>
|
||||||
|
<Link to={getTabHref('')}>
|
||||||
|
<Trans>All</Trans>
|
||||||
|
</Link>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger className="hover:text-foreground min-w-[60px]" value="SUCCESS" asChild>
|
||||||
|
<Link to={getTabHref(WebhookCallStatus.SUCCESS)}>
|
||||||
|
<Trans>Success</Trans>
|
||||||
|
</Link>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger className="hover:text-foreground min-w-[60px]" value="FAILED" asChild>
|
||||||
|
<Link to={getTabHref(WebhookCallStatus.FAILED)}>
|
||||||
|
<Trans>Failed</Trans>
|
||||||
|
</Link>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={results.data}
|
||||||
|
perPage={results.perPage}
|
||||||
|
currentPage={results.currentPage}
|
||||||
|
totalPages={results.totalPages}
|
||||||
|
onPaginationChange={onPaginationChange}
|
||||||
|
onRowClick={(row) =>
|
||||||
|
WebhookLogsSheet.call({
|
||||||
|
webhookCall: row,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
rowClassName="cursor-pointer group"
|
||||||
|
error={{
|
||||||
|
enable: isLogsLoadingError,
|
||||||
|
}}
|
||||||
|
skeleton={{
|
||||||
|
enable: isLogsLoading,
|
||||||
|
rows: 3,
|
||||||
|
component: (
|
||||||
|
<>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(table) =>
|
||||||
|
results.totalPages > 1 && (
|
||||||
|
<DataTablePagination additionalInformation="VisibleCount" table={table} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WebhookLogsSheet.Root />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const WebhookEventCombobox = () => {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const isMounted = useIsMounted();
|
||||||
|
|
||||||
|
const events = (searchParams?.get('events') ?? '').split(',').filter((value) => value !== '');
|
||||||
|
|
||||||
|
const comboBoxOptions = Object.values(WebhookTriggerEvents).map((event) => ({
|
||||||
|
label: toFriendlyWebhookEventName(event),
|
||||||
|
value: event,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const onChange = (newEvents: string[]) => {
|
||||||
|
if (!pathname) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(searchParams?.toString());
|
||||||
|
|
||||||
|
params.set('events', newEvents.join(','));
|
||||||
|
|
||||||
|
if (newEvents.length === 0) {
|
||||||
|
params.delete('events');
|
||||||
|
}
|
||||||
|
|
||||||
|
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MultiSelectCombobox
|
||||||
|
emptySelectionPlaceholder={
|
||||||
|
<p className="text-muted-foreground font-normal">
|
||||||
|
<Trans>
|
||||||
|
<span className="text-muted-foreground/70">Events:</span> All
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
enableClearAllButton={true}
|
||||||
|
inputPlaceholder={msg`Search`}
|
||||||
|
loading={!isMounted}
|
||||||
|
options={comboBoxOptions}
|
||||||
|
selectedValues={events}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,263 +0,0 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { useRevalidator } from 'react-router';
|
|
||||||
import { Link } from 'react-router';
|
|
||||||
import type { z } from 'zod';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { ZEditWebhookRequestSchema } from '@documenso/trpc/server/webhook-router/schema';
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
|
||||||
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 { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
|
||||||
import { Switch } from '@documenso/ui/primitives/switch';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { WebhookTestDialog } from '~/components/dialogs/webhook-test-dialog';
|
|
||||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
|
||||||
import { WebhookMultiSelectCombobox } from '~/components/general/webhook-multiselect-combobox';
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
import { appMetaTags } from '~/utils/meta';
|
|
||||||
|
|
||||||
import type { Route } from './+types/settings.webhooks.$id';
|
|
||||||
|
|
||||||
const ZEditWebhookFormSchema = ZEditWebhookRequestSchema.omit({ id: true, teamId: true });
|
|
||||||
|
|
||||||
type TEditWebhookFormSchema = z.infer<typeof ZEditWebhookFormSchema>;
|
|
||||||
|
|
||||||
export function meta() {
|
|
||||||
return appMetaTags('Webhooks');
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function WebhookPage({ params }: Route.ComponentProps) {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { revalidate } = useRevalidator();
|
|
||||||
|
|
||||||
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: _(msg`Webhook updated`),
|
|
||||||
description: _(msg`The webhook has been updated successfully.`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await revalidate();
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Failed to update webhook`),
|
|
||||||
description: _(
|
|
||||||
msg`We encountered an error while updating the webhook. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <SpinnerBox className="py-32" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Todo: Update UI, currently out of place.
|
|
||||||
if (!webhook) {
|
|
||||||
return (
|
|
||||||
<GenericErrorLayout
|
|
||||||
errorCode={404}
|
|
||||||
errorCodeMap={{
|
|
||||||
404: {
|
|
||||||
heading: msg`Webhook not found`,
|
|
||||||
subHeading: msg`404 Webhook not found`,
|
|
||||||
message: msg`The webhook you are looking for may have been removed, renamed or may have never existed.`,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
primaryButton={
|
|
||||||
<Button asChild>
|
|
||||||
<Link to={`/t/${team.url}/settings/webhooks`}>
|
|
||||||
<Trans>Go back</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
secondaryButton={null}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-2xl">
|
|
||||||
<SettingsHeader
|
|
||||||
title={_(msg`Edit webhook`)}
|
|
||||||
subtitle={_(msg`On this page, you can edit the webhook and its settings.`)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
||||||
<fieldset className="flex h-full 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>
|
|
||||||
<Trans>The URL for Documenso to send webhook events to.</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="enabled"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Enabled</Trans>
|
|
||||||
</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>
|
|
||||||
<Trans>Triggers</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<WebhookMultiSelectCombobox
|
|
||||||
listValues={value}
|
|
||||||
onChange={(values: string[]) => {
|
|
||||||
onChange(values);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
<Trans>The events that will trigger a webhook to be sent to your URL.</Trans>
|
|
||||||
</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>
|
|
||||||
<Trans>
|
|
||||||
A secret that will be sent to your URL so you can verify that the request has
|
|
||||||
been sent by Documenso.
|
|
||||||
</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-4">
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
|
||||||
<Trans>Update webhook</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<Alert
|
|
||||||
className="mt-6 flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
|
|
||||||
variant="neutral"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<AlertTitle>
|
|
||||||
<Trans>Test Webhook</Trans>
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription className="mr-2">
|
|
||||||
<Trans>
|
|
||||||
Send a test webhook with sample data to verify your integration is working correctly.
|
|
||||||
</Trans>
|
|
||||||
</AlertDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<WebhookTestDialog webhook={webhook}>
|
|
||||||
<Button variant="outline" disabled={!webhook.enabled}>
|
|
||||||
<Trans>Test Webhook</Trans>
|
|
||||||
</Button>
|
|
||||||
</WebhookTestDialog>
|
|
||||||
</div>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,7 +1,18 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { Plural, useLingui } from '@lingui/react/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Loader } from 'lucide-react';
|
import type { Webhook } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
CheckCircle2Icon,
|
||||||
|
EditIcon,
|
||||||
|
Loader,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
ScrollTextIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
XCircleIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
@ -10,9 +21,21 @@ import { trpc } from '@documenso/trpc/react';
|
|||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { DataTable, type DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
import { WebhookCreateDialog } from '~/components/dialogs/webhook-create-dialog';
|
import { WebhookCreateDialog } from '~/components/dialogs/webhook-create-dialog';
|
||||||
import { WebhookDeleteDialog } from '~/components/dialogs/webhook-delete-dialog';
|
import { WebhookDeleteDialog } from '~/components/dialogs/webhook-delete-dialog';
|
||||||
|
import { WebhookEditDialog } from '~/components/dialogs/webhook-edit-dialog';
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
@ -22,19 +45,72 @@ export function meta() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function WebhookPage() {
|
export default function WebhookPage() {
|
||||||
const { _, i18n } = useLingui();
|
const { t, i18n } = useLingui();
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const { data: webhooks, isLoading } = trpc.webhook.getTeamWebhooks.useQuery({
|
const { data, isLoading, isError } = trpc.webhook.getTeamWebhooks.useQuery();
|
||||||
teamId: team.id,
|
|
||||||
});
|
const results = {
|
||||||
|
data: data ?? [],
|
||||||
|
perPage: 0,
|
||||||
|
currentPage: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
header: t`Webhook`,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link to={`/t/${team.url}/settings/webhooks/${row.original.id}`}>
|
||||||
|
<p className="text-muted-foreground text-xs">{row.original.id}</p>
|
||||||
|
<p
|
||||||
|
className="text-foreground max-w-sm truncate text-xs font-semibold"
|
||||||
|
title={row.original.webhookUrl}
|
||||||
|
>
|
||||||
|
{row.original.webhookUrl}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t`Status`,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant={row.original.enabled ? 'default' : 'neutral'} size="small">
|
||||||
|
{row.original.enabled ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t`Listening to`,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<p
|
||||||
|
className="text-foreground"
|
||||||
|
title={row.original.eventTriggers
|
||||||
|
.map((event) => toFriendlyWebhookEventName(event))
|
||||||
|
.join(', ')}
|
||||||
|
>
|
||||||
|
<Plural value={row.original.eventTriggers.length} one="# Event" other="# Events" />
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t`Created`,
|
||||||
|
cell: ({ row }) => i18n.date(row.original.createdAt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t`Actions`,
|
||||||
|
cell: ({ row }) => <WebhookTableActionDropdown webhook={row.original} />,
|
||||||
|
},
|
||||||
|
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
title={_(msg`Webhooks`)}
|
title={t`Webhooks`}
|
||||||
subtitle={_(msg`On this page, you can create new Webhooks and manage the existing ones.`)}
|
subtitle={t`On this page, you can create new Webhooks and manage the existing ones.`}
|
||||||
>
|
>
|
||||||
<WebhookCreateDialog />
|
<WebhookCreateDialog />
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
@ -43,74 +119,95 @@ export default function WebhookPage() {
|
|||||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{webhooks && webhooks.length === 0 && (
|
|
||||||
// TODO: Perhaps add some illustrations here to make the page more engaging
|
<DataTable
|
||||||
<div className="mb-4">
|
columns={columns}
|
||||||
<p className="text-muted-foreground mt-2 text-sm italic">
|
data={results.data}
|
||||||
|
perPage={results.perPage}
|
||||||
|
currentPage={results.currentPage}
|
||||||
|
totalPages={results.totalPages}
|
||||||
|
error={{
|
||||||
|
enable: isError,
|
||||||
|
}}
|
||||||
|
emptyState={
|
||||||
|
<div className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4">
|
||||||
|
<p>
|
||||||
<Trans>
|
<Trans>
|
||||||
You have no webhooks yet. Your webhooks will be shown here once you create them.
|
You have no webhooks yet. Your webhooks will be shown here once you create them.
|
||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
}
|
||||||
{webhooks && webhooks.length > 0 && (
|
skeleton={{
|
||||||
<div className="mt-4 flex max-w-2xl flex-col gap-y-4">
|
enable: isLoading,
|
||||||
{webhooks?.map((webhook) => (
|
rows: 3,
|
||||||
<div
|
component: (
|
||||||
key={webhook.id}
|
<>
|
||||||
className={cn(
|
<TableCell>
|
||||||
'border-border rounded-lg border p-4',
|
<Skeleton className="h-4 w-24 rounded-full" />
|
||||||
!webhook.enabled && 'bg-muted/40',
|
</TableCell>
|
||||||
)}
|
<TableCell>
|
||||||
>
|
<Skeleton className="h-4 w-8 rounded-full" />
|
||||||
<div className="flex flex-col gap-x-4 sm:flex-row sm:items-center sm:justify-between">
|
</TableCell>
|
||||||
<div>
|
<TableCell>
|
||||||
<div className="truncate font-mono text-xs">{webhook.id}</div>
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
<div className="mt-1.5 flex items-center gap-2">
|
<TableCell>
|
||||||
<h5
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
className="max-w-[30rem] truncate text-sm sm:max-w-[18rem]"
|
</TableCell>
|
||||||
title={webhook.webhookUrl}
|
<TableCell>
|
||||||
>
|
<Skeleton className="h-4 w-6 rounded-full" />
|
||||||
{webhook.webhookUrl}
|
</TableCell>
|
||||||
</h5>
|
</>
|
||||||
|
),
|
||||||
<Badge variant={webhook.enabled ? 'neutral' : 'warning'} size="small">
|
}}
|
||||||
{webhook.enabled ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>}
|
/>
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs">
|
|
||||||
<Trans>
|
|
||||||
Listening to{' '}
|
|
||||||
{webhook.eventTriggers
|
|
||||||
.map((trigger) => toFriendlyWebhookEventName(trigger))
|
|
||||||
.join(', ')}
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs">
|
|
||||||
<Trans>Created on {i18n.date(webhook.createdAt, DateTime.DATETIME_FULL)}</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-shrink-0 gap-4 sm:mt-0">
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link to={`/t/${team.url}/settings/webhooks/${webhook.id}`}>
|
|
||||||
<Trans>Edit</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<WebhookDeleteDialog webhook={webhook}>
|
|
||||||
<Button variant="destructive">
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</Button>
|
|
||||||
</WebhookDeleteDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WebhookTableActionDropdown = ({ webhook }: { webhook: Webhook }) => {
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger data-testid="webhook-table-action-btn">
|
||||||
|
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent align="end" forceMount>
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
<Trans>Action</Trans>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to={`/t/${team.url}/settings/webhooks/${webhook.id}`}>
|
||||||
|
<ScrollTextIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Logs</Trans>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<WebhookEditDialog
|
||||||
|
webhook={webhook}
|
||||||
|
trigger={
|
||||||
|
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||||
|
<div>
|
||||||
|
<EditIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Edit</Trans>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WebhookDeleteDialog webhook={webhook}>
|
||||||
|
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||||
|
<div>
|
||||||
|
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</WebhookDeleteDialog>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
377
packages/app-tests/e2e/webhooks/webhooks-crud.spec.ts
Normal file
377
packages/app-tests/e2e/webhooks/webhooks-crud.spec.ts
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||||
|
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||||
|
import { expectTextToBeVisible } from '../fixtures/generic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to seed a webhook directly in the database for testing.
|
||||||
|
*/
|
||||||
|
const seedWebhook = async ({
|
||||||
|
webhookUrl,
|
||||||
|
eventTriggers,
|
||||||
|
secret,
|
||||||
|
enabled,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
}: {
|
||||||
|
webhookUrl: string;
|
||||||
|
eventTriggers: WebhookTriggerEvents[];
|
||||||
|
secret?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
userId: number;
|
||||||
|
teamId: number;
|
||||||
|
}) => {
|
||||||
|
return await prisma.webhook.create({
|
||||||
|
data: {
|
||||||
|
webhookUrl,
|
||||||
|
eventTriggers,
|
||||||
|
secret: secret ?? null,
|
||||||
|
enabled: enabled ?? true,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
test('[WEBHOOKS]: create webhook', async ({ page }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/t/${team.url}/settings/webhooks`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const webhookUrl = `https://example.com/webhook-${Date.now()}`;
|
||||||
|
|
||||||
|
// Click Create Webhook button
|
||||||
|
await page.getByRole('button', { name: 'Create Webhook' }).click();
|
||||||
|
|
||||||
|
// Fill in the form
|
||||||
|
await page.getByLabel('Webhook URL*').fill(webhookUrl);
|
||||||
|
|
||||||
|
// Select event trigger - click on the triggers field and select DOCUMENT_CREATED
|
||||||
|
await page.getByLabel('Triggers').click();
|
||||||
|
await page.waitForTimeout(200); // Wait for dropdown to open
|
||||||
|
await page.getByText('document.created').click();
|
||||||
|
|
||||||
|
// Click outside the triggers field to close the dropdown
|
||||||
|
await page.getByText('The URL for Documenso to send webhook events to.').click();
|
||||||
|
|
||||||
|
// Fill in the form
|
||||||
|
await page.getByLabel('Secret').fill('secret');
|
||||||
|
|
||||||
|
// Submit the form
|
||||||
|
await page.getByRole('button', { name: 'Create' }).click();
|
||||||
|
|
||||||
|
// Wait for success toast
|
||||||
|
await expectTextToBeVisible(page, 'Webhook created');
|
||||||
|
await expectTextToBeVisible(page, 'The webhook was successfully created.');
|
||||||
|
|
||||||
|
// Verify webhook appears in the list
|
||||||
|
await expect(page.getByText(webhookUrl)).toBeVisible();
|
||||||
|
|
||||||
|
// Directly check database
|
||||||
|
const dbWebhook = await prisma.webhook.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dbWebhook?.eventTriggers).toEqual([WebhookTriggerEvents.DOCUMENT_CREATED]);
|
||||||
|
expect(dbWebhook?.secret).toBe('secret');
|
||||||
|
expect(dbWebhook?.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[WEBHOOKS]: view webhooks', async ({ page }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const webhookUrl = `https://example.com/webhook-${Date.now()}`;
|
||||||
|
|
||||||
|
// Create a webhook via seeding
|
||||||
|
const webhook = await seedWebhook({
|
||||||
|
webhookUrl,
|
||||||
|
eventTriggers: [WebhookTriggerEvents.DOCUMENT_CREATED, WebhookTriggerEvents.DOCUMENT_SENT],
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/t/${team.url}/settings/webhooks`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify webhook is visible in the table
|
||||||
|
await expect(page.getByText(webhookUrl)).toBeVisible();
|
||||||
|
await expect(page.getByText('Enabled')).toBeVisible();
|
||||||
|
await expect(page.getByText('2 Events')).toBeVisible();
|
||||||
|
|
||||||
|
// Click on webhook to navigate to detail page
|
||||||
|
await page.getByText(webhookUrl).click();
|
||||||
|
|
||||||
|
// Verify detail page shows webhook information
|
||||||
|
await page.waitForURL(`/t/${team.url}/settings/webhooks/${webhook.id}`);
|
||||||
|
await expect(page.getByText(webhookUrl)).toBeVisible();
|
||||||
|
await expect(page.getByText('Enabled')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[WEBHOOKS]: delete webhook', async ({ page }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const webhookUrl = `https://example.com/webhook-${Date.now()}`;
|
||||||
|
|
||||||
|
// Create a webhook via seeding
|
||||||
|
const webhook = await seedWebhook({
|
||||||
|
webhookUrl,
|
||||||
|
eventTriggers: [WebhookTriggerEvents.DOCUMENT_CREATED],
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/t/${team.url}/settings/webhooks`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify webhook is visible
|
||||||
|
await expect(page.getByText(webhookUrl)).toBeVisible();
|
||||||
|
|
||||||
|
// Find the row with the webhook and click the action dropdown
|
||||||
|
const webhookRow = page.locator('tr', { hasText: webhookUrl });
|
||||||
|
await webhookRow.getByTestId('webhook-table-action-btn').click();
|
||||||
|
|
||||||
|
// Click Delete menu item
|
||||||
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
|
||||||
|
// Fill in confirmation field
|
||||||
|
const deleteMessage = `delete ${webhookUrl}`;
|
||||||
|
// The label contains "Confirm by typing:" followed by the delete message
|
||||||
|
await page.getByLabel(/Confirm by typing/).fill(deleteMessage);
|
||||||
|
|
||||||
|
// Click delete button
|
||||||
|
await page.getByRole('button', { name: 'Delete' }).click();
|
||||||
|
|
||||||
|
// Wait for success toast
|
||||||
|
await expectTextToBeVisible(page, 'Webhook deleted');
|
||||||
|
await expectTextToBeVisible(page, 'The webhook has been successfully deleted.');
|
||||||
|
|
||||||
|
// Verify webhook is removed from the list
|
||||||
|
await expect(page.getByText(webhookUrl)).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[WEBHOOKS]: update webhook', async ({ page }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const originalWebhookUrl = `https://example.com/webhook-original-${Date.now()}`;
|
||||||
|
const updatedWebhookUrl = `https://example.com/webhook-updated-${Date.now()}`;
|
||||||
|
|
||||||
|
// Create a webhook via seeding with initial values
|
||||||
|
const webhook = await seedWebhook({
|
||||||
|
webhookUrl: originalWebhookUrl,
|
||||||
|
eventTriggers: [WebhookTriggerEvents.DOCUMENT_CREATED, WebhookTriggerEvents.DOCUMENT_SENT],
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/t/${team.url}/settings/webhooks`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify webhook is visible with original values
|
||||||
|
await expect(page.getByText(originalWebhookUrl)).toBeVisible();
|
||||||
|
await expect(page.getByText('Enabled')).toBeVisible();
|
||||||
|
await expect(page.getByText('2 Events')).toBeVisible();
|
||||||
|
|
||||||
|
// Find the row with the webhook and click the action dropdown
|
||||||
|
const webhookRow = page.locator('tr', { hasText: originalWebhookUrl });
|
||||||
|
await webhookRow.getByTestId('webhook-table-action-btn').click();
|
||||||
|
|
||||||
|
// Click Edit menu item
|
||||||
|
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||||
|
|
||||||
|
// Wait for dialog to open
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
// Change the webhook URL
|
||||||
|
await page.getByLabel('Webhook URL').clear();
|
||||||
|
await page.getByLabel('Webhook URL').fill(updatedWebhookUrl);
|
||||||
|
|
||||||
|
// Disable the webhook (toggle the switch)
|
||||||
|
const enabledSwitch = page.getByLabel('Enabled');
|
||||||
|
const isChecked = await enabledSwitch.isChecked();
|
||||||
|
if (isChecked) {
|
||||||
|
await enabledSwitch.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change the event triggers - remove one existing event and add a new one
|
||||||
|
// The selected items are shown as badges with remove buttons
|
||||||
|
// Remove one of the existing events (DOCUMENT_SENT) by clicking its remove button
|
||||||
|
const removeButtons = page.locator('button[aria-label="Remove"]');
|
||||||
|
const removeButtonCount = await removeButtons.count();
|
||||||
|
|
||||||
|
// Remove the "DOCUMENT_SENT" event (this will remove one of the two)
|
||||||
|
if (removeButtonCount > 0) {
|
||||||
|
await removeButtons.nth(1).click();
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new event triggers
|
||||||
|
await page.getByLabel('Triggers').click();
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
// Select DOCUMENT_COMPLETED (this will be added to the remaining DOCUMENT_CREATED)
|
||||||
|
await page.getByText('document.completed').click();
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
// Click outside to close the dropdown
|
||||||
|
await page.getByText('The URL for Documenso to send webhook events to.').click();
|
||||||
|
|
||||||
|
// Submit the form
|
||||||
|
await page.getByRole('button', { name: 'Update' }).click();
|
||||||
|
|
||||||
|
// Wait for success toast
|
||||||
|
await expectTextToBeVisible(page, 'Webhook updated');
|
||||||
|
await expectTextToBeVisible(page, 'The webhook has been updated successfully.');
|
||||||
|
|
||||||
|
// Verify changes are reflected in the list
|
||||||
|
// The old URL should be gone and new URL should be visible
|
||||||
|
await expect(page.getByText(originalWebhookUrl)).not.toBeVisible();
|
||||||
|
await expect(page.getByText(updatedWebhookUrl)).toBeVisible();
|
||||||
|
// Verify webhook is disabled
|
||||||
|
await expect(page.getByText('Disabled')).toBeVisible();
|
||||||
|
// Verify event count is still 2 (one removed, one added - DOCUMENT_CREATED and DOCUMENT_COMPLETED)
|
||||||
|
await expect(page.getByText('2 Events')).toBeVisible();
|
||||||
|
|
||||||
|
// Check the database directly to verify
|
||||||
|
const dbWebhook = await prisma.webhook.findUnique({
|
||||||
|
where: {
|
||||||
|
id: webhook.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dbWebhook?.eventTriggers).toEqual([
|
||||||
|
WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||||
|
WebhookTriggerEvents.DOCUMENT_COMPLETED,
|
||||||
|
]);
|
||||||
|
expect(dbWebhook?.enabled).toBe(false);
|
||||||
|
expect(dbWebhook?.webhookUrl).toBe(updatedWebhookUrl);
|
||||||
|
expect(dbWebhook?.secret).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[WEBHOOKS]: cannot see unrelated webhooks', async ({ page }) => {
|
||||||
|
// Create two separate users with teams
|
||||||
|
const user1Data = await seedUser();
|
||||||
|
const user2Data = await seedUser();
|
||||||
|
|
||||||
|
const webhookUrl1 = `https://example.com/webhook-team1-${Date.now()}`;
|
||||||
|
const webhookUrl2 = `https://example.com/webhook-team2-${Date.now()}`;
|
||||||
|
|
||||||
|
// Create webhooks for both teams with DOCUMENT_CREATED event
|
||||||
|
const webhook1 = await seedWebhook({
|
||||||
|
webhookUrl: webhookUrl1,
|
||||||
|
eventTriggers: [WebhookTriggerEvents.DOCUMENT_CREATED],
|
||||||
|
userId: user1Data.user.id,
|
||||||
|
teamId: user1Data.team.id,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const webhook2 = await seedWebhook({
|
||||||
|
webhookUrl: webhookUrl2,
|
||||||
|
eventTriggers: [WebhookTriggerEvents.DOCUMENT_SENT],
|
||||||
|
userId: user2Data.user.id,
|
||||||
|
teamId: user2Data.team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a document on team1 to trigger the webhook
|
||||||
|
const document = await seedBlankDocument(user1Data.user, user1Data.team.id, {
|
||||||
|
createDocumentOptions: {
|
||||||
|
title: 'Test Document for Webhook',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a webhook call for team1's webhook (simulating the webhook being triggered)
|
||||||
|
// Since webhooks are triggered via jobs which may not run in tests, we create the call directly
|
||||||
|
const webhookCall1 = await prisma.webhookCall.create({
|
||||||
|
data: {
|
||||||
|
webhookId: webhook1.id,
|
||||||
|
url: webhookUrl1,
|
||||||
|
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||||
|
status: WebhookCallStatus.SUCCESS,
|
||||||
|
responseCode: 200,
|
||||||
|
requestBody: {
|
||||||
|
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||||
|
payload: {
|
||||||
|
id: document.id,
|
||||||
|
title: document.title,
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
webhookEndpoint: webhookUrl1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sign in as user1
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user1Data.user.email,
|
||||||
|
redirectPath: `/t/${user1Data.team.url}/settings/webhooks`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify user1 can see their webhook
|
||||||
|
await expect(page.getByText(webhookUrl1)).toBeVisible();
|
||||||
|
// Verify user1 cannot see user2's webhook
|
||||||
|
await expect(page.getByText(webhookUrl2)).not.toBeVisible();
|
||||||
|
|
||||||
|
// Navigate to team1's webhook logs page
|
||||||
|
await page.goto(
|
||||||
|
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${user1Data.team.url}/settings/webhooks/${webhook1.id}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify user1 can see their webhook logs
|
||||||
|
// The webhook call should be visible in the table
|
||||||
|
await expect(page.getByText(webhookCall1.id)).toBeVisible();
|
||||||
|
await expect(page.getByText('200')).toBeVisible(); // Response code
|
||||||
|
|
||||||
|
// Sign out and sign in as user2
|
||||||
|
await apiSignout({ page });
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user2Data.user.email,
|
||||||
|
redirectPath: `/t/${user2Data.team.url}/settings/webhooks`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify user2 can see their webhook
|
||||||
|
await expect(page.getByText(webhookUrl2)).toBeVisible();
|
||||||
|
// Verify user2 cannot see user1's webhook
|
||||||
|
await expect(page.getByText(webhookUrl1)).not.toBeVisible();
|
||||||
|
|
||||||
|
// Navigate to team2's webhook logs page
|
||||||
|
await page.goto(
|
||||||
|
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${user2Data.team.url}/settings/webhooks/${webhook2.id}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify user2 cannot see team1's webhook logs
|
||||||
|
// The webhook call from team1 should not be visible
|
||||||
|
await expect(page.getByText(webhookCall1.id)).not.toBeVisible();
|
||||||
|
|
||||||
|
// Attempt to access user1's webhook detail page directly via URL
|
||||||
|
await page.goto(
|
||||||
|
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${user2Data.team.url}/settings/webhooks/${webhook1.id}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify access is denied - should show error or redirect
|
||||||
|
// Based on the component, it shows a 404 error page
|
||||||
|
await expect(page.getByRole('heading', { name: 'Webhook not found' })).toBeVisible();
|
||||||
|
});
|
||||||
@ -30,6 +30,7 @@ export const run = async ({
|
|||||||
webhookEndpoint: url,
|
webhookEndpoint: url,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Note: This is duplicated in `resend-webhook-call.ts`.
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payloadData),
|
body: JSON.stringify(payloadData),
|
||||||
|
|||||||
106
packages/trpc/server/webhook-router/find-webhook-calls.ts
Normal file
106
packages/trpc/server/webhook-router/find-webhook-calls.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { Prisma, WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||||
|
|
||||||
|
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import type { FindResultResponse } from '@documenso/lib/types/search-params';
|
||||||
|
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { authenticatedProcedure } from '../trpc';
|
||||||
|
import {
|
||||||
|
ZFindWebhookCallsRequestSchema,
|
||||||
|
ZFindWebhookCallsResponseSchema,
|
||||||
|
} from './find-webhook-calls.types';
|
||||||
|
|
||||||
|
export const findWebhookCallsRoute = authenticatedProcedure
|
||||||
|
.input(ZFindWebhookCallsRequestSchema)
|
||||||
|
.output(ZFindWebhookCallsResponseSchema)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const { webhookId, page, perPage, status, query, events } = input;
|
||||||
|
|
||||||
|
ctx.logger.info({
|
||||||
|
input: { webhookId, status },
|
||||||
|
});
|
||||||
|
|
||||||
|
return await findWebhookCalls({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
teamId: ctx.teamId,
|
||||||
|
webhookId,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
status,
|
||||||
|
query,
|
||||||
|
events,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
type FindWebhookCallsOptions = {
|
||||||
|
userId: number;
|
||||||
|
teamId: number;
|
||||||
|
webhookId: string;
|
||||||
|
page?: number;
|
||||||
|
perPage?: number;
|
||||||
|
status?: WebhookCallStatus;
|
||||||
|
events?: WebhookTriggerEvents[];
|
||||||
|
query?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findWebhookCalls = async ({
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
webhookId,
|
||||||
|
page = 1,
|
||||||
|
perPage = 20,
|
||||||
|
events,
|
||||||
|
query = '',
|
||||||
|
status,
|
||||||
|
}: FindWebhookCallsOptions) => {
|
||||||
|
const webhook = await prisma.webhook.findFirst({
|
||||||
|
where: {
|
||||||
|
id: webhookId,
|
||||||
|
team: buildTeamWhereQuery({
|
||||||
|
teamId,
|
||||||
|
userId,
|
||||||
|
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP.MANAGE_TEAM,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!webhook) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause: Prisma.WebhookCallWhereInput = {
|
||||||
|
webhookId: webhook.id,
|
||||||
|
status,
|
||||||
|
id: query || undefined,
|
||||||
|
event:
|
||||||
|
events && events.length > 0
|
||||||
|
? {
|
||||||
|
in: events,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [data, count] = await Promise.all([
|
||||||
|
prisma.webhookCall.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
skip: Math.max(page - 1, 0) * perPage,
|
||||||
|
take: perPage,
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.webhookCall.count({
|
||||||
|
where: whereClause,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
count,
|
||||||
|
currentPage: Math.max(page, 1),
|
||||||
|
perPage,
|
||||||
|
totalPages: Math.ceil(count / perPage),
|
||||||
|
} satisfies FindResultResponse<typeof data>;
|
||||||
|
};
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
|
import WebhookCallSchema from '@documenso/prisma/generated/zod/modelSchema/WebhookCallSchema';
|
||||||
|
|
||||||
|
export const ZFindWebhookCallsRequestSchema = ZFindSearchParamsSchema.extend({
|
||||||
|
webhookId: z.string(),
|
||||||
|
status: z.nativeEnum(WebhookCallStatus).optional(),
|
||||||
|
events: z
|
||||||
|
.array(z.nativeEnum(WebhookTriggerEvents))
|
||||||
|
.optional()
|
||||||
|
.refine((arr) => !arr || new Set(arr).size === arr.length, {
|
||||||
|
message: 'Events must be unique',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZFindWebhookCallsResponseSchema = ZFindResultResponse.extend({
|
||||||
|
data: WebhookCallSchema.pick({
|
||||||
|
webhookId: true,
|
||||||
|
status: true,
|
||||||
|
event: true,
|
||||||
|
id: true,
|
||||||
|
url: true,
|
||||||
|
responseCode: true,
|
||||||
|
createdAt: true,
|
||||||
|
})
|
||||||
|
.extend({
|
||||||
|
requestBody: z.unknown(),
|
||||||
|
responseHeaders: z.unknown().nullable(),
|
||||||
|
responseBody: z.unknown().nullable(),
|
||||||
|
})
|
||||||
|
.array(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TFindWebhookCallsRequest = z.infer<typeof ZFindWebhookCallsRequestSchema>;
|
||||||
|
export type TFindWebhookCallsResponse = z.infer<typeof ZFindWebhookCallsResponseSchema>;
|
||||||
80
packages/trpc/server/webhook-router/resend-webhook-call.ts
Normal file
80
packages/trpc/server/webhook-router/resend-webhook-call.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { Prisma, WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||||
|
|
||||||
|
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import type { FindResultResponse } from '@documenso/lib/types/search-params';
|
||||||
|
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { authenticatedProcedure } from '../trpc';
|
||||||
|
import {
|
||||||
|
ZResendWebhookCallRequestSchema,
|
||||||
|
ZResendWebhookCallResponseSchema,
|
||||||
|
} from './resend-webhook-call.types';
|
||||||
|
|
||||||
|
export const resendWebhookCallRoute = authenticatedProcedure
|
||||||
|
.input(ZResendWebhookCallRequestSchema)
|
||||||
|
.output(ZResendWebhookCallResponseSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const { teamId, user } = ctx;
|
||||||
|
const { webhookId, webhookCallId } = input;
|
||||||
|
|
||||||
|
ctx.logger.info({
|
||||||
|
input: { webhookId, webhookCallId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const webhookCall = await prisma.webhookCall.findFirst({
|
||||||
|
where: {
|
||||||
|
id: webhookCallId,
|
||||||
|
webhook: {
|
||||||
|
id: webhookId,
|
||||||
|
team: buildTeamWhereQuery({
|
||||||
|
teamId,
|
||||||
|
userId: user.id,
|
||||||
|
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP.MANAGE_TEAM,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
webhook: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!webhookCall) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { webhook } = webhookCall;
|
||||||
|
|
||||||
|
// Note: This is duplicated in `execute-webhook.handler.ts`.
|
||||||
|
const response = await fetch(webhookCall.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(webhookCall.requestBody),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Documenso-Secret': webhook.secret ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
let responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput = Prisma.JsonNull;
|
||||||
|
|
||||||
|
try {
|
||||||
|
responseBody = JSON.parse(body);
|
||||||
|
} catch (err) {
|
||||||
|
responseBody = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.webhookCall.update({
|
||||||
|
where: {
|
||||||
|
id: webhookCall.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: response.ok ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED,
|
||||||
|
responseCode: response.status,
|
||||||
|
responseBody,
|
||||||
|
responseHeaders: Object.fromEntries(response.headers.entries()),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import WebhookCallSchema from '@documenso/prisma/generated/zod/modelSchema/WebhookCallSchema';
|
||||||
|
|
||||||
|
export const ZResendWebhookCallRequestSchema = z.object({
|
||||||
|
webhookId: z.string(),
|
||||||
|
webhookCallId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZResendWebhookCallResponseSchema = WebhookCallSchema.pick({
|
||||||
|
webhookId: true,
|
||||||
|
status: true,
|
||||||
|
event: true,
|
||||||
|
id: true,
|
||||||
|
url: true,
|
||||||
|
responseCode: true,
|
||||||
|
createdAt: true,
|
||||||
|
}).extend({
|
||||||
|
requestBody: z.unknown(),
|
||||||
|
responseHeaders: z.unknown().nullable(),
|
||||||
|
responseBody: z.unknown().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TResendWebhookRequest = z.infer<typeof ZResendWebhookCallRequestSchema>;
|
||||||
|
export type TResendWebhookResponse = z.infer<typeof ZResendWebhookCallResponseSchema>;
|
||||||
@ -6,66 +6,61 @@ import { getWebhooksByTeamId } from '@documenso/lib/server-only/webhooks/get-web
|
|||||||
import { triggerTestWebhook } from '@documenso/lib/server-only/webhooks/trigger-test-webhook';
|
import { triggerTestWebhook } from '@documenso/lib/server-only/webhooks/trigger-test-webhook';
|
||||||
|
|
||||||
import { authenticatedProcedure, router } from '../trpc';
|
import { authenticatedProcedure, router } from '../trpc';
|
||||||
|
import { findWebhookCallsRoute } from './find-webhook-calls';
|
||||||
|
import { resendWebhookCallRoute } from './resend-webhook-call';
|
||||||
import {
|
import {
|
||||||
ZCreateWebhookRequestSchema,
|
ZCreateWebhookRequestSchema,
|
||||||
ZDeleteWebhookRequestSchema,
|
ZDeleteWebhookRequestSchema,
|
||||||
ZEditWebhookRequestSchema,
|
ZEditWebhookRequestSchema,
|
||||||
ZGetTeamWebhooksRequestSchema,
|
|
||||||
ZGetWebhookByIdRequestSchema,
|
ZGetWebhookByIdRequestSchema,
|
||||||
ZTriggerTestWebhookRequestSchema,
|
ZTriggerTestWebhookRequestSchema,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
|
|
||||||
export const webhookRouter = router({
|
export const webhookRouter = router({
|
||||||
getTeamWebhooks: authenticatedProcedure
|
calls: {
|
||||||
.input(ZGetTeamWebhooksRequestSchema)
|
find: findWebhookCallsRoute,
|
||||||
.query(async ({ ctx, input }) => {
|
resend: resendWebhookCallRoute,
|
||||||
const { teamId } = input;
|
},
|
||||||
|
|
||||||
|
getTeamWebhooks: authenticatedProcedure.query(async ({ ctx }) => {
|
||||||
ctx.logger.info({
|
ctx.logger.info({
|
||||||
input: {
|
input: {
|
||||||
teamId,
|
teamId: ctx.teamId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return await getWebhooksByTeamId(teamId, ctx.user.id);
|
return await getWebhooksByTeamId(ctx.teamId, ctx.user.id);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getWebhookById: authenticatedProcedure
|
getWebhookById: authenticatedProcedure
|
||||||
.input(ZGetWebhookByIdRequestSchema)
|
.input(ZGetWebhookByIdRequestSchema)
|
||||||
.query(async ({ input, ctx }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
const { id, teamId } = input;
|
const { id } = input;
|
||||||
|
|
||||||
ctx.logger.info({
|
ctx.logger.info({
|
||||||
input: {
|
input: {
|
||||||
id,
|
id,
|
||||||
teamId,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return await getWebhookById({
|
return await getWebhookById({
|
||||||
id,
|
id,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
teamId,
|
teamId: ctx.teamId,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
createWebhook: authenticatedProcedure
|
createWebhook: authenticatedProcedure
|
||||||
.input(ZCreateWebhookRequestSchema)
|
.input(ZCreateWebhookRequestSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { enabled, eventTriggers, secret, webhookUrl, teamId } = input;
|
const { enabled, eventTriggers, secret, webhookUrl } = input;
|
||||||
|
|
||||||
ctx.logger.info({
|
|
||||||
input: {
|
|
||||||
teamId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return await createWebhook({
|
return await createWebhook({
|
||||||
enabled,
|
enabled,
|
||||||
secret,
|
secret,
|
||||||
webhookUrl,
|
webhookUrl,
|
||||||
eventTriggers,
|
eventTriggers,
|
||||||
teamId,
|
teamId: ctx.teamId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@ -73,18 +68,17 @@ export const webhookRouter = router({
|
|||||||
deleteWebhook: authenticatedProcedure
|
deleteWebhook: authenticatedProcedure
|
||||||
.input(ZDeleteWebhookRequestSchema)
|
.input(ZDeleteWebhookRequestSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { id, teamId } = input;
|
const { id } = input;
|
||||||
|
|
||||||
ctx.logger.info({
|
ctx.logger.info({
|
||||||
input: {
|
input: {
|
||||||
id,
|
id,
|
||||||
teamId,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return await deleteWebhookById({
|
return await deleteWebhookById({
|
||||||
id,
|
id,
|
||||||
teamId,
|
teamId: ctx.teamId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@ -92,12 +86,11 @@ export const webhookRouter = router({
|
|||||||
editWebhook: authenticatedProcedure
|
editWebhook: authenticatedProcedure
|
||||||
.input(ZEditWebhookRequestSchema)
|
.input(ZEditWebhookRequestSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { id, teamId, ...data } = input;
|
const { id, ...data } = input;
|
||||||
|
|
||||||
ctx.logger.info({
|
ctx.logger.info({
|
||||||
input: {
|
input: {
|
||||||
id,
|
id,
|
||||||
teamId,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -105,20 +98,19 @@ export const webhookRouter = router({
|
|||||||
id,
|
id,
|
||||||
data,
|
data,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
teamId,
|
teamId: ctx.teamId,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
testWebhook: authenticatedProcedure
|
testWebhook: authenticatedProcedure
|
||||||
.input(ZTriggerTestWebhookRequestSchema)
|
.input(ZTriggerTestWebhookRequestSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { id, event, teamId } = input;
|
const { id, event } = input;
|
||||||
|
|
||||||
ctx.logger.info({
|
ctx.logger.info({
|
||||||
input: {
|
input: {
|
||||||
id,
|
id,
|
||||||
event,
|
event,
|
||||||
teamId,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -126,7 +118,7 @@ export const webhookRouter = router({
|
|||||||
id,
|
id,
|
||||||
event,
|
event,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
teamId,
|
teamId: ctx.teamId,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,12 +1,6 @@
|
|||||||
import { WebhookTriggerEvents } from '@prisma/client';
|
import { WebhookTriggerEvents } from '@prisma/client';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const ZGetTeamWebhooksRequestSchema = z.object({
|
|
||||||
teamId: z.number(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TGetTeamWebhooksRequestSchema = z.infer<typeof ZGetTeamWebhooksRequestSchema>;
|
|
||||||
|
|
||||||
export const ZCreateWebhookRequestSchema = z.object({
|
export const ZCreateWebhookRequestSchema = z.object({
|
||||||
webhookUrl: z.string().url(),
|
webhookUrl: z.string().url(),
|
||||||
eventTriggers: z
|
eventTriggers: z
|
||||||
@ -14,14 +8,12 @@ export const ZCreateWebhookRequestSchema = z.object({
|
|||||||
.min(1, { message: 'At least one event trigger is required' }),
|
.min(1, { message: 'At least one event trigger is required' }),
|
||||||
secret: z.string().nullable(),
|
secret: z.string().nullable(),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
teamId: z.number(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookRequestSchema>;
|
export type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookRequestSchema>;
|
||||||
|
|
||||||
export const ZGetWebhookByIdRequestSchema = z.object({
|
export const ZGetWebhookByIdRequestSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
teamId: z.number(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TGetWebhookByIdRequestSchema = z.infer<typeof ZGetWebhookByIdRequestSchema>;
|
export type TGetWebhookByIdRequestSchema = z.infer<typeof ZGetWebhookByIdRequestSchema>;
|
||||||
@ -34,7 +26,6 @@ export type TEditWebhookRequestSchema = z.infer<typeof ZEditWebhookRequestSchema
|
|||||||
|
|
||||||
export const ZDeleteWebhookRequestSchema = z.object({
|
export const ZDeleteWebhookRequestSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
teamId: z.number(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TDeleteWebhookRequestSchema = z.infer<typeof ZDeleteWebhookRequestSchema>;
|
export type TDeleteWebhookRequestSchema = z.infer<typeof ZDeleteWebhookRequestSchema>;
|
||||||
@ -42,7 +33,6 @@ export type TDeleteWebhookRequestSchema = z.infer<typeof ZDeleteWebhookRequestSc
|
|||||||
export const ZTriggerTestWebhookRequestSchema = z.object({
|
export const ZTriggerTestWebhookRequestSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
event: z.nativeEnum(WebhookTriggerEvents),
|
event: z.nativeEnum(WebhookTriggerEvents),
|
||||||
teamId: z.number(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TTriggerTestWebhookRequestSchema = z.infer<typeof ZTriggerTestWebhookRequestSchema>;
|
export type TTriggerTestWebhookRequestSchema = z.infer<typeof ZTriggerTestWebhookRequestSchema>;
|
||||||
|
|||||||
@ -21,6 +21,8 @@ export interface DataTableProps<TData, TValue> {
|
|||||||
columns: ColumnDef<TData, TValue>[];
|
columns: ColumnDef<TData, TValue>[];
|
||||||
columnVisibility?: VisibilityState;
|
columnVisibility?: VisibilityState;
|
||||||
data: TData[];
|
data: TData[];
|
||||||
|
onRowClick?: (row: TData) => void;
|
||||||
|
rowClassName?: string;
|
||||||
perPage?: number;
|
perPage?: number;
|
||||||
currentPage?: number;
|
currentPage?: number;
|
||||||
totalPages?: number;
|
totalPages?: number;
|
||||||
@ -52,6 +54,8 @@ export function DataTable<TData, TValue>({
|
|||||||
hasFilters,
|
hasFilters,
|
||||||
onClearFilters,
|
onClearFilters,
|
||||||
onPaginationChange,
|
onPaginationChange,
|
||||||
|
onRowClick,
|
||||||
|
rowClassName,
|
||||||
children,
|
children,
|
||||||
emptyState,
|
emptyState,
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
@ -116,7 +120,12 @@ export function DataTable<TData, TValue>({
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{table.getRowModel().rows?.length ? (
|
{table.getRowModel().rows?.length ? (
|
||||||
table.getRowModel().rows.map((row) => (
|
table.getRowModel().rows.map((row) => (
|
||||||
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={row.getIsSelected() && 'selected'}
|
||||||
|
className={rowClassName}
|
||||||
|
onClick={() => onRowClick?.(row.original)}
|
||||||
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
|
|||||||
Reference in New Issue
Block a user