feat: allow document rejection in embeds (#1662)

This commit is contained in:
Lucas Smith
2025-02-20 11:34:19 +11:00
committed by David Nguyen
parent 139bc265c7
commit 483d7caef7
5 changed files with 103 additions and 5 deletions

View File

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

View File

@ -4,7 +4,13 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
import { type DocumentData, type Field, FieldType, RecipientRole } from '@prisma/client';
import {
type DocumentData,
type Field,
FieldType,
RecipientRole,
SigningStatus,
} from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
@ -29,9 +35,11 @@ import { injectCss } from '~/utils/css-vars';
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog';
import { EmbedClientLoading } from './embed-client-loading';
import { EmbedDocumentCompleted } from './embed-document-completed';
import { EmbedDocumentFields } from './embed-document-fields';
import { EmbedDocumentRejected } from './embed-document-rejected';
export type EmbedSignDocumentClientPageProps = {
token: string;
@ -74,6 +82,9 @@ export const EmbedSignDocumentClientPage = ({
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
const [hasRejectedDocument, setHasRejectedDocument] = useState(
recipient.signingStatus === SigningStatus.REJECTED,
);
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
allRecipients.length > 0 ? allRecipients[0].id : null,
);
@ -82,6 +93,8 @@ export const EmbedSignDocumentClientPage = ({
const [isNameLocked, setIsNameLocked] = useState(false);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId);
const isAssistantMode = recipient.role === RecipientRole.ASSISTANT;
@ -160,6 +173,25 @@ export const EmbedSignDocumentClientPage = ({
}
};
const onDocumentRejected = (reason: string) => {
if (window.parent) {
window.parent.postMessage(
{
action: 'document-rejected',
data: {
token,
documentId,
recipientId: recipient.id,
reason,
},
},
'*',
);
}
setHasRejectedDocument(true);
};
useLayoutEffect(() => {
const hash = window.location.hash.slice(1);
@ -173,6 +205,7 @@ export const EmbedSignDocumentClientPage = ({
// Since a recipient can be provided a name we can lock it without requiring
// a to be provided by the parent application, unlike direct templates.
setIsNameLocked(!!data.lockName);
setAllowDocumentRejection(!!data.allowDocumentRejection);
if (data.darkModeDisabled) {
document.documentElement.classList.add('dark-mode-disabled');
@ -207,6 +240,10 @@ export const EmbedSignDocumentClientPage = ({
}
}, [hasFinishedInit, hasDocumentLoaded]);
if (hasRejectedDocument) {
return <EmbedDocumentRejected />;
}
if (hasCompletedDocument) {
return (
<EmbedDocumentCompleted
@ -228,6 +265,16 @@ export const EmbedSignDocumentClientPage = ({
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
{allowDocumentRejection && (
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
<DocumentSigningRejectDialog
document={{ id: documentId }}
token={token}
onRejected={onDocumentRejected}
/>
</div>
)}
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */}
<div className="embed--DocumentViewer flex-1">
@ -419,7 +466,7 @@ export const EmbedSignDocumentClientPage = ({
</Button>
) : (
<Button
className="col-start-2"
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
disabled={
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
}

View File

@ -42,9 +42,14 @@ type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
export interface DocumentSigningRejectDialogProps {
document: Pick<Document, 'id'>;
token: string;
onRejected?: (reason: string) => void | Promise<void>;
}
export function DocumentSigningRejectDialog({ document, token }: DocumentSigningRejectDialogProps) {
export function DocumentSigningRejectDialog({
document,
token,
onRejected,
}: DocumentSigningRejectDialogProps) {
const { toast } = useToast();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@ -75,9 +80,13 @@ export function DocumentSigningRejectDialog({ document, token }: DocumentSigning
duration: 5000,
});
await navigate(`/sign/${token}/rejected`);
setIsOpen(false);
if (onRejected) {
await onRejected(reason);
} else {
await navigate(`/sign/${token}/rejected`);
}
} catch (err) {
toast({
title: 'Error',

View File

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

View File

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