Compare commits

..

12 Commits

45 changed files with 621 additions and 2095 deletions

View File

@ -141,12 +141,6 @@ NEXT_PUBLIC_DISABLE_SIGNUP=
# OPTIONAL: Set to true to use internal webapp url in browserless requests. # OPTIONAL: Set to true to use internal webapp url in browserless requests.
NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false
# [[TELEMETRY]]
# OPTIONAL: Set to "true" to disable anonymous telemetry for self-hosted instances.
# Telemetry helps us understand how Documenso is being used and improve the product.
# We only collect: app version, installation ID, and node ID. No personal data is collected.
DOCUMENSO_DISABLE_TELEMETRY=
# [[E2E Tests]] # [[E2E Tests]]
E2E_TEST_AUTHENTICATE_USERNAME="Test User" E2E_TEST_AUTHENTICATE_USERNAME="Test User"
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com" E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"

View File

@ -36,8 +36,6 @@ jobs:
- name: Build the docker image - name: Build the docker image
env: env:
BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }} BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }}
NEXT_PRIVATE_TELEMETRY_KEY: ${{ secrets.NEXT_PRIVATE_TELEMETRY_KEY }}
NEXT_PRIVATE_TELEMETRY_HOST: ${{ secrets.NEXT_PRIVATE_TELEMETRY_HOST }}
run: | run: |
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)" GIT_SHA="$(git rev-parse HEAD)"
@ -45,8 +43,6 @@ jobs:
docker build \ docker build \
-f ./docker/Dockerfile \ -f ./docker/Dockerfile \
--progress=plain \ --progress=plain \
--build-arg NEXT_PRIVATE_TELEMETRY_KEY="${NEXT_PRIVATE_TELEMETRY_KEY:-}" \
--build-arg NEXT_PRIVATE_TELEMETRY_HOST="${NEXT_PRIVATE_TELEMETRY_HOST:-}" \
-t "documenso/documenso-$BUILD_PLATFORM:latest" \ -t "documenso/documenso-$BUILD_PLATFORM:latest" \
-t "documenso/documenso-$BUILD_PLATFORM:$GIT_SHA" \ -t "documenso/documenso-$BUILD_PLATFORM:$GIT_SHA" \
-t "documenso/documenso-$BUILD_PLATFORM:$APP_VERSION" \ -t "documenso/documenso-$BUILD_PLATFORM:$APP_VERSION" \

View File

@ -3,5 +3,4 @@ export default {
'signing-certificate': 'Signing Certificate', 'signing-certificate': 'Signing Certificate',
'how-to': 'How To', 'how-to': 'How To',
'setting-up-oauth-providers': 'Setting up OAuth Providers', 'setting-up-oauth-providers': 'Setting up OAuth Providers',
telemetry: 'Telemetry',
}; };

View File

@ -318,36 +318,6 @@ The environment variables listed above are a subset of those available for confi
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. | | `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. | | `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |
| `NEXT_PRIVATE_BROWSERLESS_URL` | The URL for a Browserless.io instance to generate PDFs (optional). | | `NEXT_PRIVATE_BROWSERLESS_URL` | The URL for a Browserless.io instance to generate PDFs (optional). |
| `DOCUMENSO_DISABLE_TELEMETRY` | Set to `true` to disable anonymous telemetry (see [Telemetry](#telemetry) section below). |
## Telemetry
Documenso collects anonymous telemetry data to help us understand how the software is being used and improve the product. This telemetry is **enabled by default** for self-hosted instances.
### What We Collect
We collect minimal, privacy-preserving data:
- **App Version**: The version of Documenso you are running
- **Installation ID**: A unique identifier for your installation (stored in your database)
- **Node ID**: A unique identifier for each server/container instance (stored in the OS temp directory)
We do **not** collect any personal data, document contents, user information, or usage patterns.
### Events
- **Server Startup**: Captured once when the server starts
- **Server Heartbeat**: Captured every hour while the server is running
### Disabling Telemetry
To disable telemetry, set the following environment variable:
```bash
DOCUMENSO_DISABLE_TELEMETRY=true
```
This will completely disable all telemetry data collection.
## Run as a Service ## Run as a Service

View File

@ -1,85 +0,0 @@
# Telemetry
Documenso collects anonymous telemetry data from self-hosted instances to help us understand how the software is being used and make improvements to the product. This telemetry is enabled by default, but you can easily disable it if you prefer.
## What We Collect
We collect minimal, privacy-preserving information that helps us understand the health and adoption of self-hosted installations:
- **App Version**: The version of Documenso you are running. This helps us understand which versions are in use and prioritize support for older versions.
- **Installation ID**: A unique identifier for your installation. This is stored in your database and helps us count distinct installations without knowing who you are.
- **Node ID**: A unique identifier for each server or container instance. This is stored in your operating system's temporary directory and helps us understand deployment patterns (for example, how many instances are running in a cluster).
### What We Don't Collect
We do **not** collect any of the following:
- Personal information about you or your users
- Document contents or file names
- User email addresses or names
- Usage patterns or feature usage statistics
- Server logs or error messages
- Any data that could identify your organization or users
## Why We Collect Telemetry
The telemetry data we collect serves several important purposes:
1. **Product Improvement**: Understanding which versions are in use helps us prioritize bug fixes and security updates for the versions that matter most.
2. **Support Planning**: Knowing how many installations exist and their deployment patterns helps us plan support resources and documentation.
3. **Feature Development**: Understanding deployment patterns (like cluster sizes) helps us make better architectural decisions for future features.
4. **Community Health**: Tracking adoption helps us understand the growth of the self-hosted community and allocate resources accordingly.
All of this is done anonymously and in aggregate. We cannot identify you, your organization, or your users from the telemetry data we collect.
## Events We Track
We track two simple events:
- **Server Startup**: Captured once when your server starts. This tells us when installations are first set up or restarted.
- **Server Heartbeat**: Captured every hour while your server is running. This helps us understand how many active installations exist and their uptime patterns.
## How to Disable Telemetry
If you prefer not to send telemetry data, you can disable it by setting an environment variable.
### Using Environment Variables
Add the following to your environment configuration:
```bash
DOCUMENSO_DISABLE_TELEMETRY=true
```
### Docker
If you're using Docker, you can set this in your `docker-compose.yml`:
```yaml
services:
app:
environment:
- DOCUMENSO_DISABLE_TELEMETRY=true
```
Or pass it when running a container:
```bash
docker run -e DOCUMENSO_DISABLE_TELEMETRY=true ...
```
### After Disabling
Once you set `DOCUMENSO_DISABLE_TELEMETRY=true` and restart your server, no telemetry data will be sent. The telemetry client will not initialize, and no network requests will be made to our telemetry servers.
Note: If you previously had telemetry enabled, the installation ID stored in your database will remain, but it will no longer be used or sent anywhere.
## Questions or Concerns
If you have questions about our telemetry practices or concerns about privacy, please reach out to us. We're committed to transparency and respect your choice to disable telemetry if you prefer.

View File

@ -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; const ZCreateWebhookFormSchema = ZCreateWebhookRequestSchema.omit({ teamId: true });
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>; type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
@ -78,6 +78,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
eventTriggers, eventTriggers,
secret, secret,
webhookUrl, webhookUrl,
teamId: team.id,
}); });
setOpen(false); setOpen(false);

