mirror of
https://github.com/documenso/documenso.git
synced 2025-11-25 14:11:43 +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';
|
||||
|
||||
const ZCreateWebhookFormSchema = ZCreateWebhookRequestSchema.omit({ teamId: true });
|
||||
const ZCreateWebhookFormSchema = ZCreateWebhookRequestSchema;
|
||||
|
||||
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
|
||||
|
||||
@ -78,7 +78,6 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
|
||||
eventTriggers,
|
||||
secret,
|
||||
webhookUrl,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
|
||||
@ -67,7 +67,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
await deleteWebhook({ id: webhook.id, teamId: team.id });
|
||||
await deleteWebhook({ id: webhook.id });
|
||||
|
||||
toast({
|
||||
title: _(msg`Webhook deleted`),
|
||||
@ -146,26 +146,18 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
disabled={!form.formState.isValid}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>I'm sure! Delete it</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={!form.formState.isValid}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</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';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type WebhookTestDialogProps = {
|
||||
webhook: Pick<Webhook, 'id' | 'webhookUrl' | 'eventTriggers'>;
|
||||
children: React.ReactNode;
|
||||
@ -53,8 +51,6 @@ export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps)
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: testWebhook } = trpc.webhook.testWebhook.useMutation();
|
||||
@ -71,7 +67,6 @@ export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps)
|
||||
await testWebhook({
|
||||
id: webhook.id,
|
||||
event,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
toast({
|
||||
@ -150,11 +145,11 @@ export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps)
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Send Test Webhook</Trans>
|
||||
<Trans>Send</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</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>
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user