mirror of
https://github.com/documenso/documenso.git
synced 2025-11-26 22:44:41 +10:00
Merge branch 'main' into feat/audit-logs-api
This commit is contained in:
@@ -17,14 +17,13 @@
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@ts-rest/core": "^3.52.0",
|
||||
"@ts-rest/open-api": "^3.52.0",
|
||||
"@ts-rest/serverless": "^3.52.0",
|
||||
"@ts-rest/core": "^3.52.1",
|
||||
"@ts-rest/open-api": "^3.52.1",
|
||||
"@ts-rest/serverless": "^3.52.1",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"luxon": "^3.4.0",
|
||||
"luxon": "^3.7.2",
|
||||
"superjson": "^2.2.5",
|
||||
"swagger-ui-react": "^5.21.0",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
@@ -232,19 +232,19 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
|
||||
await page.getByLabel('Email').nth(1).fill('user2@example.com');
|
||||
await page.getByLabel('Name').nth(1).fill('User 2');
|
||||
await page.locator('button[role="combobox"]').nth(1).click();
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByLabel('Receives copy').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByLabel('Email').nth(2).fill('user3@example.com');
|
||||
await page.getByLabel('Name').nth(2).fill('User 3');
|
||||
await page.locator('button[role="combobox"]').nth(2).click();
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
await page.getByLabel('Needs to approve').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByLabel('Email').nth(3).fill('user4@example.com');
|
||||
await page.getByLabel('Name').nth(3).fill('User 4');
|
||||
await page.locator('button[role="combobox"]').nth(3).click();
|
||||
await page.getByRole('combobox').nth(3).click();
|
||||
await page.getByLabel('Needs to view').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@@ -252,8 +252,8 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
// Add fields
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.locator('button[role="combobox"]').nth(0).click();
|
||||
await page.getByTitle('User 1 (user1@example.com)').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'User 1 (user1@example.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
@@ -271,8 +271,8 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
},
|
||||
});
|
||||
|
||||
await page.locator('button[role="combobox"]').nth(0).click();
|
||||
await page.getByTitle('User 3 (user3@example.com)').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'User 3 (user3@example.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
@@ -574,6 +574,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
|
||||
if (i > 1) {
|
||||
await page.getByText(`User ${i} (user${i}@example.com)`).click();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
|
||||
@@ -85,16 +85,18 @@ test('[DOCUMENTS]: deleting a completed document should not remove it from recip
|
||||
// Open document action menu.
|
||||
await page
|
||||
.locator('tr', { hasText: 'Document 1 - Completed' })
|
||||
.getByRole('cell', { name: 'Download' })
|
||||
.getByRole('button')
|
||||
.nth(1)
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// delete document
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
@@ -126,13 +128,20 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients'
|
||||
});
|
||||
|
||||
// Open document action menu.
|
||||
await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click();
|
||||
await page
|
||||
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// delete document
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
|
||||
|
||||
// signout
|
||||
@@ -165,11 +174,15 @@ test('[DOCUMENTS]: deleting draft documents should permanently remove it', async
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// delete document
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await expect(page.getByPlaceholder("Type 'delete' to confirm")).not.toBeVisible();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
|
||||
|
||||
// Check document counts.
|
||||
@@ -195,11 +208,15 @@ test('[DOCUMENTS]: deleting pending documents should permanently remove it', asy
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Delete document.
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
|
||||
|
||||
// Check document counts.
|
||||
@@ -227,11 +244,15 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Delete document.
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
// Check document counts.
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
@@ -303,7 +324,8 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
|
||||
await page.getByRole('menuitem', { name: 'Hide' }).waitFor({ state: 'visible' });
|
||||
await page.getByRole('menuitem', { name: 'Hide' }).click({ force: true });
|
||||
await page.getByRole('button', { name: 'Hide' }).click({ force: true });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
// Check document counts.
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
|
||||
|
||||
@@ -1,46 +1,30 @@
|
||||
// sort-imports-ignore
|
||||
|
||||
// ---- PATCH pdfjs-dist's canvas require BEFORE importing it ----
|
||||
import Module from 'module';
|
||||
import { Canvas, Image } from 'skia-canvas';
|
||||
|
||||
// Intercept require('canvas') and return skia-canvas equivalents
|
||||
const originalRequire = Module.prototype.require;
|
||||
Module.prototype.require = function (path: string) {
|
||||
if (path === 'canvas') {
|
||||
return {
|
||||
createCanvas: (width: number, height: number) => new Canvas(width, height),
|
||||
Image, // needed by pdfjs-dist
|
||||
};
|
||||
}
|
||||
// eslint-disable-next-line prefer-rest-params, @typescript-eslint/consistent-type-assertions
|
||||
return originalRequire.apply(this, arguments as unknown as [string]);
|
||||
};
|
||||
|
||||
import pixelMatch from 'pixelmatch';
|
||||
import { PNG } from 'pngjs';
|
||||
import { createCanvas } from '@napi-rs/canvas';
|
||||
import type { TestInfo } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.js';
|
||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||
import pixelMatch from 'pixelmatch';
|
||||
import { PNG } from 'pngjs';
|
||||
|
||||
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../lib/constants/app';
|
||||
import { isBase64Image } from '../../../lib/constants/signatures';
|
||||
import { createApiToken } from '../../../lib/server-only/public-api/create-api-token';
|
||||
import { RecipientRole } from '../../../prisma/generated/types';
|
||||
import type {
|
||||
TCreateEnvelopePayload,
|
||||
TCreateEnvelopeResponse,
|
||||
} from '../../../trpc/server/envelope-router/create-envelope.types';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../lib/constants/app';
|
||||
import { createApiToken } from '../../../lib/server-only/public-api/create-api-token';
|
||||
import { RecipientRole } from '../../../prisma/generated/types';
|
||||
import { FIELD_META_TEST_FIELDS } from '../../constants/field-meta-pdf';
|
||||
import { ALIGNMENT_TEST_FIELDS } from '../../constants/field-alignment-pdf';
|
||||
import type { TDistributeEnvelopeRequest } from '../../../trpc/server/envelope-router/distribute-envelope.types';
|
||||
import { isBase64Image } from '../../../lib/constants/signatures';
|
||||
import { ALIGNMENT_TEST_FIELDS } from '../../constants/field-alignment-pdf';
|
||||
import { FIELD_META_TEST_FIELDS } from '../../constants/field-meta-pdf';
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2`;
|
||||
@@ -406,15 +390,20 @@ async function renderPdfToImage(pdfBytes: Uint8Array) {
|
||||
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const virtualCanvas = new Canvas(viewport.width, viewport.height);
|
||||
const context = virtualCanvas.getContext('2d');
|
||||
context.imageSmoothingEnabled = false;
|
||||
const canvas = createCanvas(viewport.width, viewport.height);
|
||||
const canvasContext = canvas.getContext('2d');
|
||||
canvasContext.imageSmoothingEnabled = false;
|
||||
|
||||
// @ts-expect-error skia-canvas context satisfies runtime requirements for pdfjs
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
await page.render({
|
||||
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
|
||||
canvas,
|
||||
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
|
||||
canvasContext,
|
||||
viewport,
|
||||
}).promise;
|
||||
|
||||
return {
|
||||
image: await virtualCanvas.toBuffer('png'),
|
||||
image: await canvas.encode('png'),
|
||||
|
||||
// Rounded down because the certificate page somehow gives dimensions with decimals
|
||||
width: Math.floor(viewport.width),
|
||||
|
||||
@@ -57,6 +57,9 @@ test.describe('Signing Certificate Tests', () => {
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.getByRole('button', { name: 'Sign' }).click({ force: true });
|
||||
await page.waitForURL(`/sign/${recipient.token}/complete`);
|
||||
|
||||
|
||||
@@ -87,9 +87,7 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
|
||||
// Wait for the update to complete
|
||||
const toast = page.locator('li[role="status"][data-state="open"]').first();
|
||||
await expect(toast).toBeVisible();
|
||||
await expect(toast.getByText('Document preferences updated', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Document preferences updated', { exact: true })).toBeVisible();
|
||||
|
||||
const document = await seedTeamDocumentWithMeta(team);
|
||||
|
||||
@@ -154,9 +152,7 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
|
||||
// Wait for finish
|
||||
const toast = page.locator('li[role="status"][data-state="open"]').first();
|
||||
await expect(toast).toBeVisible();
|
||||
await expect(toast.getByText('Document preferences updated', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Document preferences updated', { exact: true })).toBeVisible();
|
||||
|
||||
const template = await seedTeamTemplateWithMeta(team);
|
||||
|
||||
|
||||
@@ -97,9 +97,10 @@ test.describe('AutoSave Fields Step', () => {
|
||||
const fields = retrievedFields.fields;
|
||||
|
||||
expect(fields.length).toBe(3);
|
||||
expect(fields[0].type).toBe('SIGNATURE');
|
||||
expect(fields[1].type).toBe('TEXT');
|
||||
expect(fields[2].type).toBe('SIGNATURE');
|
||||
|
||||
expect(fields.map((field) => field.type).toSorted()).toEqual(
|
||||
['SIGNATURE', 'TEXT', 'SIGNATURE'].toSorted(),
|
||||
);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
@@ -237,10 +238,9 @@ test.describe('AutoSave Fields Step', () => {
|
||||
const fields = retrievedFields.fields;
|
||||
|
||||
expect(fields.length).toBe(4);
|
||||
expect(fields[0].type).toBe('SIGNATURE');
|
||||
expect(fields[1].type).toBe('TEXT');
|
||||
expect(fields[2].type).toBe('SIGNATURE');
|
||||
expect(fields[3].type).toBe('SIGNATURE');
|
||||
expect(fields.map((field) => field.type).toSorted()).toEqual(
|
||||
['SIGNATURE', 'TEXT', 'SIGNATURE', 'SIGNATURE'].toSorted(),
|
||||
);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
@@ -292,8 +292,9 @@ test.describe('AutoSave Fields Step', () => {
|
||||
const fields = retrievedTemplate.fields;
|
||||
|
||||
expect(fields.length).toBe(2);
|
||||
expect(fields[0].type).toBe('SIGNATURE');
|
||||
expect(fields[1].type).toBe('TEXT');
|
||||
expect(fields.map((field) => field.type).toSorted()).toEqual(
|
||||
['SIGNATURE', 'TEXT'].toSorted(),
|
||||
);
|
||||
|
||||
const textField = fields[1];
|
||||
expect(textField.fieldMeta).toBeDefined();
|
||||
|
||||
377
packages/app-tests/e2e/webhooks/webhooks-crud.spec.ts
Normal file
377
packages/app-tests/e2e/webhooks/webhooks-crud.spec.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { expectTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
/**
|
||||
* Helper function to seed a webhook directly in the database for testing.
|
||||
*/
|
||||
const seedWebhook = async ({
|
||||
webhookUrl,
|
||||
eventTriggers,
|
||||
secret,
|
||||
enabled,
|
||||
userId,
|
||||
teamId,
|
||||
}: {
|
||||
webhookUrl: string;
|
||||
eventTriggers: WebhookTriggerEvents[];
|
||||
secret?: string | null;
|
||||
enabled?: boolean;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
}) => {
|
||||
return await prisma.webhook.create({
|
||||
data: {
|
||||
webhookUrl,
|
||||
eventTriggers,
|
||||
secret: secret ?? null,
|
||||
enabled: enabled ?? true,
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
test('[WEBHOOKS]: create webhook', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/settings/webhooks`,
|
||||
});
|
||||
|
||||
const webhookUrl = `https://example.com/webhook-${Date.now()}`;
|
||||
|
||||
// Click Create Webhook button
|
||||
await page.getByRole('button', { name: 'Create Webhook' }).click();
|
||||
|
||||
// Fill in the form
|
||||
await page.getByLabel('Webhook URL*').fill(webhookUrl);
|
||||
|
||||
// Select event trigger - click on the triggers field and select DOCUMENT_CREATED
|
||||
await page.getByLabel('Triggers').click();
|
||||
await page.waitForTimeout(200); // Wait for dropdown to open
|
||||
await page.getByText('document.created').click();
|
||||
|
||||
// Click outside the triggers field to close the dropdown
|
||||
await page.getByText('The URL for Documenso to send webhook events to.').click();
|
||||
|
||||
// Fill in the form
|
||||
await page.getByLabel('Secret').fill('secret');
|
||||
|
||||
// Submit the form
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// Wait for success toast
|
||||
await expectTextToBeVisible(page, 'Webhook created');
|
||||
await expectTextToBeVisible(page, 'The webhook was successfully created.');
|
||||
|
||||
// Verify webhook appears in the list
|
||||
await expect(page.getByText(webhookUrl)).toBeVisible();
|
||||
|
||||
// Directly check database
|
||||
const dbWebhook = await prisma.webhook.findFirstOrThrow({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbWebhook?.eventTriggers).toEqual([WebhookTriggerEvents.DOCUMENT_CREATED]);
|
||||
expect(dbWebhook?.secret).toBe('secret');
|
||||
expect(dbWebhook?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test('[WEBHOOKS]: view webhooks', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const webhookUrl = `https://example.com/webhook-${Date.now()}`;
|
||||
|
||||
// Create a webhook via seeding
|
||||
const webhook = await seedWebhook({
|
||||
webhookUrl,
|
||||
eventTriggers: [WebhookTriggerEvents.DOCUMENT_CREATED, WebhookTriggerEvents.DOCUMENT_SENT],
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/settings/webhooks`,
|
||||
});
|
||||
|
||||
// Verify webhook is visible in the table
|
||||
await expect(page.getByText(webhookUrl)).toBeVisible();
|
||||
await expect(page.getByText('Enabled')).toBeVisible();
|
||||
await expect(page.getByText('2 Events')).toBeVisible();
|
||||
|
||||
// Click on webhook to navigate to detail page
|
||||
await page.getByText(webhookUrl).click();
|
||||
|
||||
// Verify detail page shows webhook information
|
||||
await page.waitForURL(`/t/${team.url}/settings/webhooks/${webhook.id}`);
|
||||
await expect(page.getByText(webhookUrl)).toBeVisible();
|
||||
await expect(page.getByText('Enabled')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[WEBHOOKS]: delete webhook', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const webhookUrl = `https://example.com/webhook-${Date.now()}`;
|
||||
|
||||
// Create a webhook via seeding
|
||||
const webhook = await seedWebhook({
|
||||
webhookUrl,
|
||||
eventTriggers: [WebhookTriggerEvents.DOCUMENT_CREATED],
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/settings/webhooks`,
|
||||
});
|
||||
|
||||
// Verify webhook is visible
|
||||
await expect(page.getByText(webhookUrl)).toBeVisible();
|
||||
|
||||
// Find the row with the webhook and click the action dropdown
|
||||
const webhookRow = page.locator('tr', { hasText: webhookUrl });
|
||||
await webhookRow.getByTestId('webhook-table-action-btn').click();
|
||||
|
||||
// Click Delete menu item
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
|
||||
// Fill in confirmation field
|
||||
const deleteMessage = `delete ${webhookUrl}`;
|
||||
// The label contains "Confirm by typing:" followed by the delete message
|
||||
await page.getByLabel(/Confirm by typing/).fill(deleteMessage);
|
||||
|
||||
// Click delete button
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
// Wait for success toast
|
||||
await expectTextToBeVisible(page, 'Webhook deleted');
|
||||
await expectTextToBeVisible(page, 'The webhook has been successfully deleted.');
|
||||
|
||||
// Verify webhook is removed from the list
|
||||
await expect(page.getByText(webhookUrl)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[WEBHOOKS]: update webhook', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const originalWebhookUrl = `https://example.com/webhook-original-${Date.now()}`;
|
||||
const updatedWebhookUrl = `https://example.com/webhook-updated-${Date.now()}`;
|
||||
|
||||
// Create a webhook via seeding with initial values
|
||||
const webhook = await seedWebhook({
|
||||
webhookUrl: originalWebhookUrl,
|
||||
eventTriggers: [WebhookTriggerEvents.DOCUMENT_CREATED, WebhookTriggerEvents.DOCUMENT_SENT],
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/settings/webhooks`,
|
||||
});
|
||||
|
||||
// Verify webhook is visible with original values
|
||||
await expect(page.getByText(originalWebhookUrl)).toBeVisible();
|
||||
await expect(page.getByText('Enabled')).toBeVisible();
|
||||
await expect(page.getByText('2 Events')).toBeVisible();
|
||||
|
||||
// Find the row with the webhook and click the action dropdown
|
||||
const webhookRow = page.locator('tr', { hasText: originalWebhookUrl });
|
||||
await webhookRow.getByTestId('webhook-table-action-btn').click();
|
||||
|
||||
// Click Edit menu item
|
||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||
|
||||
// Wait for dialog to open
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Change the webhook URL
|
||||
await page.getByLabel('Webhook URL').clear();
|
||||
await page.getByLabel('Webhook URL').fill(updatedWebhookUrl);
|
||||
|
||||
// Disable the webhook (toggle the switch)
|
||||
const enabledSwitch = page.getByLabel('Enabled');
|
||||
const isChecked = await enabledSwitch.isChecked();
|
||||
if (isChecked) {
|
||||
await enabledSwitch.click();
|
||||
}
|
||||
|
||||
// Change the event triggers - remove one existing event and add a new one
|
||||
// The selected items are shown as badges with remove buttons
|
||||
// Remove one of the existing events (DOCUMENT_SENT) by clicking its remove button
|
||||
const removeButtons = page.locator('button[aria-label="Remove"]');
|
||||
const removeButtonCount = await removeButtons.count();
|
||||
|
||||
// Remove the "DOCUMENT_SENT" event (this will remove one of the two)
|
||||
if (removeButtonCount > 0) {
|
||||
await removeButtons.nth(1).click();
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
// Add new event triggers
|
||||
await page.getByLabel('Triggers').click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Select DOCUMENT_COMPLETED (this will be added to the remaining DOCUMENT_CREATED)
|
||||
await page.getByText('document.completed').click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Click outside to close the dropdown
|
||||
await page.getByText('The URL for Documenso to send webhook events to.').click();
|
||||
|
||||
// Submit the form
|
||||
await page.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
// Wait for success toast
|
||||
await expectTextToBeVisible(page, 'Webhook updated');
|
||||
await expectTextToBeVisible(page, 'The webhook has been updated successfully.');
|
||||
|
||||
// Verify changes are reflected in the list
|
||||
// The old URL should be gone and new URL should be visible
|
||||
await expect(page.getByText(originalWebhookUrl)).not.toBeVisible();
|
||||
await expect(page.getByText(updatedWebhookUrl)).toBeVisible();
|
||||
// Verify webhook is disabled
|
||||
await expect(page.getByText('Disabled')).toBeVisible();
|
||||
// Verify event count is still 2 (one removed, one added - DOCUMENT_CREATED and DOCUMENT_COMPLETED)
|
||||
await expect(page.getByText('2 Events')).toBeVisible();
|
||||
|
||||
// Check the database directly to verify
|
||||
const dbWebhook = await prisma.webhook.findUnique({
|
||||
where: {
|
||||
id: webhook.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbWebhook?.eventTriggers).toEqual([
|
||||
WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||
WebhookTriggerEvents.DOCUMENT_COMPLETED,
|
||||
]);
|
||||
expect(dbWebhook?.enabled).toBe(false);
|
||||
expect(dbWebhook?.webhookUrl).toBe(updatedWebhookUrl);
|
||||
expect(dbWebhook?.secret).toBe('');
|
||||
});
|
||||
|
||||
test('[WEBHOOKS]: cannot see unrelated webhooks', async ({ page }) => {
|
||||
// Create two separate users with teams
|
||||
const user1Data = await seedUser();
|
||||
const user2Data = await seedUser();
|
||||
|
||||
const webhookUrl1 = `https://example.com/webhook-team1-${Date.now()}`;
|
||||
const webhookUrl2 = `https://example.com/webhook-team2-${Date.now()}`;
|
||||
|
||||
// Create webhooks for both teams with DOCUMENT_CREATED event
|
||||
const webhook1 = await seedWebhook({
|
||||
webhookUrl: webhookUrl1,
|
||||
eventTriggers: [WebhookTriggerEvents.DOCUMENT_CREATED],
|
||||
userId: user1Data.user.id,
|
||||
teamId: user1Data.team.id,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const webhook2 = await seedWebhook({
|
||||
webhookUrl: webhookUrl2,
|
||||
eventTriggers: [WebhookTriggerEvents.DOCUMENT_SENT],
|
||||
userId: user2Data.user.id,
|
||||
teamId: user2Data.team.id,
|
||||
});
|
||||
|
||||
// Create a document on team1 to trigger the webhook
|
||||
const document = await seedBlankDocument(user1Data.user, user1Data.team.id, {
|
||||
createDocumentOptions: {
|
||||
title: 'Test Document for Webhook',
|
||||
},
|
||||
});
|
||||
|
||||
// Create a webhook call for team1's webhook (simulating the webhook being triggered)
|
||||
// Since webhooks are triggered via jobs which may not run in tests, we create the call directly
|
||||
const webhookCall1 = await prisma.webhookCall.create({
|
||||
data: {
|
||||
webhookId: webhook1.id,
|
||||
url: webhookUrl1,
|
||||
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||
status: WebhookCallStatus.SUCCESS,
|
||||
responseCode: 200,
|
||||
requestBody: {
|
||||
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||
payload: {
|
||||
id: document.id,
|
||||
title: document.title,
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
webhookEndpoint: webhookUrl1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Sign in as user1
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user1Data.user.email,
|
||||
redirectPath: `/t/${user1Data.team.url}/settings/webhooks`,
|
||||
});
|
||||
|
||||
// Verify user1 can see their webhook
|
||||
await expect(page.getByText(webhookUrl1)).toBeVisible();
|
||||
// Verify user1 cannot see user2's webhook
|
||||
await expect(page.getByText(webhookUrl2)).not.toBeVisible();
|
||||
|
||||
// Navigate to team1's webhook logs page
|
||||
await page.goto(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${user1Data.team.url}/settings/webhooks/${webhook1.id}`,
|
||||
);
|
||||
|
||||
// Verify user1 can see their webhook logs
|
||||
// The webhook call should be visible in the table
|
||||
await expect(page.getByText(webhookCall1.id)).toBeVisible();
|
||||
await expect(page.getByText('200')).toBeVisible(); // Response code
|
||||
|
||||
// Sign out and sign in as user2
|
||||
await apiSignout({ page });
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user2Data.user.email,
|
||||
redirectPath: `/t/${user2Data.team.url}/settings/webhooks`,
|
||||
});
|
||||
|
||||
// Verify user2 can see their webhook
|
||||
await expect(page.getByText(webhookUrl2)).toBeVisible();
|
||||
// Verify user2 cannot see user1's webhook
|
||||
await expect(page.getByText(webhookUrl1)).not.toBeVisible();
|
||||
|
||||
// Navigate to team2's webhook logs page
|
||||
await page.goto(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${user2Data.team.url}/settings/webhooks/${webhook2.id}`,
|
||||
);
|
||||
|
||||
// Verify user2 cannot see team1's webhook logs
|
||||
// The webhook call from team1 should not be visible
|
||||
await expect(page.getByText(webhookCall1.id)).not.toBeVisible();
|
||||
|
||||
// Attempt to access user1's webhook detail page directly via URL
|
||||
await page.goto(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${user2Data.team.url}/settings/webhooks/${webhook1.id}`,
|
||||
);
|
||||
|
||||
// Verify access is denied - should show error or redirect
|
||||
// Based on the component, it shows a 404 error page
|
||||
await expect(page.getByRole('heading', { name: 'Webhook not found' })).toBeVisible();
|
||||
});
|
||||
@@ -14,13 +14,14 @@
|
||||
"devDependencies": {
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@playwright/test": "1.52.0",
|
||||
"@napi-rs/canvas": "^0.1.82",
|
||||
"@playwright/test": "1.56.1",
|
||||
"@types/node": "^20",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"pixelmatch": "^7.1.0",
|
||||
"pngjs": "^7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"start-server-and-test": "^2.0.12"
|
||||
"start-server-and-test": "^2.1.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
56611
packages/assets/pdf.worker.min.js
vendored
56611
packages/assets/pdf.worker.min.js
vendored
File diff suppressed because it is too large
Load Diff
@@ -72,7 +72,11 @@ export class AuthClient {
|
||||
public async getSession() {
|
||||
const response = await this.client['session-json'].$get();
|
||||
|
||||
await this.handleError(response);
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
@@ -82,13 +86,19 @@ export class AuthClient {
|
||||
public async getSessions() {
|
||||
const response = await this.client['sessions'].$get();
|
||||
|
||||
await this.handleError(response);
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return superjson.deserialize<{ sessions: ActiveSession[] }>(result);
|
||||
}
|
||||
|
||||
// !: Unused for now since it isn't providing the type narrowing
|
||||
// !: we need.
|
||||
private async handleError<T>(response: ClientResponse<T>): Promise<void> {
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
@@ -101,7 +111,11 @@ export class AuthClient {
|
||||
getMany: async () => {
|
||||
const response = await this.client['accounts'].$get();
|
||||
|
||||
await this.handleError(response);
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
@@ -112,7 +126,11 @@ export class AuthClient {
|
||||
param: { accountId },
|
||||
});
|
||||
|
||||
await this.handleError(response);
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -131,41 +149,75 @@ export class AuthClient {
|
||||
},
|
||||
});
|
||||
|
||||
await this.handleError(response);
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
handleSignInRedirect(data.redirectPath);
|
||||
},
|
||||
|
||||
updatePassword: async (data: TUpdatePasswordSchema) => {
|
||||
const response = await this.client['email-password']['update-password'].$post({ json: data });
|
||||
await this.handleError(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
},
|
||||
|
||||
forgotPassword: async (data: TForgotPasswordSchema) => {
|
||||
const response = await this.client['email-password']['forgot-password'].$post({ json: data });
|
||||
await this.handleError(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
},
|
||||
|
||||
resetPassword: async (data: TResetPasswordSchema) => {
|
||||
const response = await this.client['email-password']['reset-password'].$post({ json: data });
|
||||
await this.handleError(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
},
|
||||
|
||||
signUp: async (data: TSignUpSchema) => {
|
||||
const response = await this.client['email-password']['signup'].$post({ json: data });
|
||||
await this.handleError(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
},
|
||||
|
||||
resendVerifyEmail: async (data: TResendVerifyEmailSchema) => {
|
||||
const response = await this.client['email-password']['resend-verify-email'].$post({
|
||||
json: data,
|
||||
});
|
||||
await this.handleError(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
},
|
||||
|
||||
verifyEmail: async (data: TVerifyEmailSchema) => {
|
||||
const response = await this.client['email-password']['verify-email'].$post({ json: data });
|
||||
await this.handleError(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
@@ -174,23 +226,43 @@ export class AuthClient {
|
||||
public twoFactor = {
|
||||
setup: async () => {
|
||||
const response = await this.client['two-factor'].setup.$post();
|
||||
await this.handleError(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
enable: async (data: TEnableTwoFactorRequestSchema) => {
|
||||
const response = await this.client['two-factor'].enable.$post({ json: data });
|
||||
await this.handleError(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
disable: async (data: TDisableTwoFactorRequestSchema) => {
|
||||
const response = await this.client['two-factor'].disable.$post({ json: data });
|
||||
await this.handleError(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
},
|
||||
viewRecoveryCodes: async (data: TViewTwoFactorRecoveryCodesRequestSchema) => {
|
||||
const response = await this.client['two-factor']['view-recovery-codes'].$post({ json: data });
|
||||
await this.handleError(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
@@ -199,7 +271,12 @@ export class AuthClient {
|
||||
public passkey = {
|
||||
signIn: async (data: TPasskeySignin) => {
|
||||
const response = await this.client['passkey'].authorize.$post({ json: data });
|
||||
await this.handleError(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
handleSignInRedirect(data.redirectPath);
|
||||
},
|
||||
@@ -211,7 +288,11 @@ export class AuthClient {
|
||||
json: { redirectPath },
|
||||
});
|
||||
|
||||
await this.handleError(response);
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
@@ -228,7 +309,11 @@ export class AuthClient {
|
||||
json: { redirectPath },
|
||||
});
|
||||
|
||||
await this.handleError(response);
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
@@ -241,7 +326,12 @@ export class AuthClient {
|
||||
public oidc = {
|
||||
signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => {
|
||||
const response = await this.client['oauth'].authorize.oidc.$post({ json: { redirectPath } });
|
||||
await this.handleError(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
@@ -256,7 +346,11 @@ export class AuthClient {
|
||||
param: { orgUrl },
|
||||
});
|
||||
|
||||
await this.handleError(response);
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
@@ -12,14 +12,14 @@
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@hono/standard-validator": "^0.1.2",
|
||||
"@hono/standard-validator": "^0.2.0",
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"arctic": "^3.1.0",
|
||||
"hono": "4.7.0",
|
||||
"luxon": "^3.5.0",
|
||||
"nanoid": "^5.1.5",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"arctic": "^3.7.0",
|
||||
"hono": "4.10.6",
|
||||
"luxon": "^3.7.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,12 @@
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sesv2": "^3.936.0",
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
"luxon": "^3.7.2",
|
||||
"react": "^18",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
@@ -13,36 +13,36 @@
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "email dev --port 3002 --dir templates",
|
||||
"clean": "rimraf node_modules",
|
||||
"worker:test": "tsup worker/index.ts --format esm"
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@documenso/nodemailer-resend": "2.0.0",
|
||||
"@react-email/body": "0.0.4",
|
||||
"@react-email/button": "0.0.11",
|
||||
"@react-email/column": "0.0.8",
|
||||
"@react-email/container": "0.0.10",
|
||||
"@react-email/font": "0.0.4",
|
||||
"@react-email/head": "0.0.6",
|
||||
"@react-email/heading": "0.0.9",
|
||||
"@react-email/hr": "0.0.6",
|
||||
"@react-email/html": "0.0.6",
|
||||
"@react-email/img": "0.0.6",
|
||||
"@react-email/link": "0.0.6",
|
||||
"@react-email/preview": "0.0.7",
|
||||
"@react-email/render": "0.0.9",
|
||||
"@react-email/row": "0.0.6",
|
||||
"@react-email/section": "0.0.10",
|
||||
"@react-email/tailwind": "0.0.9",
|
||||
"@react-email/text": "0.0.6",
|
||||
"nodemailer": "^6.10.1",
|
||||
"react-email": "1.9.5",
|
||||
"resend": "2.0.0"
|
||||
"@documenso/nodemailer-resend": "4.0.0",
|
||||
"@react-email/body": "0.2.0",
|
||||
"@react-email/button": "0.2.0",
|
||||
"@react-email/code-block": "0.2.0",
|
||||
"@react-email/code-inline": "0.0.5",
|
||||
"@react-email/column": "0.0.13",
|
||||
"@react-email/container": "0.0.15",
|
||||
"@react-email/font": "0.0.9",
|
||||
"@react-email/head": "0.0.12",
|
||||
"@react-email/heading": "0.0.15",
|
||||
"@react-email/hr": "0.0.11",
|
||||
"@react-email/html": "0.0.11",
|
||||
"@react-email/img": "0.0.11",
|
||||
"@react-email/link": "0.0.12",
|
||||
"@react-email/preview": "0.0.13",
|
||||
"@react-email/render": "0.0.17",
|
||||
"@react-email/row": "0.0.12",
|
||||
"@react-email/section": "0.0.16",
|
||||
"@react-email/tailwind": "^2.0.1",
|
||||
"@react-email/text": "0.1.5",
|
||||
"nodemailer": "^7.0.10",
|
||||
"react-email": "^5.0.4",
|
||||
"resend": "^6.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@documenso/tsconfig": "*",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"tsup": "^7.1.0"
|
||||
"@types/nodemailer": "^6.4.21"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { I18n } from '@lingui/core';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
import * as ReactEmail from '@react-email/render';
|
||||
|
||||
import config from '@documenso/tailwind-config';
|
||||
@@ -7,6 +9,7 @@ import { BrandingProvider, type BrandingSettings } from './providers/branding';
|
||||
|
||||
export type RenderOptions = ReactEmail.Options & {
|
||||
branding?: BrandingSettings;
|
||||
i18n?: I18n;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
@@ -16,17 +19,46 @@ export const render = (element: React.ReactNode, options?: RenderOptions) => {
|
||||
const { branding, ...otherOptions } = options ?? {};
|
||||
|
||||
return ReactEmail.render(
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors,
|
||||
<BrandingProvider branding={branding}>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<BrandingProvider branding={branding}>{element}</BrandingProvider>
|
||||
</Tailwind>,
|
||||
}}
|
||||
>
|
||||
{element}
|
||||
</Tailwind>
|
||||
</BrandingProvider>,
|
||||
otherOptions,
|
||||
);
|
||||
};
|
||||
|
||||
export const renderWithI18N = (element: React.ReactNode, options?: RenderOptions) => {
|
||||
const { branding, i18n, ...otherOptions } = options ?? {};
|
||||
|
||||
if (!i18n) {
|
||||
throw new Error('i18n is required');
|
||||
}
|
||||
|
||||
return ReactEmail.render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<BrandingProvider branding={branding}>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{element}
|
||||
</Tailwind>
|
||||
</BrandingProvider>
|
||||
</I18nProvider>,
|
||||
otherOptions,
|
||||
);
|
||||
};
|
||||
@@ -35,17 +67,19 @@ export const renderAsync = async (element: React.ReactNode, options?: RenderOpti
|
||||
const { branding, ...otherOptions } = options ?? {};
|
||||
|
||||
return await ReactEmail.renderAsync(
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors,
|
||||
<BrandingProvider branding={branding}>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<BrandingProvider branding={branding}>{element}</BrandingProvider>
|
||||
</Tailwind>,
|
||||
}}
|
||||
>
|
||||
{element}
|
||||
</Tailwind>
|
||||
</BrandingProvider>,
|
||||
otherOptions,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => {
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{branding.brandingCompanyDetails ? (
|
||||
{branding.brandingEnabled && branding.brandingCompanyDetails && (
|
||||
<Text className="my-8 text-sm text-slate-400">
|
||||
{branding.brandingCompanyDetails.split('\n').map((line, idx) => {
|
||||
return (
|
||||
@@ -34,7 +34,9 @@ export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => {
|
||||
);
|
||||
})}
|
||||
</Text>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{!branding.brandingEnabled && (
|
||||
<Text className="my-8 text-sm text-slate-400">
|
||||
Documenso, Inc.
|
||||
<br />
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^7.1.1",
|
||||
"@typescript-eslint/parser": "^7.1.1",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^14.2.28",
|
||||
"eslint-config-turbo": "^1.12.5",
|
||||
"eslint-plugin-package-json": "^0.31.0",
|
||||
"eslint-config-next": "^15",
|
||||
"eslint-config-turbo": "^1.13.4",
|
||||
"eslint-plugin-package-json": "^0.85.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"eslint-plugin-unused-imports": "^4.3.0",
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,7 @@ export function usePageRenderer(renderFunction: RenderFunction) {
|
||||
canvas.style.height = `${Math.floor(scaledViewport.height)}px`;
|
||||
|
||||
const renderContext: RenderParameters = {
|
||||
canvas,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
||||
viewport: renderViewport,
|
||||
|
||||
@@ -6,7 +6,7 @@ export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
|
||||
export const NEXT_PUBLIC_WEBAPP_URL = () =>
|
||||
env('NEXT_PUBLIC_WEBAPP_URL') ?? 'http://localhost:3000';
|
||||
|
||||
export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL =
|
||||
export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL = () =>
|
||||
env('NEXT_PRIVATE_INTERNAL_WEBAPP_URL') ?? NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
|
||||
@@ -15,3 +15,6 @@ export const API_V2_BETA_URL = '/api/v2-beta';
|
||||
export const API_V2_URL = '/api/v2';
|
||||
|
||||
export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@documenso.com';
|
||||
|
||||
export const USE_INTERNAL_URL_BROWSERLESS = () =>
|
||||
env('NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS') === 'true';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Context as HonoContext } from 'hono';
|
||||
import type { Context, Handler, InngestFunction } from 'inngest';
|
||||
import { Inngest as InngestClient } from 'inngest';
|
||||
import type { Logger } from 'inngest';
|
||||
import { serve as createHonoPagesRoute } from 'inngest/hono';
|
||||
import type { Logger } from 'inngest/middleware/logger';
|
||||
|
||||
import { env } from '../../utils/env';
|
||||
import type { JobDefinition, JobRunIO, SimpleTriggerJobOptions } from './_internal/job';
|
||||
|
||||
@@ -213,7 +213,7 @@ export class LocalJobProvider extends BaseJobProvider {
|
||||
}) {
|
||||
const { jobId, jobDefinitionId, data, isRetry } = options;
|
||||
|
||||
const endpoint = `${NEXT_PRIVATE_INTERNAL_WEBAPP_URL}/api/jobs/${jobDefinitionId}/${jobId}`;
|
||||
const endpoint = `${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/api/jobs/${jobDefinitionId}/${jobId}`;
|
||||
const signature = sign(data);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
|
||||
@@ -49,7 +49,8 @@ export const run = async ({
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
const rows = parse(csvContent, { columns: true, skip_empty_lines: true });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rows = parse<any>(csvContent, { columns: true, skip_empty_lines: true });
|
||||
|
||||
if (rows.length > 100) {
|
||||
throw new Error('Maximum 100 rows allowed per upload');
|
||||
|
||||
@@ -22,53 +22,51 @@ export const run = async ({
|
||||
|
||||
const { webhookUrl: url, secret } = webhook;
|
||||
|
||||
await io.runTask('execute-webhook', async () => {
|
||||
const payloadData = {
|
||||
event,
|
||||
payload: data,
|
||||
createdAt: new Date().toISOString(),
|
||||
webhookEndpoint: url,
|
||||
};
|
||||
const payloadData = {
|
||||
event,
|
||||
payload: data,
|
||||
createdAt: new Date().toISOString(),
|
||||
webhookEndpoint: url,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payloadData),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Documenso-Secret': secret ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const body = await response.text();
|
||||
|
||||
let responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput = Prisma.JsonNull;
|
||||
|
||||
try {
|
||||
responseBody = JSON.parse(body);
|
||||
} catch (err) {
|
||||
responseBody = body;
|
||||
}
|
||||
|
||||
await prisma.webhookCall.create({
|
||||
data: {
|
||||
url,
|
||||
event,
|
||||
status: response.ok ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED,
|
||||
requestBody: payloadData as Prisma.InputJsonValue,
|
||||
responseCode: response.status,
|
||||
responseBody,
|
||||
responseHeaders: Object.fromEntries(response.headers.entries()),
|
||||
webhookId: webhook.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Webhook execution failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: response.ok,
|
||||
status: response.status,
|
||||
};
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payloadData),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Documenso-Secret': secret ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const body = await response.text();
|
||||
|
||||
let responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput = Prisma.JsonNull;
|
||||
|
||||
try {
|
||||
responseBody = JSON.parse(body);
|
||||
} catch (err) {
|
||||
responseBody = body;
|
||||
}
|
||||
|
||||
await prisma.webhookCall.create({
|
||||
data: {
|
||||
url,
|
||||
event,
|
||||
status: response.ok ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED,
|
||||
requestBody: payloadData as Prisma.InputJsonValue,
|
||||
responseCode: response.status,
|
||||
responseBody,
|
||||
responseHeaders: Object.fromEntries(response.headers.entries()),
|
||||
webhookId: webhook.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Webhook execution failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: response.ok,
|
||||
status: response.status,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -15,51 +15,52 @@
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.410.0",
|
||||
"@aws-sdk/client-sesv2": "^3.410.0",
|
||||
"@aws-sdk/cloudfront-signer": "^3.410.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.410.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.410.0",
|
||||
"@aws-sdk/client-s3": "^3.936.0",
|
||||
"@aws-sdk/client-sesv2": "^3.936.0",
|
||||
"@aws-sdk/cloudfront-signer": "^3.935.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.936.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.936.0",
|
||||
"@documenso/assets": "*",
|
||||
"@documenso/email": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@documenso/signing": "*",
|
||||
"@lingui/core": "^5.2.0",
|
||||
"@lingui/macro": "^5.2.0",
|
||||
"@lingui/react": "^5.2.0",
|
||||
"@noble/ciphers": "0.4.0",
|
||||
"@noble/hashes": "1.3.2",
|
||||
"@node-rs/bcrypt": "^1.10.0",
|
||||
"@lingui/core": "^5.6.0",
|
||||
"@lingui/macro": "^5.6.0",
|
||||
"@lingui/react": "^5.6.0",
|
||||
"@noble/ciphers": "0.6.0",
|
||||
"@noble/hashes": "1.8.0",
|
||||
"@node-rs/bcrypt": "^1.10.7",
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
"@scure/base": "^1.1.3",
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
"@team-plain/typescript-sdk": "^5.9.0",
|
||||
"@vvo/tzdb": "^6.117.0",
|
||||
"csv-parse": "^5.6.0",
|
||||
"inngest": "^3.19.13",
|
||||
"jose": "^6.0.0",
|
||||
"kysely": "0.26.3",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
"nanoid": "^5.1.5",
|
||||
"@scure/base": "^1.2.6",
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"@team-plain/typescript-sdk": "^5.11.0",
|
||||
"@vvo/tzdb": "^6.196.0",
|
||||
"csv-parse": "^6.1.0",
|
||||
"inngest": "^3.45.1",
|
||||
"jose": "^6.1.2",
|
||||
"konva": "^10.0.9",
|
||||
"kysely": "0.28.8",
|
||||
"luxon": "^3.7.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"oslo": "^0.17.0",
|
||||
"pg": "^8.11.3",
|
||||
"pino": "^9.7.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"playwright": "1.52.0",
|
||||
"posthog-js": "^1.245.0",
|
||||
"posthog-node": "^4.17.0",
|
||||
"pg": "^8.16.3",
|
||||
"pino": "^9.14.0",
|
||||
"pino-pretty": "^13.1.2",
|
||||
"playwright": "1.56.1",
|
||||
"posthog-js": "^1.297.2",
|
||||
"posthog-node": "4.18.0",
|
||||
"react": "^18",
|
||||
"remeda": "^2.17.3",
|
||||
"sharp": "0.32.6",
|
||||
"react-pdf": "^10.2.0",
|
||||
"remeda": "^2.32.0",
|
||||
"sharp": "0.34.5",
|
||||
"skia-canvas": "^3.0.8",
|
||||
"stripe": "^12.7.0",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"stripe": "^12.18.0",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/browser-chromium": "1.52.0",
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@types/pg": "^8.11.4"
|
||||
"@playwright/browser-chromium": "1.56.1",
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/pg": "^8.15.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export const getMonthlyActiveUsers = async () => {
|
||||
)
|
||||
.as('cume_count'),
|
||||
])
|
||||
.where(sql`type = ${UserSecurityAuditLogType.SIGN_IN}::"UserSecurityAuditLogType"`)
|
||||
.where(() => sql`type = ${UserSecurityAuditLogType.SIGN_IN}::"UserSecurityAuditLogType"`)
|
||||
.groupBy(({ fn }) => fn('DATE_TRUNC', [sql.lit('MONTH'), 'UserSecurityAuditLog.createdAt']))
|
||||
.orderBy('month', 'desc')
|
||||
.limit(12);
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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,7 +1,11 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import type { Browser } from 'playwright';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import {
|
||||
NEXT_PRIVATE_INTERNAL_WEBAPP_URL,
|
||||
NEXT_PUBLIC_WEBAPP_URL,
|
||||
USE_INTERNAL_URL_BROWSERLESS,
|
||||
} from '../../constants/app';
|
||||
import { type SupportedLanguageCodes, isValidLanguageCode } from '../../constants/i18n';
|
||||
import { env } from '../../utils/env';
|
||||
import { encryptSecondaryData } from '../crypto/encrypt';
|
||||
@@ -48,14 +52,19 @@ export const getAuditLogsPdf = async ({ documentId, language }: GetAuditLogsPdfO
|
||||
{
|
||||
name: 'language',
|
||||
value: lang,
|
||||
url: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
url: USE_INTERNAL_URL_BROWSERLESS()
|
||||
? NEXT_PUBLIC_WEBAPP_URL()
|
||||
: NEXT_PRIVATE_INTERNAL_WEBAPP_URL(),
|
||||
},
|
||||
]);
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encryptedId}`, {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 10_000,
|
||||
});
|
||||
await page.goto(
|
||||
`${USE_INTERNAL_URL_BROWSERLESS() ? NEXT_PUBLIC_WEBAPP_URL() : NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encryptedId}`,
|
||||
{
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
|
||||
// !: This is a workaround to ensure the page is loaded correctly.
|
||||
// !: It's not clear why but suddenly browserless cdp connections would
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import type { Browser } from 'playwright';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import {
|
||||
NEXT_PRIVATE_INTERNAL_WEBAPP_URL,
|
||||
NEXT_PUBLIC_WEBAPP_URL,
|
||||
USE_INTERNAL_URL_BROWSERLESS,
|
||||
} from '../../constants/app';
|
||||
import { type SupportedLanguageCodes, isValidLanguageCode } from '../../constants/i18n';
|
||||
import { env } from '../../utils/env';
|
||||
import { encryptSecondaryData } from '../crypto/encrypt';
|
||||
@@ -48,14 +52,19 @@ export const getCertificatePdf = async ({ documentId, language }: GetCertificate
|
||||
{
|
||||
name: 'lang',
|
||||
value: lang,
|
||||
url: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
url: USE_INTERNAL_URL_BROWSERLESS()
|
||||
? NEXT_PUBLIC_WEBAPP_URL()
|
||||
: NEXT_PRIVATE_INTERNAL_WEBAPP_URL(),
|
||||
},
|
||||
]);
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`, {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 10_000,
|
||||
});
|
||||
await page.goto(
|
||||
`${USE_INTERNAL_URL_BROWSERLESS() ? NEXT_PUBLIC_WEBAPP_URL() : NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`,
|
||||
{
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
|
||||
// !: This is a workaround to ensure the page is loaded correctly.
|
||||
// !: It's not clear why but suddenly browserless cdp connections would
|
||||
|
||||
22
packages/lib/server-only/site-settings/get-site-setting.ts
Normal file
22
packages/lib/server-only/site-settings/get-site-setting.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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,9 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZSiteSettingsBannerSchema } from './schemas/banner';
|
||||
import { ZSiteSettingsTelemetrySchema } from './schemas/telemetry';
|
||||
|
||||
// TODO: Use `z.union([...])` once we have more than one setting
|
||||
export const ZSiteSettingSchema = ZSiteSettingsBannerSchema;
|
||||
export const ZSiteSettingSchema = z.union([
|
||||
ZSiteSettingsBannerSchema,
|
||||
ZSiteSettingsTelemetrySchema,
|
||||
]);
|
||||
|
||||
export type TSiteSettingSchema = z.infer<typeof ZSiteSettingSchema>;
|
||||
|
||||
|
||||
14
packages/lib/server-only/site-settings/schemas/telemetry.ts
Normal file
14
packages/lib/server-only/site-settings/schemas/telemetry.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
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 type { TSiteSettingSchema } from './schema';
|
||||
import { type TSiteSettingSchema } from './schema';
|
||||
|
||||
export type UpsertSiteSettingOptions = TSiteSettingSchema & {
|
||||
userId: number;
|
||||
userId?: number | null;
|
||||
};
|
||||
|
||||
export const upsertSiteSetting = async ({
|
||||
|
||||
190
packages/lib/server-only/telemetry/telemetry-client.ts
Normal file
190
packages/lib/server-only/telemetry/telemetry-client.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export type HandlerTriggerWebhooksResponse =
|
||||
error: string;
|
||||
};
|
||||
|
||||
// Todo: [Webhooks] delete after deployment.
|
||||
export const handlerTriggerWebhooks = async (req: Request) => {
|
||||
const signature = req.headers.get('x-webhook-signature');
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../../constants/app';
|
||||
import { sign } from '../../crypto/sign';
|
||||
import { jobs } from '../../../jobs/client';
|
||||
import { getAllWebhooksByEventTrigger } from '../get-all-webhooks-by-event-trigger';
|
||||
|
||||
export type TriggerWebhookOptions = {
|
||||
@@ -13,35 +12,26 @@ export type TriggerWebhookOptions = {
|
||||
|
||||
export const triggerWebhook = async ({ event, data, userId, teamId }: TriggerWebhookOptions) => {
|
||||
try {
|
||||
const body = {
|
||||
event,
|
||||
data,
|
||||
userId,
|
||||
teamId,
|
||||
};
|
||||
|
||||
const registeredWebhooks = await getAllWebhooksByEventTrigger({ event, userId, teamId });
|
||||
|
||||
if (registeredWebhooks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const signature = sign(body);
|
||||
|
||||
await Promise.race([
|
||||
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL}/api/webhook/trigger`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-webhook-signature': signature,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
await Promise.allSettled(
|
||||
registeredWebhooks.map(async (webhook) => {
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.execute-webhook',
|
||||
payload: {
|
||||
event,
|
||||
webhookId: webhook.id,
|
||||
data,
|
||||
},
|
||||
});
|
||||
}),
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Request timeout')), 500);
|
||||
}),
|
||||
]).catch(() => null);
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw new Error(`Failed to trigger webhook`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { xchacha20poly1305 } from '@noble/ciphers/chacha';
|
||||
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/ciphers/utils';
|
||||
import { managedNonce } from '@noble/ciphers/webcrypto/utils';
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import { managedNonce } from '@noble/ciphers/webcrypto';
|
||||
import { sha256 } from '@noble/hashes/sha2';
|
||||
|
||||
export type SymmetricEncryptOptions = {
|
||||
key: string;
|
||||
|
||||
@@ -96,6 +96,7 @@ const createFieldSignature = (
|
||||
|
||||
img.onload = () => {
|
||||
image.setAttrs({
|
||||
image: img,
|
||||
...getImageDimensions(img, fieldWidth, fieldHeight),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
|
||||
import type { RenderOptions } from '@documenso/email/render';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { renderWithI18N } from '@documenso/email/render';
|
||||
|
||||
import { getI18nInstance } from '../client-only/providers/i18n-server';
|
||||
import {
|
||||
@@ -26,7 +24,7 @@ export const renderEmailWithI18N = async (
|
||||
|
||||
i18n.activate(lang);
|
||||
|
||||
return render(<I18nProvider i18n={i18n}>{component}</I18nProvider>, otherOptions);
|
||||
return renderWithI18N(component, { i18n, ...otherOptions });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw new Error('Failed to render email');
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.9"
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.1"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
@@ -21,19 +21,21 @@
|
||||
"seed": "tsx ./seed-database.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.18.0",
|
||||
"kysely": "0.26.3",
|
||||
"prisma": "^6.18.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"kysely": "0.28.8",
|
||||
"nanoid": "^5.1.6",
|
||||
"prisma": "^6.19.0",
|
||||
"prisma-extension-kysely": "^3.0.0",
|
||||
"prisma-kysely": "^1.8.0",
|
||||
"prisma-kysely": "^2.2.1",
|
||||
"prisma-json-types-generator": "^3.6.2",
|
||||
"ts-pattern": "^5.0.6",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"zod": "^3.25.76",
|
||||
"zod-prisma-types": "3.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv": "^16.5.0",
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"tsx": "^4.19.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,9 @@
|
||||
"dependencies": {
|
||||
"@documenso/pdf-sign": "^0.1.0",
|
||||
"@documenso/tsconfig": "*",
|
||||
"ts-pattern": "^5.0.5"
|
||||
"ts-pattern": "^5.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3.1.4"
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "3.4.15",
|
||||
"tailwindcss-animate": "^1.0.5"
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,16 +12,16 @@
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@tanstack/react-query": "5.90.5",
|
||||
"@trpc/client": "11.7.0",
|
||||
"@trpc/react-query": "11.7.0",
|
||||
"@trpc/server": "11.7.0",
|
||||
"@ts-rest/core": "^3.52.0",
|
||||
"@tanstack/react-query": "5.90.10",
|
||||
"@trpc/client": "11.7.1",
|
||||
"@trpc/react-query": "11.7.1",
|
||||
"@trpc/server": "11.7.1",
|
||||
"@ts-rest/core": "^3.52.1",
|
||||
"formidable": "^3.5.4",
|
||||
"luxon": "^3.4.0",
|
||||
"luxon": "^3.7.2",
|
||||
"superjson": "^2.2.5",
|
||||
"trpc-to-openapi": "2.4.0",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"zod": "^3.25.76",
|
||||
"zod-form-data": "^2.0.8",
|
||||
"zod-openapi": "^4.2.4"
|
||||
@@ -29,4 +29,4 @@
|
||||
"devDependencies": {
|
||||
"@types/formidable": "^3.4.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,18 +10,16 @@ export const updateSiteSettingRoute = adminProcedure
|
||||
.input(ZUpdateSiteSettingRequestSchema)
|
||||
.output(ZUpdateSiteSettingResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, enabled, data } = input;
|
||||
const { ...siteSetting } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
id,
|
||||
id: siteSetting.id,
|
||||
},
|
||||
});
|
||||
|
||||
await upsertSiteSetting({
|
||||
id,
|
||||
enabled,
|
||||
data,
|
||||
...siteSetting,
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -457,8 +457,10 @@ export const templateRouter = router({
|
||||
recipients,
|
||||
distributeDocument,
|
||||
customDocumentDataId,
|
||||
prefillFields,
|
||||
folderId,
|
||||
prefillFields,
|
||||
override,
|
||||
attachments,
|
||||
} = input;
|
||||
|
||||
ctx.logger.info({
|
||||
@@ -495,6 +497,8 @@ export const templateRouter = router({
|
||||
requestMetadata: ctx.metadata,
|
||||
folderId,
|
||||
prefillFields,
|
||||
override,
|
||||
attachments,
|
||||
});
|
||||
|
||||
if (distributeDocument) {
|
||||
|
||||
@@ -133,12 +133,42 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
|
||||
'The ID of the folder to create the document in. If not provided, the document will be created in the root folder.',
|
||||
)
|
||||
.optional(),
|
||||
|
||||
prefillFields: z
|
||||
.array(ZFieldMetaPrefillFieldsSchema)
|
||||
.describe(
|
||||
'The fields to prefill on the document before sending it out. Useful when you want to create a document from an existing template and pre-fill the fields with specific values.',
|
||||
)
|
||||
.optional(),
|
||||
|
||||
override: z
|
||||
.object({
|
||||
title: z.string().min(1).max(255).optional(),
|
||||
subject: ZDocumentMetaSubjectSchema.optional(),
|
||||
message: ZDocumentMetaMessageSchema.optional(),
|
||||
timezone: ZDocumentMetaTimezoneSchema.optional(),
|
||||
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
|
||||
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
|
||||
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||
language: ZDocumentMetaLanguageSchema.optional(),
|
||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
allowDictateNextSigner: z.boolean().optional(),
|
||||
})
|
||||
.describe('Override values from the template for the created document.')
|
||||
.optional(),
|
||||
|
||||
attachments: z
|
||||
.array(
|
||||
z.object({
|
||||
label: z.string().min(1, 'Label is required'),
|
||||
data: z.string().url('Must be a valid URL'),
|
||||
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const ZCreateDocumentFromTemplateResponseSchema = ZDocumentSchema;
|
||||
|
||||
106
packages/trpc/server/webhook-router/find-webhook-calls.ts
Normal file
106
packages/trpc/server/webhook-router/find-webhook-calls.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Prisma, WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { FindResultResponse } from '@documenso/lib/types/search-params';
|
||||
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZFindWebhookCallsRequestSchema,
|
||||
ZFindWebhookCallsResponseSchema,
|
||||
} from './find-webhook-calls.types';
|
||||
|
||||
export const findWebhookCallsRoute = authenticatedProcedure
|
||||
.input(ZFindWebhookCallsRequestSchema)
|
||||
.output(ZFindWebhookCallsResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { webhookId, page, perPage, status, query, events } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: { webhookId, status },
|
||||
});
|
||||
|
||||
return await findWebhookCalls({
|
||||
userId: ctx.user.id,
|
||||
teamId: ctx.teamId,
|
||||
webhookId,
|
||||
page,
|
||||
perPage,
|
||||
status,
|
||||
query,
|
||||
events,
|
||||
});
|
||||
});
|
||||
|
||||
type FindWebhookCallsOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
webhookId: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
status?: WebhookCallStatus;
|
||||
events?: WebhookTriggerEvents[];
|
||||
query?: string;
|
||||
};
|
||||
|
||||
export const findWebhookCalls = async ({
|
||||
userId,
|
||||
teamId,
|
||||
webhookId,
|
||||
page = 1,
|
||||
perPage = 20,
|
||||
events,
|
||||
query = '',
|
||||
status,
|
||||
}: FindWebhookCallsOptions) => {
|
||||
const webhook = await prisma.webhook.findFirst({
|
||||
where: {
|
||||
id: webhookId,
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP.MANAGE_TEAM,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!webhook) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
const whereClause: Prisma.WebhookCallWhereInput = {
|
||||
webhookId: webhook.id,
|
||||
status,
|
||||
id: query || undefined,
|
||||
event:
|
||||
events && events.length > 0
|
||||
? {
|
||||
in: events,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.webhookCall.findMany({
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
}),
|
||||
prisma.webhookCall.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
} satisfies FindResultResponse<typeof data>;
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import WebhookCallSchema from '@documenso/prisma/generated/zod/modelSchema/WebhookCallSchema';
|
||||
|
||||
export const ZFindWebhookCallsRequestSchema = ZFindSearchParamsSchema.extend({
|
||||
webhookId: z.string(),
|
||||
status: z.nativeEnum(WebhookCallStatus).optional(),
|
||||
events: z
|
||||
.array(z.nativeEnum(WebhookTriggerEvents))
|
||||
.optional()
|
||||
.refine((arr) => !arr || new Set(arr).size === arr.length, {
|
||||
message: 'Events must be unique',
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZFindWebhookCallsResponseSchema = ZFindResultResponse.extend({
|
||||
data: WebhookCallSchema.pick({
|
||||
webhookId: true,
|
||||
status: true,
|
||||
event: true,
|
||||
id: true,
|
||||
url: true,
|
||||
responseCode: true,
|
||||
createdAt: true,
|
||||
})
|
||||
.extend({
|
||||
requestBody: z.unknown(),
|
||||
responseHeaders: z.unknown().nullable(),
|
||||
responseBody: z.unknown().nullable(),
|
||||
})
|
||||
.array(),
|
||||
});
|
||||
|
||||
export type TFindWebhookCallsRequest = z.infer<typeof ZFindWebhookCallsRequestSchema>;
|
||||
export type TFindWebhookCallsResponse = z.infer<typeof ZFindWebhookCallsResponseSchema>;
|
||||
80
packages/trpc/server/webhook-router/resend-webhook-call.ts
Normal file
80
packages/trpc/server/webhook-router/resend-webhook-call.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Prisma, WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { FindResultResponse } from '@documenso/lib/types/search-params';
|
||||
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZResendWebhookCallRequestSchema,
|
||||
ZResendWebhookCallResponseSchema,
|
||||
} from './resend-webhook-call.types';
|
||||
|
||||
export const resendWebhookCallRoute = authenticatedProcedure
|
||||
.input(ZResendWebhookCallRequestSchema)
|
||||
.output(ZResendWebhookCallResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { webhookId, webhookCallId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: { webhookId, webhookCallId },
|
||||
});
|
||||
|
||||
const webhookCall = await prisma.webhookCall.findFirst({
|
||||
where: {
|
||||
id: webhookCallId,
|
||||
webhook: {
|
||||
id: webhookId,
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId: user.id,
|
||||
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP.MANAGE_TEAM,
|
||||
}),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
webhook: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!webhookCall) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
const { webhook } = webhookCall;
|
||||
|
||||
// Note: This is duplicated in `execute-webhook.handler.ts`.
|
||||
const response = await fetch(webhookCall.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(webhookCall.requestBody),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Documenso-Secret': webhook.secret ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const body = await response.text();
|
||||
|
||||
let responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput = Prisma.JsonNull;
|
||||
|
||||
try {
|
||||
responseBody = JSON.parse(body);
|
||||
} catch (err) {
|
||||
responseBody = body;
|
||||
}
|
||||
|
||||
return await prisma.webhookCall.update({
|
||||
where: {
|
||||
id: webhookCall.id,
|
||||
},
|
||||
data: {
|
||||
status: response.ok ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED,
|
||||
responseCode: response.status,
|
||||
responseBody,
|
||||
responseHeaders: Object.fromEntries(response.headers.entries()),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import WebhookCallSchema from '@documenso/prisma/generated/zod/modelSchema/WebhookCallSchema';
|
||||
|
||||
export const ZResendWebhookCallRequestSchema = z.object({
|
||||
webhookId: z.string(),
|
||||
webhookCallId: z.string(),
|
||||
});
|
||||
|
||||
export const ZResendWebhookCallResponseSchema = WebhookCallSchema.pick({
|
||||
webhookId: true,
|
||||
status: true,
|
||||
event: true,
|
||||
id: true,
|
||||
url: true,
|
||||
responseCode: true,
|
||||
createdAt: true,
|
||||
}).extend({
|
||||
requestBody: z.unknown(),
|
||||
responseHeaders: z.unknown().nullable(),
|
||||
responseBody: z.unknown().nullable(),
|
||||
});
|
||||
|
||||
export type TResendWebhookRequest = z.infer<typeof ZResendWebhookCallRequestSchema>;
|
||||
export type TResendWebhookResponse = z.infer<typeof ZResendWebhookCallResponseSchema>;
|
||||
@@ -6,66 +6,61 @@ import { getWebhooksByTeamId } from '@documenso/lib/server-only/webhooks/get-web
|
||||
import { triggerTestWebhook } from '@documenso/lib/server-only/webhooks/trigger-test-webhook';
|
||||
|
||||
import { authenticatedProcedure, router } from '../trpc';
|
||||
import { findWebhookCallsRoute } from './find-webhook-calls';
|
||||
import { resendWebhookCallRoute } from './resend-webhook-call';
|
||||
import {
|
||||
ZCreateWebhookRequestSchema,
|
||||
ZDeleteWebhookRequestSchema,
|
||||
ZEditWebhookRequestSchema,
|
||||
ZGetTeamWebhooksRequestSchema,
|
||||
ZGetWebhookByIdRequestSchema,
|
||||
ZTriggerTestWebhookRequestSchema,
|
||||
} from './schema';
|
||||
|
||||
export const webhookRouter = router({
|
||||
getTeamWebhooks: authenticatedProcedure
|
||||
.input(ZGetTeamWebhooksRequestSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { teamId } = input;
|
||||
calls: {
|
||||
find: findWebhookCallsRoute,
|
||||
resend: resendWebhookCallRoute,
|
||||
},
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
getTeamWebhooks: authenticatedProcedure.query(async ({ ctx }) => {
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
teamId: ctx.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
return await getWebhooksByTeamId(teamId, ctx.user.id);
|
||||
}),
|
||||
return await getWebhooksByTeamId(ctx.teamId, ctx.user.id);
|
||||
}),
|
||||
|
||||
getWebhookById: authenticatedProcedure
|
||||
.input(ZGetWebhookByIdRequestSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { id, teamId } = input;
|
||||
const { id } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
id,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
return await getWebhookById({
|
||||
id,
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
teamId: ctx.teamId,
|
||||
});
|
||||
}),
|
||||
|
||||
createWebhook: authenticatedProcedure
|
||||
.input(ZCreateWebhookRequestSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { enabled, eventTriggers, secret, webhookUrl, teamId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
const { enabled, eventTriggers, secret, webhookUrl } = input;
|
||||
|
||||
return await createWebhook({
|
||||
enabled,
|
||||
secret,
|
||||
webhookUrl,
|
||||
eventTriggers,
|
||||
teamId,
|
||||
teamId: ctx.teamId,
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
}),
|
||||
@@ -73,18 +68,17 @@ export const webhookRouter = router({
|
||||
deleteWebhook: authenticatedProcedure
|
||||
.input(ZDeleteWebhookRequestSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { id, teamId } = input;
|
||||
const { id } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
id,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
return await deleteWebhookById({
|
||||
id,
|
||||
teamId,
|
||||
teamId: ctx.teamId,
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
}),
|
||||
@@ -92,12 +86,11 @@ export const webhookRouter = router({
|
||||
editWebhook: authenticatedProcedure
|
||||
.input(ZEditWebhookRequestSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { id, teamId, ...data } = input;
|
||||
const { id, ...data } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
id,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -105,20 +98,19 @@ export const webhookRouter = router({
|
||||
id,
|
||||
data,
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
teamId: ctx.teamId,
|
||||
});
|
||||
}),
|
||||
|
||||
testWebhook: authenticatedProcedure
|
||||
.input(ZTriggerTestWebhookRequestSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { id, event, teamId } = input;
|
||||
const { id, event } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
id,
|
||||
event,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -126,7 +118,7 @@ export const webhookRouter = router({
|
||||
id,
|
||||
event,
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
teamId: ctx.teamId,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { WebhookTriggerEvents } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZGetTeamWebhooksRequestSchema = z.object({
|
||||
teamId: z.number(),
|
||||
});
|
||||
|
||||
export type TGetTeamWebhooksRequestSchema = z.infer<typeof ZGetTeamWebhooksRequestSchema>;
|
||||
|
||||
export const ZCreateWebhookRequestSchema = z.object({
|
||||
webhookUrl: z.string().url(),
|
||||
eventTriggers: z
|
||||
@@ -14,14 +8,12 @@ export const ZCreateWebhookRequestSchema = z.object({
|
||||
.min(1, { message: 'At least one event trigger is required' }),
|
||||
secret: z.string().nullable(),
|
||||
enabled: z.boolean(),
|
||||
teamId: z.number(),
|
||||
});
|
||||
|
||||
export type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookRequestSchema>;
|
||||
|
||||
export const ZGetWebhookByIdRequestSchema = z.object({
|
||||
id: z.string(),
|
||||
teamId: z.number(),
|
||||
});
|
||||
|
||||
export type TGetWebhookByIdRequestSchema = z.infer<typeof ZGetWebhookByIdRequestSchema>;
|
||||
@@ -34,7 +26,6 @@ export type TEditWebhookRequestSchema = z.infer<typeof ZEditWebhookRequestSchema
|
||||
|
||||
export const ZDeleteWebhookRequestSchema = z.object({
|
||||
id: z.string(),
|
||||
teamId: z.number(),
|
||||
});
|
||||
|
||||
export type TDeleteWebhookRequestSchema = z.infer<typeof ZDeleteWebhookRequestSchema>;
|
||||
@@ -42,7 +33,6 @@ export type TDeleteWebhookRequestSchema = z.infer<typeof ZDeleteWebhookRequestSc
|
||||
export const ZTriggerTestWebhookRequestSchema = z.object({
|
||||
id: z.string(),
|
||||
event: z.nativeEnum(WebhookTriggerEvents),
|
||||
teamId: z.number(),
|
||||
});
|
||||
|
||||
export type TTriggerTestWebhookRequestSchema = z.infer<typeof ZTriggerTestWebhookRequestSchema>;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"inlineSources": false,
|
||||
"isolatedModules": true,
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"preserveWatchOutput": true,
|
||||
@@ -19,6 +19,12 @@
|
||||
"strictNullChecks": true,
|
||||
"target": "ES2018"
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", "**/.d.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"**/.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -11,4 +11,4 @@
|
||||
"nextjs.json",
|
||||
"react-library.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
2
packages/tsconfig/process-env.d.ts
vendored
2
packages/tsconfig/process-env.d.ts
vendored
@@ -72,6 +72,8 @@ declare namespace NodeJS {
|
||||
|
||||
NEXT_PRIVATE_JOBS_PROVIDER?: 'inngest' | 'local';
|
||||
|
||||
NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS?: string;
|
||||
|
||||
/**
|
||||
* Inngest environment variables
|
||||
*/
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useHydrated } from '../lib/use-hydrated';
|
||||
|
||||
export const ClientOnly = async ({ children }: { children: React.ReactNode }) => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
return mounted ? children : null;
|
||||
type ClientOnlyProps = {
|
||||
children: () => React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ClientOnly = ({ children, fallback = null }: ClientOnlyProps) => {
|
||||
return useHydrated() ? <>{children()}</> : <>{fallback}</>;
|
||||
};
|
||||
|
||||
@@ -19,19 +19,19 @@ export type LoadedPDFDocument = PDFDocumentProxy;
|
||||
* This imports the worker from the `pdfjs-dist` package.
|
||||
*/
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.js',
|
||||
'pdfjs-dist/legacy/build/pdf.worker.min.mjs',
|
||||
import.meta.url,
|
||||
).toString();
|
||||
|
||||
const pdfViewerOptions = {
|
||||
cMapUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/static/cmaps`,
|
||||
cMapUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/static/cmaps/`,
|
||||
};
|
||||
|
||||
const PDFLoader = () => (
|
||||
<>
|
||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||
<Loader className="h-12 w-12 animate-spin text-documenso" />
|
||||
|
||||
<p className="text-muted-foreground mt-4">
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
<Trans>Loading document...</Trans>
|
||||
</p>
|
||||
</>
|
||||
@@ -145,9 +145,9 @@ export const PdfViewerKonva = ({
|
||||
}}
|
||||
externalLinkTarget="_blank"
|
||||
loading={
|
||||
<div className="dark:bg-background flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50">
|
||||
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50 dark:bg-background">
|
||||
{pdfError ? (
|
||||
<div className="text-muted-foreground text-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p>
|
||||
<Trans>Something went wrong while loading the document.</Trans>
|
||||
</p>
|
||||
@@ -161,8 +161,8 @@ export const PdfViewerKonva = ({
|
||||
</div>
|
||||
}
|
||||
error={
|
||||
<div className="dark:bg-background flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50">
|
||||
<div className="text-muted-foreground text-center">
|
||||
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50 dark:bg-background">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p>
|
||||
<Trans>Something went wrong while loading the document.</Trans>
|
||||
</p>
|
||||
@@ -172,13 +172,13 @@ export const PdfViewerKonva = ({
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
// options={pdfViewerOptions}
|
||||
options={pdfViewerOptions}
|
||||
>
|
||||
{Array(numPages)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<div key={i} className="last:-mb-2">
|
||||
<div className="border-border rounded border will-change-transform">
|
||||
<div className="rounded border border-border will-change-transform">
|
||||
<PDFPage
|
||||
pageNumber={i + 1}
|
||||
width={width}
|
||||
@@ -189,7 +189,7 @@ export const PdfViewerKonva = ({
|
||||
customRenderer={customPageRenderer}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground/80 my-2 text-center text-[11px]">
|
||||
<p className="my-2 text-center text-[11px] text-muted-foreground/80">
|
||||
<Trans>
|
||||
Page {i + 1} of {numPages}
|
||||
</Trans>
|
||||
|
||||
13
packages/ui/lib/use-hydrated.ts
Normal file
13
packages/ui/lib/use-hydrated.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
const subscribe = () => {
|
||||
return () => {};
|
||||
};
|
||||
|
||||
export const useHydrated = () => {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
};
|
||||
@@ -19,8 +19,8 @@
|
||||
"devDependencies": {
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@documenso/tsconfig": "*",
|
||||
"@types/luxon": "^3.3.2",
|
||||
"@types/react": "^18",
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/react": "18.3.27",
|
||||
"@types/react-dom": "^18",
|
||||
"react": "^18",
|
||||
"typescript": "5.6.2"
|
||||
@@ -28,56 +28,57 @@
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
"@hello-pangea/dnd": "^16.6.0",
|
||||
"@hookform/resolvers": "^3.3.0",
|
||||
"@lingui/macro": "^5.2.0",
|
||||
"@lingui/react": "^5.2.0",
|
||||
"@radix-ui/react-accordion": "^1.1.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.3",
|
||||
"@radix-ui/react-aspect-ratio": "^1.0.2",
|
||||
"@radix-ui/react-avatar": "^1.0.2",
|
||||
"@radix-ui/react-checkbox": "^1.0.3",
|
||||
"@radix-ui/react-collapsible": "^1.0.2",
|
||||
"@radix-ui/react-context-menu": "^2.1.3",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.4",
|
||||
"@radix-ui/react-hover-card": "^1.0.5",
|
||||
"@radix-ui/react-label": "^2.0.1",
|
||||
"@radix-ui/react-menubar": "^1.0.2",
|
||||
"@radix-ui/react-navigation-menu": "^1.1.2",
|
||||
"@radix-ui/react-popover": "^1.0.5",
|
||||
"@radix-ui/react-progress": "^1.0.2",
|
||||
"@radix-ui/react-radio-group": "^1.1.2",
|
||||
"@radix-ui/react-scroll-area": "^1.0.3",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.2",
|
||||
"@radix-ui/react-slider": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-switch": "^1.0.2",
|
||||
"@radix-ui/react-tabs": "^1.0.3",
|
||||
"@radix-ui/react-toast": "^1.1.3",
|
||||
"@radix-ui/react-toggle": "^1.0.2",
|
||||
"@radix-ui/react-toggle-group": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.6",
|
||||
"@tanstack/react-table": "^8.9.1",
|
||||
"class-variance-authority": "^0.6.0",
|
||||
"@hookform/resolvers": "^3",
|
||||
"@lingui/macro": "^5.6.0",
|
||||
"@lingui/react": "^5.6.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@scure/base": "^1.2.6",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^1.2.1",
|
||||
"cmdk": "^0.2.0",
|
||||
"framer-motion": "^10.12.8",
|
||||
"lucide-react": "^0.279.0",
|
||||
"luxon": "^3.4.2",
|
||||
"perfect-freehand": "^1.2.0",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
"cmdk": "^0.2.1",
|
||||
"framer-motion": "^12.23.24",
|
||||
"lucide-react": "^0.554.0",
|
||||
"luxon": "^3.7.2",
|
||||
"perfect-freehand": "^1.2.2",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"react": "^18",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-day-picker": "^8.7.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18",
|
||||
"react-hook-form": "^7.45.4",
|
||||
"react-pdf": "7.7.3",
|
||||
"react-rnd": "^10.4.1",
|
||||
"remeda": "^2.17.3",
|
||||
"tailwind-merge": "^1.12.0",
|
||||
"tailwindcss-animate": "^1.0.5",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"react-hook-form": "^7.66.1",
|
||||
"react-pdf": "^10.2.0",
|
||||
"react-rnd": "^10.5.2",
|
||||
"remeda": "^2.32.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ export interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
columnVisibility?: VisibilityState;
|
||||
data: TData[];
|
||||
onRowClick?: (row: TData) => void;
|
||||
rowClassName?: string;
|
||||
perPage?: number;
|
||||
currentPage?: number;
|
||||
totalPages?: number;
|
||||
@@ -52,6 +54,8 @@ export function DataTable<TData, TValue>({
|
||||
hasFilters,
|
||||
onClearFilters,
|
||||
onPaginationChange,
|
||||
onRowClick,
|
||||
rowClassName,
|
||||
children,
|
||||
emptyState,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
@@ -116,7 +120,12 @@ export function DataTable<TData, TValue>({
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
className={rowClassName}
|
||||
onClick={() => onRowClick?.(row.original)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
|
||||
10
packages/ui/primitives/pdf-viewer/base.client.tsx
Normal file
10
packages/ui/primitives/pdf-viewer/base.client.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import {
|
||||
type LoadedPDFDocument,
|
||||
type OnPDFViewerPageClick,
|
||||
PDFViewer,
|
||||
type PDFViewerProps,
|
||||
} from './base';
|
||||
|
||||
export { PDFViewer, type LoadedPDFDocument, type OnPDFViewerPageClick, type PDFViewerProps };
|
||||
|
||||
export default PDFViewer;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
@@ -10,26 +10,27 @@ import { type PDFDocumentProxy } from 'pdfjs-dist';
|
||||
import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
// import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
|
||||
// import 'react-pdf/dist/esm/Page/TextLayer.css';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
import { useToast } from './use-toast';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useToast } from '../use-toast';
|
||||
|
||||
export type LoadedPDFDocument = PDFDocumentProxy;
|
||||
|
||||
/**
|
||||
* This imports the worker from the `pdfjs-dist` package.
|
||||
* Wrapped in typeof window check to prevent SSR evaluation.
|
||||
*/
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.js',
|
||||
import.meta.url,
|
||||
).toString();
|
||||
if (typeof window !== 'undefined') {
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/legacy/build/pdf.worker.min.mjs',
|
||||
import.meta.url,
|
||||
).toString();
|
||||
}
|
||||
|
||||
const pdfViewerOptions = {
|
||||
cMapUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/static/cmaps`,
|
||||
cMapUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/static/cmaps/`,
|
||||
};
|
||||
|
||||
export type OnPDFViewerPageClick = (_event: {
|
||||
@@ -44,9 +45,9 @@ export type OnPDFViewerPageClick = (_event: {
|
||||
|
||||
const PDFLoader = () => (
|
||||
<>
|
||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||
<Loader className="h-12 w-12 animate-spin text-documenso" />
|
||||
|
||||
<p className="text-muted-foreground mt-4">
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
<Trans>Loading document...</Trans>
|
||||
</p>
|
||||
</>
|
||||
@@ -61,6 +62,7 @@ export type PDFViewerProps = {
|
||||
onDocumentLoad?: (_doc: LoadedPDFDocument) => void;
|
||||
onPageClick?: OnPDFViewerPageClick;
|
||||
overrideData?: string;
|
||||
customPageRenderer?: React.FunctionComponent;
|
||||
[key: string]: unknown;
|
||||
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;
|
||||
|
||||
@@ -73,6 +75,7 @@ export const PDFViewer = ({
|
||||
onDocumentLoad,
|
||||
onPageClick,
|
||||
overrideData,
|
||||
customPageRenderer,
|
||||
...props
|
||||
}: PDFViewerProps) => {
|
||||
const { _ } = useLingui();
|
||||
@@ -91,6 +94,16 @@ export const PDFViewer = ({
|
||||
|
||||
const isLoading = isDocumentBytesLoading || !documentBytes;
|
||||
|
||||
const envelopeItemFile = useMemo(() => {
|
||||
if (!documentBytes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
data: documentBytes,
|
||||
};
|
||||
}, [documentBytes]);
|
||||
|
||||
const onDocumentLoaded = (doc: LoadedPDFDocument) => {
|
||||
setNumPages(doc.numPages);
|
||||
onDocumentLoad?.(doc);
|
||||
@@ -203,7 +216,7 @@ export const PDFViewer = ({
|
||||
) : (
|
||||
<>
|
||||
<PDFDocument
|
||||
file={documentBytes.buffer}
|
||||
file={envelopeItemFile}
|
||||
className={cn('w-full overflow-hidden rounded', {
|
||||
'h-[80vh] max-h-[60rem]': numPages === 0,
|
||||
})}
|
||||
@@ -215,9 +228,9 @@ export const PDFViewer = ({
|
||||
}}
|
||||
externalLinkTarget="_blank"
|
||||
loading={
|
||||
<div className="dark:bg-background flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50">
|
||||
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50 dark:bg-background">
|
||||
{pdfError ? (
|
||||
<div className="text-muted-foreground text-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p>
|
||||
<Trans>Something went wrong while loading the document.</Trans>
|
||||
</p>
|
||||
@@ -231,8 +244,8 @@ export const PDFViewer = ({
|
||||
</div>
|
||||
}
|
||||
error={
|
||||
<div className="dark:bg-background flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50">
|
||||
<div className="text-muted-foreground text-center">
|
||||
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50 dark:bg-background">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p>
|
||||
<Trans>Something went wrong while loading the document.</Trans>
|
||||
</p>
|
||||
@@ -242,23 +255,25 @@ export const PDFViewer = ({
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
// options={pdfViewerOptions}
|
||||
options={pdfViewerOptions}
|
||||
>
|
||||
{Array(numPages)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<div key={i} className="last:-mb-2">
|
||||
<div className="border-border overflow-hidden rounded border will-change-transform">
|
||||
<div className="overflow-hidden rounded border border-border will-change-transform">
|
||||
<PDFPage
|
||||
pageNumber={i + 1}
|
||||
width={width}
|
||||
renderAnnotationLayer={false}
|
||||
renderTextLayer={false}
|
||||
loading={() => ''}
|
||||
renderMode={customPageRenderer ? 'custom' : 'canvas'}
|
||||
customRenderer={customPageRenderer}
|
||||
onClick={(e) => onDocumentPageClick(e, i + 1)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground/80 my-2 text-center text-[11px]">
|
||||
<p className="my-2 text-center text-[11px] text-muted-foreground/80">
|
||||
<Trans>
|
||||
Page {i + 1} of {numPages}
|
||||
</Trans>
|
||||
1
packages/ui/primitives/pdf-viewer/index.ts
Normal file
1
packages/ui/primitives/pdf-viewer/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './base';
|
||||
7
packages/ui/primitives/pdf-viewer/lazy.tsx
Normal file
7
packages/ui/primitives/pdf-viewer/lazy.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { ClientOnly } from '../../components/client-only';
|
||||
|
||||
import { PDFViewer, type PDFViewerProps } from './base.client';
|
||||
|
||||
export const PDFViewerLazy = (props: PDFViewerProps) => {
|
||||
return <ClientOnly fallback={<div>Loading...</div>}>{() => <PDFViewer {...props} />}</ClientOnly>;
|
||||
};
|
||||
Reference in New Issue
Block a user