fix: prevent signing draft documents (#1111)

## Description

Currently users can sign and complete draft documents, which will result
in a completed document in an invalid state.

## Changes Made

- Prevent recipients from inserting or uninserting fields for draft
documents
- Prevent recipients from completing draft documents 
- Remove ability to copy signing tokens unless document is pending

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

- **New Features**
- Enhanced document status visibility and control across various
components in the application. Users can now see and interact with
document statuses more dynamically in views like `DocumentPageView`,
`DocumentEditPageView`, and `DocumentsDataTable`.
- Improved document signing process with updated status checks, ensuring
actions like signing, completing, and removing fields are only available
under appropriate document statuses.

- **Bug Fixes**
- Adjusted document status validation logic in server-side operations to
prevent actions on incorrectly stated documents, enhancing the overall
security and functionality of document processing.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
David Nguyen
2024-04-19 16:17:32 +07:00
committed by GitHub
parent 3d3c53db02
commit 6e09a4700b
11 changed files with 77 additions and 41 deletions

View File

@ -118,7 +118,11 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
<div className="text-muted-foreground flex items-center"> <div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" /> <Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip recipients={recipients} position="bottom"> <StackAvatarsWithTooltip
recipients={recipients}
documentStatus={document.status}
position="bottom"
>
<span>{recipients.length} Recipient(s)</span> <span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip> </StackAvatarsWithTooltip>
</div> </div>

View File

@ -92,7 +92,11 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
<div className="text-muted-foreground flex items-center"> <div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" /> <Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip recipients={recipients} position="bottom"> <StackAvatarsWithTooltip
recipients={recipients}
documentStatus={document.status}
position="bottom"
>
<span>{recipients.length} Recipient(s)</span> <span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip> </StackAvatarsWithTooltip>
</div> </div>

View File

@ -114,7 +114,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
<DropdownMenuContent className="w-52" align="start" forceMount> <DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel> <DropdownMenuLabel>Action</DropdownMenuLabel>
{recipient && recipient?.role !== RecipientRole.CC && ( {!isDraft && recipient && recipient?.role !== RecipientRole.CC && (
<DropdownMenuItem disabled={!recipient || isComplete} asChild> <DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Link href={`/sign/${recipient?.token}`}> <Link href={`/sign/${recipient?.token}`}>
{recipient?.role === RecipientRole.VIEWER && ( {recipient?.role === RecipientRole.VIEWER && (

View File

@ -76,7 +76,12 @@ export const DocumentsDataTable = ({
{ {
header: 'Recipient', header: 'Recipient',
accessorKey: 'recipient', accessorKey: 'recipient',
cell: ({ row }) => <StackAvatarsWithTooltip recipients={row.original.Recipient} />, cell: ({ row }) => (
<StackAvatarsWithTooltip
recipients={row.original.Recipient}
documentStatus={row.original.status}
/>
),
}, },
{ {
header: 'Status', header: 'Status',

View File

@ -47,7 +47,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
getRecipientByToken({ token }).catch(() => null), getRecipientByToken({ token }).catch(() => null),
]); ]);
if (!document || !document.documentData || !recipient) { if (
!document ||
!document.documentData ||
!recipient ||
document.status === DocumentStatus.DRAFT
) {
return notFound(); return notFound();
} }

View File

@ -8,6 +8,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@ -15,18 +16,21 @@ import { StackAvatar } from './stack-avatar';
export type AvatarWithRecipientProps = { export type AvatarWithRecipientProps = {
recipient: Recipient; recipient: Recipient;
documentStatus: DocumentStatus;
}; };
export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) { export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRecipientProps) {
const [, copy] = useCopyToClipboard(); const [, copy] = useCopyToClipboard();
const { toast } = useToast(); const { toast } = useToast();
const signingToken = documentStatus === DocumentStatus.PENDING ? recipient.token : null;
const onRecipientClick = () => { const onRecipientClick = () => {
if (!recipient.token) { if (!signingToken) {
return; return;
} }
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`).then(() => { void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => {
toast({ toast({
title: 'Copied to clipboard', title: 'Copied to clipboard',
description: 'The signing link has been copied to your clipboard.', description: 'The signing link has been copied to your clipboard.',
@ -37,10 +41,10 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
return ( return (
<div <div
className={cn('my-1 flex items-center gap-2', { className={cn('my-1 flex items-center gap-2', {
'cursor-pointer hover:underline': recipient.token, 'cursor-pointer hover:underline': signingToken,
})} })}
role={recipient.token ? 'button' : undefined} role={signingToken ? 'button' : undefined}
title={recipient.token && 'Click to copy signing link for sending to recipient'} title={signingToken ? 'Click to copy signing link for sending to recipient' : undefined}
onClick={onRecipientClick} onClick={onRecipientClick}
> >
<StackAvatar <StackAvatar
@ -49,16 +53,15 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
type={getRecipientType(recipient)} type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)} fallbackText={recipientAbbreviation(recipient)}
/> />
<div>
<div <div
className="text-muted-foreground text-sm" className="text-muted-foreground text-sm"
title="Click to copy signing link for sending to recipient" title={signingToken ? 'Click to copy signing link for sending to recipient' : undefined}
> >
<p>{recipient.email} </p> <p>{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs"> <p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p> </p>
</div>
</div> </div>
</div> </div>
); );

View File

@ -5,7 +5,7 @@ import { useRef, useState } from 'react';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client'; import type { DocumentStatus, Recipient } from '@documenso/prisma/client';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { AvatarWithRecipient } from './avatar-with-recipient'; import { AvatarWithRecipient } from './avatar-with-recipient';
@ -13,12 +13,14 @@ import { StackAvatar } from './stack-avatar';
import { StackAvatars } from './stack-avatars'; import { StackAvatars } from './stack-avatars';
export type StackAvatarsWithTooltipProps = { export type StackAvatarsWithTooltipProps = {
documentStatus: DocumentStatus;
recipients: Recipient[]; recipients: Recipient[];
position?: 'top' | 'bottom'; position?: 'top' | 'bottom';
children?: React.ReactNode; children?: React.ReactNode;
}; };
export const StackAvatarsWithTooltip = ({ export const StackAvatarsWithTooltip = ({
documentStatus,
recipients, recipients,
position, position,
children, children,
@ -120,7 +122,11 @@ export const StackAvatarsWithTooltip = ({
<div> <div>
<h1 className="text-base font-medium">Waiting</h1> <h1 className="text-base font-medium">Waiting</h1>
{waitingRecipients.map((recipient: Recipient) => ( {waitingRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} /> <AvatarWithRecipient
key={recipient.id}
recipient={recipient}
documentStatus={documentStatus}
/>
))} ))}
</div> </div>
)} )}
@ -129,7 +135,11 @@ export const StackAvatarsWithTooltip = ({
<div> <div>
<h1 className="text-base font-medium">Opened</h1> <h1 className="text-base font-medium">Opened</h1>
{openedRecipients.map((recipient: Recipient) => ( {openedRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} /> <AvatarWithRecipient
key={recipient.id}
recipient={recipient}
documentStatus={documentStatus}
/>
))} ))}
</div> </div>
)} )}
@ -138,7 +148,11 @@ export const StackAvatarsWithTooltip = ({
<div> <div>
<h1 className="text-base font-medium">Uncompleted</h1> <h1 className="text-base font-medium">Uncompleted</h1>
{uncompletedRecipients.map((recipient: Recipient) => ( {uncompletedRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} /> <AvatarWithRecipient
key={recipient.id}
recipient={recipient}
documentStatus={documentStatus}
/>
))} ))}
</div> </div>
)} )}

View File

@ -49,8 +49,8 @@ export const completeDocumentWithToken = async ({
const document = await getDocument({ token, documentId }); const document = await getDocument({ token, documentId });
if (document.status === DocumentStatus.COMPLETED) { if (document.status !== DocumentStatus.PENDING) {
throw new Error(`Document ${document.id} has already been completed`); throw new Error(`Document ${document.id} must be pending`);
} }
if (document.Recipient.length === 0) { if (document.Recipient.length === 0) {

View File

@ -36,8 +36,8 @@ export const removeSignedFieldWithToken = async ({
throw new Error(`Document not found for field ${field.id}`); throw new Error(`Document not found for field ${field.id}`);
} }
if (document.status === DocumentStatus.COMPLETED) { if (document.status !== DocumentStatus.PENDING) {
throw new Error(`Document ${document.id} has already been completed`); throw new Error(`Document ${document.id} must be pending`);
} }
if (recipient?.signingStatus === SigningStatus.SIGNED) { if (recipient?.signingStatus === SigningStatus.SIGNED) {

View File

@ -58,14 +58,14 @@ export const signFieldWithToken = async ({
throw new Error(`Recipient not found for field ${field.id}`); throw new Error(`Recipient not found for field ${field.id}`);
} }
if (document.status === DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has already been completed`);
}
if (document.deletedAt) { if (document.deletedAt) {
throw new Error(`Document ${document.id} has been deleted`); throw new Error(`Document ${document.id} has been deleted`);
} }
if (document.status !== DocumentStatus.PENDING) {
throw new Error(`Document ${document.id} must be pending for signing`);
}
if (recipient?.signingStatus === SigningStatus.SIGNED) { if (recipient?.signingStatus === SigningStatus.SIGNED) {
throw new Error(`Recipient ${recipient.id} has already signed`); throw new Error(`Recipient ${recipient.id} has already signed`);
} }

View File

@ -342,14 +342,15 @@ export const seedPendingDocumentWithFullFields = async ({
}, },
}); });
const latestDocument = updateDocumentOptions const latestDocument = await prisma.document.update({
? await prisma.document.update({ where: {
where: { id: document.id,
id: document.id, },
}, data: {
data: updateDocumentOptions, ...updateDocumentOptions,
}) status: DocumentStatus.PENDING,
: document; },
});
return { return {
document: latestDocument, document: latestDocument,