+
+
Claim your documenso public profile username now!{' '}
documenso.com/u/yourname
diff --git a/apps/marketing/src/components/(marketing)/footer.tsx b/apps/marketing/src/components/(marketing)/footer.tsx
index 8d2e0c1d4..e9a08049c 100644
--- a/apps/marketing/src/components/(marketing)/footer.tsx
+++ b/apps/marketing/src/components/(marketing)/footer.tsx
@@ -13,7 +13,7 @@ import LogoImage from '@documenso/assets/logo.png';
import { cn } from '@documenso/ui/lib/utils';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
-import { StatusWidgetContainer } from './status-widget-container';
+// import { StatusWidgetContainer } from './status-widget-container';
export type FooterProps = HTMLAttributes;
@@ -65,9 +65,9 @@ export const Footer = ({ className, ...props }: FooterProps) => {
))}
-
*/}
diff --git a/apps/marketing/src/components/(marketing)/status-widget-container.tsx b/apps/marketing/src/components/(marketing)/status-widget-container.tsx
index 025c2df56..71fbec9cb 100644
--- a/apps/marketing/src/components/(marketing)/status-widget-container.tsx
+++ b/apps/marketing/src/components/(marketing)/status-widget-container.tsx
@@ -6,7 +6,7 @@ import { StatusWidget } from './status-widget';
export function StatusWidgetContainer() {
return (
}>
-
+
);
}
diff --git a/apps/marketing/src/components/(marketing)/status-widget.tsx b/apps/marketing/src/components/(marketing)/status-widget.tsx
index 1c94c0707..0b6b8aaa6 100644
--- a/apps/marketing/src/components/(marketing)/status-widget.tsx
+++ b/apps/marketing/src/components/(marketing)/status-widget.tsx
@@ -1,7 +1,6 @@
-import { use, useMemo } from 'react';
+import { memo, use } from 'react';
-import type { Status } from '@openstatus/react';
-import { getStatus } from '@openstatus/react';
+import { type Status, getStatus } from '@openstatus/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -45,9 +44,8 @@ const getStatusLevel = (level: Status) => {
}[level];
};
-export function StatusWidget() {
- const getStatusMemoized = useMemo(async () => getStatus('documenso-status'), []);
- const { status } = use(getStatusMemoized);
+export const StatusWidget = memo(function StatusWidget({ slug }: { slug: string }) {
+ const { status } = use(getStatus(slug));
const level = getStatusLevel(status);
return (
@@ -72,4 +70,4 @@ export function StatusWidget() {
);
-}
+});
diff --git a/apps/web/next.config.js b/apps/web/next.config.js
index af82847c0..85d3097ca 100644
--- a/apps/web/next.config.js
+++ b/apps/web/next.config.js
@@ -23,7 +23,7 @@ const config = {
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
experimental: {
outputFileTracingRoot: path.join(__dirname, '../../'),
- serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'],
+ serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign', 'playwright'],
serverActions: {
bodySizeLimit: '50mb',
},
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx
index 3e108aed5..7b6bb8a91 100644
--- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx
+++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx
@@ -4,7 +4,16 @@ import { useState } from 'react';
import Link from 'next/link';
-import { Copy, Download, Edit, Loader, MoreHorizontal, Share, Trash2 } from 'lucide-react';
+import {
+ Copy,
+ Download,
+ Edit,
+ Loader,
+ MoreHorizontal,
+ ScrollTextIcon,
+ Share,
+ Trash2,
+} from 'lucide-react';
import { useSession } from 'next-auth/react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
@@ -106,6 +115,13 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
)}
+
+
+
+ Logs
+
+
+
setDuplicateDialogOpen(true)}>
Duplicate
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx
index 33d6cb8fe..2d786b9c9 100644
--- a/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx
+++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx
@@ -1,7 +1,7 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
-import { ChevronLeft, DownloadIcon } from 'lucide-react';
+import { ChevronLeft } from 'lucide-react';
import { DateTime } from 'luxon';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
@@ -10,7 +10,6 @@ import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Recipient, Team } from '@documenso/prisma/client';
-import { Button } from '@documenso/ui/primitives/button';
import { Card } from '@documenso/ui/primitives/card';
import {
@@ -19,6 +18,8 @@ import {
} from '~/components/formatter/document-status';
import { DocumentLogsDataTable } from './document-logs-data-table';
+import { DownloadAuditLogButton } from './download-audit-log-button';
+import { DownloadCertificateButton } from './download-certificate-button';
export type DocumentLogsPageViewProps = {
params: {
@@ -132,15 +133,9 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
-
+
-
+
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-audit-log-button.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-audit-log-button.tsx
new file mode 100644
index 000000000..0847d63fa
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-audit-log-button.tsx
@@ -0,0 +1,74 @@
+'use client';
+
+import { DownloadIcon } from 'lucide-react';
+
+import { trpc } from '@documenso/trpc/react';
+import { cn } from '@documenso/ui/lib/utils';
+import { Button } from '@documenso/ui/primitives/button';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type DownloadAuditLogButtonProps = {
+ className?: string;
+ documentId: number;
+};
+
+export const DownloadAuditLogButton = ({ className, documentId }: DownloadAuditLogButtonProps) => {
+ const { toast } = useToast();
+
+ const { mutateAsync: downloadAuditLogs, isLoading } =
+ trpc.document.downloadAuditLogs.useMutation();
+
+ const onDownloadAuditLogsClick = async () => {
+ try {
+ const { url } = await downloadAuditLogs({ documentId });
+
+ 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: 'Something went wrong',
+ description: 'Sorry, we were unable to download the audit logs. Please try again later.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ return (
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx
new file mode 100644
index 000000000..49a330b94
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx
@@ -0,0 +1,78 @@
+'use client';
+
+import { DownloadIcon } from 'lucide-react';
+
+import { trpc } from '@documenso/trpc/react';
+import { cn } from '@documenso/ui/lib/utils';
+import { Button } from '@documenso/ui/primitives/button';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type DownloadCertificateButtonProps = {
+ className?: string;
+ documentId: number;
+};
+
+export const DownloadCertificateButton = ({
+ className,
+ documentId,
+}: DownloadCertificateButtonProps) => {
+ const { toast } = useToast();
+
+ const { mutateAsync: downloadCertificate, isLoading } =
+ trpc.document.downloadCertificate.useMutation();
+
+ const onDownloadCertificatesClick = async () => {
+ try {
+ const { url } = await downloadCertificate({ documentId });
+
+ 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: 'Something went wrong',
+ description: 'Sorry, we were unable to download the certificate. Please try again later.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ return (
+
+ );
+};
diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/data-table.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/data-table.tsx
new file mode 100644
index 000000000..016a64fbb
--- /dev/null
+++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/data-table.tsx
@@ -0,0 +1,89 @@
+'use client';
+
+import { DateTime } from 'luxon';
+import type { DateTimeFormatOptions } from 'luxon';
+import { UAParser } from 'ua-parser-js';
+
+import type { TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs';
+import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@documenso/ui/primitives/table';
+
+import { LocaleDate } from '~/components/formatter/locale-date';
+
+export type AuditLogDataTableProps = {
+ logs: TDocumentAuditLog[];
+};
+
+const dateFormat: DateTimeFormatOptions = {
+ ...DateTime.DATETIME_SHORT,
+ hourCycle: 'h12',
+};
+
+export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => {
+ const parser = new UAParser();
+
+ const uppercaseFistLetter = (text: string) => {
+ return text.charAt(0).toUpperCase() + text.slice(1);
+ };
+
+ return (
+
+
+
+ Time
+ User
+ Action
+ IP Address
+ Browser
+
+
+
+
+ {logs.map((log, i) => (
+
+
+
+
+
+
+ {log.name || log.email ? (
+
+ {log.name && (
+
+ {log.name}
+
+ )}
+
+ {log.email && (
+
+ {log.email}
+
+ )}
+
+ ) : (
+ N/A
+ )}
+
+
+
+ {uppercaseFistLetter(formatDocumentAuditLogAction(log).description)}
+
+
+ {log.ipAddress}
+
+
+ {log.userAgent ? parser.setUA(log.userAgent).getBrowser().name : 'N/A'}
+
+
+ ))}
+
+
+ );
+};
diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx
new file mode 100644
index 000000000..1db089495
--- /dev/null
+++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx
@@ -0,0 +1,139 @@
+import React from 'react';
+
+import { redirect } from 'next/navigation';
+
+import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
+import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
+import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
+import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs';
+import { Card, CardContent } from '@documenso/ui/primitives/card';
+
+import { Logo } from '~/components/branding/logo';
+import { LocaleDate } from '~/components/formatter/locale-date';
+
+import { AuditLogDataTable } from './data-table';
+
+type AuditLogProps = {
+ searchParams: {
+ d: string;
+ };
+};
+
+export default async function AuditLog({ searchParams }: AuditLogProps) {
+ const { d } = searchParams;
+
+ if (typeof d !== 'string' || !d) {
+ return redirect('/');
+ }
+
+ const rawDocumentId = decryptSecondaryData(d);
+
+ if (!rawDocumentId || isNaN(Number(rawDocumentId))) {
+ return redirect('/');
+ }
+
+ const documentId = Number(rawDocumentId);
+
+ const document = await getEntireDocument({
+ id: documentId,
+ }).catch(() => null);
+
+ if (!document) {
+ return redirect('/');
+ }
+
+ const { data: auditLogs } = await findDocumentAuditLogs({
+ documentId: documentId,
+ userId: document.userId,
+ perPage: 100_000,
+ });
+
+ return (
+
+
+
Version History
+
+
+
+
+
+ Document ID
+
+ {document.id}
+
+
+
+ Enclosed Document
+
+ {document.title}
+
+
+
+ Status
+
+ {document.deletedAt ? 'DELETED' : document.status}
+
+
+
+ Owner
+
+
+ {document.User.name} ({document.User.email})
+
+
+
+
+ Created At
+
+
+
+
+
+
+
+ Last Updated
+
+
+
+
+
+
+
+ Time Zone
+
+
+ {document.documentMeta?.timezone ?? 'N/A'}
+
+
+
+
+
Recipients
+
+
+ {document.Recipient.map((recipient) => (
+ -
+
+ [{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}]
+ {' '}
+ {recipient.name} ({recipient.email})
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx
new file mode 100644
index 000000000..447e4ad72
--- /dev/null
+++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx
@@ -0,0 +1,299 @@
+import React from 'react';
+
+import { redirect } from 'next/navigation';
+
+import { match } from 'ts-pattern';
+import { UAParser } from 'ua-parser-js';
+
+import {
+ RECIPIENT_ROLES_DESCRIPTION,
+ RECIPIENT_ROLE_SIGNING_REASONS,
+} from '@documenso/lib/constants/recipient-roles';
+import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
+import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
+import { getDocumentCertificateAuditLogs } from '@documenso/lib/server-only/document/get-document-certificate-audit-logs';
+import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
+import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
+import { FieldType } from '@documenso/prisma/client';
+import { Card, CardContent } from '@documenso/ui/primitives/card';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@documenso/ui/primitives/table';
+
+import { Logo } from '~/components/branding/logo';
+import { LocaleDate } from '~/components/formatter/locale-date';
+
+type SigningCertificateProps = {
+ searchParams: {
+ d: string;
+ };
+};
+
+const FRIENDLY_SIGNING_REASONS = {
+ ['__OWNER__']: 'I am the owner of this document',
+ ...RECIPIENT_ROLE_SIGNING_REASONS,
+};
+
+export default async function SigningCertificate({ searchParams }: SigningCertificateProps) {
+ const { d } = searchParams;
+
+ if (typeof d !== 'string' || !d) {
+ return redirect('/');
+ }
+
+ const rawDocumentId = decryptSecondaryData(d);
+
+ if (!rawDocumentId || isNaN(Number(rawDocumentId))) {
+ return redirect('/');
+ }
+
+ const documentId = Number(rawDocumentId);
+
+ const document = await getEntireDocument({
+ id: documentId,
+ }).catch(() => null);
+
+ if (!document) {
+ return redirect('/');
+ }
+
+ const auditLogs = await getDocumentCertificateAuditLogs({
+ id: documentId,
+ });
+
+ const isOwner = (email: string) => {
+ return email.toLowerCase() === document.User.email.toLowerCase();
+ };
+
+ const getDevice = (userAgent?: string | null) => {
+ if (!userAgent) {
+ return 'Unknown';
+ }
+
+ const parser = new UAParser(userAgent);
+
+ parser.setUA(userAgent);
+
+ const result = parser.getResult();
+
+ return `${result.os.name} - ${result.browser.name} ${result.browser.version}`;
+ };
+
+ const getAuthenticationLevel = (recipientId: number) => {
+ const recipient = document.Recipient.find((recipient) => recipient.id === recipientId);
+
+ if (!recipient) {
+ return 'Unknown';
+ }
+
+ const extractedAuthMethods = extractDocumentAuthMethods({
+ documentAuth: document.authOptions,
+ recipientAuth: recipient.authOptions,
+ });
+
+ let authLevel = match(extractedAuthMethods.derivedRecipientActionAuth)
+ .with('ACCOUNT', () => 'Account Re-Authentication')
+ .with('TWO_FACTOR_AUTH', () => 'Two-Factor Re-Authentication')
+ .with('PASSKEY', () => 'Passkey Re-Authentication')
+ .with('EXPLICIT_NONE', () => 'Email')
+ .with(null, () => null)
+ .exhaustive();
+
+ if (!authLevel) {
+ authLevel = match(extractedAuthMethods.derivedRecipientAccessAuth)
+ .with('ACCOUNT', () => 'Account Authentication')
+ .with(null, () => 'Email')
+ .exhaustive();
+ }
+
+ return authLevel;
+ };
+
+ const getRecipientAuditLogs = (recipientId: number) => {
+ return {
+ [DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT].filter(
+ (log) =>
+ log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT && log.data.recipientId === recipientId,
+ ),
+ [DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs[
+ DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED
+ ].filter(
+ (log) =>
+ log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED &&
+ log.data.recipientId === recipientId,
+ ),
+ [DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs[
+ DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED
+ ].filter(
+ (log) =>
+ log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED &&
+ log.data.recipientId === recipientId,
+ ),
+ };
+ };
+
+ const getRecipientSignatureField = (recipientId: number) => {
+ return document.Recipient.find((recipient) => recipient.id === recipientId)?.Field.find(
+ (field) => field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE,
+ );
+ };
+
+ return (
+
+
+
Signing Certificate
+
+
+
+
+
+
+
+ Signer Events
+ Signature
+ Details
+ {/* Security */}
+
+
+
+
+ {document.Recipient.map((recipient, i) => {
+ const logs = getRecipientAuditLogs(recipient.id);
+ const signature = getRecipientSignatureField(recipient.id);
+
+ return (
+
+
+ {recipient.name}
+ {recipient.email}
+
+ {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
+
+
+
+ Authentication Level:{' '}
+ {getAuthenticationLevel(recipient.id)}
+
+
+
+
+ {signature ? (
+ <>
+
+

+
+
+
+ Signature ID:{' '}
+
+ {signature.secondaryId}
+
+
+
+
+ IP Address:{' '}
+
+ {logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? 'Unknown'}
+
+
+
+
+ Device:{' '}
+
+ {getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
+
+
+ >
+ ) : (
+ N/A
+ )}
+
+
+
+
+
+ Sent:{' '}
+
+ {logs.EMAIL_SENT[0] ? (
+
+ ) : (
+ 'Unknown'
+ )}
+
+
+
+
+ Viewed:{' '}
+
+ {logs.DOCUMENT_OPENED[0] ? (
+
+ ) : (
+ 'Unknown'
+ )}
+
+
+
+
+ Signed:{' '}
+
+ {logs.DOCUMENT_RECIPIENT_COMPLETED[0] ? (
+
+ ) : (
+ 'Unknown'
+ )}
+
+
+
+
+ Reason:{' '}
+
+ {isOwner(recipient.email)
+ ? FRIENDLY_SIGNING_REASONS['__OWNER__']
+ : FRIENDLY_SIGNING_REASONS[recipient.role]}
+
+
+
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+ Signing certificate provided by:
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/claim-account.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/claim-account.tsx
new file mode 100644
index 000000000..b5f7c0ca8
--- /dev/null
+++ b/apps/web/src/app/(signing)/sign/[token]/complete/claim-account.tsx
@@ -0,0 +1,155 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
+import { TRPCClientError } from '@documenso/trpc/client';
+import { trpc } from '@documenso/trpc/react';
+import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@documenso/ui/primitives/form/form';
+import { Input } from '@documenso/ui/primitives/input';
+import { PasswordInput } from '@documenso/ui/primitives/password-input';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type ClaimAccountProps = {
+ defaultName: string;
+ defaultEmail: string;
+ trigger?: React.ReactNode;
+};
+
+export const ZClaimAccountFormSchema = z
+ .object({
+ name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
+ email: z.string().email().min(1),
+ password: ZPasswordSchema,
+ })
+ .refine(
+ (data) => {
+ const { name, email, password } = data;
+ return !password.includes(name) && !password.includes(email.split('@')[0]);
+ },
+ {
+ message: 'Password should not be common or based on personal information',
+ path: ['password'],
+ },
+ );
+
+export type TClaimAccountFormSchema = z.infer
;
+
+export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) => {
+ const analytics = useAnalytics();
+ const { toast } = useToast();
+ const router = useRouter();
+
+ const { mutateAsync: signup } = trpc.auth.signup.useMutation();
+
+ const form = useForm({
+ values: {
+ name: defaultName ?? '',
+ email: defaultEmail,
+ password: '',
+ },
+ resolver: zodResolver(ZClaimAccountFormSchema),
+ });
+
+ const onFormSubmit = async ({ name, email, password }: TClaimAccountFormSchema) => {
+ try {
+ await signup({ name, email, password });
+
+ router.push(`/unverified-account`);
+
+ toast({
+ title: 'Registration Successful',
+ description:
+ 'You have successfully registered. Please verify your account by clicking on the link you received in the email.',
+ duration: 5000,
+ });
+
+ analytics.capture('App: User Claim Account', {
+ email,
+ timestamp: new Date().toISOString(),
+ });
+ } catch (error) {
+ if (error instanceof TRPCClientError && error.data?.code === 'BAD_REQUEST') {
+ toast({
+ title: 'An error occurred',
+ description: error.message,
+ variant: 'destructive',
+ });
+ } else {
+ toast({
+ title: 'An unknown error occurred',
+ description:
+ 'We encountered an unknown error while attempting to sign you up. Please try again later.',
+ variant: 'destructive',
+ });
+ }
+ }
+ };
+
+ return (
+
+ );
+};
diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx
index c13d8636b..cfed976e5 100644
--- a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx
+++ b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx
@@ -3,6 +3,7 @@ import { notFound } from 'next/navigation';
import { CheckCircle2, Clock8 } from 'lucide-react';
import { getServerSession } from 'next-auth';
+import { env } from 'next-runtime-env';
import { match } from 'ts-pattern';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
@@ -16,10 +17,13 @@ import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/clie
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
+import { cn } from '@documenso/ui/lib/utils';
+import { Badge } from '@documenso/ui/primitives/badge';
import { truncateTitle } from '~/helpers/truncate-title';
import { SigningAuthPageView } from '../signing-auth-page';
+import { ClaimAccount } from './claim-account';
import { DocumentPreviewButton } from './document-preview-button';
export type CompletedSigningPageProps = {
@@ -31,6 +35,8 @@ export type CompletedSigningPageProps = {
export default async function CompletedSigningPage({
params: { token },
}: CompletedSigningPageProps) {
+ const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
+
if (!token) {
return notFound();
}
@@ -79,96 +85,120 @@ export default async function CompletedSigningPage({
const sessionData = await getServerSession();
const isLoggedIn = !!sessionData?.user;
+ const canSignUp = !isLoggedIn && NEXT_PUBLIC_DISABLE_SIGNUP !== 'true';
return (
-
- {/* Card with recipient */}
-
+
+
+
+
+ {truncatedTitle}
+
-
- {match({ status: document.status, deletedAt: document.deletedAt })
- .with({ status: DocumentStatus.COMPLETED }, () => (
-
-
- Everyone has signed
-
- ))
- .with({ deletedAt: null }, () => (
-
-
- Waiting for others to sign
-
- ))
- .otherwise(() => (
-
-
- Document no longer available to sign
-
- ))}
+ {/* Card with recipient */}
+
-
- You have
- {recipient.role === RecipientRole.SIGNER && ' signed '}
- {recipient.role === RecipientRole.VIEWER && ' viewed '}
- {recipient.role === RecipientRole.APPROVER && ' approved '}
- "{truncatedTitle}"
-
+
+ Document
+ {recipient.role === RecipientRole.SIGNER && ' Signed '}
+ {recipient.role === RecipientRole.VIEWER && ' Viewed '}
+ {recipient.role === RecipientRole.APPROVER && ' Approved '}
+
- {match({ status: document.status, deletedAt: document.deletedAt })
- .with({ status: DocumentStatus.COMPLETED }, () => (
-
- Everyone has signed! You will receive an Email copy of the signed document.
-
- ))
- .with({ deletedAt: null }, () => (
-
- You will receive an Email copy of the signed document once everyone has signed.
-
- ))
- .otherwise(() => (
-
- This document has been cancelled by the owner and is no longer available for others to
- sign.
-
- ))}
+ {match({ status: document.status, deletedAt: document.deletedAt })
+ .with({ status: DocumentStatus.COMPLETED }, () => (
+
+
+ Everyone has signed
+
+ ))
+ .with({ deletedAt: null }, () => (
+
+
+ Waiting for others to sign
+
+ ))
+ .otherwise(() => (
+
+
+ Document no longer available to sign
+
+ ))}
-
-
+ {match({ status: document.status, deletedAt: document.deletedAt })
+ .with({ status: DocumentStatus.COMPLETED }, () => (
+
+ Everyone has signed! You will receive an Email copy of the signed document.
+
+ ))
+ .with({ deletedAt: null }, () => (
+
+ You will receive an Email copy of the signed document once everyone has signed.
+
+ ))
+ .otherwise(() => (
+
+ This document has been cancelled by the owner and is no longer available for others
+ to sign.
+
+ ))}
- {document.status === DocumentStatus.COMPLETED ? (
-
- ) : (
-
- )}
+
+
+
+ {document.status === DocumentStatus.COMPLETED ? (
+
+ ) : (
+
+ )}
+
- {isLoggedIn ? (
+ {canSignUp && (
+
+
+ Need to sign documents?
+
+
+
+ Create your account and start using state-of-the-art document signing.
+
+
+
+
+ )}
+
+ {isLoggedIn && (
Go Back Home
- ) : (
-
- Want to send slick signing links like this one?{' '}
-
- Check out Documenso.
-
-
)}
diff --git a/package-lock.json b/package-lock.json
index 9eb4d3818..fb03b3a67 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,6 +22,7 @@
"eslint-config-custom": "*",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
+ "playwright": "^1.43.0",
"prettier": "^2.5.1",
"rimraf": "^5.0.1",
"turbo": "^1.9.3"
@@ -4701,6 +4702,19 @@
"node": ">=14"
}
},
+ "node_modules/@playwright/browser-chromium": {
+ "version": "1.43.0",
+ "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.43.0.tgz",
+ "integrity": "sha512-F0S4KIqSqQqm9EgsdtWjaJRpgP8cD2vWZHPSB41YI00PtXUobiv/3AnYISeL7wNuTanND7giaXQ4SIjkcIq3KQ==",
+ "dev": true,
+ "hasInstallScript": true,
+ "dependencies": {
+ "playwright-core": "1.43.0"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/@playwright/test": {
"version": "1.40.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.0.tgz",
@@ -4716,6 +4730,50 @@
"node": ">=16"
}
},
+ "node_modules/@playwright/test/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/@playwright/test/node_modules/playwright": {
+ "version": "1.40.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz",
+ "integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==",
+ "dev": true,
+ "dependencies": {
+ "playwright-core": "1.40.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/@playwright/test/node_modules/playwright-core": {
+ "version": "1.40.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz",
+ "integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==",
+ "dev": true,
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/@prisma/client": {
"version": "5.4.2",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.4.2.tgz",
@@ -17615,12 +17673,11 @@
}
},
"node_modules/playwright": {
- "version": "1.40.0",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz",
- "integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==",
- "dev": true,
+ "version": "1.43.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz",
+ "integrity": "sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==",
"dependencies": {
- "playwright-core": "1.40.0"
+ "playwright-core": "1.43.0"
},
"bin": {
"playwright": "cli.js"
@@ -17633,10 +17690,9 @@
}
},
"node_modules/playwright-core": {
- "version": "1.40.0",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz",
- "integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==",
- "dev": true,
+ "version": "1.43.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz",
+ "integrity": "sha512-iWFjyBUH97+pUFiyTqSLd8cDMMOS0r2ZYz2qEsPjH8/bX++sbIJT35MSwKnp1r/OQBAqC5XO99xFbJ9XClhf4w==",
"bin": {
"playwright-core": "cli.js"
},
@@ -17648,7 +17704,6 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
- "dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
@@ -24926,6 +24981,7 @@
"next-auth": "4.24.5",
"oslo": "^0.17.0",
"pdf-lib": "^1.17.1",
+ "playwright": "^1.43.0",
"react": "18.2.0",
"remeda": "^1.27.1",
"stripe": "^12.7.0",
@@ -24933,6 +24989,7 @@
"zod": "^3.22.4"
},
"devDependencies": {
+ "@playwright/browser-chromium": "^1.43.0",
"@types/luxon": "^3.3.1"
}
},
diff --git a/package.json b/package.json
index bafada07a..396b2ecfd 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,7 @@
"eslint-config-custom": "*",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
+ "playwright": "^1.43.0",
"prettier": "^2.5.1",
"rimraf": "^5.0.1",
"turbo": "^1.9.3"
@@ -59,4 +60,4 @@
"next": "14.0.3"
}
}
-}
+}
\ No newline at end of file
diff --git a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts
index ee6b160cc..c2ae0618c 100644
--- a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts
+++ b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts
@@ -254,7 +254,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', asyn
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`/sign/${token}/complete`);
- await expect(page.getByText('You have signed')).toBeVisible();
+ await expect(page.getByText('Document Signed')).toBeVisible();
// Check if document has been signed
const { status: completedStatus } = await getDocumentByToken(token);
diff --git a/packages/lib/constants/recipient-roles.ts b/packages/lib/constants/recipient-roles.ts
index ce1037dd9..59af9b3b5 100644
--- a/packages/lib/constants/recipient-roles.ts
+++ b/packages/lib/constants/recipient-roles.ts
@@ -32,3 +32,10 @@ export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
[RecipientRole.VIEWER]: 'VIEW_REQUEST',
[RecipientRole.APPROVER]: 'APPROVE_REQUEST',
} as const;
+
+export const RECIPIENT_ROLE_SIGNING_REASONS = {
+ [RecipientRole.SIGNER]: 'I am a signer of this document',
+ [RecipientRole.APPROVER]: 'I am an approver of this document',
+ [RecipientRole.CC]: 'I am required to recieve a copy of this document',
+ [RecipientRole.VIEWER]: 'I am a viewer of this document',
+} satisfies Record
;
diff --git a/packages/lib/package.json b/packages/lib/package.json
index 7a32b3058..1aa7e431e 100644
--- a/packages/lib/package.json
+++ b/packages/lib/package.json
@@ -39,6 +39,7 @@
"next-auth": "4.24.5",
"oslo": "^0.17.0",
"pdf-lib": "^1.17.1",
+ "playwright": "^1.43.0",
"react": "18.2.0",
"remeda": "^1.27.1",
"stripe": "^12.7.0",
@@ -46,6 +47,7 @@
"zod": "^3.22.4"
},
"devDependencies": {
- "@types/luxon": "^3.3.1"
+ "@types/luxon": "^3.3.1",
+ "@playwright/browser-chromium": "^1.43.0"
}
-}
+}
\ No newline at end of file
diff --git a/packages/lib/server-only/admin/get-entire-document.ts b/packages/lib/server-only/admin/get-entire-document.ts
index e74ee4c7b..8b7650d7b 100644
--- a/packages/lib/server-only/admin/get-entire-document.ts
+++ b/packages/lib/server-only/admin/get-entire-document.ts
@@ -10,6 +10,14 @@ export const getEntireDocument = async ({ id }: GetEntireDocumentOptions) => {
id,
},
include: {
+ documentMeta: true,
+ User: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ },
+ },
Recipient: {
include: {
Field: {
diff --git a/packages/lib/server-only/document/get-document-certificate-audit-logs.ts b/packages/lib/server-only/document/get-document-certificate-audit-logs.ts
new file mode 100644
index 000000000..e517a4608
--- /dev/null
+++ b/packages/lib/server-only/document/get-document-certificate-audit-logs.ts
@@ -0,0 +1,43 @@
+import { prisma } from '@documenso/prisma';
+
+import { DOCUMENT_AUDIT_LOG_TYPE, DOCUMENT_EMAIL_TYPE } from '../../types/document-audit-logs';
+import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
+
+export type GetDocumentCertificateAuditLogsOptions = {
+ id: number;
+};
+
+export const getDocumentCertificateAuditLogs = async ({
+ id,
+}: GetDocumentCertificateAuditLogsOptions) => {
+ const rawAuditLogs = await prisma.documentAuditLog.findMany({
+ where: {
+ documentId: id,
+ type: {
+ in: [
+ DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
+ DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
+ DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
+ ],
+ },
+ },
+ });
+
+ const auditLogs = rawAuditLogs.map((log) => parseDocumentAuditLogData(log));
+
+ const groupedAuditLogs = {
+ [DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs.filter(
+ (log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
+ ),
+ [DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs.filter(
+ (log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
+ ),
+ [DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs.filter(
+ (log) =>
+ log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT &&
+ log.data.emailType !== DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED,
+ ),
+ } as const;
+
+ return groupedAuditLogs;
+};
diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts
index ec5f93539..3e366dc81 100644
--- a/packages/lib/server-only/document/seal-document.ts
+++ b/packages/lib/server-only/document/seal-document.ts
@@ -15,6 +15,7 @@ import { signPdf } from '@documenso/signing';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file';
+import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances';
@@ -91,6 +92,10 @@ export const sealDocument = async ({
// !: Need to write the fields onto the document as a hard copy
const pdfData = await getFile(documentData);
+ const certificate = await getCertificatePdf({ documentId }).then(async (doc) =>
+ PDFDocument.load(doc),
+ );
+
const doc = await PDFDocument.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature
@@ -98,6 +103,12 @@ export const sealDocument = async ({
doc.getForm().flatten();
flattenAnnotations(doc);
+ const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices());
+
+ certificatePages.forEach((page) => {
+ doc.addPage(page);
+ });
+
for (const field of fields) {
await insertFieldInPDF(doc, field);
}
diff --git a/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts b/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts
new file mode 100644
index 000000000..a7182410e
--- /dev/null
+++ b/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts
@@ -0,0 +1,45 @@
+import { DateTime } from 'luxon';
+import type { Browser } from 'playwright';
+import { chromium } from 'playwright';
+
+import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
+import { encryptSecondaryData } from '../crypto/encrypt';
+
+export type GetCertificatePdfOptions = {
+ documentId: number;
+};
+
+export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions) => {
+ const encryptedId = encryptSecondaryData({
+ data: documentId.toString(),
+ expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
+ });
+
+ let browser: Browser;
+
+ if (process.env.NEXT_PRIVATE_BROWSERLESS_URL) {
+ browser = await chromium.connect(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 page = await browser.newPage();
+
+ await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`, {
+ waitUntil: 'networkidle',
+ });
+
+ const result = await page.pdf({
+ format: 'A4',
+ });
+
+ void browser.close();
+
+ return result;
+};
diff --git a/packages/tailwind-config/index.cjs b/packages/tailwind-config/index.cjs
index 92222462f..01e7296d3 100644
--- a/packages/tailwind-config/index.cjs
+++ b/packages/tailwind-config/index.cjs
@@ -7,6 +7,9 @@ module.exports = {
content: ['src/**/*.{ts,tsx}'],
theme: {
extend: {
+ screens: {
+ print: { raw: 'print' },
+ },
fontFamily: {
sans: ['var(--font-sans)', ...fontFamily.sans],
signature: ['var(--font-signature)'],
diff --git a/packages/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts
index f9a1795d7..645690905 100644
--- a/packages/trpc/server/auth-router/router.ts
+++ b/packages/trpc/server/auth-router/router.ts
@@ -56,7 +56,7 @@ export const authRouter = router({
return user;
} catch (err) {
- console.log(err);
+ console.error(err);
const error = AppError.parseError(err);
diff --git a/packages/trpc/server/auth-router/schema.ts b/packages/trpc/server/auth-router/schema.ts
index b84c5e1c9..71734d734 100644
--- a/packages/trpc/server/auth-router/schema.ts
+++ b/packages/trpc/server/auth-router/schema.ts
@@ -23,7 +23,7 @@ export const ZSignUpMutationSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
password: ZPasswordSchema,
- signature: z.string().min(1, { message: 'A signature is required.' }),
+ signature: z.string().nullish(),
url: z
.string()
.trim()
diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts
index 6e7e8764f..3cc61bef2 100644
--- a/packages/trpc/server/document-router/router.ts
+++ b/packages/trpc/server/document-router/router.ts
@@ -1,7 +1,10 @@
import { TRPCError } from '@trpc/server';
+import { DateTime } from 'luxon';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
+import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
import { createDocument } from '@documenso/lib/server-only/document/create-document';
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
@@ -22,6 +25,7 @@ import { authenticatedProcedure, procedure, router } from '../trpc';
import {
ZCreateDocumentMutationSchema,
ZDeleteDraftDocumentMutationSchema as ZDeleteDocumentMutationSchema,
+ ZDownloadAuditLogsMutationSchema,
ZFindDocumentAuditLogsQuerySchema,
ZGetDocumentByIdQuerySchema,
ZGetDocumentByTokenQuerySchema,
@@ -364,4 +368,66 @@ export const documentRouter = router({
});
}
}),
+
+ downloadAuditLogs: authenticatedProcedure
+ .input(ZDownloadAuditLogsMutationSchema)
+ .mutation(async ({ input, ctx }) => {
+ try {
+ const { documentId, teamId } = input;
+
+ const document = await getDocumentById({
+ id: documentId,
+ userId: ctx.user.id,
+ teamId,
+ });
+
+ const encrypted = encryptSecondaryData({
+ data: document.id.toString(),
+ expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
+ });
+
+ return {
+ url: `${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encrypted}`,
+ };
+ } catch (err) {
+ console.error(err);
+
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message:
+ 'We were unable to download the audit logs for this document. Please try again later.',
+ });
+ }
+ }),
+
+ downloadCertificate: authenticatedProcedure
+ .input(ZDownloadAuditLogsMutationSchema)
+ .mutation(async ({ input, ctx }) => {
+ try {
+ const { documentId, teamId } = input;
+
+ const document = await getDocumentById({
+ id: documentId,
+ userId: ctx.user.id,
+ teamId,
+ });
+
+ const encrypted = encryptSecondaryData({
+ data: document.id.toString(),
+ expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
+ });
+
+ return {
+ url: `${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encrypted}`,
+ };
+ } catch (err) {
+ console.error(err);
+
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message:
+ 'We were unable to download the audit logs for this document. Please try again later.',
+ });
+ }
+ }),
});
diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts
index 6ed6fcc4d..483d32e50 100644
--- a/packages/trpc/server/document-router/schema.ts
+++ b/packages/trpc/server/document-router/schema.ts
@@ -163,3 +163,8 @@ export type TDeleteDraftDocumentMutationSchema = z.infer>(
- ({ className, ...props }, ref) => (
-
- ),
-);
+const Table = React.forwardRef<
+ HTMLTableElement,
+ React.HTMLAttributes & {
+ overflowHidden?: boolean;
+ }
+>(({ className, overflowHidden, ...props }, ref) => (
+
+));
Table.displayName = 'Table';
@@ -76,11 +79,17 @@ TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef<
HTMLTableCellElement,
- React.TdHTMLAttributes
->(({ className, ...props }, ref) => (
+ React.TdHTMLAttributes & {
+ truncate?: boolean;
+ }
+>(({ className, truncate = true, ...props }, ref) => (
|
));
diff --git a/packages/ui/styles/theme.css b/packages/ui/styles/theme.css
index cb2d9d5c5..fa9231e5d 100644
--- a/packages/ui/styles/theme.css
+++ b/packages/ui/styles/theme.css
@@ -97,6 +97,21 @@
}
}
+/*
+ * Custom CSS for printing reports
+ * - Sets page margins to 0.5 inches
+ * - Hides the header and footer
+ * - Hides the print button
+ * - Sets page size to A4
+ * - Sets the font size to 12pt
+ */
+.print-provider {
+ @page {
+ margin: 1in;
+ size: A4;
+ }
+}
+
.gradient-border-mask::before {
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
diff --git a/turbo.json b/turbo.json
index 6579441be..fa89193eb 100644
--- a/turbo.json
+++ b/turbo.json
@@ -2,8 +2,13 @@
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
- "dependsOn": ["^build"],
- "outputs": [".next/**", "!.next/cache/**"]
+ "dependsOn": [
+ "^build"
+ ],
+ "outputs": [
+ ".next/**",
+ "!.next/cache/**"
+ ]
},
"lint": {
"cache": false
@@ -19,7 +24,9 @@
"persistent": true
},
"start": {
- "dependsOn": ["^build"],
+ "dependsOn": [
+ "^build"
+ ],
"cache": false,
"persistent": true
},
@@ -27,11 +34,15 @@
"cache": false
},
"test:e2e": {
- "dependsOn": ["^build"],
+ "dependsOn": [
+ "^build"
+ ],
"cache": false
}
},
- "globalDependencies": ["**/.env.*local"],
+ "globalDependencies": [
+ "**/.env.*local"
+ ],
"globalEnv": [
"APP_VERSION",
"NEXT_PRIVATE_ENCRYPTION_KEY",
@@ -93,6 +104,7 @@
"NEXT_PRIVATE_STRIPE_API_KEY",
"NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET",
"NEXT_PRIVATE_GITHUB_TOKEN",
+ "NEXT_PRIVATE_BROWSERLESS_URL",
"CI",
"VERCEL",
"VERCEL_ENV",
@@ -110,4 +122,4 @@
"E2E_TEST_AUTHENTICATE_USER_EMAIL",
"E2E_TEST_AUTHENTICATE_USER_PASSWORD"
]
-}
+}
\ No newline at end of file