mirror of
https://github.com/documenso/documenso.git
synced 2025-11-25 22:21:31 +10:00
Compare commits
12 Commits
chore/tran
...
feat/unlin
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c3a87e7e8 | |||
| c8c2a3b958 | |||
| bf6f09194d | |||
| 0bbd9aa9a1 | |||
| 5e8c3d5d92 | |||
| c97c2551db | |||
| 1863d990c8 | |||
| 38483bb88c | |||
| 9cbbdfb127 | |||
| fb6e2753df | |||
| a89c781b31 | |||
| 8b131e42c7 |
@ -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"
|
||||||
|
|||||||
4
.github/workflows/publish.yml
vendored
4
.github/workflows/publish.yml
vendored
@ -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" \
|
||||||
|
|||||||
@ -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',
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@ -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 };
|
||||||
|
|
||||||
@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
@ -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',
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|
||||||
|
|||||||
@ -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" \
|
||||||
|
|||||||
@ -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
34
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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();
|
|
||||||
});
|
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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;
|
|
||||||
};
|
|
||||||
@ -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>;
|
||||||
|
|
||||||
|
|||||||
@ -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>;
|
|
||||||
@ -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 ({
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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)"
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>;
|
|
||||||
};
|
|
||||||
@ -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>;
|
|
||||||
@ -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()),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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>;
|
|
||||||
@ -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,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user