mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 16:23:06 +10:00
Compare commits
7 Commits
chore/sing
...
feat/audit
| Author | SHA1 | Date | |
|---|---|---|---|
| 607f22513a | |||
| 99b0ad574e | |||
| 9594e1fee8 | |||
| 5e3a2b8f76 | |||
| f928503a33 | |||
| c389670785 | |||
| 99ad2eb645 |
@ -173,34 +173,59 @@ export const ConfigureFieldsView = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onFieldCopy = useCallback(
|
const onFieldCopy = useCallback(
|
||||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => {
|
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
||||||
const { duplicate = false } = options ?? {};
|
const { duplicate = false, duplicateAll = false } = options ?? {};
|
||||||
|
|
||||||
if (lastActiveField) {
|
if (lastActiveField) {
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
|
|
||||||
if (!duplicate) {
|
if (duplicate) {
|
||||||
setFieldClipboard(lastActiveField);
|
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({
|
append(newField);
|
||||||
title: 'Copied field',
|
|
||||||
description: 'Copied field to clipboard',
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newField: TConfigureFieldsFormSchema['fields'][0] = {
|
setFieldClipboard(lastActiveField);
|
||||||
...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,
|
|
||||||
};
|
|
||||||
|
|
||||||
append(newField);
|
toast({
|
||||||
|
title: 'Copied field',
|
||||||
|
description: 'Copied field to clipboard',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[append, lastActiveField, selectedRecipient?.email, selectedRecipient?.id, toast],
|
[append, lastActiveField, selectedRecipient?.email, selectedRecipient?.id, toast],
|
||||||
@ -533,6 +558,7 @@ export const ConfigureFieldsView = ({
|
|||||||
onMove={(node) => onFieldMove(node, index)}
|
onMove={(node) => onFieldMove(node, index)}
|
||||||
onRemove={() => remove(index)}
|
onRemove={() => remove(index)}
|
||||||
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
||||||
|
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
|
||||||
onFocus={() => setLastActiveField(field)}
|
onFocus={() => setLastActiveField(field)}
|
||||||
onBlur={() => setLastActiveField(null)}
|
onBlur={() => setLastActiveField(null)}
|
||||||
onAdvancedSettings={() => {
|
onAdvancedSettings={() => {
|
||||||
|
|||||||
@ -44,6 +44,7 @@ const ZTeamDocumentPreferencesFormSchema = z.object({
|
|||||||
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
|
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
|
||||||
includeSenderDetails: z.boolean(),
|
includeSenderDetails: z.boolean(),
|
||||||
includeSigningCertificate: z.boolean(),
|
includeSigningCertificate: z.boolean(),
|
||||||
|
includeAuditLog: z.boolean(),
|
||||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
|
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
|
||||||
message: msg`At least one signature type must be enabled`.id,
|
message: msg`At least one signature type must be enabled`.id,
|
||||||
}),
|
}),
|
||||||
@ -77,6 +78,7 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
: 'en',
|
: 'en',
|
||||||
includeSenderDetails: settings?.includeSenderDetails ?? false,
|
includeSenderDetails: settings?.includeSenderDetails ?? false,
|
||||||
includeSigningCertificate: settings?.includeSigningCertificate ?? true,
|
includeSigningCertificate: settings?.includeSigningCertificate ?? true,
|
||||||
|
includeAuditLog: settings?.includeAuditLog ?? false,
|
||||||
signatureTypes: extractTeamSignatureSettings(settings),
|
signatureTypes: extractTeamSignatureSettings(settings),
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
|
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
|
||||||
@ -91,6 +93,7 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
documentLanguage,
|
documentLanguage,
|
||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
|
includeAuditLog,
|
||||||
signatureTypes,
|
signatureTypes,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
@ -101,6 +104,7 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
documentLanguage,
|
documentLanguage,
|
||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
|
includeAuditLog,
|
||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
@ -307,7 +311,7 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
|
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
<Trans>
|
<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
|
it is downloaded. The signing certificate can still be downloaded from the logs
|
||||||
page separately.
|
page separately.
|
||||||
</Trans>
|
</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">
|
<div className="flex flex-row justify-end space-x-4">
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
<Trans>Update</Trans>
|
<Trans>Update</Trans>
|
||||||
|
|||||||
@ -12,7 +12,14 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
|
|||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
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';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type DocumentPageViewButtonProps = {
|
export type DocumentPageViewButtonProps = {
|
||||||
@ -42,6 +49,12 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
|
|||||||
? `${documentsPath}/f/${document.folderId}/${document.id}/edit`
|
? `${documentsPath}/f/${document.folderId}/${document.id}/edit`
|
||||||
: `${documentsPath}/${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 () => {
|
const onDownloadClick = async () => {
|
||||||
try {
|
try {
|
||||||
const documentWithData = await trpcClient.document.getDocumentById.query(
|
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({
|
return match({
|
||||||
isRecipient,
|
isRecipient,
|
||||||
isPending,
|
isPending,
|
||||||
@ -110,10 +242,26 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
|
|||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.with({ isComplete: true }, () => (
|
.with({ isComplete: true }, () => (
|
||||||
<Button className="w-full" onClick={onDownloadClick}>
|
<SplitButton className="w-full">
|
||||||
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
<SplitButtonAction onClick={onDownloadClick}>
|
||||||
<Trans>Download</Trans>
|
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
||||||
</Button>
|
<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);
|
.otherwise(() => null);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Edit,
|
Edit,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
|
FileDown,
|
||||||
|
FolderInput,
|
||||||
Loader,
|
Loader,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
MoveRight,
|
MoveRight,
|
||||||
@ -182,7 +184,7 @@ export const DocumentsTableActionDropdown = ({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<FileDown className="mr-2 h-4 w-4" />
|
||||||
<Trans>Download Original</Trans>
|
<Trans>Download Original</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
@ -201,7 +203,7 @@ export const DocumentsTableActionDropdown = ({
|
|||||||
|
|
||||||
{onMoveDocument && (
|
{onMoveDocument && (
|
||||||
<DropdownMenuItem onClick={onMoveDocument} onSelect={(e) => e.preventDefault()}>
|
<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>
|
<Trans>Move to Folder</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -69,8 +69,6 @@ export const TemplatesTableActionDropdown = ({
|
|||||||
? `${templateRootPath}/f/${row.folderId}/${row.id}/edit`
|
? `${templateRootPath}/f/${row.folderId}/${row.id}/edit`
|
||||||
: `${templateRootPath}/${row.id}/edit`;
|
: `${templateRootPath}/${row.id}/edit`;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger data-testid="template-table-action-btn">
|
<DropdownMenuTrigger data-testid="template-table-action-btn">
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Plural, Trans } from '@lingui/react/macro';
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus, SigningStatus, TeamMemberRole } from '@prisma/client';
|
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||||
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
|
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
|
||||||
import { Link, redirect } from 'react-router';
|
import { Link, redirect } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
@ -111,25 +111,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
recipients,
|
recipients,
|
||||||
};
|
};
|
||||||
|
|
||||||
let isSingleSignerDocument = false;
|
|
||||||
if (
|
|
||||||
documentWithRecipients.status === DocumentStatus.PENDING &&
|
|
||||||
documentWithRecipients.recipients.length === 1
|
|
||||||
) {
|
|
||||||
const singleRecipient = documentWithRecipients.recipients[0];
|
|
||||||
if (
|
|
||||||
singleRecipient.email === user.email &&
|
|
||||||
singleRecipient.signingStatus === SigningStatus.SIGNED
|
|
||||||
) {
|
|
||||||
isSingleSignerDocument = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return superLoaderJson({
|
return superLoaderJson({
|
||||||
document: documentWithRecipients,
|
document: documentWithRecipients,
|
||||||
documentRootPath,
|
documentRootPath,
|
||||||
fields,
|
fields,
|
||||||
isSingleSignerDocument,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -139,7 +124,7 @@ export default function DocumentPage() {
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
|
|
||||||
const { document, documentRootPath, fields, isSingleSignerDocument } = loaderData;
|
const { document, documentRootPath, fields } = loaderData;
|
||||||
|
|
||||||
const { recipients, documentData, documentMeta } = document;
|
const { recipients, documentData, documentMeta } = document;
|
||||||
|
|
||||||
@ -252,10 +237,6 @@ export default function DocumentPage() {
|
|||||||
<Trans>This document is currently a draft and has not been sent</Trans>
|
<Trans>This document is currently a draft and has not been sent</Trans>
|
||||||
))
|
))
|
||||||
.with(DocumentStatus.PENDING, () => {
|
.with(DocumentStatus.PENDING, () => {
|
||||||
if (isSingleSignerDocument) {
|
|
||||||
return <Trans>This document has been signed and is being finalized.</Trans>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pendingRecipients = recipients.filter(
|
const pendingRecipients = recipients.filter(
|
||||||
(recipient) => recipient.signingStatus === 'NOT_SIGNED',
|
(recipient) => recipient.signingStatus === 'NOT_SIGNED',
|
||||||
);
|
);
|
||||||
|
|||||||
@ -119,7 +119,7 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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 },
|
{ 'pt-0 lg:pt-0 xl:pt-0': canSignUp },
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
ZDeleteDocumentMutationSchema,
|
ZDeleteDocumentMutationSchema,
|
||||||
ZDeleteFieldMutationSchema,
|
ZDeleteFieldMutationSchema,
|
||||||
ZDeleteRecipientMutationSchema,
|
ZDeleteRecipientMutationSchema,
|
||||||
|
ZDownloadDocumentQuerySchema,
|
||||||
ZDownloadDocumentSuccessfulSchema,
|
ZDownloadDocumentSuccessfulSchema,
|
||||||
ZFindTeamMembersResponseSchema,
|
ZFindTeamMembersResponseSchema,
|
||||||
ZGenerateDocumentFromTemplateMutationResponseSchema,
|
ZGenerateDocumentFromTemplateMutationResponseSchema,
|
||||||
@ -71,6 +72,7 @@ export const ApiContractV1 = c.router(
|
|||||||
downloadSignedDocument: {
|
downloadSignedDocument: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/api/v1/documents/:id/download',
|
path: '/api/v1/documents/:id/download',
|
||||||
|
query: ZDownloadDocumentQuerySchema,
|
||||||
responses: {
|
responses: {
|
||||||
200: ZDownloadDocumentSuccessfulSchema,
|
200: ZDownloadDocumentSuccessfulSchema,
|
||||||
401: ZUnsuccessfulResponseSchema,
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
|||||||
@ -142,6 +142,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
|
|
||||||
downloadSignedDocument: authenticatedMiddleware(async (args, user, team) => {
|
downloadSignedDocument: authenticatedMiddleware(async (args, user, team) => {
|
||||||
const { id: documentId } = args.params;
|
const { id: documentId } = args.params;
|
||||||
|
const { downloadOriginalDocument } = args.query;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
|
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 {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: {
|
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 {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@ -119,6 +119,15 @@ export const ZUploadDocumentSuccessfulSchema = z.object({
|
|||||||
key: z.string(),
|
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({
|
export const ZDownloadDocumentSuccessfulSchema = z.object({
|
||||||
downloadUrl: z.string(),
|
downloadUrl: z.string(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -16,6 +16,7 @@ const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
|||||||
documentLanguage: z.string(),
|
documentLanguage: z.string(),
|
||||||
includeSenderDetails: z.boolean(),
|
includeSenderDetails: z.boolean(),
|
||||||
includeSigningCertificate: z.boolean(),
|
includeSigningCertificate: z.boolean(),
|
||||||
|
includeAuditLog: z.boolean(),
|
||||||
brandingEnabled: z.boolean(),
|
brandingEnabled: z.boolean(),
|
||||||
brandingLogo: z.string(),
|
brandingLogo: z.string(),
|
||||||
brandingUrl: z.string(),
|
brandingUrl: z.string(),
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { signPdf } from '@documenso/signing';
|
|||||||
import { AppError, AppErrorCode } from '../../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../../errors/app-error';
|
||||||
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
|
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
|
||||||
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
|
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 { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
|
||||||
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
|
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
|
||||||
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
|
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
|
||||||
@ -52,6 +53,7 @@ export const run = async ({
|
|||||||
teamGlobalSettings: {
|
teamGlobalSettings: {
|
||||||
select: {
|
select: {
|
||||||
includeSigningCertificate: true,
|
includeSigningCertificate: true,
|
||||||
|
includeAuditLog: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -152,6 +154,13 @@ export const run = async ({
|
|||||||
}).catch(() => null)
|
}).catch(() => null)
|
||||||
: 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 newDataId = await io.runTask('decorate-and-sign-pdf', async () => {
|
||||||
const pdfDoc = await PDFDocument.load(pdfData);
|
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) {
|
for (const field of fields) {
|
||||||
if (field.inserted) {
|
if (field.inserted) {
|
||||||
document.useLegacyFieldInsertion
|
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 { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
import {
|
||||||
|
ZWebhookDocumentSchema,
|
||||||
|
mapDocumentToWebhookDocumentPayload,
|
||||||
|
} from '../../types/webhook-payload';
|
||||||
import { prefixedId } from '../../universal/id';
|
import { prefixedId } from '../../universal/id';
|
||||||
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
import { getDocumentWhereInput } from './get-document-by-id';
|
import { getDocumentWhereInput } from './get-document-by-id';
|
||||||
|
|
||||||
export interface DuplicateDocumentOptions {
|
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 {
|
return {
|
||||||
documentId: createdDocument.id,
|
documentId: createdDocument.id,
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
|||||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||||
import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers';
|
import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers';
|
||||||
|
import { getAuditLogPdf } from '../htmltopdf/get-audit-log-pdf';
|
||||||
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
|
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
|
||||||
import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf';
|
import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf';
|
||||||
import { flattenAnnotations } from '../pdf/flatten-annotations';
|
import { flattenAnnotations } from '../pdf/flatten-annotations';
|
||||||
@ -53,6 +54,7 @@ export const sealDocument = async ({
|
|||||||
teamGlobalSettings: {
|
teamGlobalSettings: {
|
||||||
select: {
|
select: {
|
||||||
includeSigningCertificate: true,
|
includeSigningCertificate: true,
|
||||||
|
includeAuditLog: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -124,6 +126,13 @@ export const sealDocument = async ({
|
|||||||
}).catch(() => null)
|
}).catch(() => null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const auditLogData =
|
||||||
|
(document.team?.teamGlobalSettings?.includeAuditLog ?? false)
|
||||||
|
? await getAuditLogPdf({
|
||||||
|
documentId,
|
||||||
|
}).catch(() => null)
|
||||||
|
: null;
|
||||||
|
|
||||||
const doc = await PDFDocument.load(pdfData);
|
const doc = await PDFDocument.load(pdfData);
|
||||||
|
|
||||||
// Normalize and flatten layers that could cause issues with the signature
|
// 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) {
|
for (const field of fields) {
|
||||||
document.useLegacyFieldInsertion
|
document.useLegacyFieldInsertion
|
||||||
? await legacy_insertFieldInPDF(doc, field)
|
? await legacy_insertFieldInPDF(doc, field)
|
||||||
|
|||||||
69
packages/lib/server-only/htmltopdf/get-audit-log-pdf.ts
Normal file
69
packages/lib/server-only/htmltopdf/get-audit-log-pdf.ts
Normal file
@ -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,
|
SigningStatus,
|
||||||
WebhookTriggerEvents,
|
WebhookTriggerEvents,
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
|
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
|
||||||
import type { SupportedLanguageCodes } from '../../constants/i18n';
|
import type { SupportedLanguageCodes } from '../../constants/i18n';
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||||
@ -508,10 +510,8 @@ export const createDocumentFromTemplate = async ({
|
|||||||
fieldsToCreate = fieldsToCreate.concat(
|
fieldsToCreate = fieldsToCreate.concat(
|
||||||
fields.map((field) => {
|
fields.map((field) => {
|
||||||
const prefillField = prefillFields?.find((value) => value.id === field.id);
|
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,
|
documentId: document.id,
|
||||||
recipientId: recipient.id,
|
recipientId: recipient.id,
|
||||||
type: field.type,
|
type: field.type,
|
||||||
@ -522,8 +522,38 @@ export const createDocumentFromTemplate = async ({
|
|||||||
height: field.height,
|
height: field.height,
|
||||||
customText: '',
|
customText: '',
|
||||||
inserted: false,
|
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;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -155,6 +155,10 @@ export const ZFieldMetaPrefillFieldsSchema = z
|
|||||||
label: z.string().optional(),
|
label: z.string().optional(),
|
||||||
value: 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;
|
||||||
@ -582,11 +582,13 @@ enum TeamMemberInviteStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model TeamGlobalSettings {
|
model TeamGlobalSettings {
|
||||||
teamId Int @unique
|
teamId Int @unique
|
||||||
documentVisibility DocumentVisibility @default(EVERYONE)
|
documentVisibility DocumentVisibility @default(EVERYONE)
|
||||||
documentLanguage String @default("en")
|
documentLanguage String @default("en")
|
||||||
includeSenderDetails Boolean @default(true)
|
includeSenderDetails Boolean @default(true)
|
||||||
includeSigningCertificate Boolean @default(true)
|
|
||||||
|
includeSigningCertificate Boolean @default(true)
|
||||||
|
includeAuditLog Boolean @default(false)
|
||||||
|
|
||||||
typedSignatureEnabled Boolean @default(true)
|
typedSignatureEnabled Boolean @default(true)
|
||||||
uploadSignatureEnabled Boolean @default(true)
|
uploadSignatureEnabled Boolean @default(true)
|
||||||
|
|||||||
@ -23,6 +23,7 @@ export const updateTeamDocumentSettingsRoute = authenticatedProcedure
|
|||||||
documentLanguage,
|
documentLanguage,
|
||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
|
includeAuditLog,
|
||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
@ -54,6 +55,7 @@ export const updateTeamDocumentSettingsRoute = authenticatedProcedure
|
|||||||
documentLanguage,
|
documentLanguage,
|
||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
|
includeAuditLog,
|
||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
@ -63,6 +65,7 @@ export const updateTeamDocumentSettingsRoute = authenticatedProcedure
|
|||||||
documentLanguage,
|
documentLanguage,
|
||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
|
includeAuditLog,
|
||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export const ZUpdateTeamDocumentSettingsRequestSchema = z.object({
|
|||||||
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).optional().default('en'),
|
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).optional().default('en'),
|
||||||
includeSenderDetails: z.boolean().optional().default(false),
|
includeSenderDetails: z.boolean().optional().default(false),
|
||||||
includeSigningCertificate: z.boolean().optional().default(true),
|
includeSigningCertificate: z.boolean().optional().default(true),
|
||||||
|
includeAuditLog: z.boolean().optional().default(false),
|
||||||
typedSignatureEnabled: z.boolean().optional().default(true),
|
typedSignatureEnabled: z.boolean().optional().default(true),
|
||||||
uploadSignatureEnabled: z.boolean().optional().default(true),
|
uploadSignatureEnabled: z.boolean().optional().default(true),
|
||||||
drawSignatureEnabled: z.boolean().optional().default(true),
|
drawSignatureEnabled: z.boolean().optional().default(true),
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export const SigningCard = ({
|
|||||||
signingCelebrationImage,
|
signingCelebrationImage,
|
||||||
}: SigningCardProps) => {
|
}: SigningCardProps) => {
|
||||||
return (
|
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} />
|
<SigningCardContent name={name} signature={signature} />
|
||||||
|
|
||||||
{signingCelebrationImage && (
|
{signingCelebrationImage && (
|
||||||
@ -48,7 +48,7 @@ export const SigningCard3D = ({
|
|||||||
|
|
||||||
const [trackMouse, setTrackMouse] = useState(false);
|
const [trackMouse, setTrackMouse] = useState(false);
|
||||||
|
|
||||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
const timeoutRef = useRef<number | undefined>();
|
||||||
|
|
||||||
const cardX = useMotionValue(0);
|
const cardX = useMotionValue(0);
|
||||||
const cardY = useMotionValue(0);
|
const cardY = useMotionValue(0);
|
||||||
@ -103,7 +103,7 @@ export const SigningCard3D = ({
|
|||||||
clearTimeout(timeoutRef.current);
|
clearTimeout(timeoutRef.current);
|
||||||
|
|
||||||
// Revert the card back to the center position after the mouse stops moving.
|
// 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(cardX, 0, { duration: 2, ease: 'backInOut' });
|
||||||
void animate(cardY, 0, { duration: 2, ease: 'backInOut' });
|
void animate(cardY, 0, { duration: 2, ease: 'backInOut' });
|
||||||
|
|
||||||
@ -120,12 +120,15 @@ export const SigningCard3D = ({
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('mousemove', onMouseMove);
|
window.removeEventListener('mousemove', onMouseMove);
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
window.clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [onMouseMove]);
|
}, [onMouseMove]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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 }}
|
style={{ perspective: 800 }}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@ -400,35 +400,60 @@ export const AddFieldsFormPartial = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onFieldCopy = useCallback(
|
const onFieldCopy = useCallback(
|
||||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => {
|
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
||||||
const { duplicate = false } = options ?? {};
|
const { duplicate = false, duplicateAll = false } = options ?? {};
|
||||||
|
|
||||||
if (lastActiveField) {
|
if (lastActiveField) {
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
|
|
||||||
if (!duplicate) {
|
if (duplicate) {
|
||||||
setFieldClipboard(lastActiveField);
|
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({
|
append(newField);
|
||||||
title: 'Copied field',
|
|
||||||
description: 'Copied field to clipboard',
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newField: TAddFieldsFormSchema['fields'][0] = {
|
setFieldClipboard(lastActiveField);
|
||||||
...structuredClone(lastActiveField),
|
|
||||||
formId: nanoid(12),
|
|
||||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
|
||||||
pageX: lastActiveField.pageX + 3,
|
|
||||||
pageY: lastActiveField.pageY + 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
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(
|
const onFieldPaste = useCallback(
|
||||||
@ -641,6 +666,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
onMove={(options) => onFieldMove(options, index)}
|
onMove={(options) => onFieldMove(options, index)}
|
||||||
onRemove={() => remove(index)}
|
onRemove={() => remove(index)}
|
||||||
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
||||||
|
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
|
||||||
onAdvancedSettings={() => {
|
onAdvancedSettings={() => {
|
||||||
setCurrentField(field);
|
setCurrentField(field);
|
||||||
handleAdvancedSettings();
|
handleAdvancedSettings();
|
||||||
|
|||||||
@ -311,6 +311,7 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
|
|||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
.otherwise(() => null)}
|
.otherwise(() => null)}
|
||||||
|
|
||||||
{errors.length > 0 && (
|
{errors.length > 0 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<ul>
|
<ul>
|
||||||
@ -323,6 +324,7 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DocumentFlowFormContainerContent>
|
</DocumentFlowFormContainerContent>
|
||||||
|
|
||||||
<DocumentFlowFormContainerFooter className="mt-auto">
|
<DocumentFlowFormContainerFooter className="mt-auto">
|
||||||
<DocumentFlowFormContainerActions
|
<DocumentFlowFormContainerActions
|
||||||
goNextLabel={msg`Save`}
|
goNextLabel={msg`Save`}
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
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 { 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 { createPortal } from 'react-dom';
|
||||||
import { Rnd } from 'react-rnd';
|
import { Rnd } from 'react-rnd';
|
||||||
|
|
||||||
@ -29,6 +31,7 @@ export type FieldItemProps = {
|
|||||||
onMove?: (_node: HTMLElement) => void;
|
onMove?: (_node: HTMLElement) => void;
|
||||||
onRemove?: () => void;
|
onRemove?: () => void;
|
||||||
onDuplicate?: () => void;
|
onDuplicate?: () => void;
|
||||||
|
onDuplicateAllPages?: () => void;
|
||||||
onAdvancedSettings?: () => void;
|
onAdvancedSettings?: () => void;
|
||||||
onFocus?: () => void;
|
onFocus?: () => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
@ -55,15 +58,18 @@ export const FieldItem = ({
|
|||||||
onMove,
|
onMove,
|
||||||
onRemove,
|
onRemove,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
|
onDuplicateAllPages,
|
||||||
|
onAdvancedSettings,
|
||||||
onFocus,
|
onFocus,
|
||||||
onBlur,
|
onBlur,
|
||||||
onAdvancedSettings,
|
|
||||||
recipientIndex = 0,
|
recipientIndex = 0,
|
||||||
hasErrors,
|
hasErrors,
|
||||||
active,
|
active,
|
||||||
onFieldActivate,
|
onFieldActivate,
|
||||||
onFieldDeactivate,
|
onFieldDeactivate,
|
||||||
}: FieldItemProps) => {
|
}: FieldItemProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const [coords, setCoords] = useState({
|
const [coords, setCoords] = useState({
|
||||||
pageX: 0,
|
pageX: 0,
|
||||||
pageY: 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">
|
<div className="group flex items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5">
|
||||||
{advancedField && (
|
{advancedField && (
|
||||||
<button
|
<button
|
||||||
|
title={_(msg`Advanced settings`)}
|
||||||
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||||
onClick={onAdvancedSettings}
|
onClick={onAdvancedSettings}
|
||||||
onTouchEnd={onAdvancedSettings}
|
onTouchEnd={onAdvancedSettings}
|
||||||
@ -313,6 +320,7 @@ export const FieldItem = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
title={_(msg`Duplicate`)}
|
||||||
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||||
onClick={onDuplicate}
|
onClick={onDuplicate}
|
||||||
onTouchEnd={onDuplicate}
|
onTouchEnd={onDuplicate}
|
||||||
@ -321,6 +329,16 @@ export const FieldItem = ({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<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"
|
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
onTouchEnd={onRemove}
|
onTouchEnd={onRemove}
|
||||||
|
|||||||
83
packages/ui/primitives/split-button.tsx
Normal file
83
packages/ui/primitives/split-button.tsx
Normal 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(
|
const onFieldCopy = useCallback(
|
||||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => {
|
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
||||||
const { duplicate = false } = options ?? {};
|
const { duplicate = false, duplicateAll = false } = options ?? {};
|
||||||
|
|
||||||
if (lastActiveField) {
|
if (lastActiveField) {
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
|
|
||||||
if (!duplicate) {
|
if (duplicate) {
|
||||||
setFieldClipboard(lastActiveField);
|
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({
|
append(newField);
|
||||||
title: 'Copied field',
|
|
||||||
description: 'Copied field to clipboard',
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newField: TAddTemplateFieldsFormSchema['fields'][0] = {
|
setFieldClipboard(lastActiveField);
|
||||||
...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,
|
|
||||||
};
|
|
||||||
|
|
||||||
append(newField);
|
toast({
|
||||||
|
title: 'Copied field',
|
||||||
|
description: 'Copied field to clipboard',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[append, lastActiveField, selectedSigner?.email, selectedSigner?.id, toast],
|
||||||
append,
|
|
||||||
lastActiveField,
|
|
||||||
selectedSigner?.email,
|
|
||||||
selectedSigner?.id,
|
|
||||||
selectedSigner?.token,
|
|
||||||
toast,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const onFieldPaste = useCallback(
|
const onFieldPaste = useCallback(
|
||||||
@ -543,6 +563,7 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
onMove={(options) => onFieldMove(options, index)}
|
onMove={(options) => onFieldMove(options, index)}
|
||||||
onRemove={() => remove(index)}
|
onRemove={() => remove(index)}
|
||||||
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
||||||
|
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
|
||||||
onAdvancedSettings={() => {
|
onAdvancedSettings={() => {
|
||||||
setCurrentField(field);
|
setCurrentField(field);
|
||||||
handleAdvancedSettings();
|
handleAdvancedSettings();
|
||||||
|
|||||||
Reference in New Issue
Block a user