View File

@ -67,7 +67,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
const onSubmit = async () => { const onSubmit = async () => {
try { try {
await deleteWebhook({ id: webhook.id }); await deleteWebhook({ id: webhook.id, teamId: team.id });
toast({ toast({
title: _(msg`Webhook deleted`), title: _(msg`Webhook deleted`),
@ -146,18 +146,26 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
/> />
<DialogFooter> <DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}> <div className="flex w-full flex-nowrap gap-4">
<Trans>Cancel</Trans> <Button
</Button> type="button"
variant="secondary"
className="flex-1"
onClick={() => setOpen(false)}
>
<Trans>Cancel</Trans>
</Button>
<Button <Button
type="submit" type="submit"
variant="destructive" variant="destructive"
disabled={!form.formState.isValid} className="flex-1"
loading={form.formState.isSubmitting} disabled={!form.formState.isValid}
> loading={form.formState.isSubmitting}
<Trans>Delete</Trans> >
</Button> <Trans>I'm sure! Delete it</Trans>
</Button>
</div>
</DialogFooter> </DialogFooter>
</fieldset> </fieldset>
</form> </form>

View File

@ -1,225 +0,0 @@
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>
);
};

View File

@ -36,6 +36,8 @@ 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;
@ -51,6 +53,8 @@ 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();
@ -67,6 +71,7 @@ export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps)
await testWebhook({ await testWebhook({
id: webhook.id, id: webhook.id,
event, event,
teamId: team.id,
}); });
toast({ toast({
@ -145,11 +150,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>Close</Trans> <Trans>Cancel</Trans>
</Button> </Button>
<Button type="submit" loading={form.formState.isSubmitting}> <Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Send</Trans> <Trans>Send Test Webhook</Trans>
</Button> </Button>
</DialogFooter> </DialogFooter>
</fieldset> </fieldset>

View File

@ -1,198 +0,0 @@
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 { TFindWebhookCallsResponse } from '@documenso/trpc/server/webhook-router/find-webhook-calls.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: TFindWebhookCallsResponse['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>
);
},
);

View File

@ -1,4 +1,4 @@
import WebhookPage, { meta } from '../../t.$teamUrl+/settings.webhooks.$id._index'; import WebhookPage, { meta } from '../../t.$teamUrl+/settings.webhooks.$id';
export { meta }; export { meta };

View File

@ -1,392 +0,0 @@
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}
/>
);
};

View File

@ -0,0 +1,263 @@
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>
);
}

View File

