mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
feat: add cancellable document status (#2992)
Adds a CANCELLED envelope status that privileged members (owner or team admin/manager) can move a pending document into. Sending recipient notifications via a background job while retaining the document in the dashboard as proof of distribution. Includes a dedicated Cancelled tab, single and bulk cancel actions, the ENVELOPE_CANCELLED mutability guard, and e2e coverage for permissions and visibility.
This commit is contained in:
@@ -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,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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user