mirror of
https://github.com/documenso/documenso.git
synced 2025-11-11 04:52:41 +10:00
Compare commits
46 Commits
v1.5.5-rc.
...
feat/passk
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b43145b6f | |||
| e8d4fe46e5 | |||
| 64e3e2c64b | |||
| 15dee5ef35 | |||
| 28d6f6e2e8 | |||
| 78dc57a6eb | |||
| d3528f74f0 | |||
| dbd452be97 | |||
| 5109bb17d6 | |||
| 6974a76ed4 | |||
| 5efb0894e6 | |||
| cfec366c1a | |||
| 8622e68853 | |||
| 0e16a86e74 | |||
| 97d334a1da | |||
| 345e42537a | |||
| 8a24ca2065 | |||
| 06dd8219a5 | |||
| 74b9bc786b | |||
| 364c499927 | |||
| b0ce06f6fe | |||
| 20edee7f1a | |||
| 9bc5818d19 | |||
| 481d739c37 | |||
| 88dedc9829 | |||
| 4080806606 | |||
| e949fb14ae | |||
| e1573465f6 | |||
| 0062359977 | |||
| 03727bfad2 | |||
| c4a680caf7 | |||
| 1e33bc2aa3 | |||
| 4de122f814 | |||
| e4cf9c8251 | |||
| 41ed6c9ad7 | |||
| 713cd09a06 | |||
| 87423e240a | |||
| d7959950e2 | |||
| bb43547a45 | |||
| 3fb69422e8 | |||
| 4d5365bddc | |||
| 0eee570781 | |||
| afaeba9739 | |||
| f6e6dac46c | |||
| a97ffa97a4 | |||
| 6526377f1b |
@ -8,7 +8,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { base64 } from '@documenso/lib/universal/base64';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||
import { DocumentDataType, Prisma } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@ -115,7 +115,7 @@ export const SinglePlayerClient = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const putFileData = await putFile(uploadedFile.file);
|
||||
const putFileData = await putPdfFile(uploadedFile.file);
|
||||
|
||||
const documentToken = await createSinglePlayerDocument({
|
||||
documentData: {
|
||||
|
||||
@ -8,6 +8,7 @@ import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||
import { getCompletedFieldsForDocument } from '@documenso/lib/server-only/field/get-completed-fields-for-document';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
@ -20,6 +21,7 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
|
||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||
import { DocumentHistorySheet } from '~/components/document/document-history-sheet';
|
||||
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
|
||||
import {
|
||||
DocumentStatus as DocumentStatusComponent,
|
||||
FRIENDLY_STATUS_MAP,
|
||||
@ -84,11 +86,16 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
||||
documentMeta.password = securePassword;
|
||||
}
|
||||
|
||||
const recipients = await getRecipientsForDocument({
|
||||
documentId,
|
||||
teamId: team?.id,
|
||||
userId: user.id,
|
||||
});
|
||||
const [recipients, completedFields] = await Promise.all([
|
||||
getRecipientsForDocument({
|
||||
documentId,
|
||||
teamId: team?.id,
|
||||
userId: user.id,
|
||||
}),
|
||||
getCompletedFieldsForDocument({
|
||||
documentId,
|
||||
}),
|
||||
]);
|
||||
|
||||
const documentWithRecipients = {
|
||||
...document,
|
||||
@ -155,6 +162,13 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{document.status === DocumentStatus.PENDING && (
|
||||
<DocumentReadOnlyFields
|
||||
fields={completedFields}
|
||||
documentMeta={document.documentMeta || undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||
<div className="space-y-6">
|
||||
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
||||
|
||||
@ -133,7 +133,11 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
|
||||
<DownloadCertificateButton className="mr-2" documentId={document.id} />
|
||||
<DownloadCertificateButton
|
||||
className="mr-2"
|
||||
documentId={document.id}
|
||||
documentStatus={document.status}
|
||||
/>
|
||||
|
||||
<DownloadAuditLogButton documentId={document.id} />
|
||||
</div>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { DownloadIcon } from 'lucide-react';
|
||||
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -10,11 +11,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
export type DownloadCertificateButtonProps = {
|
||||
className?: string;
|
||||
documentId: number;
|
||||
documentStatus: DocumentStatus;
|
||||
};
|
||||
|
||||
export const DownloadCertificateButton = ({
|
||||
className,
|
||||
documentId,
|
||||
documentStatus,
|
||||
}: DownloadCertificateButtonProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
@ -69,6 +72,7 @@ export const DownloadCertificateButton = ({
|
||||
className={cn('w-full sm:w-auto', className)}
|
||||
loading={isLoading}
|
||||
variant="outline"
|
||||
disabled={documentStatus !== DocumentStatus.COMPLETED}
|
||||
onClick={() => void onDownloadCertificatesClick()}
|
||||
>
|
||||
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
||||
|
||||
@ -10,8 +10,9 @@ import { useSession } from 'next-auth/react';
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { TRPCClientError } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@ -57,7 +58,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const { type, data } = await putFile(file);
|
||||
const { type, data } = await putPdfFile(file);
|
||||
|
||||
const { id: documentDataId } = await createDocumentData({
|
||||
type,
|
||||
@ -83,13 +84,21 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
||||
});
|
||||
|
||||
router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error instanceof TRPCClientError) {
|
||||
console.error(err);
|
||||
|
||||
if (error.code === 'INVALID_DOCUMENT_FILE') {
|
||||
toast({
|
||||
title: 'Invalid file',
|
||||
description: 'You cannot upload encrypted PDFs',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else if (err instanceof TRPCClientError) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.message,
|
||||
description: err.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
|
||||
@ -12,7 +12,7 @@ import * as z from 'zod';
|
||||
|
||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||
import { base64 } from '@documenso/lib/universal/base64';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
@ -98,7 +98,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
||||
const file: File = uploadedFile.file;
|
||||
|
||||
try {
|
||||
const { type, data } = await putFile(file);
|
||||
const { type, data } = await putPdfFile(file);
|
||||
|
||||
const { id: templateDocumentDataId } = await createDocumentData({
|
||||
type,
|
||||
|
||||
@ -6,6 +6,7 @@ import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-c
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||
@ -37,7 +38,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
|
||||
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
|
||||
|
||||
const [document, fields, recipient] = await Promise.all([
|
||||
const [document, fields, recipient, completedFields] = await Promise.all([
|
||||
getDocumentAndSenderByToken({
|
||||
token,
|
||||
userId: user?.id,
|
||||
@ -45,6 +46,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
}).catch(() => null),
|
||||
getFieldsForToken({ token }),
|
||||
getRecipientByToken({ token }).catch(() => null),
|
||||
getCompletedFieldsForToken({ token }),
|
||||
]);
|
||||
|
||||
if (
|
||||
@ -125,7 +127,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
signature={user?.email === recipient.email ? user.signature : undefined}
|
||||
>
|
||||
<DocumentAuthProvider document={document} recipient={recipient} user={user}>
|
||||
<SigningPageView recipient={recipient} document={document} fields={fields} />
|
||||
<SigningPageView
|
||||
recipient={recipient}
|
||||
document={document}
|
||||
fields={fields}
|
||||
completedFields={completedFields}
|
||||
/>
|
||||
</DocumentAuthProvider>
|
||||
</SigningProvider>
|
||||
);
|
||||
|
||||
@ -4,12 +4,14 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import type { CompletedField } from '@documenso/lib/types/fields';
|
||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
|
||||
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
|
||||
import { truncateTitle } from '~/helpers/truncate-title';
|
||||
|
||||
import { DateField } from './date-field';
|
||||
@ -23,9 +25,15 @@ export type SigningPageViewProps = {
|
||||
document: DocumentAndSender;
|
||||
recipient: Recipient;
|
||||
fields: Field[];
|
||||
completedFields: CompletedField[];
|
||||
};
|
||||
|
||||
export const SigningPageView = ({ document, recipient, fields }: SigningPageViewProps) => {
|
||||
export const SigningPageView = ({
|
||||
document,
|
||||
recipient,
|
||||
fields,
|
||||
completedFields,
|
||||
}: SigningPageViewProps) => {
|
||||
const truncatedTitle = truncateTitle(document.title);
|
||||
|
||||
const { documentData, documentMeta } = document;
|
||||
@ -70,6 +78,8 @@ export const SigningPageView = ({ document, recipient, fields }: SigningPageView
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DocumentReadOnlyFields fields={completedFields} />
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{fields.map((field) =>
|
||||
match(field.type)
|
||||
|
||||
@ -5,7 +5,7 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
export default function SignatureDisclosure() {
|
||||
return (
|
||||
<div>
|
||||
<article className="prose">
|
||||
<article className="prose dark:prose-invert">
|
||||
<h1>Electronic Signature Disclosure</h1>
|
||||
|
||||
<h2>Welcome</h2>
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { DocumentStatus, Recipient } from '@documenso/prisma/client';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||
|
||||
import { AvatarWithRecipient } from './avatar-with-recipient';
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
@ -25,11 +23,6 @@ export const StackAvatarsWithTooltip = ({
|
||||
position,
|
||||
children,
|
||||
}: StackAvatarsWithTooltipProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const isControlled = useRef(false);
|
||||
const isMouseOverTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const waitingRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) === 'waiting',
|
||||
);
|
||||
@ -46,117 +39,74 @@ export const StackAvatarsWithTooltip = ({
|
||||
(recipient) => getRecipientType(recipient) === 'unsigned',
|
||||
);
|
||||
|
||||
const onMouseEnter = () => {
|
||||
if (isMouseOverTimeout.current) {
|
||||
clearTimeout(isMouseOverTimeout.current);
|
||||
}
|
||||
|
||||
if (isControlled.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
isMouseOverTimeout.current = setTimeout(() => {
|
||||
setOpen((o) => (!o ? true : o));
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
if (isMouseOverTimeout.current) {
|
||||
clearTimeout(isMouseOverTimeout.current);
|
||||
}
|
||||
|
||||
if (isControlled.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setOpen((o) => (o ? false : o));
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const onOpenChange = (newOpen: boolean) => {
|
||||
isControlled.current = newOpen;
|
||||
|
||||
setOpen(newOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger
|
||||
className="flex cursor-pointer"
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{children || <StackAvatars recipients={recipients} />}
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
side={position}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className="flex flex-col gap-y-5 py-2"
|
||||
>
|
||||
{completedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">Completed</h1>
|
||||
{completedRecipients.map((recipient: Recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<div className="">
|
||||
<p className="text-muted-foreground text-sm">{recipient.email}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
||||
</p>
|
||||
</div>
|
||||
<PopoverHover
|
||||
trigger={children || <StackAvatars recipients={recipients} />}
|
||||
contentProps={{
|
||||
className: 'flex flex-col gap-y-5 py-2',
|
||||
side: position,
|
||||
}}
|
||||
>
|
||||
{completedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">Completed</h1>
|
||||
{completedRecipients.map((recipient: Recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<div className="">
|
||||
<p className="text-muted-foreground text-sm">{recipient.email}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{waitingRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">Waiting</h1>
|
||||
{waitingRecipients.map((recipient: Recipient) => (
|
||||
<AvatarWithRecipient
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
documentStatus={documentStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{waitingRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">Waiting</h1>
|
||||
{waitingRecipients.map((recipient: Recipient) => (
|
||||
<AvatarWithRecipient
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
documentStatus={documentStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">Opened</h1>
|
||||
{openedRecipients.map((recipient: Recipient) => (
|
||||
<AvatarWithRecipient
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
documentStatus={documentStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{openedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">Opened</h1>
|
||||
{openedRecipients.map((recipient: Recipient) => (
|
||||
<AvatarWithRecipient
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
documentStatus={documentStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uncompletedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">Uncompleted</h1>
|
||||
{uncompletedRecipients.map((recipient: Recipient) => (
|
||||
<AvatarWithRecipient
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
documentStatus={documentStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{uncompletedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">Uncompleted</h1>
|
||||
{uncompletedRecipients.map((recipient: Recipient) => (
|
||||
<AvatarWithRecipient
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
documentStatus={documentStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PopoverHover>
|
||||
);
|
||||
};
|
||||
|
||||
112
apps/web/src/components/document/document-read-only-fields.tsx
Normal file
112
apps/web/src/components/document/document-read-only-fields.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import {
|
||||
DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
convertToLocalSystemFormat,
|
||||
} from '@documenso/lib/constants/date-formats';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import type { CompletedField } from '@documenso/lib/types/fields';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { DocumentMeta } from '@documenso/prisma/client';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||
|
||||
export type DocumentReadOnlyFieldsProps = {
|
||||
fields: CompletedField[];
|
||||
documentMeta?: DocumentMeta;
|
||||
};
|
||||
|
||||
export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnlyFieldsProps) => {
|
||||
const [hiddenFieldIds, setHiddenFieldIds] = useState<Record<string, boolean>>({});
|
||||
|
||||
const handleHideField = (fieldId: string) => {
|
||||
setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true }));
|
||||
};
|
||||
|
||||
return (
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{fields.map(
|
||||
(field) =>
|
||||
!hiddenFieldIds[field.secondaryId] && (
|
||||
<FieldRootContainer
|
||||
field={field}
|
||||
key={field.id}
|
||||
cardClassName="border-gray-100/50 !shadow-none backdrop-blur-[1px] bg-background/90"
|
||||
>
|
||||
<div className="absolute -right-3 -top-3">
|
||||
<PopoverHover
|
||||
trigger={
|
||||
<Avatar className="dark:border-border h-8 w-8 border-2 border-solid border-gray-200/50 transition-colors hover:border-gray-200">
|
||||
<AvatarFallback className="bg-neutral-50 text-xs text-gray-400">
|
||||
{extractInitials(field.Recipient.name || field.Recipient.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
}
|
||||
contentProps={{
|
||||
className: 'flex w-fit flex-col py-2.5 text-sm',
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
<span className="font-semibold">
|
||||
{field.Recipient.name
|
||||
? `${field.Recipient.name} (${field.Recipient.email})`
|
||||
: field.Recipient.email}{' '}
|
||||
</span>
|
||||
inserted a {FRIENDLY_FIELD_TYPE[field.type].toLowerCase()}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-2.5 h-6 text-xs focus:outline-none focus-visible:ring-0"
|
||||
onClick={() => handleHideField(field.secondaryId)}
|
||||
>
|
||||
Hide field
|
||||
</Button>
|
||||
</PopoverHover>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground break-all text-sm">
|
||||
{match(field)
|
||||
.with({ type: FieldType.SIGNATURE }, (field) =>
|
||||
field.Signature?.signatureImageAsBase64 ? (
|
||||
<img
|
||||
src={field.Signature.signatureImageAsBase64}
|
||||
alt="Signature"
|
||||
className="h-full w-full object-contain dark:invert"
|
||||
/>
|
||||
) : (
|
||||
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
|
||||
{field.Signature?.typedSignature}
|
||||
</p>
|
||||
),
|
||||
)
|
||||
.with(
|
||||
{ type: P.union(FieldType.NAME, FieldType.TEXT, FieldType.EMAIL) },
|
||||
() => field.customText,
|
||||
)
|
||||
.with({ type: FieldType.DATE }, () =>
|
||||
convertToLocalSystemFormat(
|
||||
field.customText,
|
||||
documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
|
||||
),
|
||||
)
|
||||
.with({ type: FieldType.FREE_SIGNATURE }, () => null)
|
||||
.exhaustive()}
|
||||
</div>
|
||||
</FieldRootContainer>
|
||||
),
|
||||
)}
|
||||
</ElementVisible>
|
||||
);
|
||||
};
|
||||
@ -1,12 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
|
||||
import {
|
||||
browserSupportsWebAuthn,
|
||||
browserSupportsWebAuthnAutofill,
|
||||
startAuthentication,
|
||||
} from '@simplewebauthn/browser';
|
||||
import { KeyRoundIcon } from 'lucide-react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@ -123,7 +127,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
||||
setTwoFactorAuthenticationMethod(method);
|
||||
};
|
||||
|
||||
const onSignInWithPasskey = async () => {
|
||||
const onSignInWithPasskey = async (autofill: boolean = false) => {
|
||||
if (!browserSupportsWebAuthn()) {
|
||||
toast({
|
||||
title: 'Not supported',
|
||||
@ -136,11 +140,11 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
||||
}
|
||||
|
||||
try {
|
||||
setIsPasskeyLoading(true);
|
||||
!autofill && setIsPasskeyLoading(true);
|
||||
|
||||
const options = await createPasskeySigninOptions();
|
||||
|
||||
const credential = await startAuthentication(options);
|
||||
const credential = await startAuthentication(options, autofill);
|
||||
|
||||
const result = await signIn('webauthn', {
|
||||
credential: JSON.stringify(credential),
|
||||
@ -256,6 +260,22 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void signInWithPasskeyAutofill();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const signInWithPasskeyAutofill = async () => {
|
||||
const autoFillSupported = await browserSupportsWebAuthnAutofill();
|
||||
if (autoFillSupported) {
|
||||
try {
|
||||
await onSignInWithPasskey(true);
|
||||
} catch (error) {
|
||||
console.error('Error during Passkey autofill attempt:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
@ -274,7 +294,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
||||
<FormLabel>Email</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
<Input type="email" {...field} autoComplete="email webauthn" />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
@ -290,7 +310,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
||||
<FormLabel>Password</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<PasswordInput {...field} />
|
||||
<PasswordInput {...field} autoComplete="password webauthn" />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
@ -346,7 +366,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
||||
disabled={isSubmitting}
|
||||
loading={isPasskeyLoading}
|
||||
className="bg-background text-muted-foreground border"
|
||||
onClick={onSignInWithPasskey}
|
||||
onClick={async () => await onSignInWithPasskey()}
|
||||
>
|
||||
{!isPasskeyLoading && <KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />}
|
||||
Passkey
|
||||
|
||||
@ -2,6 +2,8 @@ import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import NextAuth from 'next-auth';
|
||||
|
||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
||||
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@ -18,15 +20,29 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
||||
error: '/signin',
|
||||
},
|
||||
events: {
|
||||
signIn: async ({ user }) => {
|
||||
await prisma.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
type: UserSecurityAuditLogType.SIGN_IN,
|
||||
},
|
||||
});
|
||||
signIn: async ({ user: { id: userId } }) => {
|
||||
const [user] = await Promise.all([
|
||||
await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
}),
|
||||
await prisma.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
type: UserSecurityAuditLogType.SIGN_IN,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Create the Stripe customer and attach it to the user if it doesn't exist.
|
||||
if (user.customerId === null && IS_BILLING_ENABLED()) {
|
||||
await getStripeCustomerByUser(user).catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
},
|
||||
signOut: async ({ token }) => {
|
||||
const userId = typeof token.id === 'string' ? parseInt(token.id) : token.id;
|
||||
|
||||
@ -3,7 +3,7 @@ import { createTrpcContext } from '@documenso/trpc/server/context';
|
||||
import { appRouter } from '@documenso/trpc/server/router';
|
||||
|
||||
export const config = {
|
||||
maxDuration: 60,
|
||||
maxDuration: 120,
|
||||
api: {
|
||||
bodyParser: {
|
||||
sizeLimit: '50mb',
|
||||
|
||||
28
package-lock.json
generated
28
package-lock.json
generated
@ -22,7 +22,7 @@
|
||||
"eslint-config-custom": "*",
|
||||
"husky": "^9.0.11",
|
||||
"lint-staged": "^15.2.2",
|
||||
"playwright": "^1.43.0",
|
||||
"playwright": "1.43.0",
|
||||
"prettier": "^2.5.1",
|
||||
"rimraf": "^5.0.1",
|
||||
"turbo": "^1.9.3"
|
||||
@ -4716,12 +4716,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.0.tgz",
|
||||
"integrity": "sha512-PdW+kn4eV99iP5gxWNSDQCbhMaDVej+RXL5xr6t04nbKLCBwYtA046t7ofoczHOm8u6c+45hpDKQVZqtqwkeQg==",
|
||||
"version": "1.43.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.1.tgz",
|
||||
"integrity": "sha512-HgtQzFgNEEo4TE22K/X7sYTYNqEMMTZmFS8kTq6m8hXj+m1D8TgwgIbumHddJa9h4yl4GkKb8/bgAl2+g7eDgA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.40.0"
|
||||
"playwright": "1.43.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@ -4745,12 +4745,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test/node_modules/playwright": {
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz",
|
||||
"integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==",
|
||||
"version": "1.43.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.1.tgz",
|
||||
"integrity": "sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.40.0"
|
||||
"playwright-core": "1.43.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@ -4763,9 +4763,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test/node_modules/playwright-core": {
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz",
|
||||
"integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==",
|
||||
"version": "1.43.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.1.tgz",
|
||||
"integrity": "sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
@ -24981,7 +24981,7 @@
|
||||
"next-auth": "4.24.5",
|
||||
"oslo": "^0.17.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"playwright": "^1.43.0",
|
||||
"playwright": "1.43.0",
|
||||
"react": "18.2.0",
|
||||
"remeda": "^1.27.1",
|
||||
"stripe": "^12.7.0",
|
||||
@ -24989,7 +24989,7 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/browser-chromium": "^1.43.0",
|
||||
"@playwright/browser-chromium": "1.43.0",
|
||||
"@types/luxon": "^3.3.1"
|
||||
}
|
||||
},
|
||||
|
||||
@ -38,7 +38,7 @@
|
||||
"eslint-config-custom": "*",
|
||||
"husky": "^9.0.11",
|
||||
"lint-staged": "^15.2.2",
|
||||
"playwright": "^1.43.0",
|
||||
"playwright": "1.43.0",
|
||||
"prettier": "^2.5.1",
|
||||
"rimraf": "^5.0.1",
|
||||
"turbo": "^1.9.3"
|
||||
|
||||
@ -22,7 +22,7 @@ import { updateRecipient } from '@documenso/lib/server-only/recipient/update-rec
|
||||
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
|
||||
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import {
|
||||
getPresignGetUrl,
|
||||
getPresignPostUrl,
|
||||
@ -229,6 +229,13 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||
});
|
||||
|
||||
await upsertDocumentMeta({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
...body.meta,
|
||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||
});
|
||||
|
||||
const recipients = await setRecipientsForDocument({
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
@ -296,7 +303,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
formValues: body.formValues,
|
||||
});
|
||||
|
||||
const newDocumentData = await putFile({
|
||||
const newDocumentData = await putPdfFile({
|
||||
name: fileName,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(prefilled),
|
||||
@ -324,10 +331,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
await upsertDocumentMeta({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
subject: body.meta.subject,
|
||||
message: body.meta.message,
|
||||
dateFormat: body.meta.dateFormat,
|
||||
timezone: body.meta.timezone,
|
||||
...body.meta,
|
||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||
});
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import { sealDocument } from '@documenso/lib/server-only/document/seal-document'
|
||||
import { redis } from '@documenso/lib/server-only/redis';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { alphaid, nanoid } from '@documenso/lib/universal/id';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
DocumentStatus,
|
||||
@ -74,7 +74,7 @@ export const onEarlyAdoptersCheckout = async ({ session }: OnEarlyAdoptersChecko
|
||||
new URL('@documenso/assets/documenso-supporter-pledge.pdf', import.meta.url),
|
||||
).then(async (res) => res.arrayBuffer());
|
||||
|
||||
const { id: documentDataId } = await putFile({
|
||||
const { id: documentDataId } = await putPdfFile({
|
||||
name: 'Documenso Supporter Pledge.pdf',
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(documentBuffer),
|
||||
|
||||
@ -21,6 +21,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
|
||||
* Does not take any person or group properties into account.
|
||||
*/
|
||||
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
|
||||
app_allow_encrypted_documents: false,
|
||||
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
|
||||
app_document_page_view_history_sheet: false,
|
||||
app_passkey: WEBAPP_BASE_URL === 'http://localhost:3000', // Temp feature flag.
|
||||
|
||||
@ -39,7 +39,7 @@
|
||||
"next-auth": "4.24.5",
|
||||
"oslo": "^0.17.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"playwright": "^1.43.0",
|
||||
"playwright": "1.43.0",
|
||||
"react": "18.2.0",
|
||||
"remeda": "^1.27.1",
|
||||
"stripe": "^12.7.0",
|
||||
@ -48,6 +48,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@playwright/browser-chromium": "^1.43.0"
|
||||
"@playwright/browser-chromium": "1.43.0"
|
||||
}
|
||||
}
|
||||
@ -137,7 +137,7 @@ export const completeDocumentWithToken = async ({
|
||||
await sendPendingEmail({ documentId, recipientId: recipient.id });
|
||||
}
|
||||
|
||||
const documents = await prisma.document.updateMany({
|
||||
const haveAllRecipientsSigned = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: document.id,
|
||||
Recipient: {
|
||||
@ -146,13 +146,9 @@ export const completeDocumentWithToken = async ({
|
||||
},
|
||||
},
|
||||
},
|
||||
data: {
|
||||
status: DocumentStatus.COMPLETED,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (documents.count > 0) {
|
||||
if (haveAllRecipientsSigned) {
|
||||
await sealDocument({ documentId: document.id, requestMetadata });
|
||||
}
|
||||
|
||||
|
||||
@ -75,18 +75,20 @@ export const deleteDocument = async ({
|
||||
}
|
||||
|
||||
// Continue to hide the document from the user if they are a recipient.
|
||||
// Dirty way of doing this but it's faster than refetching the document.
|
||||
if (userRecipient?.documentDeletedAt === null) {
|
||||
await prisma.recipient.update({
|
||||
where: {
|
||||
documentId_email: {
|
||||
documentId: document.id,
|
||||
email: user.email,
|
||||
await prisma.recipient
|
||||
.update({
|
||||
where: {
|
||||
id: userRecipient.id,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
documentDeletedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
data: {
|
||||
documentDeletedAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
// Do nothing.
|
||||
});
|
||||
}
|
||||
|
||||
// Return partial document for API v1 response.
|
||||
|
||||
@ -110,7 +110,7 @@ export const resendDocument = async ({
|
||||
assetBaseUrl,
|
||||
signDocumentLink,
|
||||
customBody: renderCustomEmailTemplate(
|
||||
selfSigner ? selfSignerCustomEmail : customEmail?.message || '',
|
||||
selfSigner && !customEmail?.message ? selfSignerCustomEmail : customEmail?.message || '',
|
||||
customEmailTemplate,
|
||||
),
|
||||
role: recipient.role,
|
||||
@ -135,7 +135,7 @@ export const resendDocument = async ({
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: customEmail?.subject
|
||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||
? renderCustomEmailTemplate(`Reminder: ${customEmail.subject}`, customEmailTemplate)
|
||||
: emailSubject,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
|
||||
@ -14,7 +14,7 @@ import { signPdf } from '@documenso/signing';
|
||||
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
import { putFile } from '../../universal/upload/put-file';
|
||||
import { putPdfFile } from '../../universal/upload/put-file';
|
||||
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
|
||||
import { flattenAnnotations } from '../pdf/flatten-annotations';
|
||||
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
||||
@ -40,6 +40,11 @@ export const sealDocument = async ({
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
Recipient: {
|
||||
every: {
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
@ -53,10 +58,6 @@ export const sealDocument = async ({
|
||||
throw new Error(`Document ${document.id} has no document data`);
|
||||
}
|
||||
|
||||
if (document.status !== DocumentStatus.COMPLETED) {
|
||||
throw new Error(`Document ${document.id} has not been completed`);
|
||||
}
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
@ -92,9 +93,9 @@ export const sealDocument = async ({
|
||||
// !: Need to write the fields onto the document as a hard copy
|
||||
const pdfData = await getFile(documentData);
|
||||
|
||||
const certificate = await getCertificatePdf({ documentId }).then(async (doc) =>
|
||||
PDFDocument.load(doc),
|
||||
);
|
||||
const certificate = await getCertificatePdf({ documentId })
|
||||
.then(async (doc) => PDFDocument.load(doc))
|
||||
.catch(() => null);
|
||||
|
||||
const doc = await PDFDocument.load(pdfData);
|
||||
|
||||
@ -103,11 +104,13 @@ export const sealDocument = async ({
|
||||
doc.getForm().flatten();
|
||||
flattenAnnotations(doc);
|
||||
|
||||
const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices());
|
||||
if (certificate) {
|
||||
const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices());
|
||||
|
||||
certificatePages.forEach((page) => {
|
||||
doc.addPage(page);
|
||||
});
|
||||
certificatePages.forEach((page) => {
|
||||
doc.addPage(page);
|
||||
});
|
||||
}
|
||||
|
||||
for (const field of fields) {
|
||||
await insertFieldInPDF(doc, field);
|
||||
@ -119,7 +122,7 @@ export const sealDocument = async ({
|
||||
|
||||
const { name, ext } = path.parse(document.title);
|
||||
|
||||
const { data: newData } = await putFile({
|
||||
const { data: newData } = await putPdfFile({
|
||||
name: `${name}_signed${ext}`,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(pdfBuffer),
|
||||
@ -138,6 +141,16 @@ export const sealDocument = async ({
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.document.update({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
data: {
|
||||
status: DocumentStatus.COMPLETED,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentData.update({
|
||||
where: {
|
||||
id: documentData.id,
|
||||
|
||||
@ -4,8 +4,11 @@ import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||
import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
|
||||
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@ -18,7 +21,6 @@ import {
|
||||
RECIPIENT_ROLE_TO_EMAIL_TYPE,
|
||||
} from '../../constants/recipient-roles';
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
import { putFile } from '../../universal/upload/put-file';
|
||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
@ -100,7 +102,7 @@ export const sendDocument = async ({
|
||||
formValues: document.formValues as Record<string, string | number | boolean>,
|
||||
});
|
||||
|
||||
const newDocumentData = await putFile({
|
||||
const newDocumentData = await putPdfFile({
|
||||
name: document.title,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(prefilled),
|
||||
@ -149,7 +151,7 @@ export const sendDocument = async ({
|
||||
assetBaseUrl,
|
||||
signDocumentLink,
|
||||
customBody: renderCustomEmailTemplate(
|
||||
selfSigner ? selfSignerCustomEmail : customEmail?.message || '',
|
||||
selfSigner && !customEmail?.message ? selfSignerCustomEmail : customEmail?.message || '',
|
||||
customEmailTemplate,
|
||||
),
|
||||
role: recipient.role,
|
||||
@ -211,6 +213,31 @@ export const sendDocument = async ({
|
||||
}),
|
||||
);
|
||||
|
||||
const allRecipientsHaveNoActionToTake = document.Recipient.every(
|
||||
(recipient) => recipient.role === RecipientRole.CC,
|
||||
);
|
||||
|
||||
if (allRecipientsHaveNoActionToTake) {
|
||||
const updatedDocument = await updateDocument({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
data: { status: DocumentStatus.COMPLETED },
|
||||
});
|
||||
|
||||
await sealDocument({ documentId: updatedDocument.id, requestMetadata });
|
||||
|
||||
// Keep the return type the same for the `sendDocument` method
|
||||
return await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updatedDocument = await prisma.$transaction(async (tx) => {
|
||||
if (document.status === DocumentStatus.DRAFT) {
|
||||
await tx.documentAuditLog.create({
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type GetCompletedFieldsForDocumentOptions = {
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const getCompletedFieldsForDocument = async ({
|
||||
documentId,
|
||||
}: GetCompletedFieldsForDocumentOptions) => {
|
||||
return await prisma.field.findMany({
|
||||
where: {
|
||||
documentId,
|
||||
Recipient: {
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
},
|
||||
inserted: true,
|
||||
},
|
||||
include: {
|
||||
Signature: true,
|
||||
Recipient: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type GetCompletedFieldsForTokenOptions = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const getCompletedFieldsForToken = async ({ token }: GetCompletedFieldsForTokenOptions) => {
|
||||
return await prisma.field.findMany({
|
||||
where: {
|
||||
Document: {
|
||||
Recipient: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
Recipient: {
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
},
|
||||
inserted: true,
|
||||
},
|
||||
include: {
|
||||
Signature: true,
|
||||
Recipient: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -18,7 +18,9 @@ export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions
|
||||
let browser: Browser;
|
||||
|
||||
if (process.env.NEXT_PRIVATE_BROWSERLESS_URL) {
|
||||
browser = await chromium.connect(process.env.NEXT_PRIVATE_BROWSERLESS_URL);
|
||||
// !: Use CDP rather than the default `connect` method to avoid coupling to the playwright version.
|
||||
// !: Previously we would have to keep the playwright version in sync with the browserless version to avoid errors.
|
||||
browser = await chromium.connectOverCDP(process.env.NEXT_PRIVATE_BROWSERLESS_URL);
|
||||
} else {
|
||||
browser = await chromium.launch();
|
||||
}
|
||||
@ -33,6 +35,7 @@ export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`, {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
const result = await page.pdf({
|
||||
|
||||
@ -89,6 +89,10 @@ export const createDocumentFromTemplate = async ({
|
||||
|
||||
const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email);
|
||||
|
||||
if (!documentRecipient) {
|
||||
throw new Error('Recipient not found.');
|
||||
}
|
||||
|
||||
return {
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
@ -99,7 +103,7 @@ export const createDocumentFromTemplate = async ({
|
||||
customText: field.customText,
|
||||
inserted: field.inserted,
|
||||
documentId: document.id,
|
||||
recipientId: documentRecipient?.id || null,
|
||||
recipientId: documentRecipient.id,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@ -81,6 +81,10 @@ export const duplicateTemplate = async ({
|
||||
(doc) => doc.email === recipient?.email,
|
||||
);
|
||||
|
||||
if (!duplicatedTemplateRecipient) {
|
||||
throw new Error('Recipient not found.');
|
||||
}
|
||||
|
||||
return {
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
@ -91,7 +95,7 @@ export const duplicateTemplate = async ({
|
||||
customText: field.customText,
|
||||
inserted: field.inserted,
|
||||
templateId: duplicatedTemplate.id,
|
||||
recipientId: duplicatedTemplateRecipient?.id || null,
|
||||
recipientId: duplicatedTemplateRecipient.id,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@ -4,6 +4,7 @@ export const getWebhooksByUserId = async (userId: number) => {
|
||||
return await prisma.webhook.findMany({
|
||||
where: {
|
||||
userId,
|
||||
teamId: null,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
|
||||
3
packages/lib/types/fields.ts
Normal file
3
packages/lib/types/fields.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { getCompletedFieldsForToken } from '../server-only/field/get-completed-fields-for-token';
|
||||
|
||||
export type CompletedField = Awaited<ReturnType<typeof getCompletedFieldsForToken>>[number];
|
||||
@ -17,6 +17,7 @@ export const getFlag = async (
|
||||
options?: GetFlagOptions,
|
||||
): Promise<TFeatureFlagValue> => {
|
||||
const requestHeaders = options?.requestHeaders ?? {};
|
||||
delete requestHeaders['content-length'];
|
||||
|
||||
if (!isFeatureFlagEnabled()) {
|
||||
return LOCAL_FEATURE_FLAGS[flag] ?? true;
|
||||
@ -25,7 +26,7 @@ export const getFlag = async (
|
||||
const url = new URL(`${APP_BASE_URL()}/api/feature-flag/get`);
|
||||
url.searchParams.set('flag', flag);
|
||||
|
||||
const response = await fetch(url, {
|
||||
return await fetch(url, {
|
||||
headers: {
|
||||
...requestHeaders,
|
||||
},
|
||||
@ -35,9 +36,10 @@ export const getFlag = async (
|
||||
})
|
||||
.then(async (res) => res.json())
|
||||
.then((res) => ZFeatureFlagValueSchema.parse(res))
|
||||
.catch(() => false);
|
||||
|
||||
return response;
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
return LOCAL_FEATURE_FLAGS[flag] ?? false;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@ -50,6 +52,7 @@ export const getAllFlags = async (
|
||||
options?: GetFlagOptions,
|
||||
): Promise<Record<string, TFeatureFlagValue>> => {
|
||||
const requestHeaders = options?.requestHeaders ?? {};
|
||||
delete requestHeaders['content-length'];
|
||||
|
||||
if (!isFeatureFlagEnabled()) {
|
||||
return LOCAL_FEATURE_FLAGS;
|
||||
@ -67,7 +70,10 @@ export const getAllFlags = async (
|
||||
})
|
||||
.then(async (res) => res.json())
|
||||
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
|
||||
.catch(() => LOCAL_FEATURE_FLAGS);
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
return LOCAL_FEATURE_FLAGS;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@ -89,7 +95,10 @@ export const getAllAnonymousFlags = async (): Promise<Record<string, TFeatureFla
|
||||
})
|
||||
.then(async (res) => res.json())
|
||||
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
|
||||
.catch(() => LOCAL_FEATURE_FLAGS);
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
return LOCAL_FEATURE_FLAGS;
|
||||
});
|
||||
};
|
||||
|
||||
interface GetFlagOptions {
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import { base64 } from '@scure/base';
|
||||
import { env } from 'next-runtime-env';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
|
||||
import { DocumentDataType } from '@documenso/prisma/client';
|
||||
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import { createDocumentData } from '../../server-only/document-data/create-document-data';
|
||||
|
||||
type File = {
|
||||
@ -12,14 +15,38 @@ type File = {
|
||||
arrayBuffer: () => Promise<ArrayBuffer>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Uploads a document file to the appropriate storage location and creates
|
||||
* a document data record.
|
||||
*/
|
||||
export const putPdfFile = async (file: File) => {
|
||||
const isEncryptedDocumentsAllowed = await getFlag('app_allow_encrypted_documents').catch(
|
||||
() => false,
|
||||
);
|
||||
|
||||
// This will prevent uploading encrypted PDFs or anything that can't be opened.
|
||||
if (!isEncryptedDocumentsAllowed) {
|
||||
await PDFDocument.load(await file.arrayBuffer()).catch((e) => {
|
||||
console.error(`PDF upload parse error: ${e.message}`);
|
||||
|
||||
throw new AppError('INVALID_DOCUMENT_FILE');
|
||||
});
|
||||
}
|
||||
|
||||
const { type, data } = await putFile(file);
|
||||
|
||||
return await createDocumentData({ type, data });
|
||||
};
|
||||
|
||||
/**
|
||||
* Uploads a file to the appropriate storage location.
|
||||
*/
|
||||
export const putFile = async (file: File) => {
|
||||
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
|
||||
|
||||
const { type, data } = await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
|
||||
return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
|
||||
.with('s3', async () => putFileInS3(file))
|
||||
.otherwise(async () => putFileInDatabase(file));
|
||||
|
||||
return await createDocumentData({ type, data });
|
||||
};
|
||||
|
||||
const putFileInDatabase = async (file: File) => {
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Made the column `recipientId` on table `Field` required. This step will fail if there are existing NULL values in that column.
|
||||
|
||||
*/
|
||||
-- Drop all Fields where the recipientId is null
|
||||
DELETE FROM "Field" WHERE "recipientId" IS NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Field" ALTER COLUMN "recipientId" SET NOT NULL;
|
||||
@ -0,0 +1,23 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "PasswordResetToken" DROP CONSTRAINT "PasswordResetToken_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Signature" DROP CONSTRAINT "Signature_fieldId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Team" DROP CONSTRAINT "Team_ownerUserId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TeamMember" DROP CONSTRAINT "TeamMember_userId_fkey";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Signature" ADD CONSTRAINT "Signature_fieldId_fkey" FOREIGN KEY ("fieldId") REFERENCES "Field"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Team" ADD CONSTRAINT "Team_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -98,7 +98,7 @@ model PasswordResetToken {
|
||||
createdAt DateTime @default(now())
|
||||
expiry DateTime
|
||||
userId Int
|
||||
User User @relation(fields: [userId], references: [id])
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Passkey {
|
||||
@ -387,7 +387,7 @@ model Field {
|
||||
secondaryId String @unique @default(cuid())
|
||||
documentId Int?
|
||||
templateId Int?
|
||||
recipientId Int?
|
||||
recipientId Int
|
||||
type FieldType
|
||||
page Int
|
||||
positionX Decimal @default(0)
|
||||
@ -398,7 +398,7 @@ model Field {
|
||||
inserted Boolean
|
||||
Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
||||
Recipient Recipient? @relation(fields: [recipientId], references: [id], onDelete: Cascade)
|
||||
Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
|
||||
Signature Signature?
|
||||
|
||||
@@index([documentId])
|
||||
@ -415,7 +415,7 @@ model Signature {
|
||||
typedSignature String?
|
||||
|
||||
Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
|
||||
Field Field @relation(fields: [fieldId], references: [id], onDelete: Restrict)
|
||||
Field Field @relation(fields: [fieldId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([recipientId])
|
||||
}
|
||||
@ -457,7 +457,7 @@ model Team {
|
||||
emailVerification TeamEmailVerification?
|
||||
transferVerification TeamTransferVerification?
|
||||
|
||||
owner User @relation(fields: [ownerUserId], references: [id])
|
||||
owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
|
||||
subscription Subscription?
|
||||
|
||||
document Document[]
|
||||
@ -483,7 +483,7 @@ model TeamMember {
|
||||
createdAt DateTime @default(now())
|
||||
role TeamMemberRole
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, teamId])
|
||||
@ -564,5 +564,5 @@ model SiteSettings {
|
||||
data Json
|
||||
lastModifiedByUserId Int?
|
||||
lastModifiedAt DateTime @default(now())
|
||||
lastModifiedByUser User? @relation(fields: [lastModifiedByUserId], references: [id])
|
||||
lastModifiedByUser User? @relation(fields: [lastModifiedByUserId], references: [id], onDelete: SetNull)
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import { DateTime } from 'luxon';
|
||||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
|
||||
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
||||
import { createDocument } from '@documenso/lib/server-only/document/create-document';
|
||||
@ -20,6 +21,7 @@ import { updateDocumentSettings } from '@documenso/lib/server-only/document/upda
|
||||
import { updateTitle } from '@documenso/lib/server-only/document/update-title';
|
||||
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
|
||||
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import {
|
||||
@ -413,6 +415,10 @@ export const documentRouter = router({
|
||||
teamId,
|
||||
});
|
||||
|
||||
if (document.status !== DocumentStatus.COMPLETED) {
|
||||
throw new AppError('DOCUMENT_NOT_COMPLETE');
|
||||
}
|
||||
|
||||
const encrypted = encryptSecondaryData({
|
||||
data: document.id.toString(),
|
||||
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
|
||||
|
||||
@ -10,7 +10,7 @@ import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/cons
|
||||
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
|
||||
import { alphaid } from '@documenso/lib/universal/id';
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
DocumentStatus,
|
||||
@ -86,7 +86,7 @@ export const singleplayerRouter = router({
|
||||
},
|
||||
});
|
||||
|
||||
const { id: documentDataId } = await putFile({
|
||||
const { id: documentDataId } = await putPdfFile({
|
||||
name: `${documentName}.pdf`,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(signedPdfBuffer),
|
||||
|
||||
@ -19,6 +19,7 @@ export type FieldContainerPortalProps = {
|
||||
field: Field;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
cardClassName?: string;
|
||||
};
|
||||
|
||||
export function FieldContainerPortal({
|
||||
@ -44,7 +45,7 @@ export function FieldContainerPortal({
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldRootContainer({ field, children }: FieldContainerPortalProps) {
|
||||
export function FieldRootContainer({ field, children, cardClassName }: FieldContainerPortalProps) {
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
@ -78,6 +79,7 @@ export function FieldRootContainer({ field, children }: FieldContainerPortalProp
|
||||
{
|
||||
'border-orange-300 ring-1 ring-orange-300': !field.inserted && isValidating,
|
||||
},
|
||||
cardClassName,
|
||||
)}
|
||||
ref={ref}
|
||||
data-inserted={field.inserted ? 'true' : 'false'}
|
||||
|
||||
@ -9,7 +9,11 @@ import { useForm } from 'react-hook-form';
|
||||
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import { DocumentAccessAuth, DocumentActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import {
|
||||
DocumentAccessAuth,
|
||||
DocumentActionAuth,
|
||||
DocumentAuth,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { DocumentStatus, type Field, type Recipient, SendStatus } from '@documenso/prisma/client';
|
||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
@ -216,9 +220,9 @@ export const AddSettingsFormPartial = ({
|
||||
</p>
|
||||
|
||||
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
|
||||
<li>
|
||||
{/* <li>
|
||||
<strong>Require account</strong> - The recipient must be signed in
|
||||
</li>
|
||||
</li> */}
|
||||
<li>
|
||||
<strong>Require passkey</strong> - The recipient must have an account
|
||||
and passkey configured via their settings
|
||||
@ -242,11 +246,13 @@ export const AddSettingsFormPartial = ({
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent position="popper">
|
||||
{Object.values(DocumentActionAuth).map((authType) => (
|
||||
<SelectItem key={authType} value={authType}>
|
||||
{DOCUMENT_AUTH_TYPES[authType].value}
|
||||
</SelectItem>
|
||||
))}
|
||||
{Object.values(DocumentActionAuth)
|
||||
.filter((auth) => auth !== DocumentAuth.ACCOUNT)
|
||||
.map((authType) => (
|
||||
<SelectItem key={authType} value={authType}>
|
||||
{DOCUMENT_AUTH_TYPES[authType].value}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
{/* Note: -1 is remapped in the Zod schema to the required value. */}
|
||||
<SelectItem value={'-1'}>None</SelectItem>
|
||||
|
||||
@ -302,10 +302,10 @@ export const AddSignersFormPartial = ({
|
||||
global action signing authentication method configured in
|
||||
the "General Settings" step
|
||||
</li>
|
||||
<li>
|
||||
{/* <li>
|
||||
<strong>Require account</strong> - The recipient must be
|
||||
signed in
|
||||
</li>
|
||||
</li> */}
|
||||
<li>
|
||||
<strong>Require passkey</strong> - The recipient must have
|
||||
an account and passkey configured via their settings
|
||||
@ -326,11 +326,13 @@ export const AddSignersFormPartial = ({
|
||||
{/* Note: -1 is remapped in the Zod schema to the required value. */}
|
||||
<SelectItem value="-1">Inherit authentication method</SelectItem>
|
||||
|
||||
{Object.values(RecipientActionAuth).map((authType) => (
|
||||
<SelectItem key={authType} value={authType}>
|
||||
{DOCUMENT_AUTH_TYPES[authType].value}
|
||||
</SelectItem>
|
||||
))}
|
||||
{Object.values(RecipientActionAuth)
|
||||
.filter((auth) => auth !== RecipientActionAuth.ACCOUNT)
|
||||
.map((authType) => (
|
||||
<SelectItem key={authType} value={authType}>
|
||||
{DOCUMENT_AUTH_TYPES[authType].value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
@ -30,4 +30,66 @@ const PopoverContent = React.forwardRef<
|
||||
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent };
|
||||
type PopoverHoverProps = {
|
||||
trigger: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
contentProps?: React.ComponentPropsWithoutRef<typeof PopoverContent>;
|
||||
};
|
||||
|
||||
const PopoverHover = ({ trigger, children, contentProps }: PopoverHoverProps) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const isControlled = React.useRef(false);
|
||||
const isMouseOver = React.useRef<boolean>(false);
|
||||
|
||||
const onMouseEnter = () => {
|
||||
isMouseOver.current = true;
|
||||
|
||||
if (isControlled.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
isMouseOver.current = false;
|
||||
|
||||
if (isControlled.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setOpen(isMouseOver.current);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const onOpenChange = (newOpen: boolean) => {
|
||||
isControlled.current = newOpen;
|
||||
|
||||
setOpen(newOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger
|
||||
className="flex cursor-pointer outline-none"
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{trigger}
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
side="top"
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
{...contentProps}
|
||||
>
|
||||
{children}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverHover };
|
||||
|
||||
@ -282,6 +282,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
disabled={
|
||||
isSubmitting || getValues('signers').some((signer) => signer.email === user?.email)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user