@ -1,18 +1,7 @@
import { useMemo } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { Plural, useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { Webhook } from '@prisma/client'; import { Loader } from 'lucide-react';
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';
@ -21,21 +10,9 @@ 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';
@ -45,72 +22,19 @@ export function meta() {
} }
export default function WebhookPage() { export default function WebhookPage() {
const { t, i18n } = useLingui(); const { _, i18n } = useLingui();
const team = useCurrentTeam(); const team = useCurrentTeam();
const { data, isLoading, isError } = trpc.webhook.getTeamWebhooks.useQuery(); const { data: webhooks, isLoading } = 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={t`Webhooks`} title={_(msg`Webhooks`)}
subtitle={t`On this page, you can create new Webhooks and manage the existing ones.`} subtitle={_(msg`On this page, you can create new Webhooks and manage the existing ones.`)}
> >
<WebhookCreateDialog /> <WebhookCreateDialog />
</SettingsHeader> </SettingsHeader>
@ -119,95 +43,74 @@ 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
<div className="mb-4">
<p className="text-muted-foreground mt-2 text-sm italic">
<Trans>
You have no webhooks yet. Your webhooks will be shown here once you create them.
</Trans>
</p>
</div>
)}
{webhooks && webhooks.length > 0 && (
<div className="mt-4 flex max-w-2xl flex-col gap-y-4">
{webhooks?.map((webhook) => (
<div
key={webhook.id}
className={cn(
'border-border rounded-lg border p-4',
!webhook.enabled && 'bg-muted/40',
)}
>
<div className="flex flex-col gap-x-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="truncate font-mono text-xs">{webhook.id}</div>
<DataTable <div className="mt-1.5 flex items-center gap-2">
columns={columns} <h5
data={results.data} className="max-w-[30rem] truncate text-sm sm:max-w-[18rem]"
perPage={results.perPage} title={webhook.webhookUrl}
currentPage={results.currentPage} >
totalPages={results.totalPages} {webhook.webhookUrl}
error={{ </h5>
enable: isError,
}} <Badge variant={webhook.enabled ? 'neutral' : 'warning'} size="small">
emptyState={ {webhook.enabled ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>}
<div className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4"> </Badge>
<p> </div>
<Trans>
You have no webhooks yet. Your webhooks will be shown here once you create them. <p className="text-muted-foreground mt-2 text-xs">
</Trans> <Trans>
</p> Listening to{' '}
</div> {webhook.eventTriggers
} .map((trigger) => toFriendlyWebhookEventName(trigger))
skeleton={{ .join(', ')}
enable: isLoading, </Trans>
rows: 3, </p>
component: (
<> <p className="text-muted-foreground mt-2 text-xs">
<TableCell> <Trans>Created on {i18n.date(webhook.createdAt, DateTime.DATETIME_FULL)}</Trans>
<Skeleton className="h-4 w-24 rounded-full" /> </p>
</TableCell> </div>
<TableCell>
<Skeleton className="h-4 w-8 rounded-full" /> <div className="mt-4 flex flex-shrink-0 gap-4 sm:mt-0">
</TableCell> <Button asChild variant="outline">
<TableCell> <Link to={`/t/${team.url}/settings/webhooks/${webhook.id}`}>
<Skeleton className="h-4 w-12 rounded-full" /> <Trans>Edit</Trans>
</TableCell> </Link>
<TableCell> </Button>
<Skeleton className="h-4 w-12 rounded-full" /> <WebhookDeleteDialog webhook={webhook}>
</TableCell> <Button variant="destructive">
<TableCell> <Trans>Delete</Trans>
<Skeleton className="h-4 w-6 rounded-full" /> </Button>
</TableCell> </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>
);
};

View File

@ -56,7 +56,7 @@
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
"posthog-js": "^1.297.2", "posthog-js": "^1.297.2",
"posthog-node": "4.18.0", "posthog-node": "^4.18.0",
"react": "^18", "react": "^18",
"react-call": "^1.8.1", "react-call": "^1.8.1",
"react-dom": "^18", "react-dom": "^18",
@ -86,7 +86,6 @@
"@react-router/remix-routes-option-adapter": "^7.9.6", "@react-router/remix-routes-option-adapter": "^7.9.6",
"@rollup/plugin-babel": "^6.1.0", "@rollup/plugin-babel": "^6.1.0",
"@rollup/plugin-commonjs": "^28.0.9", "@rollup/plugin-commonjs": "^28.0.9",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-typescript": "^12.3.0", "@rollup/plugin-typescript": "^12.3.0",
"@simplewebauthn/types": "^9.0.1", "@simplewebauthn/types": "^9.0.1",
@ -108,5 +107,5 @@
"vite-plugin-babel-macros": "^1.0.6", "vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
}, },
"version": "2.1.0" "version": "2.0.14"
} }

View File

@ -1,7 +1,6 @@
import linguiMacro from '@lingui/babel-plugin-lingui-macro'; import linguiMacro from '@lingui/babel-plugin-lingui-macro';
import babel from '@rollup/plugin-babel'; import babel from '@rollup/plugin-babel';
import commonjs from '@rollup/plugin-commonjs'; import commonjs from '@rollup/plugin-commonjs';
import json from '@rollup/plugin-json';
import resolve from '@rollup/plugin-node-resolve'; import resolve from '@rollup/plugin-node-resolve';
import typescript from '@rollup/plugin-typescript'; import typescript from '@rollup/plugin-typescript';
import path from 'node:path'; import path from 'node:path';
@ -40,7 +39,6 @@ const config = {
], ],
extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'], extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
}), }),
json(),
commonjs(), commonjs(),
babel({ babel({
babelHelpers: 'bundled', babelHelpers: 'bundled',

View File

@ -10,7 +10,6 @@ import { tsRestHonoApp } from '@documenso/api/hono';
import { auth } from '@documenso/auth/server'; import { auth } from '@documenso/auth/server';
import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app'; import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app';
import { jobsClient } from '@documenso/lib/jobs/client'; import { jobsClient } from '@documenso/lib/jobs/client';
import { TelemetryClient } from '@documenso/lib/server-only/telemetry/telemetry-client';
import { getIpAddress } from '@documenso/lib/universal/get-ip-address'; import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
import { logger } from '@documenso/lib/utils/logger'; import { logger } from '@documenso/lib/utils/logger';
import { openApiDocument } from '@documenso/trpc/server/open-api'; import { openApiDocument } from '@documenso/trpc/server/open-api';
@ -113,8 +112,4 @@ app.use(`${API_V2_BETA_URL}/*`, async (c) =>
}), }),
); );
// Start telemetry client for anonymous usage tracking.
// Can be disabled by setting DOCUMENSO_DISABLE_TELEMETRY=true
void TelemetryClient.start();
export default app; export default app;

View File

