Compare commits

...

18 Commits

Author SHA1 Message Date
7705dbae0c feat: add document log page link (#1099)
## Description

Adds a link from the document page view to the document page log view

<img width="289" alt="image"
src="https://github.com/documenso/documenso/assets/20962767/335af85a-26c3-4849-a54e-25eb62373574">
2024-04-11 15:04:36 +07:00
8b58f10cbe feat: add cta on complete page (#1028)
![CleanShot 2024-03-18 at 11 45
40](https://github.com/documenso/documenso/assets/25515812/ae3b88de-359d-4019-866a-a76097bbb0fe)
![CleanShot 2024-03-18 at 11 46
25](https://github.com/documenso/documenso/assets/25515812/b5ff7078-623e-476c-8800-17d14bc8efa9)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Introduced a "Claim Account" feature allowing new users to sign up by
providing their name, email, and password.
- Enhanced user experience for both logged-in and non-logged-in users
with improved UI/UX and additional functionality.

- **Enhancements**
- Implemented form validation and error handling for a smoother sign-up
process.
	- Integrated analytics to track user actions during account claiming.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
Co-authored-by: David Nguyen <davidngu28@gmail.com>
2024-04-11 10:09:04 +03:00
fe1f0e6a76 fix: remove status widget for now (#1098)
- removing widget since stability fix seems somehow worse in prod
2024-04-10 18:25:56 +02:00
a82975fd78 chore: keep import until fix or complete remove 2024-04-10 18:24:32 +02:00
a4967f19e8 fix: remove status widget for now 2024-04-10 18:22:46 +02:00
a311869c9b fix: status widget rerendering (#1097)
---
name: Pull Request
about: Submit changes to the project for review and inclusion
---

## Description

Should fix the rerendering of the status widget 
## Related Issue

<!--- If this pull request is related to a specific issue, reference it
here using #issue_number. -->
<!--- For example, "Fixes #123" or "Addresses #456". -->

## Changes Made

<!--- Provide a summary of the changes made in this pull request. -->
<!--- Include any relevant technical details or architecture changes.
-->

- Change 1
- Change 2
- ...

## Testing Performed

<!--- Describe the testing that you have performed to validate these
changes. -->
<!--- Include information about test cases, testing environments, and
results. -->

- Tested feature X in scenario Y.
- Ran unit tests for component Z.
- Tested on browsers A, B, and C.
- ...

## Checklist

<!--- Please check the boxes that apply to this pull request. -->
<!--- You can add or remove items as needed. -->

- [ ] 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.
- [ ] I have followed the project's coding style guidelines.
- [ ] I have addressed the code review feedback from the previous
submission, if applicable.

## Additional Notes

<!--- Provide any additional context or notes for the reviewers. -->
<!--- This might include details about design decisions, potential
concerns, or anything else relevant. -->
2024-04-10 17:52:20 +02:00
6f3cea52e8 fix: handle older cert data 2024-04-10 22:34:14 +07:00
732827f81d Merge branch 'main' into main 2024-04-10 17:32:32 +02:00
bfff1234bb fix: handle older cert data 2024-04-10 22:32:25 +07:00
93a149d637 fix: handle older cert data 2024-04-10 22:10:20 +07:00
f7ae3104ea fix: status widget rerendering 2024-04-10 17:05:22 +02:00
64870f22b9 feat: add certificate and audit log pdfs (#1092) 2024-04-10 20:42:21 +07:00
12e4bc918d fix: marketing header darkmode (#1096)
Co-authored-by: Adithya Krishna <aadithya794@gmail.com>
2024-04-10 16:30:11 +03:00
e36763a85d feat: update marketing banner (#1095)
![CleanShot 2024-04-10 at 15 51
27](https://github.com/documenso/documenso/assets/25515812/d2ad275c-4e68-42f2-8882-a20129c0b0bd)
2024-04-10 16:07:14 +03:00
0bc9c590a7 fix: use ts-match 2024-04-10 20:03:01 +07:00
4d4dfd3c5f fix: implement review feedback, resolve build errors 2024-04-10 17:39:16 +07:00
c9b4915fc8 fix: remove hardcoded ids 2024-04-10 15:30:04 +07:00
110f9bae12 feat: add certificate and audit log pdfs 2024-04-10 15:13:18 +07:00
33 changed files with 1296 additions and 146 deletions

View File

@ -22,7 +22,7 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
const config = {
experimental: {
outputFileTracingRoot: path.join(__dirname, '../../'),
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'],
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign', 'playwright'],
serverActions: {
bodySizeLimit: '50mb',
},

View File

@ -2,10 +2,8 @@
import React, { useEffect, useState } from 'react';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import launchWeekTwoImage from '@documenso/assets/images/background-lw-2.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
@ -48,16 +46,8 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
})}
>
{showProfilesAnnouncementBar && (
<div className="relative inline-flex w-full items-center justify-center overflow-hidden px-4 py-2.5">
<div className="absolute inset-0 -z-[1]">
<Image
src={launchWeekTwoImage}
className="h-full w-full object-cover"
alt="Launch Week 2"
/>
</div>
<div className="text-background text-center text-sm text-white">
<div className="relative inline-flex w-full items-center justify-center overflow-hidden bg-[#e7f3df] px-4 py-2.5">
<div className="text-black text-center text-sm font-medium">
Claim your documenso public profile username now!{' '}
<span className="hidden font-semibold md:inline">documenso.com/u/yourname</span>
<div className="mt-1.5 block md:ml-4 md:mt-0 md:inline-block">

View File

@ -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<HTMLDivElement>;
@ -65,9 +65,9 @@ export const Footer = ({ className, ...props }: FooterProps) => {
))}
</div>
<div className="mt-6">
{/* <div className="mt-6">
<StatusWidgetContainer />
</div>
</div> */}
</div>
<div className="grid w-full max-w-sm grid-cols-2 gap-x-4 gap-y-2 md:w-auto md:gap-x-8">

View File

@ -6,7 +6,7 @@ import { StatusWidget } from './status-widget';
export function StatusWidgetContainer() {
return (
<Suspense fallback={<StatusWidgetFallback />}>
<StatusWidget />
<StatusWidget slug="documenso-status" />
</Suspense>
);
}

View File

@ -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() {
</span>
</a>
);
}
});

View File

@ -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',
},

View File

@ -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
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link href={`${documentsPath}/${document.id}/logs`}>
<ScrollTextIcon className="mr-2 h-4 w-4" />
Logs
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" />
Duplicate

View File

@ -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
</div>
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
<Button variant="outline" className="mr-2 w-full sm:w-auto">
<DownloadIcon className="mr-1.5 h-4 w-4" />
Download certificate
</Button>
<DownloadCertificateButton className="mr-2" documentId={document.id} />
<Button className="w-full sm:w-auto">
<DownloadIcon className="mr-1.5 h-4 w-4" />
Download PDF
</Button>
<DownloadAuditLogButton documentId={document.id} />
</div>
</div>

View File

@ -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 (
<Button
className={cn('w-full sm:w-auto', className)}
loading={isLoading}
onClick={() => void onDownloadAuditLogsClick()}
>
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
Download Audit Logs
</Button>
);
};

View File

@ -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 (
<Button
className={cn('w-full sm:w-auto', className)}
loading={isLoading}
variant="outline"
onClick={() => void onDownloadCertificatesClick()}
>
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
Download Certificate
</Button>
);
};

View File

@ -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 (
<Table overflowHidden>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>User</TableHead>
<TableHead>Action</TableHead>
<TableHead>IP Address</TableHead>
<TableHead>Browser</TableHead>
</TableRow>
</TableHeader>
<TableBody className="print:text-xs">
{logs.map((log, i) => (
<TableRow className="break-inside-avoid" key={i}>
<TableCell>
<LocaleDate format={dateFormat} date={log.createdAt} />
</TableCell>
<TableCell>
{log.name || log.email ? (
<div>
{log.name && (
<p className="break-all" title={log.name}>
{log.name}
</p>
)}
{log.email && (
<p className="text-muted-foreground break-all" title={log.email}>
{log.email}
</p>
)}
</div>
) : (
<p>N/A</p>
)}
</TableCell>
<TableCell>
{uppercaseFistLetter(formatDocumentAuditLogAction(log).description)}
</TableCell>
<TableCell>{log.ipAddress}</TableCell>
<TableCell>
{log.userAgent ? parser.setUA(log.userAgent).getBrowser().name : 'N/A'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
};

View File

@ -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 (
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
<div className="flex items-center">
<h1 className="my-8 text-2xl font-bold">Version History</h1>
</div>
<Card>
<CardContent className="grid grid-cols-2 gap-4 p-6 text-sm print:text-xs">
<p>
<span className="font-medium">Document ID</span>
<span className="mt-1 block break-words">{document.id}</span>
</p>
<p>
<span className="font-medium">Enclosed Document</span>
<span className="mt-1 block break-words">{document.title}</span>
</p>
<p>
<span className="font-medium">Status</span>
<span className="mt-1 block">{document.deletedAt ? 'DELETED' : document.status}</span>
</p>
<p>
<span className="font-medium">Owner</span>
<span className="mt-1 block break-words">
{document.User.name} ({document.User.email})
</span>
</p>
<p>
<span className="font-medium">Created At</span>
<span className="mt-1 block">
<LocaleDate date={document.createdAt} format="yyyy-mm-dd hh:mm:ss a (ZZZZ)" />
</span>
</p>
<p>
<span className="font-medium">Last Updated</span>
<span className="mt-1 block">
<LocaleDate date={document.updatedAt} format="yyyy-mm-dd hh:mm:ss a (ZZZZ)" />
</span>
</p>
<p>
<span className="font-medium">Time Zone</span>
<span className="mt-1 block break-words">
{document.documentMeta?.timezone ?? 'N/A'}
</span>
</p>
<div>
<p className="font-medium">Recipients</p>
<ul className="mt-1 list-inside list-disc">
{document.Recipient.map((recipient) => (
<li key={recipient.id}>
<span className="text-muted-foreground">
[{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}]
</span>{' '}
{recipient.name} ({recipient.email})
</li>
))}
</ul>
</div>
</CardContent>
</Card>
<Card className="mt-8">
<CardContent className="p-0">
<AuditLogDataTable logs={auditLogs} />
</CardContent>
</Card>
<div className="my-8 flex-row-reverse">
<div className="flex items-end justify-end gap-x-4">
<Logo className="max-h-6 print:max-h-4" />
</div>
</div>
</div>
);
}

View File

@ -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 (
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
<div className="flex items-center">
<h1 className="my-8 text-2xl font-bold">Signing Certificate</h1>
</div>
<Card>
<CardContent className="p-0">
<Table overflowHidden>
<TableHeader>
<TableRow>
<TableHead>Signer Events</TableHead>
<TableHead>Signature</TableHead>
<TableHead>Details</TableHead>
{/* <TableHead>Security</TableHead> */}
</TableRow>
</TableHeader>
<TableBody className="print:text-xs">
{document.Recipient.map((recipient, i) => {
const logs = getRecipientAuditLogs(recipient.id);
const signature = getRecipientSignatureField(recipient.id);
return (
<TableRow key={i} className="print:break-inside-avoid">
<TableCell truncate={false} className="w-[min-content] max-w-[220px] align-top">
<div className="hyphens-auto break-words font-medium">{recipient.name}</div>
<div className="break-all">{recipient.email}</div>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">Authentication Level:</span>{' '}
<span className="block">{getAuthenticationLevel(recipient.id)}</span>
</p>
</TableCell>
<TableCell truncate={false} className="w-[min-content] align-top">
{signature ? (
<>
<div
className="inline-block rounded-lg p-1"
style={{
boxShadow: `0px 0px 0px 4.88px rgba(122, 196, 85, 0.1), 0px 0px 0px 1.22px rgba(122, 196, 85, 0.6), 0px 0px 0px 0.61px rgba(122, 196, 85, 1)`,
}}
>
<img
src={`${signature.Signature?.signatureImageAsBase64}`}
alt="Signature"
className="max-h-12 max-w-full"
/>
</div>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">Signature ID:</span>{' '}
<span className="block font-mono uppercase">
{signature.secondaryId}
</span>
</p>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">IP Address:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? 'Unknown'}
</span>
</p>
<p className="text-muted-foreground mt-1 text-sm print:text-xs">
<span className="font-medium">Device:</span>{' '}
<span className="inline-block">
{getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
</span>
</p>
</>
) : (
<p className="text-muted-foreground">N/A</p>
)}
</TableCell>
<TableCell truncate={false} className="w-[min-content] align-top">
<div className="space-y-1">
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">Sent:</span>{' '}
<span className="inline-block">
{logs.EMAIL_SENT[0] ? (
<LocaleDate
date={logs.EMAIL_SENT[0].createdAt}
format="yyyy-MM-dd hh:mm:ss a (ZZZZ)"
/>
) : (
'Unknown'
)}
</span>
</p>
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">Viewed:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_OPENED[0] ? (
<LocaleDate
date={logs.DOCUMENT_OPENED[0].createdAt}
format="yyyy-MM-dd hh:mm:ss a (ZZZZ)"
/>
) : (
'Unknown'
)}
</span>
</p>
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">Signed:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0] ? (
<LocaleDate
date={logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt}
format="yyyy-MM-dd hh:mm:ss a (ZZZZ)"
/>
) : (
'Unknown'
)}
</span>
</p>
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">Reason:</span>{' '}
<span className="inline-block">
{isOwner(recipient.email)
? FRIENDLY_SIGNING_REASONS['__OWNER__']
: FRIENDLY_SIGNING_REASONS[recipient.role]}
</span>
</p>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
<div className="my-8 flex-row-reverse">
<div className="flex items-end justify-end gap-x-4">
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
Signing certificate provided by:
</p>
<Logo className="max-h-6 print:max-h-4" />
</div>
</div>
</div>
);
}

View File

@ -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<typeof ZClaimAccountFormSchema>;
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<TClaimAccountFormSchema>({
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 (
<div className="mt-2 w-full">
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="mt-4">
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="Enter your name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="email"
control={form.control}
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel>Email address</FormLabel>
<FormControl>
<Input {...field} placeholder="Enter your email" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="password"
control={form.control}
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel>Set a password</FormLabel>
<FormControl>
<PasswordInput {...field} placeholder="Pick a password" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="mt-6 w-full" loading={form.formState.isSubmitting}>
Claim account
</Button>
</fieldset>
</form>
</Form>
</div>
);
};

View File

@ -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 (
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44">
{/* Card with recipient */}
<SigningCard3D
name={recipientName}
signature={signatures.at(0)}
signingCelebrationImage={signingCelebration}
/>
<div
className={cn(
'-mx-4 flex flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44',
{ 'pt-0 lg:pt-0 xl:pt-0': canSignUp },
)}
>
<div
className={cn('relative mt-6 flex w-full flex-col items-center justify-center', {
'mt-0 flex-col divide-y overflow-hidden pt-6 md:pt-16 lg:flex-row lg:divide-x lg:divide-y-0 lg:pt-20 xl:pt-24':
canSignUp,
})}
>
<div
className={cn('flex flex-col items-center', {
'mb-8 p-4 md:mb-0 md:p-12': canSignUp,
})}
>
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
{truncatedTitle}
</Badge>
<div className="relative mt-6 flex w-full flex-col items-center">
{match({ status: document.status, deletedAt: document.deletedAt })
.with({ status: DocumentStatus.COMPLETED }, () => (
<div className="text-documenso-700 flex items-center text-center">
<CheckCircle2 className="mr-2 h-5 w-5" />
<span className="text-sm">Everyone has signed</span>
</div>
))
.with({ deletedAt: null }, () => (
<div className="flex items-center text-center text-blue-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">Waiting for others to sign</span>
</div>
))
.otherwise(() => (
<div className="flex items-center text-center text-red-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">Document no longer available to sign</span>
</div>
))}
{/* Card with recipient */}
<SigningCard3D
name={recipientName}
signature={signatures.at(0)}
signingCelebrationImage={signingCelebration}
/>
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
You have
{recipient.role === RecipientRole.SIGNER && ' signed '}
{recipient.role === RecipientRole.VIEWER && ' viewed '}
{recipient.role === RecipientRole.APPROVER && ' approved '}
<span className="mt-1.5 block">"{truncatedTitle}"</span>
</h2>
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
Document
{recipient.role === RecipientRole.SIGNER && ' Signed '}
{recipient.role === RecipientRole.VIEWER && ' Viewed '}
{recipient.role === RecipientRole.APPROVER && ' Approved '}
</h2>
{match({ status: document.status, deletedAt: document.deletedAt })
.with({ status: DocumentStatus.COMPLETED }, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
Everyone has signed! You will receive an Email copy of the signed document.
</p>
))
.with({ deletedAt: null }, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
You will receive an Email copy of the signed document once everyone has signed.
</p>
))
.otherwise(() => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
This document has been cancelled by the owner and is no longer available for others to
sign.
</p>
))}
{match({ status: document.status, deletedAt: document.deletedAt })
.with({ status: DocumentStatus.COMPLETED }, () => (
<div className="text-documenso-700 mt-4 flex items-center text-center">
<CheckCircle2 className="mr-2 h-5 w-5" />
<span className="text-sm">Everyone has signed</span>
</div>
))
.with({ deletedAt: null }, () => (
<div className="flex items-center text-center text-blue-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">Waiting for others to sign</span>
</div>
))
.otherwise(() => (
<div className="flex items-center text-center text-red-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">Document no longer available to sign</span>
</div>
))}
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
<DocumentShareButton documentId={document.id} token={recipient.token} />
{match({ status: document.status, deletedAt: document.deletedAt })
.with({ status: DocumentStatus.COMPLETED }, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
Everyone has signed! You will receive an Email copy of the signed document.
</p>
))
.with({ deletedAt: null }, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
You will receive an Email copy of the signed document once everyone has signed.
</p>
))
.otherwise(() => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
This document has been cancelled by the owner and is no longer available for others
to sign.
</p>
))}
{document.status === DocumentStatus.COMPLETED ? (
<DocumentDownloadButton
className="flex-1"
fileName={document.title}
documentData={documentData}
disabled={document.status !== DocumentStatus.COMPLETED}
/>
) : (
<DocumentPreviewButton
className="text-[11px]"
title="Signatures will appear once the document has been completed"
documentData={documentData}
/>
)}
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
<DocumentShareButton documentId={document.id} token={recipient.token} />
{document.status === DocumentStatus.COMPLETED ? (
<DocumentDownloadButton
className="flex-1"
fileName={document.title}
documentData={documentData}
disabled={document.status !== DocumentStatus.COMPLETED}
/>
) : (
<DocumentPreviewButton
className="text-[11px]"
title="Signatures will appear once the document has been completed"
documentData={documentData}
/>
)}
</div>
</div>
{isLoggedIn ? (
{canSignUp && (
<div className={`flex max-w-xl flex-col items-center justify-center p-4 md:p-12`}>
<h2 className="mt-8 text-center text-xl font-semibold md:mt-0">
Need to sign documents?
</h2>
<p className="text-muted-foreground/60 mt-4 max-w-[55ch] text-center leading-normal">
Create your account and start using state-of-the-art document signing.
</p>
<ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} />
</div>
)}
{isLoggedIn && (
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
Go Back Home
</Link>
) : (
<p className="text-muted-foreground/60 mt-36 text-sm">
Want to send slick signing links like this one?{' '}
<Link
href="https://documenso.com"
className="text-documenso-700 hover:text-documenso-600"
>
Check out Documenso.
</Link>
</p>
)}
</div>
</div>

77
package-lock.json generated
View File

@ -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"
}
},

View File

@ -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"
}
}
}
}

