Merge branch 'main' into feature/pdf-placeholder-selection-fields

This commit is contained in:
Catalin Pit
2026-06-18 09:32:18 +03:00
committed by GitHub
50 changed files with 1824 additions and 50 deletions
@@ -0,0 +1,134 @@
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Label } from '@documenso/ui/primitives/label';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
export type EnvelopeCancelDialogProps = {
id: string;
title: string;
trigger?: React.ReactNode;
onCancel?: () => Promise<void> | void;
};
export const EnvelopeCancelDialog = ({ id, title, trigger, onCancel }: EnvelopeCancelDialogProps) => {
const { toast } = useToast();
const { t } = useLingui();
const trpcUtils = trpcReact.useUtils();
const [open, setOpen] = useState(false);
const [reason, setReason] = useState('');
const { mutateAsync: cancelEnvelope, isPending } = trpcReact.envelope.cancel.useMutation({
onSuccess: async () => {
toast({
title: t`Document cancelled`,
description: t`"${title}" has been successfully cancelled`,
duration: 5000,
});
await trpcUtils.document.findDocumentsInternal.invalidate();
await onCancel?.();
setOpen(false);
},
onError: () => {
toast({
title: t`Something went wrong`,
description: t`This document could not be cancelled at this time. Please try again.`,
variant: 'destructive',
duration: 7500,
});
},
});
useEffect(() => {
if (open) {
setReason('');
}
}, [open]);
return (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
You are about to cancel <strong>"{title}"</strong>
</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="warning" className="-mt-1">
<AlertDescription>
<p>
<Trans>Once confirmed, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>
<Trans>The document signing process will be stopped</Trans>
</li>
<li>
<Trans>Recipients will be notified that the document was cancelled</Trans>
</li>
<li>
<Trans>The document will remain in your dashboard marked as Cancelled</Trans>
</li>
</ul>
</AlertDescription>
</Alert>
<div className="flex flex-col gap-2">
<Label htmlFor="cancel-reason">
<Trans>Reason (optional)</Trans>
</Label>
<Textarea
id="cancel-reason"
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder={t`Add an optional reason for cancelling this document`}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
type="button"
loading={isPending}
onClick={() => void cancelEnvelope({ envelopeId: id, reason: reason || undefined })}
variant="destructive"
>
<Trans>Cancel document</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -166,7 +166,7 @@ export const EnvelopeDeleteDialog = ({
</ul>
</AlertDescription>
))
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED, DocumentStatus.CANCELLED), () => (
<AlertDescription>
<p>
<Trans>By deleting this document, the following will occur:</Trans>
@@ -0,0 +1,159 @@
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Label } from '@documenso/ui/primitives/label';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { plural } from '@lingui/core/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
export type EnvelopesBulkCancelDialogProps = {
envelopeIds: string[];
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const EnvelopesBulkCancelDialog = ({
envelopeIds,
open,
onOpenChange,
onSuccess,
...props
}: EnvelopesBulkCancelDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const trpcUtils = trpc.useUtils();
const [reason, setReason] = useState('');
useEffect(() => {
if (open) {
setReason('');
}
}, [open]);
const { mutateAsync: bulkCancelEnvelopes, isPending } = trpc.envelope.bulk.cancel.useMutation({
onSuccess: async (result) => {
await trpcUtils.document.findDocumentsInternal.invalidate();
if (result.failedIds.length > 0) {
toast({
title: t`Documents partially cancelled`,
description: t`${plural(result.cancelledCount, {
one: '# document cancelled.',
other: '# documents cancelled.',
})} ${plural(result.failedIds.length, {
one: '# document could not be cancelled.',
other: '# documents could not be cancelled.',
})}`,
variant: 'destructive',
});
} else {
toast({
title: t`Documents cancelled`,
description: plural(result.cancelledCount, {
one: '# document has been cancelled.',
other: '# documents have been cancelled.',
}),
variant: 'default',
});
}
onSuccess?.();
onOpenChange(false);
},
onError: () => {
toast({
title: t`Error`,
description: t`An error occurred while cancelling the documents.`,
variant: 'destructive',
});
},
});
return (
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Cancel Documents</Trans>
</DialogTitle>
<DialogDescription>
<Plural
value={envelopeIds.length}
one="You are about to cancel the selected document."
other="You are about to cancel # documents."
/>
</DialogDescription>
</DialogHeader>
<Alert variant="warning">
<AlertDescription>
<p>
<Trans>Only pending documents you have permission to manage will be cancelled.</Trans>
</p>
<p className="mt-1">
<Trans>Once confirmed, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>
<Trans>The document signing process will be stopped</Trans>
</li>
<li>
<Trans>Recipients will be notified that the document was cancelled</Trans>
</li>
<li>
<Trans>The documents will remain in your dashboard marked as Cancelled</Trans>
</li>
</ul>
</AlertDescription>
</Alert>
<div className="flex flex-col gap-2">
<Label htmlFor="bulk-cancel-reason">
<Trans>Reason (optional)</Trans>
</Label>
<Textarea
id="bulk-cancel-reason"
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder={t`Add an optional reason for cancelling these documents`}
/>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<Button
onClick={(e) => {
e.preventDefault();
void bulkCancelEnvelopes({ envelopeIds, reason: reason || undefined });
}}
loading={isPending}
variant="destructive"
>
<Trans>Cancel documents</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -40,6 +40,12 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
icon: XCircle,
color: 'text-red-500 dark:text-red-300',
},
CANCELLED: {
label: msg`Cancelled`,
labelExtended: msg`Document cancelled`,
icon: XCircle,
color: 'text-red-500 dark:text-red-300',
},
INBOX: {
label: msg`Inbox`,
labelExtended: msg`Document inbox`,
@@ -148,6 +148,11 @@ export default function EnvelopeEditorHeader() {
<Trans>Rejected</Trans>
</Badge>
))
.with(DocumentStatus.CANCELLED, () => (
<Badge variant="destructive" className="shrink-0">
<Trans>Cancelled</Trans>
</Badge>
))
.exhaustive()}
{autosaveError && (
@@ -3,7 +3,7 @@ import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/documen
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
import { findRecipientByEmail } from '@documenso/lib/utils/recipients';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { formatDocumentsPath, isMemberManagerOrAbove } from '@documenso/lib/utils/teams';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import {
@@ -30,11 +30,13 @@ import {
Pencil,
Share,
Trash2,
XCircle,
} from 'lucide-react';
import { useState } from 'react';
import { Link } from 'react-router';
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
import { EnvelopeCancelDialog } from '~/components/dialogs/envelope-cancel-dialog';
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
@@ -74,6 +76,12 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
const isCurrentTeamDocument = team && row.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
// Cancelling a document is restricted server-side to the document owner or a
// privileged team member (ADMIN/MANAGER). Mirror that here so plain MEMBERs
// don't see a Cancel action that would fail on the server.
const isPrivilegedTeamMember = isMemberManagerOrAbove(team.currentTeamRole);
const canCancelDocument = isOwner || isPrivilegedTeamMember;
const { canTitleBeChanged } = getEnvelopeItemPermissions(
{
completedAt: row.completedAt,
@@ -184,11 +192,23 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
</DropdownMenuItem>
)}
{/* No point displaying this if there's no functionality. */}
{/* <DropdownMenuItem disabled>
<XCircle className="mr-2 h-4 w-4" />
Void
</DropdownMenuItem> */}
{canCancelDocument && isPending && (
<EnvelopeCancelDialog
id={row.envelopeId}
title={row.title}
onCancel={async () => {
await trpcUtils.document.findDocumentsInternal.invalidate();
}}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<XCircle className="mr-2 h-4 w-4" />
<Trans>Cancel</Trans>
</div>
</DropdownMenuItem>
}
/>
)}
<EnvelopeDeleteDialog
id={row.envelopeId}
@@ -1,7 +1,7 @@
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Bird, CheckCircle2 } from 'lucide-react';
import { Bird, CheckCircle2, XCircle } from 'lucide-react';
import { match } from 'ts-pattern';
export type DocumentsTableEmptyStateProps = { status: ExtendedDocumentStatus };
@@ -24,6 +24,11 @@ export const DocumentsTableEmptyState = ({ status }: DocumentsTableEmptyStatePro
message: msg`There are no active drafts at the current moment. You can upload a document to start drafting.`,
icon: CheckCircle2,
}))
.with(ExtendedDocumentStatus.CANCELLED, () => ({
title: msg`Nothing cancelled`,
message: msg`There are no cancelled documents. Documents you cancel will remain here as a record that they were distributed.`,
icon: XCircle,
}))
.with(ExtendedDocumentStatus.ALL, () => ({
title: msg`We're all empty`,
message: msg`You have not yet created or received any documents. To create a document please upload one.`,
@@ -1,11 +1,12 @@
import { Button } from '@documenso/ui/primitives/button';
import { Trans, useLingui } from '@lingui/react/macro';
import { FolderInputIcon, Trash2Icon, XIcon } from 'lucide-react';
import { FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react';
export type EnvelopesTableBulkActionBarProps = {
selectedCount: number;
onMoveClick: () => void;
onDeleteClick: () => void;
onCancelClick?: () => void;
onClearSelection: () => void;
};
@@ -13,6 +14,7 @@ export const EnvelopesTableBulkActionBar = ({
selectedCount,
onMoveClick,
onDeleteClick,
onCancelClick,
onClearSelection,
}: EnvelopesTableBulkActionBarProps) => {
const { t } = useLingui();
@@ -34,6 +36,13 @@ export const EnvelopesTableBulkActionBar = ({
<Trans>Move to Folder</Trans>
</Button>
{onCancelClick && (
<Button type="button" variant="outline" size="sm" onClick={onCancelClick}>
<XCircleIcon className="mr-2 h-4 w-4" />
<Trans>Cancel</Trans>
</Button>
)}
<Button type="button" variant="destructive" size="sm" onClick={onDeleteClick}>
<Trash2Icon className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
@@ -224,6 +224,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
{match(envelope.status)
.with(DocumentStatus.COMPLETED, () => <Trans>This document has been signed by all recipients</Trans>)
.with(DocumentStatus.REJECTED, () => <Trans>This document has been rejected by a recipient</Trans>)
.with(DocumentStatus.CANCELLED, () => <Trans>This document has been cancelled</Trans>)
.with(DocumentStatus.DRAFT, () => (
<Trans>This document is currently a draft and has not been sent</Trans>
))
@@ -20,6 +20,7 @@ import { Link, useParams, useSearchParams } from 'react-router';
import { z } from 'zod';
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
import { EnvelopesBulkCancelDialog } from '~/components/dialogs/envelopes-bulk-cancel-dialog';
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
import { DocumentSearch } from '~/components/general/document/document-search';
@@ -61,6 +62,7 @@ export default function DocumentsPage() {
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('documents-bulk-selection', {});
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [isBulkCancelDialogOpen, setIsBulkCancelDialogOpen] = useState(false);
const selectedEnvelopeIds = useMemo(() => {
return Object.keys(rowSelection).filter((id) => rowSelection[id]);
@@ -71,6 +73,7 @@ export default function DocumentsPage() {
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.CANCELLED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
});
@@ -150,6 +153,7 @@ export default function DocumentsPage() {
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.CANCELLED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.ALL,
]
@@ -227,6 +231,7 @@ export default function DocumentsPage() {
selectedCount={selectedEnvelopeIds.length}
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
onCancelClick={() => setIsBulkCancelDialogOpen(true)}
onClearSelection={() => setRowSelection({})}
/>
@@ -246,6 +251,13 @@ export default function DocumentsPage() {
onOpenChange={setIsBulkDeleteDialogOpen}
onSuccess={() => setRowSelection({})}
/>
<EnvelopesBulkCancelDialog
envelopeIds={selectedEnvelopeIds}
open={isBulkCancelDialogOpen}
onOpenChange={setIsBulkCancelDialogOpen}
onSuccess={() => setRowSelection({})}
/>
</div>
</EnvelopeDropZoneWrapper>
);
@@ -9,7 +9,7 @@ import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useLingui } from '@lingui/react';
import { useLayoutEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { useNavigate, useSearchParams } from 'react-router';
import { ConfigureDocumentProvider } from '~/components/embed/authoring/configure-document-context';
import { ConfigureDocumentView } from '~/components/embed/authoring/configure-document-view';
@@ -22,6 +22,9 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
const { toast } = useToast();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const presignToken = searchParams.get('token') ?? undefined;
const [configuration, setConfiguration] = useState<TConfigureEmbedFormSchema | null>(null);
const [fields, setFields] = useState<TConfigureFieldsFormSchema | null>(null);
@@ -57,11 +60,14 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
const fields = data.fields;
const documentData = await putPdfFile({
arrayBuffer: async () => Promise.resolve(configuration.documentData!.data.buffer),
name: configuration.documentData.name,
type: configuration.documentData.type,
});
const documentData = await putPdfFile(
{
arrayBuffer: async () => Promise.resolve(configuration.documentData!.data.buffer),
name: configuration.documentData.name,
type: configuration.documentData.type,
},
{ presignToken },
);
// Use the externalId from the URL fragment if available
const documentExternalId = externalId || configuration.meta.externalId;
@@ -8,7 +8,7 @@ import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useLingui } from '@lingui/react';
import { useLayoutEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { useNavigate, useSearchParams } from 'react-router';
import { ConfigureDocumentProvider } from '~/components/embed/authoring/configure-document-context';
import { ConfigureDocumentView } from '~/components/embed/authoring/configure-document-view';
@@ -20,6 +20,9 @@ export default function EmbeddingAuthoringTemplateCreatePage() {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const presignToken = searchParams.get('token') ?? undefined;
const [configuration, setConfiguration] = useState<TConfigureEmbedFormSchema | null>(null);
const [fields, setFields] = useState<TConfigureFieldsFormSchema | null>(null);
@@ -55,11 +58,14 @@ export default function EmbeddingAuthoringTemplateCreatePage() {
const fields = data.fields;
const documentData = await putPdfFile({
arrayBuffer: async () => Promise.resolve(configuration.documentData!.data.buffer),
name: configuration.documentData.name,
type: configuration.documentData.type,
});
const documentData = await putPdfFile(
{
arrayBuffer: async () => Promise.resolve(configuration.documentData!.data.buffer),
name: configuration.documentData.name,
type: configuration.documentData.type,
},
{ presignToken },
);
// Use the externalId from the URL fragment if available
const metaWithExternalId = {
+1 -1
View File
@@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.12.0"
"version": "2.13.0"
}
@@ -1,4 +1,6 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { generatePartialSignedPdf } from '@documenso/lib/server-only/pdf/generate-partial-signed-pdf';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { sha256 } from '@documenso/lib/universal/crypto';
@@ -26,6 +28,29 @@ type DocumentDataInput = {
initialData: string;
};
export const resolveFileUploadUserId = async (c: Context<HonoEnv>): Promise<number | null> => {
const session = await getOptionalSession(c);
if (session.user?.id) {
return session.user.id;
}
const authorizationHeader = c.req.header('authorization');
const [bearerToken] = (authorizationHeader || '').split('Bearer ').filter((part) => part.length > 0);
const queryToken = c.req.query('token');
const presignToken = bearerToken || queryToken;
if (!presignToken) {
return null;
}
const verifiedToken = await verifyEmbeddingPresignToken({ token: presignToken }).catch(() => undefined);
return verifiedToken?.userId ?? null;
};
type EnvelopeForPendingDownload = {
id: string;
status: DocumentStatus;
+19 -2
View File
@@ -10,8 +10,9 @@ import type { Prisma } from '@prisma/client';
import { Hono } from 'hono';
import type { HonoEnv } from '../../router';
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest } from './files.helpers';
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers';
import {
isAllowedUploadContentType,
type TGetPresignedPostUrlResponse,
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
ZGetEnvelopeItemFileRequestParamsSchema,
@@ -31,6 +32,12 @@ export const filesRoute = new Hono<HonoEnv>()
*/
.post('/upload-pdf', sValidator('form', ZUploadPdfRequestSchema), async (c) => {
try {
const userId = await resolveFileUploadUserId(c);
if (!userId) {
return c.json({ error: 'Unauthorized' }, 401);
}
const { file } = c.req.valid('form');
if (!file) {
@@ -55,10 +62,20 @@ export const filesRoute = new Hono<HonoEnv>()
}
})
.post('/presigned-post-url', sValidator('json', ZGetPresignedPostUrlRequestSchema), async (c) => {
const userId = await resolveFileUploadUserId(c);
if (!userId) {
return c.json({ error: 'Unauthorized' }, 401);
}
const { fileName, contentType } = c.req.valid('json');
if (!isAllowedUploadContentType(contentType)) {
return c.json({ error: 'Unsupported content type' }, 400);
}
try {
const { key, url } = await getPresignPostUrl(fileName, contentType);
const { key, url } = await getPresignPostUrl(fileName, contentType, userId);
return c.json({ key, url } satisfies TGetPresignedPostUrlResponse);
} catch (err) {
@@ -13,6 +13,14 @@ export const ZUploadPdfResponseSchema = DocumentDataSchema.pick({
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>;
export const ALLOWED_UPLOAD_CONTENT_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'] as const;
export const isAllowedUploadContentType = (contentType: string): boolean => {
const normalizedContentType = contentType.split(';').at(0)?.trim().toLowerCase();
return ALLOWED_UPLOAD_CONTENT_TYPES.some((allowed) => allowed === normalizedContentType);
};
export const ZGetPresignedPostUrlRequestSchema = z.object({
fileName: z.string().min(1),
contentType: z.string().min(1),
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "2.12.0",
"version": "2.13.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "2.12.0",
"version": "2.13.0",
"hasInstallScript": true,
"workspaces": [
"apps/*",
@@ -406,7 +406,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "2.12.0",
"version": "2.13.0",
"dependencies": {
"@cantoo/pdf-lib": "^2.5.3",
"@documenso/api": "*",
+1 -1
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.12.0",
"version": "2.13.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
+7 -2
View File
@@ -1,7 +1,7 @@
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DocumentDataType, EnvelopeType, SigningStatus } from '@prisma/client';
import { DocumentDataType, DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { tsr } from '@ts-rest/serverless/fetch';
import { match } from 'ts-pattern';
import '@documenso/lib/constants/time-zones';
@@ -240,7 +240,12 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
};
}
if (!downloadOriginalDocument && !isDocumentCompleted(envelope.status)) {
// A cancelled document was never sealed, so its data is the unsigned original.
// Treat it as not-completed here so a "signed" version is never served for it.
// REJECTED and COMPLETED keep their prior behavior.
const hasSignedArtifact = isDocumentCompleted(envelope.status) && envelope.status !== DocumentStatus.CANCELLED;
if (!downloadOriginalDocument && !hasSignedArtifact) {
return {
status: 400,
body: {
@@ -0,0 +1,242 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
const createTokenForUser = async (userId: number, teamId: number, tokenName: string) => {
const { token } = await createApiToken({
userId,
teamId,
tokenName,
expiresIn: null,
});
return token;
};
test.describe('Envelope cancel endpoint authorization', () => {
test('hides the document from an outsider attempting to cancel it', async ({ request }) => {
const { user: owner, team } = await seedUser();
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient]);
const { user: outsider, team: outsiderTeam } = await seedUser();
const outsiderToken = await createTokenForUser(outsider.id, outsiderTeam.id, 'outsider');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${outsiderToken}` },
data: { envelopeId: document.id },
});
// Outsiders must not be able to determine whether the envelope exists.
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
// The document must be untouched.
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.PENDING);
});
test('hides the document from a recipient attempting to cancel it', async ({ request }) => {
const { user: owner, team } = await seedUser();
const { user: recipient, team: recipientTeam } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient]);
const recipientToken = await createTokenForUser(recipient.id, recipientTeam.id, 'recipient');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${recipientToken}` },
data: { envelopeId: document.id },
});
// A recipient is not a member of the document's team, so they must not be
// able to determine whether it exists via this endpoint.
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.PENDING);
});
// Note: a non-privileged MEMBER cannot obtain an API token at all (token
// creation requires the MANAGE_TEAM permission), so the MEMBER cancellation
// restriction is covered through the UI tests in cancel-documents.spec.ts
// rather than at the API layer.
test('allows the document owner to cancel a pending document', async ({ request }) => {
const { user: owner, team } = await seedUser();
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient]);
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${ownerToken}` },
data: { envelopeId: document.id },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true, completedAt: true, deletedAt: true },
});
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
expect(documentInDb.completedAt).not.toBeNull();
expect(documentInDb.deletedAt).toBeNull();
});
test('allows a team ADMIN to cancel a pending document they do not own', async ({ request }) => {
const { team, owner } = await seedTeam();
const adminUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.ADMIN,
});
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient]);
const adminToken = await createTokenForUser(adminUser.id, team.id, 'admin');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${adminToken}` },
data: { envelopeId: document.id },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
});
test('allows a team MANAGER to cancel a pending document they do not own', async ({ request }) => {
const { team, owner } = await seedTeam();
const managerUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MANAGER,
});
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient]);
const managerToken = await createTokenForUser(managerUser.id, team.id, 'manager');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${managerToken}` },
data: { envelopeId: document.id },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
});
test('rejects cancelling a draft document', async ({ request }) => {
const { user: owner, team } = await seedUser();
const document = await seedDraftDocument(owner, team.id, []);
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner-draft');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${ownerToken}` },
data: { envelopeId: document.id },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.DRAFT);
});
test('rejects cancelling a completed document', async ({ request }) => {
const { user: owner, team } = await seedUser();
const { user: recipient } = await seedUser();
const document = await seedCompletedDocument(owner, team.id, [recipient]);
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner-completed');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${ownerToken}` },
data: { envelopeId: document.id },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.COMPLETED);
});
test('rejects double cancellation of an already cancelled document', async ({ request }) => {
const { user: owner, team } = await seedUser();
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient]);
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner-double');
const firstRes = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${ownerToken}` },
data: { envelopeId: document.id },
});
expect(firstRes.status()).toBe(200);
const secondRes = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${ownerToken}` },
data: { envelopeId: document.id },
});
expect(secondRes.ok()).toBeFalsy();
expect(secondRes.status()).toBe(400);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
});
});
@@ -0,0 +1,102 @@
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const examplePdf = fs.readFileSync(path.join(__dirname, '../../../../../../assets/example.pdf'));
test.describe.configure({
mode: 'parallel',
});
const createPresignTokenForUser = async (userId: number, teamId: number) => {
const { token: apiToken } = await createApiToken({
userId,
teamId,
tokenName: 'file-upload-test',
expiresIn: null,
});
const { token: presignToken } = await createEmbeddingPresignToken({ apiToken });
return presignToken;
};
const buildPdfFormData = () => {
const formData = new FormData();
formData.append('file', new File([examplePdf], 'test.pdf', { type: 'application/pdf' }));
return formData;
};
test.describe('File upload endpoint authorization', () => {
test('rejects an unauthenticated upload-pdf request', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/upload-pdf`, {
multipart: buildPdfFormData(),
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects an unauthenticated presigned-post-url request', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: { 'Content-Type': 'application/json' },
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a presigned-post-url request with an invalid presign token', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer not-a-real-token',
},
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a presigned-post-url request with a disallowed content type', async ({ request }) => {
const { user, team } = await seedUser();
const presignToken = await createPresignTokenForUser(user.id, team.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${presignToken}`,
},
data: { fileName: 'malware.exe', contentType: 'application/x-msdownload' },
});
// Authenticated, but the content type is not on the allow-list.
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('allows an upload-pdf request authorized by a valid presign token', async ({ request }) => {
const { user, team } = await seedUser();
const presignToken = await createPresignTokenForUser(user.id, team.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/upload-pdf`, {
headers: { Authorization: `Bearer ${presignToken}` },
multipart: buildPdfFormData(),
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.id).toBeDefined();
});
});
@@ -1,9 +1,12 @@
import { seedDraftDocument } from '@documenso/prisma/seed/documents';
import { prisma } from '@documenso/prisma';
import { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { expectToastTextToBeVisible } from '../fixtures/generic';
test.describe.configure({ mode: 'parallel' });
@@ -250,3 +253,147 @@ test('[BULK_ACTIONS]: can move documents from folder to home (root)', async ({ p
await page.goto(`/t/${sender.team.url}/documents/f/${folder.id}`);
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).not.toBeVisible();
});
// ─── Bulk cancel ─────────────────────────────────────────────────────────────
test('[BULK_ACTIONS]: can cancel multiple pending documents', async ({ page }) => {
const sender = await seedUser({ setTeamEmailAsOwner: true });
const { user: recipient } = await seedUser();
const [pending1, pending2] = await Promise.all([
seedPendingDocument(sender.user, sender.team.id, [recipient], {
createDocumentOptions: { title: 'Bulk Cancel Pending 1' },
}),
seedPendingDocument(sender.user, sender.team.id, [recipient], {
createDocumentOptions: { title: 'Bulk Cancel Pending 2' },
}),
]);
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Cancel Pending 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Cancel Pending 2' }).getByRole('checkbox').click();
await expect(page.getByText('2 selected')).toBeVisible();
// The bulk action bar Cancel button (distinct from the dialog's confirm button).
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('heading', { name: 'Cancel Documents' })).toBeVisible();
await expect(dialog.getByText('You are about to cancel 2 documents')).toBeVisible();
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
await expectToastTextToBeVisible(page, 'Documents cancelled');
// Selection clears after a successful cancel.
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
// Both documents are now cancelled in the database.
for (const document of [pending1, pending2]) {
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true, deletedAt: true },
});
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
expect(envelope.deletedAt).toBeNull();
}
});
test('[BULK_ACTIONS]: bulk cancel only affects pending documents', async ({ page }) => {
const sender = await seedUser({ setTeamEmailAsOwner: true });
const { user: recipient } = await seedUser();
const pending = await seedPendingDocument(sender.user, sender.team.id, [recipient], {
createDocumentOptions: { title: 'Mixed Cancel Pending' },
});
const draft = await seedDraftDocument(sender.user, sender.team.id, [], {
createDocumentOptions: { title: 'Mixed Cancel Draft' },
});
const completed = await seedCompletedDocument(sender.user, sender.team.id, [recipient], {
createDocumentOptions: { title: 'Mixed Cancel Completed' },
});
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText('3 selected')).toBeVisible();
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
// Only one of the three was pending, so this is a partial result.
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
const pendingEnvelope = await prisma.envelope.findFirstOrThrow({
where: { id: pending.id },
select: { status: true },
});
expect(pendingEnvelope.status).toBe(DocumentStatus.CANCELLED);
// The draft and completed documents are untouched.
const draftEnvelope = await prisma.envelope.findFirstOrThrow({
where: { id: draft.id },
select: { status: true },
});
expect(draftEnvelope.status).toBe(DocumentStatus.DRAFT);
const completedEnvelope = await prisma.envelope.findFirstOrThrow({
where: { id: completed.id },
select: { status: true },
});
expect(completedEnvelope.status).toBe(DocumentStatus.COMPLETED);
});
test('[BULK_ACTIONS]: a MEMBER cannot bulk cancel documents they do not own', async ({ page }) => {
const { team, owner } = await seedTeam();
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const { user: recipient } = await seedUser();
const ownerDocument = await seedPendingDocument(owner, team.id, [recipient], {
createDocumentOptions: { title: 'Member Cannot Cancel This', visibility: 'EVERYONE' },
});
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
await page.locator('tr', { hasText: 'Member Cannot Cancel This' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
// The server rejects the cancellation for a document the MEMBER does not own,
// so it reports zero cancelled (a partial result with the document in failedIds).
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
// The document remains pending.
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: ownerDocument.id },
select: { status: true },
});
expect(envelope.status).toBe(DocumentStatus.PENDING);
await apiSignout({ page });
});
@@ -0,0 +1,342 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { seedCancelledDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Page, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
import { expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
test.describe.configure({ mode: 'serial' });
const seedCancelDocumentsTestRequirements = async () => {
const [sender, recipientA, recipientB] = await Promise.all([
seedUser({ setTeamEmailAsOwner: true }),
seedUser({ setTeamEmailAsOwner: true }),
seedUser({ setTeamEmailAsOwner: true }),
]);
const pendingDocument = await seedPendingDocument(sender.user, sender.team.id, [recipientA.user, recipientB.user], {
createDocumentOptions: { title: 'Document 1 - Pending' },
});
return {
sender,
recipients: [recipientA, recipientB],
pendingDocument,
};
};
const cancelDocumentViaUi = async (page: Page, documentTitle: string, reason?: string) => {
const documentActionBtn = page.locator('tr', { hasText: documentTitle }).getByTestId('document-table-action-btn');
await openDropdownMenu(page, documentActionBtn);
await expect(page.getByRole('menuitem', { name: 'Cancel' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Cancel' }).click();
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
if (reason) {
await page.getByPlaceholder('Add an optional reason for cancelling this document').fill(reason);
}
await page.getByRole('button', { name: 'Cancel document' }).click();
};
test('[DOCUMENTS]: cancelling a pending document keeps it in the owner dashboard as cancelled', async ({ page }) => {
const { sender, pendingDocument } = await seedCancelDocumentsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await cancelDocumentViaUi(page, 'Document 1 - Pending', 'No longer required');
await expectToastTextToBeVisible(page, 'Document cancelled');
// The document must remain in the dashboard, unlike deleting a pending document.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Cancelled', 1);
await checkDocumentTabCount(page, 'All', 1);
// The cancelled document is still listed.
await page.getByRole('tab', { name: 'Cancelled' }).click();
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
// The envelope status is persisted as CANCELLED.
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
id: pendingDocument.id,
},
select: {
status: true,
completedAt: true,
deletedAt: true,
},
});
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
expect(envelope.completedAt).not.toBeNull();
expect(envelope.deletedAt).toBeNull();
});
test('[DOCUMENTS]: cancelling a pending document retains it for recipients', async ({ page }) => {
const { sender, recipients } = await seedCancelDocumentsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await cancelDocumentViaUi(page, 'Document 1 - Pending');
await expectToastTextToBeVisible(page, 'Document cancelled');
await apiSignout({ page });
// Recipients should still be able to see the document as a record of distribution.
for (const recipient of recipients) {
await apiSignin({
page,
email: recipient.user.email,
redirectPath: `/t/${recipient.team.url}/documents`,
});
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
await apiSignout({ page });
}
});
test('[DOCUMENTS]: a cancelled document can be deleted, hiding it from the owner without removing it', async ({
page,
}) => {
const { sender, recipients, pendingDocument } = await seedCancelDocumentsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await cancelDocumentViaUi(page, 'Document 1 - Pending');
await expectToastTextToBeVisible(page, 'Document cancelled');
// Delete the now-cancelled document. Being terminal, it should soft delete (hide).
await page.getByRole('tab', { name: 'Cancelled' }).click();
const documentActionBtn = page
.locator('tr', { hasText: 'Document 1 - Pending' })
.getByTestId('document-table-action-btn');
await openDropdownMenu(page, documentActionBtn);
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await page.waitForTimeout(2500);
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
// The envelope is soft deleted, not hard deleted.
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
id: pendingDocument.id,
},
select: {
status: true,
deletedAt: true,
},
});
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
expect(envelope.deletedAt).not.toBeNull();
await apiSignout({ page });
// Recipients should still retain the document after the owner deletes it.
await apiSignin({
page,
email: recipients[0].user.email,
redirectPath: `/t/${recipients[0].team.url}/documents`,
});
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
});
// ─── Visibility: a cancelled document must respect team document visibility ───
test('[DOCUMENTS]: cancelled document with ADMIN visibility is hidden from a MEMBER', async ({ page }) => {
const { team, owner } = await seedTeam();
const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
const managerUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
await seedCancelledDocument(owner, team.id, [], {
createDocumentOptions: {
visibility: 'ADMIN',
title: 'Cancelled Admin Only Document',
},
});
// The MEMBER must NOT see the ADMIN-visibility cancelled document on any tab.
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
});
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).not.toBeVisible();
// Also confirm it doesn't leak via the ALL tab.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/documents`);
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).not.toBeVisible();
await apiSignout({ page });
// The MANAGER must NOT see an ADMIN-visibility document either.
await apiSignin({
page,
email: managerUser.email,
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
});
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).not.toBeVisible();
await apiSignout({ page });
// The ADMIN must see it.
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
});
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).toBeVisible();
});
test('[DOCUMENTS]: cancelled document with MANAGER_AND_ABOVE visibility is hidden from a MEMBER', async ({ page }) => {
const { team, owner } = await seedTeam();
const managerUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
await seedCancelledDocument(owner, team.id, [], {
createDocumentOptions: {
visibility: 'MANAGER_AND_ABOVE',
title: 'Cancelled Manager Document',
},
});
// The MEMBER must NOT see it.
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
});
await expect(page.getByRole('link', { name: 'Cancelled Manager Document', exact: true })).not.toBeVisible();
await apiSignout({ page });
// The MANAGER must see it.
await apiSignin({
page,
email: managerUser.email,
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
});
await expect(page.getByRole('link', { name: 'Cancelled Manager Document', exact: true })).toBeVisible();
});
test('[DOCUMENTS]: a recipient sees a cancelled document regardless of restricted visibility', async ({ page }) => {
const { team, owner } = await seedTeam();
// A MEMBER who is also a recipient on an ADMIN-visibility document.
const memberRecipient = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
await seedCancelledDocument(owner, team.id, [memberRecipient], {
createDocumentOptions: {
visibility: 'ADMIN',
title: 'Cancelled Admin Doc With Recipient',
},
});
// Even though the document is ADMIN-only, the MEMBER is a recipient, so they
// must still see it (proof of distribution), matching completed-document behaviour.
await apiSignin({
page,
email: memberRecipient.email,
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
});
await expect(page.getByRole('link', { name: 'Cancelled Admin Doc With Recipient', exact: true })).toBeVisible();
});
// ─── UI gating: only privileged members see the Cancel action ────────────────
test('[DOCUMENTS]: a MEMBER does not see the Cancel action on a pending document', async ({ page }) => {
const { team, owner } = await seedTeam();
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const { user: recipient } = await seedUser();
await seedPendingDocument(owner, team.id, [recipient], {
createDocumentOptions: { title: 'Member Gating Pending Document', visibility: 'EVERYONE' },
});
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
const documentActionBtn = page
.locator('tr', { hasText: 'Member Gating Pending Document' })
.getByTestId('document-table-action-btn');
await openDropdownMenu(page, documentActionBtn);
// The dropdown must render (Edit is always there) but Cancel must be absent.
await expect(page.getByRole('menuitem', { name: 'Edit' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Cancel' })).not.toBeVisible();
});
test('[DOCUMENTS]: a team ADMIN sees and can use the Cancel action on a document they do not own', async ({ page }) => {
const { team, owner } = await seedTeam();
const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient], {
createDocumentOptions: { title: 'Admin Cancellable Document', visibility: 'EVERYONE' },
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
await cancelDocumentViaUi(page, 'Admin Cancellable Document');
await expectToastTextToBeVisible(page, 'Document cancelled');
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
});
+3
View File
@@ -23,6 +23,9 @@ export const DOCUMENT_STATUS: {
[DocumentStatus.REJECTED]: {
description: msg`Rejected`,
},
[DocumentStatus.CANCELLED]: {
description: msg`Cancelled`,
},
[DocumentStatus.DRAFT]: {
description: msg`Draft`,
},
+3
View File
@@ -27,6 +27,7 @@ export enum AppErrorCode {
ENVELOPE_DRAFT = 'ENVELOPE_DRAFT',
ENVELOPE_COMPLETED = 'ENVELOPE_COMPLETED',
ENVELOPE_REJECTED = 'ENVELOPE_REJECTED',
ENVELOPE_CANCELLED = 'ENVELOPE_CANCELLED',
ENVELOPE_LEGACY = 'ENVELOPE_LEGACY',
/**
* Authoring mutation rejected because the envelope is an AES/QES envelope
@@ -80,6 +81,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.ENVELOPE_DRAFT]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_COMPLETED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_REJECTED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_CANCELLED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
@@ -286,6 +288,7 @@ export class AppError extends Error {
AppErrorCode.ENVELOPE_DRAFT,
AppErrorCode.ENVELOPE_COMPLETED,
AppErrorCode.ENVELOPE_REJECTED,
AppErrorCode.ENVELOPE_CANCELLED,
AppErrorCode.ENVELOPE_LEGACY,
AppErrorCode.ENVELOPE_TSP_LOCKED,
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
@@ -18,6 +18,7 @@ export const getDocumentStats = async () => {
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.CANCELLED]: 0,
[ExtendedDocumentStatus.ALL]: 0,
};
@@ -0,0 +1,129 @@
import { jobs } from '@documenso/lib/jobs/client';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType, WebhookTriggerEvents } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { isMemberManagerOrAbove } from '../../utils/teams';
import { getMemberRoles } from '../team/get-member-roles';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CancelDocumentOptions = {
id: EnvelopeIdOptions;
userId: number;
teamId: number;
reason?: string;
requestMetadata: ApiRequestMetadata;
};
export const cancelDocument = async ({ id, userId, teamId, reason, requestMetadata }: CancelDocumentOptions) => {
// Note: This is an unsafe request, we validate the ownership/permission later in the function.
const envelope = await prisma.envelope.findUnique({
where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
include: {
recipients: true,
documentMeta: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const isUserOwner = envelope.userId === userId;
const teamRole = await getMemberRoles({
teamId: envelope.teamId,
reference: {
type: 'User',
id: userId,
},
})
.then((roles) => roles.teamRole)
.catch(() => null);
const isUserTeamMember = teamRole !== null;
// Callers with no relationship to the document must not be able to determine
// whether it exists, so respond as if it was not found.
if (!isUserOwner && !isUserTeamMember) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const isPrivilegedTeamMember = teamRole && isMemberManagerOrAbove(teamRole);
// The document is visible to the caller, but cancelling requires elevated permissions.
if (!isUserOwner && !isPrivilegedTeamMember) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Not allowed',
});
}
if (envelope.status !== DocumentStatus.PENDING) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Only pending documents can be cancelled',
});
}
const updatedEnvelope = await prisma.$transaction(async (tx) => {
const updated = await tx.envelope.update({
where: {
id: envelope.id,
},
data: {
status: DocumentStatus.CANCELLED,
completedAt: new Date(),
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
envelopeId: envelope.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CANCELLED,
metadata: requestMetadata,
data: {
reason,
},
}),
});
return updated;
});
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
// Send cancellation emails to recipients via the resilient background job.
await jobs.triggerJob({
name: 'send.document.cancelled.emails',
payload: {
documentId: legacyDocumentId,
cancellationReason: reason,
requestMetadata: requestMetadata.requestMetadata,
},
});
// Trigger the webhook with the updated (cancelled) envelope payload.
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
data: ZWebhookDocumentSchema.parse(
mapEnvelopeToWebhookDocumentPayload({
...envelope,
status: updatedEnvelope.status,
completedAt: updatedEnvelope.completedAt,
}),
),
userId,
teamId,
});
return updatedEnvelope;
};
@@ -220,7 +220,11 @@ export const findDocuments = async ({
eb.or([
eb('Envelope.userId', '=', user.id),
eb.and([
eb('Envelope.status', 'in', [sql.lit(DocumentStatus.COMPLETED), sql.lit(DocumentStatus.PENDING)]),
eb('Envelope.status', 'in', [
sql.lit(DocumentStatus.COMPLETED),
sql.lit(DocumentStatus.PENDING),
sql.lit(DocumentStatus.CANCELLED),
]),
recipientExists(eb, user.email),
]),
]),
@@ -291,6 +295,16 @@ export const findDocuments = async ({
]),
),
)
.with(ExtendedDocumentStatus.CANCELLED, () =>
qb
.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.CANCELLED))
.where((eb) =>
eb.and([
personalDeletedFilter(eb),
eb.or([eb('Envelope.userId', '=', user.id), recipientExists(eb, user.email)]),
]),
),
)
.exhaustive();
};
@@ -429,6 +443,18 @@ export const findDocuments = async ({
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
}),
)
.with(ExtendedDocumentStatus.CANCELLED, () =>
qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.CANCELLED)).where((eb) => {
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
if (teamEmail) {
accessBranches.push(senderEmailIs(eb, teamEmail));
accessBranches.push(recipientExists(eb, teamEmail));
}
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
}),
)
.exhaustive();
};
+18 -2
View File
@@ -239,6 +239,20 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
});
// CANCELLED: team-owned cancelled + team-email received cancelled docs
const cancelledQuery = buildBaseQuery()
.where('Envelope.status', '=', sql.lit(DocumentStatus.CANCELLED))
.where((eb) => {
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
if (teamEmail) {
accessBranches.push(senderEmailIs(eb, teamEmail));
accessBranches.push(recipientExists(eb, teamEmail));
}
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
});
// INBOX: non-draft docs where team email is a NOT_SIGNED, non-CC recipient
// Returns 0 if the team has no team email.
const inboxQuery = teamEmail
@@ -260,21 +274,23 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
// ─── Execute all counts in parallel ──────────────────────────────────
const [draft, pending, completed, rejected, inbox] = await Promise.all([
const [draft, pending, completed, rejected, cancelled, inbox] = await Promise.all([
cappedCount(draftQuery),
cappedCount(pendingQuery),
cappedCount(completedQuery),
cappedCount(rejectedQuery),
cappedCount(cancelledQuery),
inboxQuery ? cappedCount(inboxQuery) : Promise.resolve(0),
]);
const all = Math.min(draft + pending + completed + rejected + inbox, STATS_COUNT_CAP);
const all = Math.min(draft + pending + completed + rejected + cancelled + inbox, STATS_COUNT_CAP);
const stats: Record<ExtendedDocumentStatus, number> = {
[ExtendedDocumentStatus.DRAFT]: draft,
[ExtendedDocumentStatus.PENDING]: pending,
[ExtendedDocumentStatus.COMPLETED]: completed,
[ExtendedDocumentStatus.REJECTED]: rejected,
[ExtendedDocumentStatus.CANCELLED]: cancelled,
[ExtendedDocumentStatus.INBOX]: inbox,
[ExtendedDocumentStatus.ALL]: all,
};
@@ -34,8 +34,9 @@ type EnvelopeIdRef = Pick<Envelope, 'id'>;
* Throws:
* - `ENVELOPE_TSP_LOCKED` when the envelope is PENDING (the case unique to
* the TSP lock — SES routes happily allow PENDING).
* - `ENVELOPE_COMPLETED` / `ENVELOPE_REJECTED` for those terminal states, to
* stay consistent with the existing envelope-state error vocabulary.
* - `ENVELOPE_COMPLETED` / `ENVELOPE_REJECTED` / `ENVELOPE_CANCELLED` for those
* terminal states, to stay consistent with the existing envelope-state error
* vocabulary.
*/
export function assertEnvelopeMutable(envelope: EnvelopeMutableSnapshot): Promise<void>;
export function assertEnvelopeMutable(envelope: EnvelopeIdRef, tx: Prisma.TransactionClient): Promise<void>;
@@ -73,6 +74,7 @@ const assertSnapshotMutable = (envelope: EnvelopeMutableSnapshot): void => {
.with(DocumentStatus.PENDING, () => AppErrorCode.ENVELOPE_TSP_LOCKED)
.with(DocumentStatus.COMPLETED, () => AppErrorCode.ENVELOPE_COMPLETED)
.with(DocumentStatus.REJECTED, () => AppErrorCode.ENVELOPE_REJECTED)
.with(DocumentStatus.CANCELLED, () => AppErrorCode.ENVELOPE_CANCELLED)
.otherwise(() => AppErrorCode.INVALID_REQUEST);
throw new AppError(errorCode, {
@@ -17,6 +17,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
const now = new Date();
const basePayload = {
id: 10,
envelopeId: 'env_123',
externalId: null,
userId: 1,
authOptions: null,
@@ -52,6 +53,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
recipients: [
{
id: 52,
envelopeId: 'env_123',
documentId: 10,
templateId: null,
email: 'signer@documenso.com',
@@ -73,6 +75,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
Recipient: [
{
id: 52,
envelopeId: 'env_123',
documentId: 10,
templateId: null,
email: 'signer@documenso.com',
@@ -269,6 +272,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
recipients: [
{
id: 50,
envelopeId: 'env_123',
documentId: 10,
templateId: null,
email: 'signer2@documenso.com',
@@ -291,6 +295,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
},
{
id: 51,
envelopeId: 'env_123',
documentId: 10,
templateId: null,
email: 'signer1@documenso.com',
@@ -315,6 +320,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
Recipient: [
{
id: 50,
envelopeId: 'env_123',
documentId: 10,
templateId: null,
email: 'signer2@documenso.com',
@@ -337,6 +343,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
},
{
id: 51,
envelopeId: 'env_123',
documentId: 10,
templateId: null,
email: 'signer1@documenso.com',
@@ -444,6 +451,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
recipients: [
{
id: 7,
envelopeId: 'env_123',
documentId: 7,
templateId: null,
email: 'signer1@documenso.com',
@@ -468,6 +476,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
Recipient: [
{
id: 7,
envelopeId: 'env_123',
documentId: 7,
templateId: null,
email: 'signer@documenso.com',
+12
View File
@@ -31,6 +31,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_COMPLETED', // When the document is sealed and fully completed.
'DOCUMENT_CREATED', // When the document is created.
'DOCUMENT_DELETED', // When the document is soft deleted.
'DOCUMENT_CANCELLED', // When a privileged member cancels the document.
'DOCUMENT_FIELDS_AUTO_INSERTED', // When a field is auto inserted during send due to default values (radio/dropdown/checkbox).
'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient.
'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient.
@@ -296,6 +297,16 @@ export const ZDocumentAuditLogEventDocumentDeletedSchema = z.object({
}),
});
/**
* Event: Document cancelled.
*/
export const ZDocumentAuditLogEventDocumentCancelledSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CANCELLED),
data: z.object({
reason: z.string().optional(),
}),
});
/**
* Event: Document field inserted.
*/
@@ -815,6 +826,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventDocumentCompletedSchema,
ZDocumentAuditLogEventDocumentCreatedSchema,
ZDocumentAuditLogEventDocumentDeletedSchema,
ZDocumentAuditLogEventDocumentCancelledSchema,
ZDocumentAuditLogEventDocumentMovedToTeamSchema,
ZDocumentAuditLogEventDocumentDelegatedOwnerCreatedSchema,
ZDocumentAuditLogEventDocumentFieldsAutoInsertedSchema,
+4
View File
@@ -21,6 +21,7 @@ import { mapSecondaryIdToDocumentId, mapSecondaryIdToTemplateId } from '../utils
*/
export const ZWebhookRecipientSchema = z.object({
id: z.number(),
envelopeId: z.string(),
documentId: z.number().nullable(),
templateId: z.number().nullable(),
email: z.string(),
@@ -64,6 +65,7 @@ export const ZWebhookDocumentMetaSchema = z.object({
*/
export const ZWebhookDocumentSchema = z.object({
id: z.number(),
envelopeId: z.string(),
externalId: z.string().nullable(),
userId: z.number(),
authOptions: z.any().nullable(),
@@ -117,6 +119,7 @@ export const mapEnvelopeToWebhookDocumentPayload = (
const mappedRecipients = rawRecipients.map((recipient) => ({
id: recipient.id,
envelopeId: envelope.id,
documentId: envelope.type === EnvelopeType.DOCUMENT ? legacyId : null,
templateId: envelope.type === EnvelopeType.TEMPLATE ? legacyId : null,
email: recipient.email,
@@ -137,6 +140,7 @@ export const mapEnvelopeToWebhookDocumentPayload = (
return {
id: legacyId,
envelopeId: envelope.id,
externalId: envelope.externalId,
userId: envelope.userId,
authOptions: envelope.authOptions,
+27 -5
View File
@@ -13,7 +13,27 @@ type File = {
arrayBuffer: () => Promise<ArrayBuffer>;
};
export const putPdfFile = async (file: File) => {
/**
* Options for uploads that are not authorized by a logged-in session.
*
* Embedded authoring flows run cross-origin without a session cookie, so they
* must authorize uploads with their embedding presign token instead.
*/
export type PutFileOptions = {
presignToken?: string;
};
const buildUploadAuthHeaders = (options?: PutFileOptions): Record<string, string> => {
if (!options?.presignToken) {
return {};
}
return {
Authorization: `Bearer ${options.presignToken}`,
};
};
export const putPdfFile = async (file: File, options?: PutFileOptions) => {
const formData = new FormData();
// Create a proper File object from the data
@@ -25,6 +45,7 @@ export const putPdfFile = async (file: File) => {
const response = await fetch('/api/files/upload-pdf', {
method: 'POST',
headers: buildUploadAuthHeaders(options),
body: formData,
});
@@ -41,12 +62,12 @@ export const putPdfFile = async (file: File) => {
/**
* Uploads a file to the appropriate storage location.
*/
export const putFile = async (file: File) => {
export const putFile = async (file: File, options?: PutFileOptions) => {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
.with('s3', async () => putFileInObjectStorage(file, {}))
.with('azure-blob', async () => putFileInObjectStorage(file, { 'x-ms-blob-type': 'BlockBlob' }))
.with('s3', async () => putFileInObjectStorage(file, {}, options))
.with('azure-blob', async () => putFileInObjectStorage(file, { 'x-ms-blob-type': 'BlockBlob' }, options))
.otherwise(async () => putFileInDatabase(file));
};
@@ -63,11 +84,12 @@ const putFileInDatabase = async (file: File) => {
};
};
const putFileInObjectStorage = async (file: File, extraHeaders: Record<string, string>) => {
const putFileInObjectStorage = async (file: File, extraHeaders: Record<string, string>, options?: PutFileOptions) => {
const getPresignedUrlResponse = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/files/presigned-post-url`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildUploadAuthHeaders(options),
},
body: JSON.stringify({
fileName: file.name,
@@ -355,6 +355,14 @@ export const formatDocumentAuditLogAction = (i18n: I18n, auditLog: TDocumentAudi
you: msg`You deleted the document`,
user: msg`${user} deleted the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CANCELLED }, () => ({
anonymous: msg({
message: `Document cancelled`,
context: `Audit log format`,
}),
you: msg`You cancelled the document`,
user: msg`${user} cancelled the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELDS_AUTO_INSERTED }, () => ({
anonymous: msg({
message: `System auto inserted fields`,
+3 -1
View File
@@ -12,7 +12,9 @@ import { mapRecipientToLegacyRecipient } from './recipients';
export const isDocumentCompleted = (document: Pick<Envelope, 'status'> | DocumentStatus) => {
const status = typeof document === 'string' ? document : document.status;
return status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED;
return (
status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED || status === DocumentStatus.CANCELLED
);
};
/**
+3 -2
View File
@@ -242,12 +242,13 @@ export const getEnvelopeItemPermissions = (
envelope: Pick<Envelope, 'completedAt' | 'deletedAt' | 'type' | 'status'>,
recipients: Pick<Recipient, 'role' | 'signingStatus' | 'sendStatus'>[],
): EnvelopeItemPermissions => {
// Always reject completed/rejected/deleted envelopes.
// Always reject completed/rejected/cancelled/deleted envelopes.
if (
envelope.completedAt ||
envelope.deletedAt ||
envelope.status === DocumentStatus.REJECTED ||
envelope.status === DocumentStatus.COMPLETED
envelope.status === DocumentStatus.COMPLETED ||
envelope.status === DocumentStatus.CANCELLED
) {
return {
canTitleBeChanged: false,
+9 -1
View File
@@ -1,4 +1,4 @@
import type { TeamGroup, TeamMemberRole } from '@documenso/prisma/generated/types';
import { type TeamGroup, TeamMemberRole } from '@documenso/prisma/generated/types';
import type { DocumentVisibility, OrganisationGlobalSettings, Prisma, TeamGlobalSettings } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
@@ -235,3 +235,11 @@ export const extractDerivedTeamSettings = (
return derivedSettings;
};
export const isMemberManagerOrAbove = (role: TeamMemberRole) => {
return role === TeamMemberRole.ADMIN || role === TeamMemberRole.MANAGER;
};
export const isMemberAdmin = (role: TeamMemberRole) => {
return role === TeamMemberRole.ADMIN;
};
@@ -0,0 +1 @@
ALTER TYPE "DocumentStatus" ADD VALUE 'CANCELLED';
+1
View File
@@ -382,6 +382,7 @@ enum DocumentStatus {
PENDING
COMPLETED
REJECTED
CANCELLED
}
enum DocumentSource {
+85
View File
@@ -722,6 +722,91 @@ export const seedCompletedDocument = async (
return document;
};
export const seedCancelledDocument = async (
sender: User,
teamId: number,
recipients: (User | string)[],
options: CreateDocumentOptions = {},
) => {
const { key, createDocumentOptions = {}, internalVersion = 1 } = options;
const documentData = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
});
const documentMeta = await prisma.documentMeta.create({
data: {},
});
const documentId = await incrementDocumentId();
const document = await prisma.envelope.create({
data: {
id: prefixedId('envelope'),
secondaryId: documentId.formattedDocumentId,
internalVersion,
signatureLevel: SignatureLevel.SES,
type: EnvelopeType.DOCUMENT,
documentMetaId: documentMeta.id,
source: DocumentSource.DOCUMENT,
teamId,
title: `[TEST] Document ${key} - Cancelled`,
status: DocumentStatus.CANCELLED,
completedAt: new Date(),
envelopeItems: {
create: {
id: prefixedId('envelope_item'),
title: `[TEST] Document ${key} - Cancelled`,
documentDataId: documentData.id,
order: 1,
},
},
userId: sender.id,
...createDocumentOptions,
},
include: {
envelopeItems: true,
},
});
for (const recipient of recipients) {
const email = typeof recipient === 'string' ? recipient : recipient.email;
const name = typeof recipient === 'string' ? recipient : (recipient.name ?? '');
await prisma.recipient.create({
data: {
email,
name,
token: nanoid(),
readStatus: ReadStatus.OPENED,
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.NOT_SIGNED,
envelopeId: document.id,
fields: {
create: {
page: 1,
type: FieldType.NAME,
inserted: false,
customText: name,
positionX: new Prisma.Decimal(1),
positionY: new Prisma.Decimal(1),
width: new Prisma.Decimal(1),
height: new Prisma.Decimal(1),
envelopeId: document.id,
envelopeItemId: document.envelopeItems[0].id,
},
},
},
});
}
return document;
};
/**
* Create 5 team documents:
* - Completed document with 2 recipients.
@@ -3,7 +3,7 @@ import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelop
import { getPresignGetUrl } from '@documenso/lib/universal/upload/server-actions';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import type { DocumentData } from '@prisma/client';
import { DocumentDataType, EnvelopeType } from '@prisma/client';
import { DocumentDataType, DocumentStatus, EnvelopeType } from '@prisma/client';
import { authenticatedProcedure } from '../trpc';
import {
@@ -58,7 +58,12 @@ export const downloadDocumentBetaRoute = authenticatedProcedure
});
}
if (version === 'signed' && !isDocumentCompleted(envelope.status)) {
// A cancelled document was never sealed, so its data is the unsigned original.
// Treat it as not-completed here so a "signed" version is never served for it.
// REJECTED and COMPLETED keep their prior behavior.
const hasSignedArtifact = isDocumentCompleted(envelope.status) && envelope.status !== DocumentStatus.CANCELLED;
if (version === 'signed' && !hasSignedArtifact) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document is not completed yet.',
});
@@ -4,7 +4,7 @@ import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-e
import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { authenticatedProcedure } from '../trpc';
import {
@@ -60,7 +60,9 @@ export const downloadDocumentCertificateRoute = authenticatedProcedure
});
}
if (!isDocumentCompleted(envelope.status)) {
// A cancelled document was never sealed/completed, so a signing certificate
// must not be generated for it. REJECTED and COMPLETED keep their prior behavior.
if (!isDocumentCompleted(envelope.status) || envelope.status === DocumentStatus.CANCELLED) {
throw new AppError('DOCUMENT_NOT_COMPLETE');
}
@@ -19,6 +19,7 @@ export const ZFindDocumentsInternalResponseSchema = ZFindResultResponse.extend({
[ExtendedDocumentStatus.PENDING]: z.number(),
[ExtendedDocumentStatus.COMPLETED]: z.number(),
[ExtendedDocumentStatus.REJECTED]: z.number(),
[ExtendedDocumentStatus.CANCELLED]: z.number(),
[ExtendedDocumentStatus.INBOX]: z.number(),
[ExtendedDocumentStatus.ALL]: z.number(),
}),
@@ -0,0 +1,95 @@
import { cancelDocument } from '@documenso/lib/server-only/document/cancel-document';
import { getMultipleEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelopes-by-ids';
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
import pMap from 'p-map';
import { authenticatedProcedure } from '../trpc';
import { ZBulkCancelEnvelopesRequestSchema, ZBulkCancelEnvelopesResponseSchema } from './bulk-cancel-envelopes.types';
export const bulkCancelEnvelopesRoute = authenticatedProcedure
.input(ZBulkCancelEnvelopesRequestSchema)
.output(ZBulkCancelEnvelopesResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { envelopeIds, reason } = input;
ctx.logger.info({
input: {
envelopeIds,
},
});
// Cancelling only applies to documents. Templates are filtered out here so
// selecting a template never counts towards a cancellation.
const { envelopeWhereInput } = await getMultipleEnvelopeWhereInput({
ids: {
type: 'envelopeId',
ids: envelopeIds,
},
userId: user.id,
teamId,
type: EnvelopeType.DOCUMENT,
});
const envelopes = await prisma.envelope.findMany({
where: envelopeWhereInput,
select: {
id: true,
},
});
const results = await pMap(
envelopes,
async (envelope) => {
const { id: envelopeId } = envelope;
try {
await cancelDocument({
id: {
type: 'envelopeId',
id: envelopeId,
},
userId: user.id,
teamId,
reason,
requestMetadata: ctx.metadata,
});
return {
success: true,
envelopeId,
};
} catch (err) {
ctx.logger.warn(
{
envelopeId,
error: err,
},
'Failed to cancel envelope during bulk cancel',
);
return {
success: false,
envelopeId,
};
}
},
{
concurrency: 10,
stopOnError: false,
},
);
const cancelledCount = results.filter((r) => r.success).length;
const failedIds = results.filter((r) => !r.success).map((r) => r.envelopeId);
// Include envelope IDs that were not attempted (unauthorized/not found/not a document).
const attemptedIds = new Set(envelopes.map((e) => e.id));
const unattemptedIds = envelopeIds.filter((id) => !attemptedIds.has(id));
return {
cancelledCount,
failedIds: [...failedIds, ...unattemptedIds],
};
});
@@ -0,0 +1,21 @@
import { z } from 'zod';
// Keeping this as a private API for a little while until we're sure it's stable
// and the request/response schemas are finalized.
export const ZBulkCancelEnvelopesRequestSchema = z.object({
envelopeIds: z
.array(z.string())
.min(1)
.max(100)
.describe('The IDs of the envelopes to cancel. The maximum number of envelopes you can cancel at once is 100.'),
reason: z.string().optional(),
});
export const ZBulkCancelEnvelopesResponseSchema = z.object({
cancelledCount: z.number(),
failedIds: z.array(z.string()),
});
export type TBulkCancelEnvelopesRequest = z.infer<typeof ZBulkCancelEnvelopesRequestSchema>;
export type TBulkCancelEnvelopesResponse = z.infer<typeof ZBulkCancelEnvelopesResponseSchema>;
@@ -0,0 +1,37 @@
import { cancelDocument } from '@documenso/lib/server-only/document/cancel-document';
import { ZGenericSuccessResponse } from '../schema';
import { authenticatedProcedure } from '../trpc';
import {
cancelEnvelopeMeta,
ZCancelEnvelopeRequestSchema,
ZCancelEnvelopeResponseSchema,
} from './cancel-envelope.types';
export const cancelEnvelopeRoute = authenticatedProcedure
.meta(cancelEnvelopeMeta)
.input(ZCancelEnvelopeRequestSchema)
.output(ZCancelEnvelopeResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const { envelopeId, reason } = input;
ctx.logger.info({
input: {
envelopeId,
},
});
await cancelDocument({
id: {
type: 'envelopeId',
id: envelopeId,
},
userId: ctx.user.id,
teamId,
reason,
requestMetadata: ctx.metadata,
});
return ZGenericSuccessResponse;
});
@@ -0,0 +1,23 @@
import { z } from 'zod';
import { ZSuccessResponseSchema } from '../schema';
import type { TrpcRouteMeta } from '../trpc';
export const cancelEnvelopeMeta: TrpcRouteMeta = {
openapi: {
method: 'POST',
path: '/envelope/cancel',
summary: 'Cancel envelope',
tags: ['Envelope'],
},
};
export const ZCancelEnvelopeRequestSchema = z.object({
envelopeId: z.string(),
reason: z.string().optional(),
});
export const ZCancelEnvelopeResponseSchema = ZSuccessResponseSchema;
export type TCancelEnvelopeRequest = z.infer<typeof ZCancelEnvelopeRequestSchema>;
export type TCancelEnvelopeResponse = z.infer<typeof ZCancelEnvelopeResponseSchema>;
@@ -3,8 +3,10 @@ import { createAttachmentRoute } from './attachment/create-attachment';
import { deleteAttachmentRoute } from './attachment/delete-attachment';
import { findAttachmentsRoute } from './attachment/find-attachments';
import { updateAttachmentRoute } from './attachment/update-attachment';
import { bulkCancelEnvelopesRoute } from './bulk-cancel-envelopes';
import { bulkDeleteEnvelopesRoute } from './bulk-delete-envelopes';
import { bulkMoveEnvelopesRoute } from './bulk-move-envelopes';
import { cancelEnvelopeRoute } from './cancel-envelope';
import { createEnvelopeRoute } from './create-envelope';
import { createEnvelopeItemsRoute } from './create-envelope-items';
import { deleteEnvelopeRoute } from './delete-envelope';
@@ -85,6 +87,7 @@ export const envelopeRouter = router({
bulk: {
move: bulkMoveEnvelopesRoute,
delete: bulkDeleteEnvelopesRoute,
cancel: bulkCancelEnvelopesRoute,
},
editor: {
get: getEditorEnvelopeRoute,
@@ -95,6 +98,7 @@ export const envelopeRouter = router({
use: useEnvelopeRoute,
update: updateEnvelopeRoute,
delete: deleteEnvelopeRoute,
cancel: cancelEnvelopeRoute,
duplicate: duplicateEnvelopeRoute,
saveAsTemplate: saveAsTemplateRoute,
distribute: distributeEnvelopeRoute,
@@ -102,12 +102,14 @@ export const AddSubjectFormPartial = ({
: msg`Send`,
[DocumentStatus.COMPLETED]: msg`Update`,
[DocumentStatus.REJECTED]: msg`Update`,
[DocumentStatus.CANCELLED]: msg`Update`,
},
[DocumentDistributionMethod.NONE]: {
[DocumentStatus.DRAFT]: msg`Generate Links`,
[DocumentStatus.PENDING]: msg`View Document`,
[DocumentStatus.COMPLETED]: msg`View Document`,
[DocumentStatus.REJECTED]: msg`View Document`,
[DocumentStatus.CANCELLED]: msg`View Document`,
},
};