Compare commits

...

14 Commits

Author SHA1 Message Date
Lucas Smith 607f22513a feat: allow adding audit logs to compeleted document 2025-05-19 09:10:08 +10:00
Ephraim Duncan 99b0ad574e feat: bulk add fields (#1683)
## Demo

![CleanShot 2025-03-04 at 02 17
47](https://github.com/user-attachments/assets/2cffaee3-9933-49e9-bdab-eadfd4c35030)

---------

Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
2025-05-14 19:35:32 +00:00
Ephraim Duncan 9594e1fee8 chore: minor ui fixes (#1793) 2025-05-14 20:08:03 +10:00
Lucas Smith 5e3a2b8f76 fix: allow prefilling date field (#1794)
Allows the prefilling of date fields when creating a document from a
template.

Current implementation is super dirty and should be replaced asap.
2025-05-14 20:06:53 +10:00
Ephraim Duncan f928503a33 chore: update dropdown icons (#1790)
### Before

![CleanShot 2025-05-12 at 11 11
05@2x](https://github.com/user-attachments/assets/af2a60bf-9676-405d-8c3d-e6b2256b53ae)

### After

![CleanShot 2025-05-12 at 11 10
25@2x](https://github.com/user-attachments/assets/aec67e9c-f0f2-4b0d-9baa-7aa327680cf1)
2025-05-14 16:44:13 +10:00
Ephraim Duncan c389670785 fix: trigger webhook for duplicated documents (#1789) 2025-05-14 16:43:31 +10:00
Lucas Smith 99ad2eb645 fix: allow download of original document via api (#1788) 2025-05-14 08:22:11 +10:00
Lucas Smith 2f48679b0b fix: make lang cookie httpOnly (#1783) 2025-05-08 15:59:43 +10:00
Mythie e40c5d9d24 v1.10.3 2025-05-03 09:23:25 +10:00
Mythie ab323f149f fix: resolve issue with uploading templates 2025-05-03 09:23:17 +10:00
Mythie bf1c1ff9dc v1.10.2 2025-05-03 08:11:27 +10:00
Mythie 516e237966 fix: resolve issue with uploading templates 2025-05-03 08:09:44 +10:00
Mythie ac7d24eb12 v1.10.1 2025-05-03 07:39:19 +10:00
Mythie 0931c472a7 fix: resolve issue with uploading templates 2025-05-03 07:38:48 +10:00
37 changed files with 3235 additions and 232 deletions
@@ -173,34 +173,59 @@ export const ConfigureFieldsView = ({
});
const onFieldCopy = useCallback(
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => {
const { duplicate = false } = options ?? {};
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
const { duplicate = false, duplicateAll = false } = options ?? {};
if (lastActiveField) {
event?.preventDefault();
if (!duplicate) {
setFieldClipboard(lastActiveField);
if (duplicate) {
const newField: TConfigureFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
nativeId: undefined,
formId: nanoid(12),
signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail,
recipientId: selectedRecipient?.id ?? lastActiveField.recipientId,
pageX: lastActiveField.pageX + 3,
pageY: lastActiveField.pageY + 3,
};
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
append(newField);
return;
}
if (duplicateAll) {
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
pages.forEach((_, index) => {
const pageNumber = index + 1;
if (pageNumber === lastActiveField.pageNumber) {
return;
}
const newField: TConfigureFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
nativeId: undefined,
formId: nanoid(12),
signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail,
recipientId: selectedRecipient?.id ?? lastActiveField.recipientId,
pageNumber,
};
append(newField);
});
return;
}
const newField: TConfigureFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
nativeId: undefined,
formId: nanoid(12),
signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail,
recipientId: selectedRecipient?.id ?? lastActiveField.recipientId,
pageX: lastActiveField.pageX + 3,
pageY: lastActiveField.pageY + 3,
};
setFieldClipboard(lastActiveField);
append(newField);
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
});
}
},
[append, lastActiveField, selectedRecipient?.email, selectedRecipient?.id, toast],
@@ -533,6 +558,7 @@ export const ConfigureFieldsView = ({
onMove={(node) => onFieldMove(node, index)}
onRemove={() => remove(index)}
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
onFocus={() => setLastActiveField(field)}
onBlur={() => setLastActiveField(null)}
onAdvancedSettings={() => {
@@ -44,6 +44,7 @@ const ZTeamDocumentPreferencesFormSchema = z.object({
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
includeSenderDetails: z.boolean(),
includeSigningCertificate: z.boolean(),
includeAuditLog: z.boolean(),
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
message: msg`At least one signature type must be enabled`.id,
}),
@@ -77,6 +78,7 @@ export const TeamDocumentPreferencesForm = ({
: 'en',
includeSenderDetails: settings?.includeSenderDetails ?? false,
includeSigningCertificate: settings?.includeSigningCertificate ?? true,
includeAuditLog: settings?.includeAuditLog ?? false,
signatureTypes: extractTeamSignatureSettings(settings),
},
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
@@ -91,6 +93,7 @@ export const TeamDocumentPreferencesForm = ({
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
signatureTypes,
} = data;
@@ -101,6 +104,7 @@ export const TeamDocumentPreferencesForm = ({
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
@@ -307,7 +311,7 @@ export const TeamDocumentPreferencesForm = ({
<FormDescription>
<Trans>
Controls whether the signing certificate will be included in the document when
Controls whether the signing certificate will be included with the document when
it is downloaded. The signing certificate can still be downloaded from the logs
page separately.
</Trans>
@@ -316,6 +320,36 @@ export const TeamDocumentPreferencesForm = ({
)}
/>
<FormField
control={form.control}
name="includeAuditLog"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Include the Audit Log in the Document</Trans>
</FormLabel>
<div>
<FormControl className="block">
<Switch
ref={field.ref}
name={field.name}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormDescription>
<Trans>
Controls whether the audit log will be included with the document when it is
downloaded. The audit log can still be downloaded from the logs page separately.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
@@ -12,7 +12,14 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
SplitButton,
SplitButtonAction,
SplitButtonDropdown,
SplitButtonDropdownItem,
} from '@documenso/ui/primitives/split-button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentPageViewButtonProps = {
@@ -42,6 +49,12 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
? `${documentsPath}/f/${document.folderId}/${document.id}/edit`
: `${documentsPath}/${document.id}/edit`;
const { mutateAsync: downloadCertificate, isPending: isDownloadingCertificate } =
trpc.document.downloadCertificate.useMutation();
const { mutateAsync: downloadAuditLogs, isPending: isDownloadingAuditLogs } =
trpc.document.downloadAuditLogs.useMutation();
const onDownloadClick = async () => {
try {
const documentWithData = await trpcClient.document.getDocumentById.query(
@@ -71,6 +84,125 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
}
};
const onDownloadOriginalClick = async () => {
try {
const documentWithData = await trpcClient.document.getDocumentById.query(
{
documentId: document.id,
},
{
context: {
teamId: document.team?.id?.toString(),
},
},
);
const documentData = documentWithData?.documentData;
if (!documentData) {
throw new Error('No document available');
}
await downloadPDF({ documentData, fileName: documentWithData.title, version: 'original' });
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
};
const onDownloadCertificateClick = async () => {
try {
const { url } = await downloadCertificate({ documentId: document.id });
const iframe = Object.assign(window.document.createElement('iframe'), {
src: url,
});
Object.assign(iframe.style, {
position: 'fixed',
top: '0',
left: '0',
width: '0',
height: '0',
});
const onLoaded = () => {
if (iframe.contentDocument?.readyState === 'complete') {
iframe.contentWindow?.print();
iframe.contentWindow?.addEventListener('afterprint', () => {
window.document.body.removeChild(iframe);
});
}
};
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
window.document.body.appendChild(iframe);
onLoaded();
} catch (error) {
console.error(error);
toast({
title: _(msg`Something went wrong`),
description: _(
msg`Sorry, we were unable to download the certificate. Please try again later.`,
),
variant: 'destructive',
});
}
};
const onDownloadAuditLogClick = async () => {
try {
const { url } = await downloadAuditLogs({ documentId: document.id });
const iframe = Object.assign(window.document.createElement('iframe'), {
src: url,
});
Object.assign(iframe.style, {
position: 'fixed',
top: '0',
left: '0',
width: '0',
height: '0',
});
const onLoaded = () => {
if (iframe.contentDocument?.readyState === 'complete') {
iframe.contentWindow?.print();
iframe.contentWindow?.addEventListener('afterprint', () => {
window.document.body.removeChild(iframe);
});
}
};
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
window.document.body.appendChild(iframe);
onLoaded();
} catch (error) {
console.error(error);
toast({
title: _(msg`Something went wrong`),
description: _(
msg`Sorry, we were unable to download the audit logs. Please try again later.`,
),
variant: 'destructive',
});
}
};
return match({
isRecipient,
isPending,
@@ -110,10 +242,26 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
</Button>
))
.with({ isComplete: true }, () => (
<Button className="w-full" onClick={onDownloadClick}>
<Download className="-ml-1 mr-2 inline h-4 w-4" />
<Trans>Download</Trans>
</Button>
<SplitButton className="w-full">
<SplitButtonAction onClick={onDownloadClick}>
<Download className="-ml-1 mr-2 inline h-4 w-4" />
<Trans>Download</Trans>
</SplitButtonAction>
<SplitButtonDropdown>
<SplitButtonDropdownItem onClick={onDownloadOriginalClick}>
<Trans>Download Original Document</Trans>
</SplitButtonDropdownItem>
<SplitButtonDropdownItem onClick={onDownloadCertificateClick}>
<Trans>Download Document Certificate</Trans>
</SplitButtonDropdownItem>
<SplitButtonDropdownItem onClick={onDownloadAuditLogClick}>
<Trans>Download Audit Log</Trans>
</SplitButtonDropdownItem>
</SplitButtonDropdown>
</SplitButton>
))
.otherwise(() => null);
};
@@ -10,6 +10,8 @@ import {
Download,
Edit,
EyeIcon,
FileDown,
FolderInput,
Loader,
MoreHorizontal,
MoveRight,
@@ -182,7 +184,7 @@ export const DocumentsTableActionDropdown = ({
</DropdownMenuItem>
<DropdownMenuItem onClick={onDownloadOriginalClick}>
<Download className="mr-2 h-4 w-4" />
<FileDown className="mr-2 h-4 w-4" />
<Trans>Download Original</Trans>
</DropdownMenuItem>
@@ -201,7 +203,7 @@ export const DocumentsTableActionDropdown = ({
{onMoveDocument && (
<DropdownMenuItem onClick={onMoveDocument} onSelect={(e) => e.preventDefault()}>
<MoveRight className="mr-2 h-4 w-4" />
<FolderInput className="mr-2 h-4 w-4" />
<Trans>Move to Folder</Trans>
</DropdownMenuItem>
)}
@@ -69,8 +69,6 @@ export const TemplatesTableActionDropdown = ({
? `${templateRootPath}/f/${row.folderId}/${row.id}/edit`
: `${templateRootPath}/${row.id}/edit`;
return (
<DropdownMenu>
<DropdownMenuTrigger data-testid="template-table-action-btn">
@@ -119,7 +119,7 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
return (
<div
className={cn(
'-mx-4 flex flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44',
'-mx-4 flex flex-col items-center overflow-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44',
{ 'pt-0 lg:pt-0 xl:pt-0': canSignUp },
)}
>
@@ -1,6 +1,10 @@
import { createCookie } from 'react-router';
import { env } from '@documenso/lib/utils/env';
export const langCookie = createCookie('lang', {
path: '/',
maxAge: 60 * 60 * 24 * 365 * 2,
httpOnly: true,
secure: env('NODE_ENV') === 'production',
});
+1 -1
View File
@@ -100,5 +100,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "1.10.0"
"version": "1.10.3"
}
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "1.10.0",
"version": "1.10.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "1.10.0",
"version": "1.10.3",
"workspaces": [
"apps/*",
"packages/*"
@@ -95,7 +95,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "1.10.0",
"version": "1.10.3",
"dependencies": {
"@documenso/api": "*",
"@documenso/assets": "*",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"private": true,
"version": "1.10.0",
"version": "1.10.3",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",
+2
View File
@@ -11,6 +11,7 @@ import {
ZDeleteDocumentMutationSchema,
ZDeleteFieldMutationSchema,
ZDeleteRecipientMutationSchema,
ZDownloadDocumentQuerySchema,
ZDownloadDocumentSuccessfulSchema,
ZFindTeamMembersResponseSchema,
ZGenerateDocumentFromTemplateMutationResponseSchema,
@@ -71,6 +72,7 @@ export const ApiContractV1 = c.router(
downloadSignedDocument: {
method: 'GET',
path: '/api/v1/documents/:id/download',
query: ZDownloadDocumentQuerySchema,
responses: {
200: ZDownloadDocumentSuccessfulSchema,
401: ZUnsuccessfulResponseSchema,
+5 -2
View File
@@ -142,6 +142,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
downloadSignedDocument: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId } = args.params;
const { downloadOriginalDocument } = args.query;
try {
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
@@ -177,7 +178,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
};
}
if (!isDocumentCompleted(document.status)) {
if (!downloadOriginalDocument && !isDocumentCompleted(document.status)) {
return {
status: 400,
body: {
@@ -186,7 +187,9 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
};
}
const { url } = await getPresignGetUrl(document.documentData.data);
const { url } = await getPresignGetUrl(
downloadOriginalDocument ? document.documentData.data : document.documentData.initialData,
);
return {
status: 200,
+9
View File
@@ -119,6 +119,15 @@ export const ZUploadDocumentSuccessfulSchema = z.object({
key: z.string(),
});
export const ZDownloadDocumentQuerySchema = z.object({
downloadOriginalDocument: z
.preprocess((val) => String(val) === 'true' || String(val) === '1', z.boolean())
.optional()
.default(false),
});
export type TDownloadDocumentQuerySchema = z.infer<typeof ZDownloadDocumentQuerySchema>;
export const ZDownloadDocumentSuccessfulSchema = z.object({
downloadUrl: z.string(),
});
@@ -16,6 +16,7 @@ const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
documentLanguage: z.string(),
includeSenderDetails: z.boolean(),
includeSigningCertificate: z.boolean(),
includeAuditLog: z.boolean(),
brandingEnabled: z.boolean(),
brandingLogo: z.string(),
brandingUrl: z.string(),
@@ -9,6 +9,7 @@ import { signPdf } from '@documenso/signing';
import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
import { getAuditLogPdf } from '../../../server-only/htmltopdf/get-audit-log-pdf';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
@@ -52,6 +53,7 @@ export const run = async ({
teamGlobalSettings: {
select: {
includeSigningCertificate: true,
includeAuditLog: true,
},
},
},
@@ -152,6 +154,13 @@ export const run = async ({
}).catch(() => null)
: null;
const auditLogData =
(document.team?.teamGlobalSettings?.includeAuditLog ?? false)
? await getAuditLogPdf({
documentId,
}).catch(() => null)
: null;
const newDataId = await io.runTask('decorate-and-sign-pdf', async () => {
const pdfDoc = await PDFDocument.load(pdfData);
@@ -178,6 +187,16 @@ export const run = async ({
});
}
if (auditLogData) {
const auditLogDoc = await PDFDocument.load(auditLogData);
const auditLogPages = await pdfDoc.copyPages(auditLogDoc, auditLogDoc.getPageIndices());
auditLogPages.forEach((page) => {
pdfDoc.addPage(page);
});
}
for (const field of fields) {
if (field.inserted) {
document.useLegacyFieldInsertion
@@ -1,9 +1,14 @@
import { DocumentSource, type Prisma } from '@prisma/client';
import { DocumentSource, type Prisma, WebhookTriggerEvents } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { prefixedId } from '../../universal/id';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getDocumentWhereInput } from './get-document-by-id';
export interface DuplicateDocumentOptions {
@@ -86,7 +91,24 @@ export const duplicateDocument = async ({
};
}
const createdDocument = await prisma.document.create(createDocumentArguments);
const createdDocument = await prisma.document.create({
...createDocumentArguments,
include: {
recipients: true,
documentMeta: true,
},
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse({
...mapDocumentToWebhookDocumentPayload(createdDocument),
recipients: createdDocument.recipients,
documentMeta: createdDocument.documentMeta,
}),
userId: userId,
teamId: teamId,
});
return {
documentId: createdDocument.id,
@@ -17,6 +17,7 @@ import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers';
import { getAuditLogPdf } from '../htmltopdf/get-audit-log-pdf';
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations';
@@ -53,6 +54,7 @@ export const sealDocument = async ({
teamGlobalSettings: {
select: {
includeSigningCertificate: true,
includeAuditLog: true,
},
},
},
@@ -124,6 +126,13 @@ export const sealDocument = async ({
}).catch(() => null)
: null;
const auditLogData =
(document.team?.teamGlobalSettings?.includeAuditLog ?? false)
? await getAuditLogPdf({
documentId,
}).catch(() => null)
: null;
const doc = await PDFDocument.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature
@@ -146,6 +155,16 @@ export const sealDocument = async ({
});
}
if (auditLogData) {
const auditLog = await PDFDocument.load(auditLogData);
const auditLogPages = await doc.copyPages(auditLog, auditLog.getPageIndices());
auditLogPages.forEach((page) => {
doc.addPage(page);
});
}
for (const field of fields) {
document.useLegacyFieldInsertion
? await legacy_insertFieldInPDF(doc, field)
@@ -0,0 +1,69 @@
import { DateTime } from 'luxon';
import type { Browser } from 'playwright';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { type SupportedLanguageCodes, isValidLanguageCode } from '../../constants/i18n';
import { env } from '../../utils/env';
import { encryptSecondaryData } from '../crypto/encrypt';
export type GetAuditLogPdfOptions = {
documentId: number;
// eslint-disable-next-line @typescript-eslint/ban-types
language?: SupportedLanguageCodes | (string & {});
};
export const getAuditLogPdf = async ({ documentId, language }: GetAuditLogPdfOptions) => {
const { chromium } = await import('playwright');
const encryptedId = encryptSecondaryData({
data: documentId.toString(),
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
});
let browser: Browser;
const browserlessUrl = env('NEXT_PRIVATE_BROWSERLESS_URL');
if (browserlessUrl) {
// !: Use CDP rather than the default `connect` method to avoid coupling to the playwright version.
// !: Previously we would have to keep the playwright version in sync with the browserless version to avoid errors.
browser = await chromium.connectOverCDP(browserlessUrl);
} else {
browser = await chromium.launch();
}
if (!browser) {
throw new Error(
'Failed to establish a browser, please ensure you have either a Browserless.io url or chromium browser installed',
);
}
const browserContext = await browser.newContext();
const page = await browserContext.newPage();
const lang = isValidLanguageCode(language) ? language : 'en';
await page.context().addCookies([
{
name: 'language',
value: lang,
url: NEXT_PUBLIC_WEBAPP_URL(),
},
]);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encryptedId}`, {
waitUntil: 'networkidle',
timeout: 10_000,
});
const result = await page.pdf({
format: 'A4',
});
await browserContext.close();
void browser.close();
return result;
};
@@ -9,11 +9,13 @@ import {
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import type { SupportedLanguageCodes } from '../../constants/i18n';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
@@ -508,10 +510,8 @@ export const createDocumentFromTemplate = async ({
fieldsToCreate = fieldsToCreate.concat(
fields.map((field) => {
const prefillField = prefillFields?.find((value) => value.id === field.id);
// Use type assertion to help TypeScript understand the structure
const updatedFieldMeta = getUpdatedFieldMeta(field, prefillField);
return {
const payload = {
documentId: document.id,
recipientId: recipient.id,
type: field.type,
@@ -522,8 +522,38 @@ export const createDocumentFromTemplate = async ({
height: field.height,
customText: '',
inserted: false,
fieldMeta: updatedFieldMeta,
fieldMeta: field.fieldMeta,
};
if (prefillField) {
match(prefillField)
.with({ type: 'date' }, (selector) => {
if (!selector.value) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Date value is required for field ${field.id}`,
});
}
const date = new Date(selector.value);
if (isNaN(date.getTime())) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid date value for field ${field.id}: ${selector.value}`,
});
}
payload.customText = DateTime.fromJSDate(date).toFormat(
template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
);
payload.inserted = true;
})
.otherwise((selector) => {
payload.fieldMeta = getUpdatedFieldMeta(field, selector);
});
}
return payload;
}),
);
});
@@ -44,28 +44,36 @@ export const createTemplate = async ({
}
}
const folder = await prisma.folder.findFirstOrThrow({
where: {
id: folderId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
if (folderId) {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
},
}
: {
userId,
teamId: null,
}),
},
});
}
: {
userId,
teamId: null,
}),
},
});
if (!team) {
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
}
if (teamId && !team) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
@@ -75,7 +83,7 @@ export const createTemplate = async ({
userId,
templateDocumentDataId,
teamId,
folderId: folder.id,
folderId: folderId,
templateMeta: {
create: {
language: team?.teamGlobalSettings?.documentLanguage,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4
View File
@@ -155,6 +155,10 @@ export const ZFieldMetaPrefillFieldsSchema = z
label: z.string().optional(),
value: z.string().optional(),
}),
z.object({
type: z.literal('date'),
value: z.string().optional(),
}),
]),
);
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "includeAuditLog" BOOLEAN NOT NULL DEFAULT false;
+7 -5
View File
@@ -582,11 +582,13 @@ enum TeamMemberInviteStatus {
}
model TeamGlobalSettings {
teamId Int @unique
documentVisibility DocumentVisibility @default(EVERYONE)
documentLanguage String @default("en")
includeSenderDetails Boolean @default(true)
includeSigningCertificate Boolean @default(true)
teamId Int @unique
documentVisibility DocumentVisibility @default(EVERYONE)
documentLanguage String @default("en")
includeSenderDetails Boolean @default(true)
includeSigningCertificate Boolean @default(true)
includeAuditLog Boolean @default(false)
typedSignatureEnabled Boolean @default(true)
uploadSignatureEnabled Boolean @default(true)
@@ -23,6 +23,7 @@ export const updateTeamDocumentSettingsRoute = authenticatedProcedure
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
@@ -54,6 +55,7 @@ export const updateTeamDocumentSettingsRoute = authenticatedProcedure
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
@@ -63,6 +65,7 @@ export const updateTeamDocumentSettingsRoute = authenticatedProcedure
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
@@ -14,6 +14,7 @@ export const ZUpdateTeamDocumentSettingsRequestSchema = z.object({
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).optional().default('en'),
includeSenderDetails: z.boolean().optional().default(false),
includeSigningCertificate: z.boolean().optional().default(true),
includeAuditLog: z.boolean().optional().default(false),
typedSignatureEnabled: z.boolean().optional().default(true),
uploadSignatureEnabled: z.boolean().optional().default(true),
drawSignatureEnabled: z.boolean().optional().default(true),
+7 -4
View File
@@ -24,7 +24,7 @@ export const SigningCard = ({
signingCelebrationImage,
}: SigningCardProps) => {
return (
<div className={cn('relative w-full max-w-xs md:max-w-sm', className)}>
<div className={cn('relative w-full max-w-sm md:max-w-md', className)}>
<SigningCardContent name={name} signature={signature} />
{signingCelebrationImage && (
@@ -48,7 +48,7 @@ export const SigningCard3D = ({
const [trackMouse, setTrackMouse] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout>();
const timeoutRef = useRef<number | undefined>();
const cardX = useMotionValue(0);
const cardY = useMotionValue(0);
@@ -103,7 +103,7 @@ export const SigningCard3D = ({
clearTimeout(timeoutRef.current);
// Revert the card back to the center position after the mouse stops moving.
timeoutRef.current = setTimeout(() => {
timeoutRef.current = window.setTimeout(() => {
void animate(cardX, 0, { duration: 2, ease: 'backInOut' });
void animate(cardY, 0, { duration: 2, ease: 'backInOut' });
@@ -120,12 +120,15 @@ export const SigningCard3D = ({
return () => {
window.removeEventListener('mousemove', onMouseMove);
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
};
}, [onMouseMove]);
return (
<div
className={cn('relative w-full max-w-xs md:max-w-sm', className)}
className={cn('relative w-full max-w-sm md:max-w-md', className)}
style={{ perspective: 800 }}
>
<motion.div
@@ -400,35 +400,60 @@ export const AddFieldsFormPartial = ({
);
const onFieldCopy = useCallback(
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => {
const { duplicate = false } = options ?? {};
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
const { duplicate = false, duplicateAll = false } = options ?? {};
if (lastActiveField) {
event?.preventDefault();
if (!duplicate) {
setFieldClipboard(lastActiveField);
if (duplicate) {
const newField: TAddFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
nativeId: undefined,
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
pageX: lastActiveField.pageX + 3,
pageY: lastActiveField.pageY + 3,
};
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
append(newField);
return;
}
if (duplicateAll) {
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
pages.forEach((_, index) => {
const pageNumber = index + 1;
if (pageNumber === lastActiveField.pageNumber) {
return;
}
const newField: TAddFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
nativeId: undefined,
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
pageNumber,
};
append(newField);
});
return;
}
const newField: TAddFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
pageX: lastActiveField.pageX + 3,
pageY: lastActiveField.pageY + 3,
};
setFieldClipboard(lastActiveField);
append(newField);
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
});
}
},
[append, lastActiveField, selectedSigner?.email, toast],
[append, lastActiveField, selectedSigner?.email, selectedSigner?.id, toast],
);
const onFieldPaste = useCallback(
@@ -641,6 +666,7 @@ export const AddFieldsFormPartial = ({
onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)}
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
onAdvancedSettings={() => {
setCurrentField(field);
handleAdvancedSettings();
@@ -311,6 +311,7 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
/>
))
.otherwise(() => null)}
{errors.length > 0 && (
<div className="mt-4">
<ul>
@@ -323,6 +324,7 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
</div>
)}
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter className="mt-auto">
<DocumentFlowFormContainerActions
goNextLabel={msg`Save`}
@@ -1,7 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { FieldType } from '@prisma/client';
import { CopyPlus, Settings2, Trash } from 'lucide-react';
import { CopyPlus, Settings2, SquareStack, Trash } from 'lucide-react';
import { createPortal } from 'react-dom';
import { Rnd } from 'react-rnd';
@@ -29,6 +31,7 @@ export type FieldItemProps = {
onMove?: (_node: HTMLElement) => void;
onRemove?: () => void;
onDuplicate?: () => void;
onDuplicateAllPages?: () => void;
onAdvancedSettings?: () => void;
onFocus?: () => void;
onBlur?: () => void;
@@ -55,15 +58,18 @@ export const FieldItem = ({
onMove,
onRemove,
onDuplicate,
onDuplicateAllPages,
onAdvancedSettings,
onFocus,
onBlur,
onAdvancedSettings,
recipientIndex = 0,
hasErrors,
active,
onFieldActivate,
onFieldDeactivate,
}: FieldItemProps) => {
const { _ } = useLingui();
const [coords, setCoords] = useState({
pageX: 0,
pageY: 0,
@@ -304,6 +310,7 @@ export const FieldItem = ({
<div className="group flex items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5">
{advancedField && (
<button
title={_(msg`Advanced settings`)}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onAdvancedSettings}
onTouchEnd={onAdvancedSettings}
@@ -313,6 +320,7 @@ export const FieldItem = ({
)}
<button
title={_(msg`Duplicate`)}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onDuplicate}
onTouchEnd={onDuplicate}
@@ -321,6 +329,16 @@ export const FieldItem = ({
</button>
<button
title={_(msg`Duplicate on all pages`)}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onDuplicateAllPages}
onTouchEnd={onDuplicateAllPages}
>
<SquareStack className="h-3 w-3" />
</button>
<button
title={_(msg`Remove`)}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onRemove}
onTouchEnd={onRemove}
+83
View File
@@ -0,0 +1,83 @@
import * as React from 'react';
import { ChevronDown } from 'lucide-react';
import { cn } from '../lib/utils';
import { Button } from './button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from './dropdown-menu';
const SplitButtonContext = React.createContext<{
variant?: React.ComponentProps<typeof Button>['variant'];
size?: React.ComponentProps<typeof Button>['size'];
}>({});
const SplitButton = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & {
variant?: React.ComponentProps<typeof Button>['variant'];
size?: React.ComponentProps<typeof Button>['size'];
}
>(({ className, children, variant = 'default', size = 'default', ...props }, ref) => {
return (
<SplitButtonContext.Provider value={{ variant, size }}>
<div ref={ref} className={cn('inline-flex', className)} {...props}>
{children}
</div>
</SplitButtonContext.Provider>
);
});
SplitButton.displayName = 'SplitButton';
const SplitButtonAction = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ className, children, ...props }, ref) => {
const { variant, size } = React.useContext(SplitButtonContext);
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn('flex-1 rounded-r-none border-r-0', className)}
{...props}
>
{children}
</Button>
);
});
SplitButtonAction.displayName = 'SplitButtonAction';
const SplitButtonDropdown = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ children, ...props }, ref) => {
const { variant, size } = React.useContext(SplitButtonContext);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant={variant}
size={size}
className="rounded-l-none px-2 focus-visible:ring-offset-0"
>
<ChevronDown className="h-4 w-4" />
<span className="sr-only">More options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" {...props} ref={ref}>
{children}
</DropdownMenuContent>
</DropdownMenu>
);
},
);
SplitButtonDropdown.displayName = 'SplitButtonDropdown';
const SplitButtonDropdownItem = DropdownMenuItem;
export { SplitButton, SplitButtonAction, SplitButtonDropdown, SplitButtonDropdownItem };
@@ -139,44 +139,64 @@ export const AddTemplateFieldsFormPartial = ({
);
const onFieldCopy = useCallback(
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => {
const { duplicate = false } = options ?? {};
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
const { duplicate = false, duplicateAll = false } = options ?? {};
if (lastActiveField) {
event?.preventDefault();
if (!duplicate) {
setFieldClipboard(lastActiveField);
if (duplicate) {
const newField: TAddTemplateFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
nativeId: undefined,
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
signerId: selectedSigner?.id ?? lastActiveField.signerId,
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
pageX: lastActiveField.pageX + 3,
pageY: lastActiveField.pageY + 3,
};
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
append(newField);
return;
}
if (duplicateAll) {
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
pages.forEach((_, index) => {
const pageNumber = index + 1;
if (pageNumber === lastActiveField.pageNumber) {
return;
}
const newField: TAddTemplateFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
nativeId: undefined,
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
signerId: selectedSigner?.id ?? lastActiveField.signerId,
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
pageNumber,
};
append(newField);
});
return;
}
const newField: TAddTemplateFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
signerId: selectedSigner?.id ?? lastActiveField.signerId,
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
pageX: lastActiveField.pageX + 3,
pageY: lastActiveField.pageY + 3,
};
setFieldClipboard(lastActiveField);
append(newField);
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
});
}
},
[
append,
lastActiveField,
selectedSigner?.email,
selectedSigner?.id,
selectedSigner?.token,
toast,
],
[append, lastActiveField, selectedSigner?.email, selectedSigner?.id, toast],
);
const onFieldPaste = useCallback(
@@ -543,6 +563,7 @@ export const AddTemplateFieldsFormPartial = ({
onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)}
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
onAdvancedSettings={() => {
setCurrentField(field);
handleAdvancedSettings();