@ -50,13 +50,6 @@ ENV NEXT_PRIVATE_ENCRYPTION_KEY="$NEXT_PRIVATE_ENCRYPTION_KEY"
ARG NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF" ARG NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
ENV NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="$NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY" ENV NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="$NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY"
# Telemetry credentials (optional, baked into image at build time)
ARG NEXT_PRIVATE_TELEMETRY_KEY=""
ENV NEXT_PRIVATE_TELEMETRY_KEY="$NEXT_PRIVATE_TELEMETRY_KEY"
ARG NEXT_PRIVATE_TELEMETRY_HOST=""
ENV NEXT_PRIVATE_TELEMETRY_HOST="$NEXT_PRIVATE_TELEMETRY_HOST"
# Uncomment and use build args to enable remote caching # Uncomment and use build args to enable remote caching
# ARG TURBO_TEAM # ARG TURBO_TEAM
@ -90,13 +83,6 @@ FROM base AS runner
ENV HUSKY 0 ENV HUSKY 0
ENV DOCKER_OUTPUT 1 ENV DOCKER_OUTPUT 1
# Telemetry credentials (baked into image at build time, can be disabled at runtime)
ARG NEXT_PRIVATE_TELEMETRY_KEY=""
ENV NEXT_PRIVATE_TELEMETRY_KEY="$NEXT_PRIVATE_TELEMETRY_KEY"
ARG NEXT_PRIVATE_TELEMETRY_HOST=""
ENV NEXT_PRIVATE_TELEMETRY_HOST="$NEXT_PRIVATE_TELEMETRY_HOST"
# Don't run production as root # Don't run production as root
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nodejs RUN adduser --system --uid 1001 nodejs

View File

@ -19,8 +19,6 @@ echo "Git SHA: $GIT_SHA"
# Build with temporary base tag # Build with temporary base tag
docker build -f "$SCRIPT_DIR/Dockerfile" \ docker build -f "$SCRIPT_DIR/Dockerfile" \
--progress=plain \ --progress=plain \
--build-arg NEXT_PRIVATE_TELEMETRY_KEY="${NEXT_PRIVATE_TELEMETRY_KEY:-}" \
--build-arg NEXT_PRIVATE_TELEMETRY_HOST="${NEXT_PRIVATE_TELEMETRY_HOST:-}" \
-t "documenso-base" \ -t "documenso-base" \
"$MONOREPO_ROOT" "$MONOREPO_ROOT"

View File

@ -25,8 +25,6 @@ docker buildx build \
-f "$SCRIPT_DIR/Dockerfile" \ -f "$SCRIPT_DIR/Dockerfile" \
--platform=$PLATFORM \ --platform=$PLATFORM \
--progress=plain \ --progress=plain \
--build-arg NEXT_PRIVATE_TELEMETRY_KEY="${NEXT_PRIVATE_TELEMETRY_KEY:-}" \
--build-arg NEXT_PRIVATE_TELEMETRY_HOST="${NEXT_PRIVATE_TELEMETRY_HOST:-}" \
-t "documenso/documenso:latest" \ -t "documenso/documenso:latest" \
-t "documenso/documenso:$GIT_SHA" \ -t "documenso/documenso:$GIT_SHA" \
-t "documenso/documenso:$APP_VERSION" \ -t "documenso/documenso:$APP_VERSION" \

View File

@ -26,8 +26,6 @@ docker buildx build \
-f "$SCRIPT_DIR/Dockerfile" \ -f "$SCRIPT_DIR/Dockerfile" \
--platform=$PLATFORM \ --platform=$PLATFORM \
--progress=plain \ --progress=plain \
--build-arg NEXT_PRIVATE_TELEMETRY_KEY="${NEXT_PRIVATE_TELEMETRY_KEY:-}" \
--build-arg NEXT_PRIVATE_TELEMETRY_HOST="${NEXT_PRIVATE_TELEMETRY_HOST:-}" \
-t "documenso-base" \ -t "documenso-base" \
"$MONOREPO_ROOT" "$MONOREPO_ROOT"