View File

@ -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);

View File

@ -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<keyof typeof RecipientRole, string>;

View File

@ -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"
}
}
}

View File

@ -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: {

View File

@ -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;
};

View File

@ -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);
}

View File

@ -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;
};

View File

@ -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)'],

View File

@ -56,7 +56,7 @@ export const authRouter = router({
return user;
} catch (err) {
console.log(err);
console.error(err);
const error = AppError.parseError(err);

View File

@ -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()

View File

@ -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.',
});
}
}),
});

View File

@ -163,3 +163,8 @@ export type TDeleteDraftDocumentMutationSchema = z.infer<typeof ZDeleteDraftDocu
export const ZSearchDocumentsMutationSchema = z.object({
query: z.string(),
});
export const ZDownloadAuditLogsMutationSchema = z.object({
documentId: z.number(),
teamId: z.number().optional(),
});

View File

@ -61,6 +61,9 @@ declare namespace NodeJS {
NEXT_PUBLIC_DISABLE_SIGNUP?: string;
//
NEXT_PRIVATE_BROWSERLESS_URL?: string;
/**
* Vercel environment variables
*/

View File

@ -2,13 +2,16 @@ import * as React from 'react';
import { cn } from '../lib/utils';
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="w-full overflow-auto">
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
</div>
),
);
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement> & {
overflowHidden?: boolean;
}
>(({ className, overflowHidden, ...props }, ref) => (
<div className={cn('w-full', overflowHidden ? 'overflow-hidden' : 'overflow-auto')}>
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
</div>
));
Table.displayName = 'Table';
@ -76,11 +79,17 @@ TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
React.TdHTMLAttributes<HTMLTableCellElement> & {
truncate?: boolean;
}
>(({ className, truncate = true, ...props }, ref) => (
<td
ref={ref}
className={cn('truncate p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
className={cn(
'p-4 align-middle [&:has([role=checkbox])]:pr-0',
truncate && 'truncate',
className,
)}
{...props}
/>
));

View File

@ -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);

View File

@ -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"
]
}
}