Compare commits

...

14 Commits

Author SHA1 Message Date
Catalin Pit
9182d014b4 Merge branch 'main' into feat/customize-doc-audit-log-certificate 2025-02-24 09:36:31 +02:00
Catalin Pit
5f602d897b chore: fix seal document handler 2025-02-20 10:50:59 +02:00
Mythie
00b46561c2 v1.9.1-rc.2 2025-02-20 11:35:03 +11:00
Lucas Smith
11bc93a9a4 feat: allow document rejection in embeds (#1662)
Bing bang
2025-02-20 11:34:19 +11:00
Catalin Pit
0084a94bb1 chore: improve logic 2025-02-19 15:04:44 +02:00
David Nguyen
11528090a5 fix: prepare auth migration (#1648)
Add schema session migration in preparation for auth migration.
2025-02-18 15:17:47 +11:00
Catalin Pit
a4f1a138d0 chore: redo changes 2025-02-17 15:11:16 +02:00
Catalin Pit
6a9ae132c7 Merge branch 'main' into feat/customize-doc-audit-log-certificate 2025-02-17 11:19:18 +02:00
Catalin Pit
afb156f073 chore: audit trail log cert 2025-02-17 11:18:41 +02:00
Ephraim Duncan
3c4863f285 chore: add asssitant role to the docs (#1638) 2025-02-17 15:42:37 +11:00
Catalin Pit
f6a24224fe feat: download options for document 2025-02-14 16:25:15 +02:00
Catalin Pit
080bb405f0 feat: include audit logs 2025-02-14 14:03:39 +02:00
Catalin Pit
8b1b0de935 feat: customize doc audit logs and certificate 2025-02-14 11:30:48 +02:00
Ephraim Duncan
2ff330f9d4 chore: update local seed data (#1622)
## Description

Add multiple example documents, pending documents, and templates for
both admin and example users

## Changes Made
- Added seeding of multiple example documents and templates for both
example and admin users

## Checklist

- [x] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [ ] I have updated the documentation to reflect these changes, if
applicable.
- [x] I have followed the project's coding style guidelines.
- [x] I have addressed the code review feedback from the previous
submission, if applicable.
2025-02-10 22:55:12 +11:00
36 changed files with 912 additions and 97 deletions

View File

@@ -14,4 +14,4 @@
"public-api": "Public API",
"embedding": "Embedding",
"webhooks": "Webhooks"
}
}

View File

@@ -85,12 +85,13 @@ You can also set the recipient's role, which determines their actions and permis
Documenso has 4 roles for recipients with different permissions and actions.
| Role | Function | Action required | Signature |
| :------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
| Viewer | Needs to confirm they viewed the document. | Yes | No |
| BCC | Receives a copy of the signed document after completion. No action is required. | No | No |
| Role | Function | Action required | Signature |
| :-------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
| Viewer | Needs to confirm they viewed the document. | Yes | No |
| Assistant | Can help prepare the document by filling in fields on behalf of other signers. | Yes | No |
| CC | Receives a copy of the signed document after completion. No action is required. | No | No |
### Fields

View File

@@ -1,6 +1,6 @@
{
"name": "@documenso/web",
"version": "1.9.1-rc.1",
"version": "1.9.1-rc.2",
"private": true,
"license": "AGPL-3.0",
"scripts": {

View File

@@ -14,6 +14,12 @@ import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { trpc as trpcClient } from '@documenso/trpc/client';
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 = {
@@ -25,7 +31,9 @@ export type DocumentPageViewButtonProps = {
team?: Pick<Team, 'id' | 'url'>;
};
export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps) => {
export const DocumentPageViewButton = ({
document: activeDocument,
}: DocumentPageViewButtonProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
@@ -34,25 +42,27 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
return null;
}
const recipient = document.recipients.find((recipient) => recipient.email === session.user.email);
const recipient = activeDocument.recipients.find(
(recipient) => recipient.email === session.user.email,
);
const isRecipient = !!recipient;
const isPending = document.status === DocumentStatus.PENDING;
const isComplete = document.status === DocumentStatus.COMPLETED;
const isPending = activeDocument.status === DocumentStatus.PENDING;
const isComplete = activeDocument.status === DocumentStatus.COMPLETED;
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const role = recipient?.role;
const documentsPath = formatDocumentsPath(document.team?.url);
const documentsPath = formatDocumentsPath(activeDocument.team?.url);
const onDownloadClick = async () => {
try {
const documentWithData = await trpcClient.document.getDocumentById.query(
{
documentId: document.id,
documentId: activeDocument.id,
},
{
context: {
teamId: document.team?.id?.toString(),
teamId: activeDocument.team?.id?.toString(),
},
},
);
@@ -63,7 +73,10 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
throw new Error('No document available');
}
await downloadPDF({ documentData, fileName: documentWithData.title });
await downloadPDF({
documentData,
fileName: documentWithData.title,
});
} catch (err) {
toast({
title: _(msg`Something went wrong`),
@@ -73,6 +86,100 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
}
};
const onDownloadAuditLogClick = async () => {
try {
const { url } = await trpcClient.document.downloadAuditLogs.mutate({
documentId: activeDocument.id,
});
const iframe = Object.assign(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', () => {
document.body.removeChild(iframe);
});
}
};
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
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',
});
}
};
const onDownloadSigningCertificateClick = async () => {
try {
const { url } = await trpcClient.document.downloadCertificate.mutate({
documentId: activeDocument.id,
});
const iframe = Object.assign(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', () => {
document.body.removeChild(iframe);
});
}
};
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
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',
});
}
};
return match({
isRecipient,
isPending,
@@ -106,16 +213,27 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
))
.with({ isComplete: false }, () => (
<Button className="w-full" asChild>
<Link href={`${documentsPath}/${document.id}/edit`}>
<Link href={`${documentsPath}/${activeDocument.id}/edit`}>
<Trans>Edit</Trans>
</Link>
</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="flex w-full">
<SplitButtonAction className="w-full" onClick={() => void onDownloadClick()}>
<Download className="-ml-1 mr-2 inline h-4 w-4" />
<Trans>Download</Trans>
</SplitButtonAction>
<SplitButtonDropdown>
<SplitButtonDropdownItem onClick={() => void onDownloadAuditLogClick()}>
<Trans>Only Audit Log</Trans>
</SplitButtonDropdownItem>
<SplitButtonDropdownItem onClick={() => void onDownloadSigningCertificateClick()}>
<Trans>Only Signing Certificate</Trans>
</SplitButtonDropdownItem>
</SplitButtonDropdown>
</SplitButton>
))
.otherwise(() => null);
};