34
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "@documenso/root", "name": "@documenso/root",
"version": "2.1.0", "version": "2.0.14",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@documenso/root", "name": "@documenso/root",
"version": "2.1.0", "version": "2.0.14",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*" "packages/*"
@ -18,7 +18,6 @@
"@lingui/core": "^5.6.0", "@lingui/core": "^5.6.0",
"inngest-cli": "^1.13.7", "inngest-cli": "^1.13.7",
"luxon": "^3.7.2", "luxon": "^3.7.2",
"posthog-node": "4.18.0",
"react": "^18", "react": "^18",
"typescript": "5.6.2", "typescript": "5.6.2",
"zod": "^3.25.76" "zod": "^3.25.76"
@ -105,7 +104,7 @@
}, },
"apps/remix": { "apps/remix": {
"name": "@documenso/remix", "name": "@documenso/remix",
"version": "2.1.0", "version": "2.0.14",
"dependencies": { "dependencies": {
"@cantoo/pdf-lib": "^2.5.3", "@cantoo/pdf-lib": "^2.5.3",
"@documenso/api": "*", "@documenso/api": "*",
@ -149,7 +148,7 @@
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
"posthog-js": "^1.297.2", "posthog-js": "^1.297.2",
"posthog-node": "4.18.0", "posthog-node": "^4.18.0",
"react": "^18", "react": "^18",
"react-call": "^1.8.1", "react-call": "^1.8.1",
"react-dom": "^18", "react-dom": "^18",
@ -179,7 +178,6 @@
"@react-router/remix-routes-option-adapter": "^7.9.6", "@react-router/remix-routes-option-adapter": "^7.9.6",
"@rollup/plugin-babel": "^6.1.0", "@rollup/plugin-babel": "^6.1.0",
"@rollup/plugin-commonjs": "^28.0.9", "@rollup/plugin-commonjs": "^28.0.9",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-typescript": "^12.3.0", "@rollup/plugin-typescript": "^12.3.0",
"@simplewebauthn/types": "^9.0.1", "@simplewebauthn/types": "^9.0.1",
@ -14697,27 +14695,6 @@
} }
} }
}, },
"node_modules/@rollup/plugin-json": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz",
"integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.1.0"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-node-resolve": { "node_modules/@rollup/plugin-node-resolve": {
"version": "16.0.3", "version": "16.0.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz",
@ -30833,7 +30810,6 @@
"version": "4.18.0", "version": "4.18.0",
"resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-4.18.0.tgz", "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-4.18.0.tgz",
"integrity": "sha512-XROs1h+DNatgKh/AlIlCtDxWzwrKdYDb2mOs58n4yN8BkGN9ewqeQwG5ApS4/IzwCb7HPttUkOVulkYatd2PIw==", "integrity": "sha512-XROs1h+DNatgKh/AlIlCtDxWzwrKdYDb2mOs58n4yN8BkGN9ewqeQwG5ApS4/IzwCb7HPttUkOVulkYatd2PIw==",
"hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.8.2" "axios": "^1.8.2"
@ -37105,7 +37081,7 @@
"pino-pretty": "^13.1.2", "pino-pretty": "^13.1.2",
"playwright": "1.56.1", "playwright": "1.56.1",
"posthog-js": "^1.297.2", "posthog-js": "^1.297.2",
"posthog-node": "4.18.0", "posthog-node": "^4.18.0",
"react": "^18", "react": "^18",
"react-pdf": "^10.2.0", "react-pdf": "^10.2.0",
"remeda": "^2.32.0", "remeda": "^2.32.0",

View File

@ -5,7 +5,7 @@
"apps/*", "apps/*",
"packages/*" "packages/*"
], ],
"version": "2.1.0", "version": "2.0.14",
"scripts": { "scripts": {
"build": "turbo run build", "build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix", "dev": "turbo run dev --filter=@documenso/remix",
@ -89,7 +89,6 @@
"@lingui/core": "^5.6.0", "@lingui/core": "^5.6.0",
"inngest-cli": "^1.13.7", "inngest-cli": "^1.13.7",
"luxon": "^3.7.2", "luxon": "^3.7.2",
"posthog-node": "4.18.0",
"react": "^18", "react": "^18",
"typescript": "5.6.2", "typescript": "5.6.2",
"zod": "^3.25.76" "zod": "^3.25.76"

View File

@ -1,377 +0,0 @@
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();
});

View File

@ -48,7 +48,7 @@
"pino-pretty": "^13.1.2", "pino-pretty": "^13.1.2",
"playwright": "1.56.1", "playwright": "1.56.1",
"posthog-js": "^1.297.2", "posthog-js": "^1.297.2",
"posthog-node": "4.18.0", "posthog-node": "^4.18.0",
"react": "^18", "react": "^18",
"react-pdf": "^10.2.0", "react-pdf": "^10.2.0",
"remeda": "^2.32.0", "remeda": "^2.32.0",

View File

@ -0,0 +1,84 @@
import { deletedAccountServiceAccount } from '@documenso/lib/server-only/user/service-accounts/deleted-account';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
type HandleDocumentOwnershipOnDeletionOptions = {
documentIds: number[];
organisationOwnerId: number;
};
export const handleDocumentOwnershipOnDeletion = async ({
documentIds,
organisationOwnerId,
}: HandleDocumentOwnershipOnDeletionOptions) => {
if (documentIds.length === 0) {
return;
}
const serviceAccount = await deletedAccountServiceAccount();
const serviceAccountTeam = serviceAccount.ownedOrganisations[0].teams[0];
await prisma.document.deleteMany({
where: {
id: {
in: documentIds,
},
status: DocumentStatus.DRAFT,
},
});
const organisationOwner = await prisma.user.findUnique({
where: {
id: organisationOwnerId,
},
select: {
id: true,
ownedOrganisations: {
select: {
id: true,
teams: {
select: {
id: true,
},
},
},
},
},
});
if (organisationOwner && organisationOwner.ownedOrganisations.length > 0) {
const ownerPersonalTeam = organisationOwner.ownedOrganisations[0].teams[0];
await prisma.document.updateMany({
where: {
id: {
in: documentIds,
},
status: {
in: [DocumentStatus.PENDING, DocumentStatus.REJECTED, DocumentStatus.COMPLETED],
},
},
data: {
userId: organisationOwner.id,
teamId: ownerPersonalTeam.id,
deletedAt: new Date(),
},
});
} else {
await prisma.document.updateMany({
where: {
id: {
in: documentIds,
},
status: {
in: [DocumentStatus.PENDING, DocumentStatus.REJECTED, DocumentStatus.COMPLETED],
},
},
data: {
userId: serviceAccount.id,
teamId: serviceAccountTeam.id,
deletedAt: new Date(),
},
});
}
};

View File

@ -0,0 +1,16 @@
import { PostHog } from 'posthog-node';
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
export default function PostHogServerClient() {
const postHogConfig = extractPostHogConfig();
if (!postHogConfig) {
return null;
}
return new PostHog(postHogConfig.key, {
host: postHogConfig.host,
fetch: async (...args) => fetch(...args),
});
}

View File

@ -1,22 +0,0 @@
import { prisma } from '@documenso/prisma';
import type { TSiteSettingSchema } from './schema';
import { ZSiteSettingSchema } from './schema';
export const getSiteSetting = async <
T extends TSiteSettingSchema['id'],
U = Extract<TSiteSettingSchema, { id: T }>,
>(options: {
id: T;
}): Promise<U> => {
const { id } = options;
const setting = await prisma.siteSettings.findFirstOrThrow({
where: {
id,
},
});
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return ZSiteSettingSchema.parse(setting) as U;
};

View File

@ -1,12 +1,9 @@
import { z } from 'zod'; import { z } from 'zod';
import { ZSiteSettingsBannerSchema } from './schemas/banner'; import { ZSiteSettingsBannerSchema } from './schemas/banner';
import { ZSiteSettingsTelemetrySchema } from './schemas/telemetry';
export const ZSiteSettingSchema = z.union([ // TODO: Use `z.union([...])` once we have more than one setting
ZSiteSettingsBannerSchema, export const ZSiteSettingSchema = ZSiteSettingsBannerSchema;
ZSiteSettingsTelemetrySchema,
]);
export type TSiteSettingSchema = z.infer<typeof ZSiteSettingSchema>; export type TSiteSettingSchema = z.infer<typeof ZSiteSettingSchema>;

View File

@ -1,14 +0,0 @@
import { z } from 'zod';
import { ZSiteSettingsBaseSchema } from './_base';
export const SITE_SETTINGS_TELEMETRY_ID = 'telemetry.installation';
export const ZSiteSettingsTelemetrySchema = ZSiteSettingsBaseSchema.extend({
id: z.literal(SITE_SETTINGS_TELEMETRY_ID),
data: z.object({
installationId: z.string(),
}),
});
export type TSiteSettingsTelemetrySchema = z.infer<typeof ZSiteSettingsTelemetrySchema>;

View File

@ -1,9 +1,9 @@
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { type TSiteSettingSchema } from './schema'; import type { TSiteSettingSchema } from './schema';
export type UpsertSiteSettingOptions = TSiteSettingSchema & { export type UpsertSiteSettingOptions = TSiteSettingSchema & {
userId?: number | null; userId: number;
}; };
export const upsertSiteSetting = async ({ export const upsertSiteSetting = async ({

View File

@ -1,190 +0,0 @@
/* eslint-disable require-atomic-updates */
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { PostHog } from 'posthog-node';
import { version } from '../../../../package.json';
import { prefixedId } from '../../universal/id';
import { getSiteSetting } from '../site-settings/get-site-setting';
import { SITE_SETTINGS_TELEMETRY_ID } from '../site-settings/schemas/telemetry';
import { upsertSiteSetting } from '../site-settings/upsert-site-setting';
const TELEMETRY_KEY = process.env.NEXT_PRIVATE_TELEMETRY_KEY;
const TELEMETRY_HOST = process.env.NEXT_PRIVATE_TELEMETRY_HOST;
const TELEMETRY_DISABLED = !!process.env.DOCUMENSO_DISABLE_TELEMETRY;
const NODE_ID_FILENAME = '.documenso-node-id';
const HEARTBEAT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
// Version is hardcoded to avoid rollup JSON import issues
const APP_VERSION = version;
export class TelemetryClient {
private static instance: TelemetryClient | null = null;
private client: PostHog | null = null;
private heartbeatInterval: NodeJS.Timeout | null = null;
private installationId: string | null = null;
private nodeId: string | null = null;
private constructor() {}
/**
* Start the telemetry client.
*
* This will initialize the PostHog client, load or create the installation ID and node ID,
* capture a startup event, and start a heartbeat interval.
*
* If telemetry is disabled via `DOCUMENSO_DISABLE_TELEMETRY=true` or credentials are not
* provided, this will be a no-op.
*/
public static async start(): Promise<void> {
if (TELEMETRY_DISABLED) {
console.log(
'[Telemetry] Telemetry is disabled. To enable, remove the DOCUMENSO_DISABLE_TELEMETRY environment variable.',
);
return;
}
if (!TELEMETRY_KEY || !TELEMETRY_HOST) {
console.log('[Telemetry] Telemetry credentials not configured. Telemetry will not be sent.');
return;
}
if (TelemetryClient.instance) {
return;
}
const instance = new TelemetryClient();
TelemetryClient.instance = instance;
await instance.initialize();
}
/**
* Stop the telemetry client.
*
* This will clear the heartbeat interval and shutdown the PostHog client.
*/
public static async stop(): Promise<void> {
const instance = TelemetryClient.instance;
if (!instance) {
return;
}
if (instance.heartbeatInterval) {
clearInterval(instance.heartbeatInterval);
}
if (instance.client) {
await instance.client.shutdown();
}
TelemetryClient.instance = null;
}
private async initialize(): Promise<void> {
this.client = new PostHog(TELEMETRY_KEY!, {
host: TELEMETRY_HOST,
disableGeoip: false,
});
// Load or create IDs
this.installationId = await this.getOrCreateInstallationId();
this.nodeId = await this.getOrCreateNodeId();
console.log(
'[Telemetry] Telemetry is enabled. Documenso collects anonymous usage data to help improve the product.',
);
console.log(
'[Telemetry] We collect: app version, installation ID, and node ID. No personal data, document contents, or user information is collected.',
);
console.log(
'[Telemetry] To disable telemetry, set DOCUMENSO_DISABLE_TELEMETRY=true in your environment variables.',
);
console.log(
'[Telemetry] Learn more: https://documenso.com/docs/developers/self-hosting/telemetry',
);
// Capture startup event
this.captureEvent('telemetry_selfhoster_startup');
// Start heartbeat
this.heartbeatInterval = setInterval(() => {
this.captureEvent('telemetry_selfhoster_heartbeat');
}, HEARTBEAT_INTERVAL_MS);
}
private captureEvent(event: string): void {
if (!this.client || !this.installationId) {
return;
}
this.client.capture({
distinctId: this.installationId,
event,
properties: {
appVersion: APP_VERSION,
installationId: this.installationId,
nodeId: this.nodeId,
},
});
}
private async getOrCreateInstallationId(): Promise<string> {
try {
// Try to get from site settings
const existing = await getSiteSetting({ id: SITE_SETTINGS_TELEMETRY_ID }).catch(() => null);
if (existing) {
if (existing.data.installationId) {
return existing.data.installationId;
}
}
// Create new installation ID
const installationId = prefixedId('installation');
await upsertSiteSetting({
id: SITE_SETTINGS_TELEMETRY_ID,
data: { installationId },
enabled: true,
});
return installationId;
} catch {
// If database is not available, generate a temporary ID
return prefixedId('installation');
}
}
private async getOrCreateNodeId(): Promise<string | null> {
const nodeIdPath = path.join(os.tmpdir(), NODE_ID_FILENAME);
try {
const existingId = await fs.readFile(nodeIdPath, 'utf-8');
if (existingId.trim()) {
return existingId.trim();
}
} catch {
// File doesn't exist or can't be read, continue to create
}
// Generate new node ID
const nodeId = prefixedId('node');
try {
await fs.writeFile(nodeIdPath, nodeId, 'utf-8');
} catch {
// Read-only filesystem, use memory for nodeId
}
return nodeId;
}
}