View File

@@ -187,6 +187,8 @@ export const EditDocumentForm = ({
title: data.title,
externalId: data.externalId || null,
visibility: data.visibility,
includeSigningCertificate: data.includeSigningCertificate,
includeAuditTrailLog: data.includeAuditTrailLog,
globalAccessAuth: data.globalAccessAuth ?? null,
globalActionAuth: data.globalActionAuth ?? null,
},

View File

@@ -43,9 +43,10 @@ type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
export interface RejectDocumentDialogProps {
document: Pick<Document, 'id'>;
token: string;
onRejected?: (reason: string) => void | Promise<void>;
}
export function RejectDocumentDialog({ document, token }: RejectDocumentDialogProps) {
export function RejectDocumentDialog({ document, token, onRejected }: RejectDocumentDialogProps) {
const { toast } = useToast();
const router = useRouter();
const searchParams = useSearchParams();
@@ -79,7 +80,11 @@ export function RejectDocumentDialog({ document, token }: RejectDocumentDialogPr
setIsOpen(false);
router.push(`/sign/${token}/rejected`);
if (onRejected) {
await onRejected(reason);
} else {
router.push(`/sign/${token}/rejected`);
}
} catch (err) {
toast({
title: 'Error',

View File

@@ -41,6 +41,7 @@ const ZTeamDocumentPreferencesFormSchema = z.object({
includeSenderDetails: z.boolean(),
typedSignatureEnabled: z.boolean(),
includeSigningCertificate: z.boolean(),
includeAuditTrailLog: z.boolean(),
});
type TTeamDocumentPreferencesFormSchema = z.infer<typeof ZTeamDocumentPreferencesFormSchema>;
@@ -72,6 +73,7 @@ export const TeamDocumentPreferencesForm = ({
includeSenderDetails: settings?.includeSenderDetails ?? false,
typedSignatureEnabled: settings?.typedSignatureEnabled ?? true,
includeSigningCertificate: settings?.includeSigningCertificate ?? true,
includeAuditTrailLog: settings?.includeAuditTrailLog ?? false,
},
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
});
@@ -86,6 +88,7 @@ export const TeamDocumentPreferencesForm = ({
includeSenderDetails,
includeSigningCertificate,
typedSignatureEnabled,
includeAuditTrailLog,
} = data;
await updateTeamDocumentPreferences({
@@ -96,6 +99,7 @@ export const TeamDocumentPreferencesForm = ({
includeSenderDetails,
typedSignatureEnabled,
includeSigningCertificate,
includeAuditTrailLog,
},
});
@@ -300,6 +304,37 @@ export const TeamDocumentPreferencesForm = ({
)}
/>
<FormField
control={form.control}
name="includeAuditTrailLog"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Include the Audit Trail 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 trail log will be included in the document when it is
downloaded. The audit trail 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>Save</Trans>

View File

@@ -0,0 +1,40 @@
import { Trans } from '@lingui/macro';
import { XCircle } from 'lucide-react';
import type { Signature } from '@documenso/prisma/client';
export type EmbedDocumentRejectedPageProps = {
name?: string;
signature?: Signature;
};
export const EmbedDocumentRejected = ({ name }: EmbedDocumentRejectedPageProps) => {
return (
<div className="embed--DocumentRejected relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<div className="flex flex-col items-center">
<div className="flex items-center gap-x-4">
<XCircle className="text-destructive h-10 w-10" />
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
<Trans>Document Rejected</Trans>
</h2>
</div>
<div className="text-destructive mt-4 flex items-center text-center text-sm">
<Trans>You have rejected this document</Trans>
</div>
<p className="text-muted-foreground mt-6 max-w-[60ch] text-center text-sm">
<Trans>
The document owner has been notified of your decision. They may contact you with further
instructions if necessary.
</Trans>
</p>
<p className="text-muted-foreground mt-2 max-w-[60ch] text-center text-sm">
<Trans>No further action is required from you at this time.</Trans>
</p>
</div>
</div>
);
};

View File

@@ -10,7 +10,13 @@ import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { DocumentMeta, TemplateMeta } from '@documenso/prisma/client';
import { type DocumentData, type Field, FieldType, RecipientRole } from '@documenso/prisma/client';
import {
type DocumentData,
type Field,
FieldType,
RecipientRole,
SigningStatus,
} from '@documenso/prisma/client';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
@@ -26,11 +32,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
import { RecipientProvider } from '~/app/(signing)/sign/[token]/recipient-context';
import { RejectDocumentDialog } from '~/app/(signing)/sign/[token]/reject-document-dialog';
import { Logo } from '~/components/branding/logo';
import { EmbedClientLoading } from '../../client-loading';
import { EmbedDocumentCompleted } from '../../completed';
import { EmbedDocumentFields } from '../../document-fields';
import { EmbedDocumentRejected } from '../../rejected';
import { injectCss } from '../../util';
import { ZSignDocumentEmbedDataSchema } from './schema';
@@ -75,6 +83,9 @@ export const EmbedSignDocumentClientPage = ({
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
const [hasRejectedDocument, setHasRejectedDocument] = useState(
recipient.signingStatus === SigningStatus.REJECTED,
);
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
allRecipients.length > 0 ? allRecipients[0].id : null,
);
@@ -83,6 +94,8 @@ export const EmbedSignDocumentClientPage = ({
const [isNameLocked, setIsNameLocked] = useState(false);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId);
const isAssistantMode = recipient.role === RecipientRole.ASSISTANT;
@@ -161,6 +174,25 @@ export const EmbedSignDocumentClientPage = ({
}
};
const onDocumentRejected = (reason: string) => {
if (window.parent) {
window.parent.postMessage(
{
action: 'document-rejected',
data: {
token,
documentId,
recipientId: recipient.id,
reason,
},
},
'*',
);
}
setHasRejectedDocument(true);
};
useLayoutEffect(() => {
const hash = window.location.hash.slice(1);
@@ -174,6 +206,7 @@ export const EmbedSignDocumentClientPage = ({
// Since a recipient can be provided a name we can lock it without requiring
// a to be provided by the parent application, unlike direct templates.
setIsNameLocked(!!data.lockName);
setAllowDocumentRejection(!!data.allowDocumentRejection);
if (data.darkModeDisabled) {
document.documentElement.classList.add('dark-mode-disabled');
@@ -208,6 +241,10 @@ export const EmbedSignDocumentClientPage = ({
}
}, [hasFinishedInit, hasDocumentLoaded]);
if (hasRejectedDocument) {
return <EmbedDocumentRejected name={fullName} />;
}
if (hasCompletedDocument) {
return (
<EmbedDocumentCompleted
@@ -229,6 +266,16 @@ export const EmbedSignDocumentClientPage = ({
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
{allowDocumentRejection && (
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
<RejectDocumentDialog
document={{ id: documentId }}
token={token}
onRejected={onDocumentRejected}
/>
</div>
)}
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */}
<div className="embed--DocumentViewer flex-1">
@@ -420,7 +467,7 @@ export const EmbedSignDocumentClientPage = ({
</Button>
) : (
<Button
className="col-start-2"
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
disabled={
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
}

View File

@@ -13,4 +13,5 @@ export const ZSignDocumentEmbedDataSchema = ZBaseEmbedDataSchema.extend({
.optional()
.transform((value) => value || undefined),
lockName: z.boolean().optional().default(false),
allowDocumentRejection: z.boolean().optional(),
});

View File

@@ -363,6 +363,40 @@ export const DocumentHistorySheet = ({
]}
/>
))
.with(
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SIGNING_CERTIFICATE_UPDATED },
({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Old',
value: data.from,
},
{
key: 'New',
value: data.to,
},
]}
/>
),
)
.with(
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_AUDIT_TRAIL_UPDATED },
({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Old',
value: data.from,
},
{
key: 'New',
value: data.to,
},
]}
/>
),
)
.exhaustive()}
{isUserDetailsVisible && (

6
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "1.9.1-rc.1",
"version": "1.9.1-rc.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "1.9.1-rc.1",
"version": "1.9.1-rc.2",
"workspaces": [
"apps/*",
"packages/*"
@@ -106,7 +106,7 @@
},
"apps/web": {
"name": "@documenso/web",
"version": "1.9.1-rc.1",
"version": "1.9.1-rc.2",
"license": "AGPL-3.0",
"dependencies": {
"@documenso/api": "*",

View File

@@ -1,6 +1,6 @@
{
"private": true,
"version": "1.9.1-rc.1",
"version": "1.9.1-rc.2",
"scripts": {
"build": "turbo run build",
"build:web": "turbo run build --filter=@documenso/web",

View File

@@ -17,6 +17,7 @@ const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
documentLanguage: z.string(),
includeSenderDetails: z.boolean(),
includeSigningCertificate: z.boolean(),
includeAuditTrailLog: z.boolean(),
brandingEnabled: z.boolean(),
brandingLogo: z.string(),
brandingUrl: z.string(),

View File

@@ -13,6 +13,7 @@ import { signPdf } from '@documenso/signing';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
import { flattenForm } from '../../../server-only/pdf/flatten-form';
@@ -57,6 +58,7 @@ export const run = async ({
teamGlobalSettings: {
select: {
includeSigningCertificate: true,
includeAuditTrailLog: true,
},
},
},
@@ -121,13 +123,36 @@ export const run = async ({
const pdfData = await getFile(documentData);
const certificateData =
(document.team?.teamGlobalSettings?.includeSigningCertificate ?? true)
? await getCertificatePdf({
documentId,
language: document.documentMeta?.language,
}).catch(() => null)
: null;
let includeSigningCertificate;
if (document.teamId) {
includeSigningCertificate =
document.team?.teamGlobalSettings?.includeSigningCertificate ?? true;
} else {
includeSigningCertificate = document.includeSigningCertificate ?? true;
}
const certificateData = includeSigningCertificate
? await getCertificatePdf({
documentId,
language: document.documentMeta?.language,
}).catch(() => null)
: null;
let includeAuditTrailLog;
if (document.teamId) {
includeAuditTrailLog = document.team?.teamGlobalSettings?.includeAuditTrailLog ?? true;
} else {
includeAuditTrailLog = document.includeAuditTrailLog ?? true;
}
const auditLogData = includeAuditTrailLog
? await getAuditLogsPdf({
documentId,
language: document.documentMeta?.language,
}).catch(() => null)
: null;
const newDataId = await io.runTask('decorate-and-sign-pdf', async () => {
const pdfDoc = await PDFDocument.load(pdfData);
@@ -150,6 +175,16 @@ export const run = async ({
});
}
if (auditLogData) {
const auditLog = await PDFDocument.load(auditLogData);
const auditLogPages = await pdfDoc.copyPages(auditLog, auditLog.getPageIndices());
auditLogPages.forEach((page) => {
pdfDoc.addPage(page);
});
}
for (const field of fields) {
if (field.inserted) {
await insertFieldInPDF(pdfDoc, field);

View File

@@ -12,6 +12,7 @@ import {
WebhookTriggerEvents,
} from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import type { TRecipientActionAuth } from '../../types/document-auth';
import {
@@ -72,6 +73,13 @@ export const completeDocumentWithToken = async ({
throw new Error(`Recipient ${recipient.id} has already signed`);
}
if (recipient.signingStatus === SigningStatus.REJECTED) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Recipient has already rejected the document',
statusCode: 400,
});
}
if (document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token });

View File

@@ -124,6 +124,8 @@ export const createDocument = async ({
team?.teamGlobalSettings?.documentVisibility,
userTeamRole ?? TeamMemberRole.MEMBER,
),
includeSigningCertificate: team?.teamGlobalSettings?.includeSigningCertificate ?? true,
includeAuditTrailLog: team?.teamGlobalSettings?.includeAuditTrailLog ?? true,
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {

View File

@@ -22,6 +22,7 @@ import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file';
import { putPdfFile } from '../../universal/upload/put-file';
import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers';
import { getAuditLogsPdf } from '../htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations';
import { flattenForm } from '../pdf/flatten-form';
@@ -61,6 +62,7 @@ export const sealDocument = async ({
teamGlobalSettings: {
select: {
includeSigningCertificate: true,
includeAuditTrailLog: true,
},
},
},
@@ -109,13 +111,36 @@ export const sealDocument = async ({
// !: Need to write the fields onto the document as a hard copy
const pdfData = await getFile(documentData);
const certificateData =
(document.team?.teamGlobalSettings?.includeSigningCertificate ?? true)
? await getCertificatePdf({
documentId,
language: document.documentMeta?.language,
}).catch(() => null)
: null;
let includeSigningCertificate;
if (document.teamId) {
includeSigningCertificate =
document.team?.teamGlobalSettings?.includeSigningCertificate ?? true;
} else {
includeSigningCertificate = document.includeSigningCertificate ?? true;
}
const certificateData = includeSigningCertificate
? await getCertificatePdf({
documentId,
language: document.documentMeta?.language,
}).catch(() => null)
: null;
let includeAuditTrailLog;
if (document.teamId) {
includeAuditTrailLog = document.team?.teamGlobalSettings?.includeAuditTrailLog ?? true;
} else {
includeAuditTrailLog = document.includeAuditTrailLog ?? true;
}
const auditLogData = includeAuditTrailLog
? await getAuditLogsPdf({
documentId,
language: document.documentMeta?.language,
}).catch(() => null)
: null;
const doc = await PDFDocument.load(pdfData);
@@ -134,6 +159,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) {
await insertFieldInPDF(doc, field);
}

View File

@@ -21,6 +21,8 @@ export type UpdateDocumentOptions = {
title?: string;
externalId?: string | null;
visibility?: DocumentVisibility | null;
includeSigningCertificate?: boolean;
includeAuditTrailLog?: boolean;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
};
@@ -156,6 +158,12 @@ export const updateDocument = async ({
documentGlobalActionAuth === undefined || documentGlobalActionAuth === newGlobalActionAuth;
const isDocumentVisibilitySame =
data.visibility === undefined || data.visibility === document.visibility;
const isIncludeSigningCertificateSame =
data.includeSigningCertificate === undefined ||
data.includeSigningCertificate === document.includeSigningCertificate;
const isIncludeAuditTrailLogSame =
data.includeAuditTrailLog === undefined ||
data.includeAuditTrailLog === document.includeAuditTrailLog;
const auditLogs: CreateDocumentAuditLogDataResponse[] = [];
@@ -235,6 +243,34 @@ export const updateDocument = async ({
);
}
if (!isIncludeSigningCertificateSame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SIGNING_CERTIFICATE_UPDATED,
documentId,
metadata: requestMetadata,
data: {
from: String(document.includeSigningCertificate),
to: String(data.includeSigningCertificate || false),
},
}),
);
}
if (!isIncludeAuditTrailLogSame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_AUDIT_TRAIL_UPDATED,
documentId,
metadata: requestMetadata,
data: {
from: String(document.includeAuditTrailLog),
to: String(data.includeAuditTrailLog || false),
},
}),
);
}
// Early return if nothing is required.
if (auditLogs.length === 0) {
return document;
@@ -254,6 +290,8 @@ export const updateDocument = async ({
title: data.title,
externalId: data.externalId,
visibility: data.visibility as DocumentVisibility,
includeSigningCertificate: data.includeSigningCertificate,
includeAuditTrailLog: data.includeAuditTrailLog,
authOptions,
},
});

View File

@@ -0,0 +1,74 @@
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 { encryptSecondaryData } from '../crypto/encrypt';
export type GetAuditLogsPdfParams = {
documentId: number;
// eslint-disable-next-line @typescript-eslint/ban-types
language?: SupportedLanguageCodes | (string & {});
};
export const getAuditLogsPdf = async ({ documentId, language }: GetAuditLogsPdfParams) => {
const { chromium } = await import('playwright');
const encryptedId = encryptSecondaryData({
data: documentId.toString(),
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
});
let browser: Browser;
if (process.env.NEXT_PRIVATE_BROWSERLESS_URL) {
// !: 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(process.env.NEXT_PRIVATE_BROWSERLESS_URL);
} 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(),
},
]);
try {
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;
} catch (error) {
await browserContext.close();
void browser.close();
throw error;
}
};

View File

@@ -17,6 +17,7 @@ export type UpdateTeamDocumentSettingsOptions = {
includeSenderDetails: boolean;
typedSignatureEnabled: boolean;
includeSigningCertificate: boolean;
includeAuditTrailLog: boolean;
};
};
@@ -36,6 +37,7 @@ export const updateTeamDocumentSettings = async ({
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
includeAuditTrailLog,
typedSignatureEnabled,
} = settings;
@@ -61,6 +63,7 @@ export const updateTeamDocumentSettings = async ({
includeSenderDetails,
typedSignatureEnabled,
includeSigningCertificate,
includeAuditTrailLog,
},
update: {
documentVisibility,
@@ -68,6 +71,7 @@ export const updateTeamDocumentSettings = async ({
includeSenderDetails,
typedSignatureEnabled,
includeSigningCertificate,
includeAuditTrailLog,
},
});
};

View File

@@ -29,7 +29,9 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient.
'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient.
'DOCUMENT_FIELD_PREFILLED', // When a field is prefilled by an assistant.
'DOCUMENT_VISIBILITY_UPDATED', // When the document visibility scope is updated
'DOCUMENT_VISIBILITY_UPDATED', // When the document visibility scope is updated.
'DOCUMENT_SIGNING_CERTIFICATE_UPDATED', // When the include signing certificate is updated.
'DOCUMENT_AUDIT_TRAIL_UPDATED', // When the include audit trail is updated.
'DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED', // When the global access authentication is updated.
'DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED', // When the global action authentication is updated.
'DOCUMENT_META_UPDATED', // When the document meta data is updated.
@@ -397,6 +399,16 @@ export const ZDocumentAuditLogEventDocumentVisibilitySchema = z.object({
data: ZGenericFromToSchema,
});
export const ZDocumentAuditLogEventDocumentSigningCertificateUpdatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SIGNING_CERTIFICATE_UPDATED),
data: ZGenericFromToSchema,
});
export const ZDocumentAuditLogEventDocumentAuditTrailUpdatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_AUDIT_TRAIL_UPDATED),
data: ZGenericFromToSchema,
});
/**
* Event: Document global authentication access updated.
*/
@@ -574,6 +586,8 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
ZDocumentAuditLogEventDocumentFieldPrefilledSchema,
ZDocumentAuditLogEventDocumentVisibilitySchema,
ZDocumentAuditLogEventDocumentSigningCertificateUpdatedSchema,
ZDocumentAuditLogEventDocumentAuditTrailUpdatedSchema,
ZDocumentAuditLogEventDocumentGlobalAuthAccessUpdatedSchema,
ZDocumentAuditLogEventDocumentGlobalAuthActionUpdatedSchema,
ZDocumentAuditLogEventDocumentMetaUpdatedSchema,

View File

@@ -18,6 +18,8 @@ import { ZRecipientLiteSchema } from './recipient';
*/
export const ZDocumentSchema = DocumentSchema.pick({
visibility: true,
includeSigningCertificate: true,
includeAuditTrailLog: true,
status: true,
source: true,
id: true,
@@ -82,6 +84,8 @@ export const ZDocumentLiteSchema = DocumentSchema.pick({
deletedAt: true,
teamId: true,
templateId: true,
includeSigningCertificate: true,
includeAuditTrailLog: true,
});
/**
@@ -104,6 +108,8 @@ export const ZDocumentManySchema = DocumentSchema.pick({
deletedAt: true,
teamId: true,
templateId: true,
includeSigningCertificate: true,
includeAuditTrailLog: true,
}).extend({
user: UserSchema.pick({
id: true,

View File

@@ -322,6 +322,14 @@ export const formatDocumentAuditLogAction = (
anonymous: msg`Document visibility updated`,
identified: msg`${prefix} updated the document visibility`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SIGNING_CERTIFICATE_UPDATED }, () => ({
anonymous: msg`Document signing certificate updated`,
identified: msg`${prefix} updated the document signing certificate`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_AUDIT_TRAIL_UPDATED }, () => ({
anonymous: msg`Document audit trail updated`,
identified: msg`${prefix} updated the document audit trail`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED }, () => ({
anonymous: msg`Document access auth updated`,
identified: msg`${prefix} updated the document access auth requirements`,

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "includeAuditTrailLog" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,18 @@
/*
Warnings:
- You are about to drop the column `expires` on the `Session` table. All the data in the column will be lost.
- Added the required column `expiresAt` to the `Session` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `Session` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Account" ADD COLUMN "password" TEXT;
-- AlterTable
ALTER TABLE "Session" DROP COLUMN "expires",
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "expiresAt" TIMESTAMP(3) NOT NULL,
ADD COLUMN "ipAddress" TEXT,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL,
ADD COLUMN "userAgent" TEXT;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Document" ADD COLUMN "includeAuditTrail" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "includeSigningCertificate" BOOLEAN NOT NULL DEFAULT true;

View File

@@ -0,0 +1,9 @@
/*
Warnings:
- You are about to drop the column `includeAuditTrail` on the `Document` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Document" DROP COLUMN "includeAuditTrail",
ADD COLUMN "includeAuditTrailLog" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -270,18 +270,25 @@ model Account {
scope String?
id_token String? @db.Text
session_state String?
password String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
id String @id @default(cuid())
sessionToken String @unique
userId Int
expires DateTime
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
ipAddress String?
userAgent String?
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
}
enum DocumentStatus {
@@ -304,30 +311,32 @@ enum DocumentVisibility {
/// @zod.import(["import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';", "import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';"])
model Document {
id Int @id @default(autoincrement())
externalId String? /// @zod.string.describe("A custom external ID you can use to identify the document.")
userId Int /// @zod.number.describe("The ID of the user that created this document.")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
authOptions Json? /// [DocumentAuthOptions] @zod.custom.use(ZDocumentAuthOptionsSchema)
formValues Json? /// [DocumentFormValues] @zod.custom.use(ZDocumentFormValuesSchema)
visibility DocumentVisibility @default(EVERYONE)
title String
status DocumentStatus @default(DRAFT)
recipients Recipient[]
fields Field[]
shareLinks DocumentShareLink[]
documentDataId String
documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade)
documentMeta DocumentMeta?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
completedAt DateTime?
deletedAt DateTime?
teamId Int?
team Team? @relation(fields: [teamId], references: [id])
templateId Int?
template Template? @relation(fields: [templateId], references: [id], onDelete: SetNull)
source DocumentSource
id Int @id @default(autoincrement())
externalId String? /// @zod.string.describe("A custom external ID you can use to identify the document.")
userId Int /// @zod.number.describe("The ID of the user that created this document.")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
authOptions Json? /// [DocumentAuthOptions] @zod.custom.use(ZDocumentAuthOptionsSchema)
formValues Json? /// [DocumentFormValues] @zod.custom.use(ZDocumentFormValuesSchema)
visibility DocumentVisibility @default(EVERYONE)
includeSigningCertificate Boolean @default(true)
includeAuditTrailLog Boolean @default(false)
title String
status DocumentStatus @default(DRAFT)
recipients Recipient[]
fields Field[]
shareLinks DocumentShareLink[]
documentDataId String
documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade)
documentMeta DocumentMeta?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
completedAt DateTime?
deletedAt DateTime?
teamId Int?
team Team? @relation(fields: [teamId], references: [id])
templateId Int?
template Template? @relation(fields: [templateId], references: [id], onDelete: SetNull)
source DocumentSource
auditLogs DocumentAuditLog[]
@@ -536,6 +545,7 @@ model TeamGlobalSettings {
includeSenderDetails Boolean @default(true)
typedSignatureEnabled Boolean @default(true)
includeSigningCertificate Boolean @default(true)
includeAuditTrailLog Boolean @default(false)
brandingEnabled Boolean @default(false)
brandingLogo String @default("")

View File

@@ -5,6 +5,18 @@ import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { prisma } from '..';
import { DocumentDataType, DocumentSource, Role, TeamMemberRole } from '../client';
import { seedPendingDocument } from './documents';
import { seedDirectTemplate, seedTemplate } from './templates';
const createDocumentData = async ({ documentData }: { documentData: string }) => {
return prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: documentData,
initialData: documentData,
},
});
};
export const seedDatabase = async () => {
const examplePdf = fs
@@ -39,35 +51,80 @@ export const seedDatabase = async () => {
update: {},
});
const examplePdfData = await prisma.documentData.upsert({
where: {
id: 'clmn0kv5k0000pe04vcqg5zla',
},
create: {
id: 'clmn0kv5k0000pe04vcqg5zla',
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
update: {},
});
for (let i = 1; i <= 4; i++) {
const documentData = await createDocumentData({ documentData: examplePdf });
await prisma.document.create({
data: {
source: DocumentSource.DOCUMENT,
title: 'Example Document',
documentDataId: examplePdfData.id,
userId: exampleUser.id,
recipients: {
create: {
name: String(adminUser.name),
email: adminUser.email,
token: Math.random().toString(36).slice(2, 9),
await prisma.document.create({
data: {
source: DocumentSource.DOCUMENT,
title: `Example Document ${i}`,
documentDataId: documentData.id,
userId: exampleUser.id,
recipients: {
create: {
name: String(adminUser.name),
email: adminUser.email,
token: Math.random().toString(36).slice(2, 9),
},
},
},
});
}
for (let i = 1; i <= 4; i++) {
const documentData = await createDocumentData({ documentData: examplePdf });
await prisma.document.create({
data: {
source: DocumentSource.DOCUMENT,
title: `Document ${i}`,
documentDataId: documentData.id,
userId: adminUser.id,
recipients: {
create: {
name: String(exampleUser.name),
email: exampleUser.email,
token: Math.random().toString(36).slice(2, 9),
},
},
},
});
}
await seedPendingDocument(exampleUser, [adminUser], {
key: 'example-pending',
createDocumentOptions: {
title: 'Pending Document',
},
});
await seedPendingDocument(adminUser, [exampleUser], {
key: 'admin-pending',
createDocumentOptions: {
title: 'Pending Document',
},
});
await Promise.all([
seedTemplate({
title: 'Template 1',
userId: exampleUser.id,
}),
seedDirectTemplate({
title: 'Direct Template 1',
userId: exampleUser.id,
}),
seedTemplate({
title: 'Template 1',
userId: adminUser.id,
}),
seedDirectTemplate({
title: 'Direct Template 1',
userId: adminUser.id,
}),
]);
const testUsers = [
'test@documenso.com',
'test2@documenso.com',

View File

@@ -65,7 +65,7 @@ export const documentRouter = router({
.input(ZGetDocumentByIdQuerySchema)
.query(async ({ input, ctx }) => {
const { teamId } = ctx;
const { documentId } = input;
const { documentId, includeCertificate, includeAuditLog } = input;
return await getDocumentById({
userId: ctx.user.id,

View File

@@ -63,6 +63,16 @@ export const ZDocumentVisibilitySchema = z
.nativeEnum(DocumentVisibility)
.describe('The visibility of the document.');
export const ZDocumentIncludeSigningCertificateSchema = z
.boolean()
.default(true)
.describe('Whether to include a signing certificate in the document.');
export const ZDocumentIncludeAuditTrailSchema = z
.boolean()
.default(true)
.describe('Whether to include an audit trail in the document.');
export const ZDocumentMetaTimezoneSchema = z
.string()
.describe(
@@ -141,6 +151,8 @@ export const ZFindDocumentAuditLogsQuerySchema = ZFindSearchParamsSchema.extend(
export const ZGetDocumentByIdQuerySchema = z.object({
documentId: z.number(),
includeCertificate: z.boolean().default(true).optional(),
includeAuditLog: z.boolean().default(true).optional(),
});
export const ZDuplicateDocumentRequestSchema = z.object({
@@ -235,6 +247,8 @@ export const ZUpdateDocumentRequestSchema = z.object({
title: ZDocumentTitleSchema.optional(),
externalId: ZDocumentExternalIdSchema.nullish(),
visibility: ZDocumentVisibilitySchema.optional(),
includeSigningCertificate: ZDocumentIncludeSigningCertificateSchema.optional(),
includeAuditTrailLog: ZDocumentIncludeAuditTrailSchema.optional(),
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullish(),
globalActionAuth: ZDocumentActionAuthTypesSchema.nullish(),
})

View File

@@ -206,6 +206,7 @@ export const ZUpdateTeamDocumentSettingsMutationSchema = z.object({
includeSenderDetails: z.boolean().optional().default(false),
typedSignatureEnabled: z.boolean().optional().default(true),
includeSigningCertificate: z.boolean().optional().default(true),
includeAuditTrailLog: z.boolean().optional().default(true),
}),
});

View File

@@ -42,6 +42,7 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Checkbox } from '../checkbox';
import { Combobox } from '../combobox';
import { Input } from '../input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
@@ -92,6 +93,8 @@ export const AddSettingsFormPartial = ({
visibility: document.visibility || '',
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined,
globalActionAuth: documentAuthOption?.globalActionAuth || undefined,
includeSigningCertificate: document.includeSigningCertificate ?? true,
includeAuditTrailLog: document.includeAuditTrailLog ?? true,
meta: {
timezone:
TIME_ZONES.find((timezone) => timezone === document.documentMeta?.timezone) ??
@@ -259,6 +262,111 @@ export const AddSettingsFormPartial = ({
/>
)}
<FormField
control={form.control}
name="globalActionAuth"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Recipient action authentication</Trans>
<DocumentGlobalAuthActionTooltip />
</FormLabel>
<FormControl>
<DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<Accordion type="multiple" className="mt-6">
<AccordionItem value="advanced-options" className="border-none">
<AccordionTrigger className="text-foreground mb-2 rounded border px-3 py-2 text-left hover:bg-neutral-200/30 hover:no-underline">
<Trans>Certificates</Trans>
</AccordionTrigger>
<AccordionContent className="text-muted-foreground -mx-1 px-1 pt-2 text-sm leading-relaxed">
<div className="flex flex-col space-y-6">
<FormField
control={form.control}
name="includeSigningCertificate"
render={({ field }) => (
<FormItem>
<div className="flex flex-row items-center gap-4">
<FormControl>
<Checkbox
checked={field.value}
className="h-5 w-5"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="m-0 flex flex-row items-center">
<Trans>Include signing certificate</Trans>{' '}
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<Trans>
Including the signing certificate means that the certificate
will be attached to the document. You won't be able to remove
it. <br />
<br />
If you don't include it, you can download it individually.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="includeAuditTrailLog"
render={({ field }) => (
<FormItem>
<div className="flex flex-row items-center gap-4">
<FormControl>
<Checkbox
checked={field.value}
className="h-5 w-5"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="m-0 flex flex-row items-center">
<Trans>Include audit trail</Trans>{' '}
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<Trans>
Including the audit trail means that the log of all actions will
be attached to the document. You won't be able to remove it.{' '}
<br />
<br />
If you don't include it, you can download it individually.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
{isDocumentEnterprise && (
<FormField
control={form.control}

View File

@@ -29,6 +29,8 @@ export const ZAddSettingsFormSchema = z.object({
title: z.string().trim().min(1, { message: "Title can't be empty" }),
externalId: z.string().optional(),
visibility: z.nativeEnum(DocumentVisibility).optional(),
includeSigningCertificate: z.boolean().default(true).optional(),
includeAuditTrailLog: z.boolean().default(true).optional(),
globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentAccessAuthTypesSchema.optional(),
),

View File

@@ -0,0 +1,83 @@
'use client';
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('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 };