View File

@ -5,6 +5,20 @@ export const deletedAccountServiceAccount = async () => {
where: { where: {
email: 'deleted-account@documenso.com', email: 'deleted-account@documenso.com',
}, },
select: {
id: true,
email: true,
ownedOrganisations: {
select: {
id: true,
teams: {
select: {
id: true,
},
},
},
},
},
}); });
if (!serviceAccount) { if (!serviceAccount) {

View File

@ -8,7 +8,7 @@ msgstr ""
"Language: pl\n" "Language: pl\n"
"Project-Id-Version: documenso-app\n" "Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-11-21 00:14\n" "PO-Revision-Date: 2025-11-20 02:32\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Polish\n" "Language-Team: Polish\n"
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
@ -7584,7 +7584,7 @@ msgstr "Liczba podpisów"
#: packages/ui/components/document/envelope-recipient-field-tooltip.tsx #: packages/ui/components/document/envelope-recipient-field-tooltip.tsx
#: packages/ui/components/document/document-read-only-fields.tsx #: packages/ui/components/document/document-read-only-fields.tsx
msgid "Signed" msgid "Signed"
msgstr "Podpisano" msgstr "Podpisał"
#: apps/remix/app/components/dialogs/envelope-download-dialog.tsx #: apps/remix/app/components/dialogs/envelope-download-dialog.tsx
msgctxt "Signed document (adjective)" msgctxt "Signed document (adjective)"

View File

@ -10,16 +10,18 @@ export const updateSiteSettingRoute = adminProcedure
.input(ZUpdateSiteSettingRequestSchema) .input(ZUpdateSiteSettingRequestSchema)
.output(ZUpdateSiteSettingResponseSchema) .output(ZUpdateSiteSettingResponseSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { ...siteSetting } = input; const { id, enabled, data } = input;
ctx.logger.info({ ctx.logger.info({
input: { input: {
id: siteSetting.id, id,
}, },
}); });
await upsertSiteSetting({ await upsertSiteSetting({
...siteSetting, id,
enabled,
data,
userId: ctx.user.id, userId: ctx.user.id,
}); });
}); });

View File

@ -3,6 +3,7 @@ import {
ORGANISATION_USER_ACCOUNT_TYPE, ORGANISATION_USER_ACCOUNT_TYPE,
} from '@documenso/lib/constants/organisations'; } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { handleDocumentOwnershipOnDeletion } from '@documenso/lib/server-only/document/handle-document-ownership-on-deletion';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
@ -32,6 +33,24 @@ export const deleteOrganisationRoute = authenticatedProcedure
userId: user.id, userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_ORGANISATION'], roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_ORGANISATION'],
}), }),
select: {
id: true,
owner: {
select: {
id: true,
},
},
teams: {
select: {
id: true,
documents: {
select: {
id: true,
},
},
},
},
},
}); });
if (!organisation) { if (!organisation) {
@ -40,6 +59,15 @@ export const deleteOrganisationRoute = authenticatedProcedure
}); });
} }
const documentIds = organisation.teams.flatMap((team) => team.documents.map((doc) => doc.id));
if (documentIds && documentIds.length > 0) {
await handleDocumentOwnershipOnDeletion({
documentIds,
organisationOwnerId: organisation.owner.id,
});
}
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await tx.account.deleteMany({ await tx.account.deleteMany({
where: { where: {

View File

@ -1,4 +1,8 @@
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { handleDocumentOwnershipOnDeletion } from '@documenso/lib/server-only/document/handle-document-ownership-on-deletion';
import { deleteTeam } from '@documenso/lib/server-only/team/delete-team'; import { deleteTeam } from '@documenso/lib/server-only/team/delete-team';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc'; import { authenticatedProcedure } from '../trpc';
import { ZDeleteTeamRequestSchema, ZDeleteTeamResponseSchema } from './delete-team.types'; import { ZDeleteTeamRequestSchema, ZDeleteTeamResponseSchema } from './delete-team.types';
@ -11,12 +15,53 @@ export const deleteTeamRoute = authenticatedProcedure
const { teamId } = input; const { teamId } = input;
const { user } = ctx; const { user } = ctx;
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
});
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId: team?.organisationId,
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_ORGANISATION'],
}),
select: {
id: true,
owner: {
select: {
id: true,
},
},
teams: {
select: {
id: true,
documents: {
select: {
id: true,
},
},
},
},
},
});
ctx.logger.info({ ctx.logger.info({
input: { input: {
teamId, teamId,
}, },
}); });
const documentIds = organisation?.teams.flatMap((team) => team.documents.map((doc) => doc.id));
if (documentIds && documentIds.length > 0 && organisation) {
await handleDocumentOwnershipOnDeletion({
documentIds,
organisationOwnerId: organisation.owner.id,
});
}
await deleteTeam({ await deleteTeam({
userId: user.id, userId: user.id,
teamId, teamId,

View File

@ -1,106 +0,0 @@
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>;
};

View File

@ -1,37 +0,0 @@
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>;

View File

@ -1,80 +0,0 @@
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()),
},
});
});

View File

@ -1,26 +0,0 @@
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>;

View File

@ -6,61 +6,66 @@ 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({
calls: { getTeamWebhooks: authenticatedProcedure
find: findWebhookCallsRoute, .input(ZGetTeamWebhooksRequestSchema)
resend: resendWebhookCallRoute, .query(async ({ ctx, input }) => {
}, const { teamId } = input;
getTeamWebhooks: authenticatedProcedure.query(async ({ ctx }) => { ctx.logger.info({
ctx.logger.info({ input: {
input: { teamId,
teamId: ctx.teamId, },
}, });
});
return await getWebhooksByTeamId(ctx.teamId, ctx.user.id); return await getWebhooksByTeamId(teamId, ctx.user.id);
}), }),
getWebhookById: authenticatedProcedure getWebhookById: authenticatedProcedure
.input(ZGetWebhookByIdRequestSchema) .input(ZGetWebhookByIdRequestSchema)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const { id } = input; const { id, teamId } = 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: ctx.teamId, teamId,
}); });
}), }),
createWebhook: authenticatedProcedure createWebhook: authenticatedProcedure
.input(ZCreateWebhookRequestSchema) .input(ZCreateWebhookRequestSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { enabled, eventTriggers, secret, webhookUrl } = input; const { enabled, eventTriggers, secret, webhookUrl, teamId } = input;
ctx.logger.info({
input: {
teamId,
},
});
return await createWebhook({ return await createWebhook({
enabled, enabled,
secret, secret,
webhookUrl, webhookUrl,
eventTriggers, eventTriggers,
teamId: ctx.teamId, teamId,
userId: ctx.user.id, userId: ctx.user.id,
}); });
}), }),
@ -68,17 +73,18 @@ export const webhookRouter = router({
deleteWebhook: authenticatedProcedure deleteWebhook: authenticatedProcedure
.input(ZDeleteWebhookRequestSchema) .input(ZDeleteWebhookRequestSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { id } = input; const { id, teamId } = input;
ctx.logger.info({ ctx.logger.info({
input: { input: {
id, id,
teamId,
}, },
}); });
return await deleteWebhookById({ return await deleteWebhookById({
id, id,
teamId: ctx.teamId, teamId,
userId: ctx.user.id, userId: ctx.user.id,
}); });
}), }),
@ -86,11 +92,12 @@ export const webhookRouter = router({
editWebhook: authenticatedProcedure editWebhook: authenticatedProcedure
.input(ZEditWebhookRequestSchema) .input(ZEditWebhookRequestSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { id, ...data } = input; const { id, teamId, ...data } = input;
ctx.logger.info({ ctx.logger.info({
input: { input: {
id, id,
teamId,
}, },
}); });
@ -98,19 +105,20 @@ export const webhookRouter = router({
id, id,
data, data,
userId: ctx.user.id, userId: ctx.user.id,
teamId: ctx.teamId, teamId,
}); });
}), }),
testWebhook: authenticatedProcedure testWebhook: authenticatedProcedure
.input(ZTriggerTestWebhookRequestSchema) .input(ZTriggerTestWebhookRequestSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { id, event } = input; const { id, event, teamId } = input;
ctx.logger.info({ ctx.logger.info({
input: { input: {
id, id,
event, event,
teamId,
}, },
}); });
@ -118,7 +126,7 @@ export const webhookRouter = router({
id, id,
event, event,
userId: ctx.user.id, userId: ctx.user.id,
teamId: ctx.teamId, teamId,
}); });
}), }),
}); });

View File

@ -1,6 +1,12 @@
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
@ -8,12 +14,14 @@ 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>;
@ -26,6 +34,7 @@ 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>;
@ -33,6 +42,7 @@ 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>;

View File

@ -21,8 +21,6 @@ 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;
@ -54,8 +52,6 @@ export function DataTable<TData, TValue>({
hasFilters, hasFilters,
onClearFilters, onClearFilters,
onPaginationChange, onPaginationChange,
onRowClick,
rowClassName,
children, children,
emptyState, emptyState,
}: DataTableProps<TData, TValue>) { }: DataTableProps<TData, TValue>) {
@ -120,12 +116,7 @@ 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 <TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
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}

View File

@ -109,9 +109,6 @@
"NEXT_PRIVATE_INNGEST_APP_ID", "NEXT_PRIVATE_INNGEST_APP_ID",
"INNGEST_EVENT_KEY", "INNGEST_EVENT_KEY",
"NEXT_PRIVATE_INNGEST_EVENT_KEY", "NEXT_PRIVATE_INNGEST_EVENT_KEY",
"NEXT_PRIVATE_TELEMETRY_KEY",
"NEXT_PRIVATE_TELEMETRY_HOST",
"DOCUMENSO_DISABLE_TELEMETRY",
"CI", "CI",
"NODE_ENV", "NODE_ENV",
"POSTGRES_URL", "POSTGRES_URL",