Compare commits

...

9 Commits

Author SHA1 Message Date
64c6a51e04 v1.12.0-rc.2 2025-06-07 02:25:14 +10:00
d1eddb02c4 fix: add missing awaits for font normalization 2025-06-07 02:24:59 +10:00
60a623fafd v1.12.0-rc.1 2025-06-07 00:56:40 +10:00
6059b79a8e fix: type error 2025-06-07 00:56:33 +10:00
c73d61955b v1.12.0-rc.0 2025-06-07 00:47:49 +10:00
7c3ca72359 fix: track uninserted fields for multisign 2025-06-07 00:44:41 +10:00
55c8632620 feat: password reauthentication for documents and recipients (#1827)
Adds password reauthentication to our existing reauth providers,
additionally swaps from an exclusive provider to an inclusive type where
multiple methods can be selected to offer a this or that experience.
2025-06-07 00:27:19 +10:00
ce66da0055 feat: multisign embedding (#1823)
Adds the ability to use a multisign embedding for cases where multiple
documents need to be signed in a convenient manner.
2025-06-05 12:58:52 +10:00
695ed418e2 fix: documents failing to seal (#1821)
During our field rework that makes fields appear
more accurately between signing and the completed pdf we swapped to
using text fields. Unfortunately as part of that we dropped using the
Noto font for the text field causing ANSI encoding issues when
encountering certain characters.

This change restores the font and handles a nasty issue we had with our
form flattening reverting our selected font.
2025-06-04 23:29:36 +10:00
84 changed files with 2299 additions and 533 deletions

View File

@ -332,7 +332,7 @@ export const EmbedDirectTemplateClientPage = ({
{/* Widget */}
<div
key={isExpanded ? 'expanded' : 'collapsed'}
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:bottom-[unset] md:top-4 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined}
>
<div className="border-border bg-widget flex h-fit w-full flex-col rounded-xl border px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6">

View File

@ -290,7 +290,7 @@ export const EmbedSignDocumentClientPage = ({
{/* Widget */}
<div
key={isExpanded ? 'expanded' : 'collapsed'}
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:bottom-[unset] md:top-4 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined}
>
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">

View File

@ -0,0 +1,182 @@
import { Trans } from '@lingui/react/macro';
import { ReadStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { ArrowRight, EyeIcon, XCircle } from 'lucide-react';
import { match } from 'ts-pattern';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Progress } from '@documenso/ui/primitives/progress';
// Get the return type from getRecipientByToken
type RecipientWithFields = Awaited<ReturnType<typeof getRecipientByToken>>;
interface DocumentEnvelope {
document: DocumentAndSender;
recipient: RecipientWithFields;
}
interface MultiSignDocumentListProps {
envelopes: DocumentEnvelope[];
onDocumentSelect: (document: DocumentEnvelope['document']) => void;
}
export function MultiSignDocumentList({ envelopes, onDocumentSelect }: MultiSignDocumentListProps) {
// Calculate progress
const completedDocuments = envelopes.filter(
(envelope) => envelope.recipient.signingStatus === SigningStatus.SIGNED,
);
const totalDocuments = envelopes.length;
const progressPercentage = (completedDocuments.length / totalDocuments) * 100;
// Find next document to sign (first one that's not signed and not rejected)
const nextDocumentToSign = envelopes.find(
(envelope) =>
envelope.recipient.signingStatus !== SigningStatus.SIGNED &&
envelope.recipient.signingStatus !== SigningStatus.REJECTED,
);
const allDocumentsCompleted = completedDocuments.length === totalDocuments;
const hasAssistantOrCcRecipient = envelopes.some(
(envelope) =>
envelope.recipient.role === RecipientRole.ASSISTANT ||
envelope.recipient.role === RecipientRole.CC,
);
function handleView(doc: DocumentEnvelope['document']) {
onDocumentSelect(doc);
}
function handleNextDocument() {
if (nextDocumentToSign) {
onDocumentSelect(nextDocumentToSign.document);
}
}
if (hasAssistantOrCcRecipient) {
return (
<div className="mx-auto mt-16 flex w-full max-w-lg flex-col md:mt-16 md:rounded-2xl md:border md:px-8 md:py-16 md:shadow-lg">
<div className="flex items-center justify-center">
<XCircle className="text-destructive h-16 w-16 md:h-24 md:w-24" strokeWidth={1.2} />
</div>
<h2 className="mt-12 text-xl font-bold md:text-2xl">
<Trans>It looks like we ran into an issue!</Trans>
</h2>
<p className="text-muted-foreground mt-6">
<Trans>
One of the documents in the current bundle has a signing role that is not compatible
with the current signing experience.
</Trans>
</p>
<p className="text-muted-foreground mt-2">
<Trans>
Assistants and Copy roles are currently not compatible with the multi-sign experience.
</Trans>
</p>
<p className="text-muted-foreground mt-2">
<Trans>Please contact the site owner for further assistance.</Trans>
</p>
</div>
);
}
return (
<div className="bg-background mx-auto w-full max-w-lg md:my-12 md:rounded-2xl md:border md:p-8 md:shadow-lg">
<h2 className="text-foreground mb-1 text-lg font-semibold">
<Trans>Sign Documents</Trans>
</h2>
<p className="text-muted-foreground text-sm">
<Trans>
You have been requested to sign the following documents. Review each document carefully
and complete the signing process.
</Trans>
</p>
{/* Progress Section */}
<div className="mt-6">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground font-medium">
<Trans>Progress</Trans>
</span>
<span className="text-muted-foreground">
{completedDocuments.length} of {totalDocuments} completed
</span>
</div>
<div className="mt-4">
<Progress value={progressPercentage} className="h-2" />
</div>
</div>
<div className="mt-6 flex flex-col gap-4">
{envelopes.map((envelope) => (
<div
key={envelope.document.id}
className="border-border flex items-center gap-4 rounded-lg border px-4 py-2"
>
<span className="text-foreground flex-1 truncate text-sm font-medium">
{envelope.document.title}
</span>
{match(envelope.recipient)
.with({ signingStatus: SigningStatus.SIGNED }, () => (
<Badge size="small" variant="default">
<Trans>Completed</Trans>
</Badge>
))
.with({ signingStatus: SigningStatus.REJECTED }, () => (
<Badge size="small" variant="destructive">
<Trans>Rejected</Trans>
</Badge>
))
.with({ readStatus: ReadStatus.OPENED }, () => (
<Badge size="small" variant="neutral">
<Trans>Viewed</Trans>
</Badge>
))
.otherwise(() => null)}
<Button
className="-mr-2"
variant="outline"
size="sm"
onClick={() => handleView(envelope.document)}
>
<EyeIcon className="mr-1 h-4 w-4" />
<Trans>View</Trans>
</Button>
</div>
))}
</div>
{/* Next Document Button */}
{!allDocumentsCompleted && nextDocumentToSign && (
<div className="mt-6">
<Button onClick={handleNextDocument} className="w-full" size="lg">
<Trans>View next document</Trans>
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
)}
{allDocumentsCompleted && (
<Alert className="mt-6 text-center">
<AlertTitle>
<Trans>All documents have been completed!</Trans>
</AlertTitle>
<AlertDescription>
<Trans>Thank you for completing the signing process.</Trans>
</AlertDescription>
</Alert>
)}
</div>
);
}

View File

@ -0,0 +1,396 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, SigningStatus } from '@prisma/client';
import { Loader, LucideChevronDown, LucideChevronUp, X } from 'lucide-react';
import { P, match } from 'ts-pattern';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import PDFViewer from '@documenso/ui/primitives/pdf-viewer';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider';
import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog';
import { EmbedDocumentFields } from '../embed-document-fields';
interface MultiSignDocumentSigningViewProps {
token: string;
recipientId: number;
onBack: () => void;
onDocumentCompleted?: (data: { token: string; documentId: number; recipientId: number }) => void;
onDocumentRejected?: (data: {
token: string;
documentId: number;
recipientId: number;
reason: string;
}) => void;
onDocumentError?: () => void;
onDocumentReady?: () => void;
isNameLocked?: boolean;
allowDocumentRejection?: boolean;
}
export const MultiSignDocumentSigningView = ({
token,
recipientId,
onBack,
onDocumentCompleted,
onDocumentRejected,
onDocumentError,
onDocumentReady,
isNameLocked = false,
allowDocumentRejection = false,
}: MultiSignDocumentSigningViewProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { fullName, email, signature, setFullName, setSignature } =
useRequiredDocumentSigningContext();
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const { data: document, isLoading } = trpc.embeddingPresign.getMultiSignDocument.useQuery(
{ token },
{
staleTime: 0,
},
);
const { mutateAsync: signFieldWithToken } = trpc.field.signFieldWithToken.useMutation();
const { mutateAsync: removeSignedFieldWithToken } =
trpc.field.removeSignedFieldWithToken.useMutation();
const { mutateAsync: completeDocumentWithToken } =
trpc.recipient.completeDocumentWithToken.useMutation();
const hasSignatureField = document?.fields.some((field) => field.type === FieldType.SIGNATURE);
const [pendingFields, completedFields] = [
document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ??
[],
document?.fields.filter((field) => field.recipient.signingStatus === SigningStatus.SIGNED) ??
[],
];
const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? [];
const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => {
try {
await signFieldWithToken(payload);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
variant: 'destructive',
});
}
};
const onUnsignField = async (payload: TRemovedSignedFieldWithTokenMutationSchema) => {
try {
await removeSignedFieldWithToken(payload);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
console.error(err);
}
};
const onDocumentComplete = async () => {
try {
setIsSubmitting(true);
await completeDocumentWithToken({
documentId: document!.id,
token,
});
onBack();
onDocumentCompleted?.({
token,
documentId: document!.id,
recipientId,
});
} catch (err) {
onDocumentError?.();
toast({
title: 'Error',
description: 'Failed to complete the document. Please try again.',
variant: 'destructive',
});
} finally {
setIsSubmitting(false);
}
};
const onNextFieldClick = () => {
setShowPendingFieldTooltip(true);
setIsExpanded(false);
};
const onRejected = (reason: string) => {
if (onDocumentRejected && document) {
onDocumentRejected({
token,
documentId: document.id,
recipientId,
reason,
});
}
};
return (
<div className="bg-background min-h-screen overflow-hidden">
<div id="document-field-portal-root" className="relative h-full w-full overflow-y-auto p-8">
{match({ isLoading, document })
.with({ isLoading: true }, () => (
<div className="flex min-h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Loader className="text-primary h-8 w-8 animate-spin" />
<p className="text-muted-foreground text-sm">
<Trans>Loading document...</Trans>
</p>
</div>
</div>
))
.with({ isLoading: false, document: undefined }, () => (
<div className="flex min-h-[400px] w-full items-center justify-center">
<p className="text-muted-foreground text-sm">
<Trans>Failed to load document</Trans>
</p>
</div>
))
.with({ document: P.nonNullable }, ({ document }) => (
<>
<div className="mx-auto flex w-full max-w-screen-xl items-baseline justify-between">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-semibold">{document.title}</h1>
</div>
<Button variant="ghost" size="sm" onClick={onBack} className="p-2">
<X className="h-5 w-5" />
</Button>
</div>
{allowDocumentRejection && (
<div className="embed--Actions mb-4 mt-8 flex w-full flex-row-reverse items-baseline justify-between">
<DocumentSigningRejectDialog
document={document}
token={token}
onRejected={onRejected}
/>
</div>
)}
<div className="embed--DocumentContainer relative mx-auto mt-8 flex w-full max-w-screen-xl flex-col gap-x-6 gap-y-12 md:flex-row">
<div
className={cn('embed--DocumentViewer flex-1', {
'md:mx-auto md:max-w-2xl': document.status === DocumentStatus.COMPLETED,
})}
>
<PDFViewer
documentData={document.documentData}
onDocumentLoad={() => {
setHasDocumentLoaded(true);
onDocumentReady?.();
}}
/>
</div>
{/* Widget */}
{document.status !== DocumentStatus.COMPLETED && (
<div
key={isExpanded ? 'expanded' : 'collapsed'}
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:bottom-[unset] md:top-0 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined}
>
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
{/* Header */}
<div className="embed--DocumentWidgetHeader">
<div className="flex items-center justify-between gap-x-2">
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
<Trans>Sign document</Trans>
</h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
{isExpanded ? (
<LucideChevronDown
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(false)}
/>
) : (
<LucideChevronUp
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(true)}
/>
)}
</Button>
</div>
</div>
<div className="embed--DocumentWidgetContent hidden group-data-[expanded]/document-widget:block md:block">
<p className="text-muted-foreground mt-2 text-sm">
<Trans>Sign the document to complete the process.</Trans>
</p>
<hr className="border-border mb-8 mt-4" />
</div>
{/* Form */}
<div className="embed--DocumentWidgetForm -mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
<div className="flex flex-1 flex-col gap-y-4">
{
<>
<div>
<Label htmlFor="full-name">
<Trans>Full Name</Trans>
</Label>
<Input
type="text"
id="full-name"
className="bg-background mt-2"
disabled={isNameLocked}
value={fullName}
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
/>
</div>
<div>
<Label htmlFor="email">
<Trans>Email</Trans>
</Label>
<Input
type="email"
id="email"
className="bg-background mt-2"
value={email}
disabled
/>
</div>
{hasSignatureField && (
<div>
<Label htmlFor="Signature">
<Trans>Signature</Trans>
</Label>
<SignaturePadDialog
className="mt-2"
disabled={isSubmitting}
disableAnimation
value={signature ?? ''}
onChange={(v) => setSignature(v ?? '')}
typedSignatureEnabled={
document.documentMeta?.typedSignatureEnabled
}
uploadSignatureEnabled={
document.documentMeta?.uploadSignatureEnabled
}
drawSignatureEnabled={
document.documentMeta?.drawSignatureEnabled
}
/>
</div>
)}
</>
}
</div>
</div>
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
<div className="embed--DocumentWidgetFooter mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
{uninsertedFields.length > 0 ? (
<Button className="col-start-2" onClick={onNextFieldClick}>
<Trans>Next</Trans>
</Button>
) : (
<Button
className="col-span-2"
loading={isSubmitting}
onClick={onDocumentComplete}
>
<Trans>Complete</Trans>
</Button>
)}
</div>
</div>
</div>
)}
</div>
{hasDocumentLoaded && (
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{showPendingFieldTooltip && pendingFields.length > 0 && (
<FieldToolTip
key={pendingFields[0].id}
field={pendingFields[0]}
color="warning"
>
<Trans>Click to insert field</Trans>
</FieldToolTip>
)}
</ElementVisible>
)}
{/* Fields */}
{hasDocumentLoaded && (
<EmbedDocumentFields
fields={pendingFields}
metadata={document.documentMeta}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
)}
{/* Completed fields */}
{document.status !== DocumentStatus.COMPLETED && (
<DocumentReadOnlyFields
documentMeta={document.documentMeta ?? undefined}
fields={completedFields}
/>
)}
</>
))
.otherwise(() => null)}
</div>
</div>
);
};

View File

@ -130,7 +130,7 @@ export const DirectTemplateConfigureForm = ({
{...field}
disabled={
field.disabled ||
derivedRecipientAccessAuth !== null ||
derivedRecipientAccessAuth.length > 0 ||
user?.email !== undefined
}
placeholder="recipient@documenso.com"

View File

@ -1,5 +1,8 @@
import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client';
import { ChevronLeftIcon } from 'lucide-react';
import { P, match } from 'ts-pattern';
import {
@ -7,6 +10,7 @@ import {
type TRecipientActionAuth,
type TRecipientActionAuthTypes,
} from '@documenso/lib/types/document-auth';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
@ -18,11 +22,12 @@ import {
import { DocumentSigningAuth2FA } from './document-signing-auth-2fa';
import { DocumentSigningAuthAccount } from './document-signing-auth-account';
import { DocumentSigningAuthPasskey } from './document-signing-auth-passkey';
import { DocumentSigningAuthPassword } from './document-signing-auth-password';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
export type DocumentSigningAuthDialogProps = {
title?: string;
documentAuthType: TRecipientActionAuthTypes;
availableAuthTypes: TRecipientActionAuthTypes[];
description?: string;
actionTarget: FieldType | 'DOCUMENT';
open: boolean;
@ -37,54 +42,158 @@ export type DocumentSigningAuthDialogProps = {
export const DocumentSigningAuthDialog = ({
title,
description,
documentAuthType,
availableAuthTypes,
open,
onOpenChange,
onReauthFormSubmit,
}: DocumentSigningAuthDialogProps) => {
const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext();
// Filter out EXPLICIT_NONE from available auth types for the chooser
const validAuthTypes = availableAuthTypes.filter(
(authType) => authType !== DocumentAuth.EXPLICIT_NONE,
);
const [selectedAuthType, setSelectedAuthType] = useState<TRecipientActionAuthTypes | null>(() => {
// Auto-select if there's only one valid option
if (validAuthTypes.length === 1) {
return validAuthTypes[0];
}
// Return null if multiple options - show chooser
return null;
});
const handleOnOpenChange = (value: boolean) => {
if (isCurrentlyAuthenticating) {
return;
}
// Reset selected auth type when dialog closes
if (!value) {
setSelectedAuthType(() => {
if (validAuthTypes.length === 1) {
return validAuthTypes[0];
}
return null;
});
}
onOpenChange(value);
};
const handleBackToChooser = () => {
setSelectedAuthType(null);
};
// If no valid auth types available, don't render anything
if (validAuthTypes.length === 0) {
return null;
}
return (
<Dialog open={open} onOpenChange={handleOnOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title || <Trans>Sign field</Trans>}</DialogTitle>
<DialogTitle>
{selectedAuthType && validAuthTypes.length > 1 && (
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleBackToChooser}
className="h-6 w-6 p-0"
>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<span>{title || <Trans>Sign field</Trans>}</span>
</div>
)}
{(!selectedAuthType || validAuthTypes.length === 1) &&
(title || <Trans>Sign field</Trans>)}
</DialogTitle>
<DialogDescription>
{description || <Trans>Reauthentication is required to sign this field</Trans>}
</DialogDescription>
</DialogHeader>
{match({ documentAuthType, user })
.with(
{ documentAuthType: DocumentAuth.ACCOUNT },
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in.
() => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />,
)
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
<DocumentSigningAuthPasskey
open={open}
onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => (
<DocumentSigningAuth2FA
open={open}
onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
.exhaustive()}
{/* Show chooser if no auth type is selected and there are multiple options */}
{!selectedAuthType && validAuthTypes.length > 1 && (
<div className="space-y-4">
<p className="text-muted-foreground text-sm">
<Trans>Choose your preferred authentication method:</Trans>
</p>
<div className="grid gap-2">
{validAuthTypes.map((authType) => (
<Button
key={authType}
type="button"
variant="outline"
className="h-auto justify-start p-4"
onClick={() => setSelectedAuthType(authType)}
>
<div className="text-left">
<div className="font-medium">
{match(authType)
.with(DocumentAuth.ACCOUNT, () => <Trans>Account</Trans>)
.with(DocumentAuth.PASSKEY, () => <Trans>Passkey</Trans>)
.with(DocumentAuth.TWO_FACTOR_AUTH, () => <Trans>2FA</Trans>)
.with(DocumentAuth.PASSWORD, () => <Trans>Password</Trans>)
.exhaustive()}
</div>
<div className="text-muted-foreground text-sm">
{match(authType)
.with(DocumentAuth.ACCOUNT, () => <Trans>Sign in to your account</Trans>)
.with(DocumentAuth.PASSKEY, () => (
<Trans>Use your passkey for authentication</Trans>
))
.with(DocumentAuth.TWO_FACTOR_AUTH, () => (
<Trans>Enter your 2FA code</Trans>
))
.with(DocumentAuth.PASSWORD, () => <Trans>Enter your password</Trans>)
.exhaustive()}
</div>
</div>
</Button>
))}
</div>
</div>
)}
{/* Show the selected auth component */}
{selectedAuthType &&
match({ documentAuthType: selectedAuthType, user })
.with(
{ documentAuthType: DocumentAuth.ACCOUNT },
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in.
() => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />,
)
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
<DocumentSigningAuthPasskey
open={open}
onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => (
<DocumentSigningAuth2FA
open={open}
onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.PASSWORD }, () => (
<DocumentSigningAuthPassword
open={open}
onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
.exhaustive()}
</DialogContent>
</Dialog>
);

View File

@ -0,0 +1,148 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
export type DocumentSigningAuthPasswordProps = {
actionTarget?: 'FIELD' | 'DOCUMENT';
actionVerb?: string;
open: boolean;
onOpenChange: (value: boolean) => void;
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
};
const ZPasswordAuthFormSchema = z.object({
password: z
.string()
.min(1, { message: 'Password is required' })
.max(72, { message: 'Password must be at most 72 characters long' }),
});
type TPasswordAuthFormSchema = z.infer<typeof ZPasswordAuthFormSchema>;
export const DocumentSigningAuthPassword = ({
actionTarget = 'FIELD',
actionVerb = 'sign',
onReauthFormSubmit,
open,
onOpenChange,
}: DocumentSigningAuthPasswordProps) => {
const { recipient, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
useRequiredDocumentSigningAuthContext();
const form = useForm<TPasswordAuthFormSchema>({
resolver: zodResolver(ZPasswordAuthFormSchema),
defaultValues: {
password: '',
},
});
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
const onFormSubmit = async ({ password }: TPasswordAuthFormSchema) => {
try {
setIsCurrentlyAuthenticating(true);
await onReauthFormSubmit({
type: DocumentAuth.PASSWORD,
password,
});
setIsCurrentlyAuthenticating(false);
onOpenChange(false);
} catch (err) {
setIsCurrentlyAuthenticating(false);
const error = AppError.parseError(err);
setFormErrorCode(error.code);
// Todo: Alert.
}
};
useEffect(() => {
form.reset({
password: '',
});
setFormErrorCode(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isCurrentlyAuthenticating}>
<div className="space-y-4">
{formErrorCode && (
<Alert variant="destructive">
<AlertTitle>
<Trans>Unauthorized</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
We were unable to verify your details. Please try again or contact support
</Trans>
</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Password</Trans>
</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
autoComplete="current-password"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isCurrentlyAuthenticating}>
<Trans>Sign</Trans>
</Button>
</DialogFooter>
</div>
</fieldset>
</form>
</Form>
);
};

View File

@ -1,7 +1,6 @@
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { type Document, FieldType, type Passkey, type Recipient } from '@prisma/client';
import { match } from 'ts-pattern';
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
@ -33,8 +32,8 @@ export type DocumentSigningAuthContextValue = {
recipient: Recipient;
recipientAuthOption: TRecipientAuthOptions;
setRecipient: (_value: Recipient) => void;
derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null;
derivedRecipientActionAuth: TRecipientActionAuthTypes | null;
derivedRecipientAccessAuth: TRecipientAccessAuthTypes[];
derivedRecipientActionAuth: TRecipientActionAuthTypes[];
isAuthRedirectRequired: boolean;
isCurrentlyAuthenticating: boolean;
setIsCurrentlyAuthenticating: (_value: boolean) => void;
@ -100,7 +99,7 @@ export const DocumentSigningAuthProvider = ({
},
{
placeholderData: (previousData) => previousData,
enabled: derivedRecipientActionAuth === DocumentAuth.PASSKEY,
enabled: derivedRecipientActionAuth?.includes(DocumentAuth.PASSKEY) ?? false,
},
);
@ -121,21 +120,28 @@ export const DocumentSigningAuthProvider = ({
* Will be `null` if the user still requires authentication, or if they don't need
* authentication.
*/
const preCalculatedActionAuthOptions = match(derivedRecipientActionAuth)
.with(DocumentAuth.ACCOUNT, () => {
if (recipient.email !== user?.email) {
return null;
}
const preCalculatedActionAuthOptions = useMemo(() => {
if (
!derivedRecipientActionAuth ||
derivedRecipientActionAuth.length === 0 ||
derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE)
) {
return {
type: DocumentAuth.EXPLICIT_NONE,
};
}
if (
derivedRecipientActionAuth.includes(DocumentAuth.ACCOUNT) &&
user?.email == recipient.email
) {
return {
type: DocumentAuth.ACCOUNT,
};
})
.with(DocumentAuth.EXPLICIT_NONE, () => ({
type: DocumentAuth.EXPLICIT_NONE,
}))
.with(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, null, () => null)
.exhaustive();
}
return null;
}, [derivedRecipientActionAuth, user, recipient]);
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
// Directly run callback if no auth required.
@ -170,7 +176,8 @@ export const DocumentSigningAuthProvider = ({
// Assume that a user must be logged in for any auth requirements.
const isAuthRedirectRequired = Boolean(
derivedRecipientActionAuth &&
derivedRecipientActionAuth !== DocumentAuth.EXPLICIT_NONE &&
derivedRecipientActionAuth.length > 0 &&
!derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE) &&
user?.email !== recipient.email,
);
@ -208,7 +215,7 @@ export const DocumentSigningAuthProvider = ({
onOpenChange={() => setDocumentAuthDialogPayload(null)}
onReauthFormSubmit={documentAuthDialogPayload.onReauthFormSubmit}
actionTarget={documentAuthDialogPayload.actionTarget}
documentAuthType={derivedRecipientActionAuth}
availableAuthTypes={derivedRecipientActionAuth}
/>
)}
</DocumentSigningAuthContext.Provider>
@ -217,7 +224,7 @@ export const DocumentSigningAuthProvider = ({
type ExecuteActionAuthProcedureOptions = Omit<
DocumentSigningAuthDialogProps,
'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole'
'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole' | 'availableAuthTypes'
>;
DocumentSigningAuthProvider.displayName = 'DocumentSigningAuthProvider';

View File

@ -44,6 +44,7 @@ const AUTO_SIGNABLE_FIELD_TYPES: string[] = [
// other field types.
const NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES: string[] = [
DocumentAuth.PASSKEY,
DocumentAuth.PASSWORD,
DocumentAuth.TWO_FACTOR_AUTH,
];
@ -96,8 +97,8 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
return true;
});
const actionAuthAllowsAutoSign = !NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES.includes(
derivedRecipientActionAuth ?? '',
const actionAuthAllowsAutoSign = derivedRecipientActionAuth.every(
(actionAuth) => !NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES.includes(actionAuth),
);
const onSubmit = async () => {
@ -110,16 +111,16 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
.with(FieldType.DATE, () => new Date().toISOString())
.otherwise(() => '');
const authOptions = match(derivedRecipientActionAuth)
const authOptions = match(derivedRecipientActionAuth.at(0))
.with(DocumentAuth.ACCOUNT, () => ({
type: DocumentAuth.ACCOUNT,
}))
.with(DocumentAuth.EXPLICIT_NONE, () => ({
type: DocumentAuth.EXPLICIT_NONE,
}))
.with(null, () => undefined)
.with(undefined, () => undefined)
.with(
P.union(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH),
P.union(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth.PASSWORD),
// This is a bit dirty, but the sentinel value used here is incredibly short-lived.
() => 'NOT_SUPPORTED' as const,
)

View File

@ -92,8 +92,6 @@ export const DocumentSigningCompleteDialog = ({
};
const onFormSubmit = async (data: TNextSignerFormSchema) => {
console.log('data', data);
console.log('form.formState.errors', form.formState.errors);
try {
if (allowDictateNextSigner && data.name && data.email) {
await onSignatureComplete({ name: data.name, email: data.email });

View File

@ -183,8 +183,8 @@ export const DocumentEditForm = ({
title: data.title,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: data.globalAccessAuth ?? null,
globalActionAuth: data.globalActionAuth ?? null,
globalAccessAuth: data.globalAccessAuth ?? [],
globalActionAuth: data.globalActionAuth ?? [],
},
meta: {
timezone,
@ -229,7 +229,7 @@ export const DocumentEditForm = ({
recipients: data.signers.map((signer) => ({
...signer,
// Explicitly set to null to indicate we want to remove auth if required.
actionAuth: signer.actionAuth || null,
actionAuth: signer.actionAuth ?? [],
})),
}),
]);

View File

@ -81,11 +81,15 @@ export const DocumentHistorySheet = ({
* @param text The text to format
* @returns The formatted text
*/
const formatGenericText = (text?: string | null) => {
const formatGenericText = (text?: string | string[] | null): string => {
if (!text) {
return '';
}
if (Array.isArray(text)) {
return text.map((t) => formatGenericText(t)).join(', ');
}
return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' ');
};
@ -245,11 +249,19 @@ export const DocumentHistorySheet = ({
values={[
{
key: 'Old',
value: DOCUMENT_AUTH_TYPES[data.from || '']?.value || 'None',
value: Array.isArray(data.from)
? data.from
.map((f) => DOCUMENT_AUTH_TYPES[f]?.value || 'None')
.join(', ')
: DOCUMENT_AUTH_TYPES[data.from || '']?.value || 'None',
},
{
key: 'New',
value: DOCUMENT_AUTH_TYPES[data.to || '']?.value || 'None',
value: Array.isArray(data.to)
? data.to
.map((f) => DOCUMENT_AUTH_TYPES[f]?.value || 'None')
.join(', ')
: DOCUMENT_AUTH_TYPES[data.to || '']?.value || 'None',
},
]}
/>

View File

@ -134,8 +134,8 @@ export const TemplateEditForm = ({
title: data.title,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: data.globalAccessAuth ?? null,
globalActionAuth: data.globalActionAuth ?? null,
globalAccessAuth: data.globalAccessAuth ?? [],
globalActionAuth: data.globalActionAuth ?? [],
},
meta: {
...data.meta,

View File

@ -3,6 +3,7 @@ import { useLingui } from '@lingui/react';
import { FieldType, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { redirect } from 'react-router';
import { prop, sortBy } from 'remeda';
import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
import { renderSVG } from 'uqr';
@ -133,18 +134,30 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
recipientAuth: recipient.authOptions,
});
let authLevel = match(extractedAuthMethods.derivedRecipientActionAuth)
const insertedAuditLogsWithFieldAuth = sortBy(
auditLogs.DOCUMENT_FIELD_INSERTED.filter(
(log) => log.data.recipientId === recipient.id && log.data.fieldSecurity,
),
[prop('createdAt'), 'desc'],
);
const actionAuthMethod = insertedAuditLogsWithFieldAuth.at(0)?.data?.fieldSecurity?.type;
let authLevel = match(actionAuthMethod)
.with('ACCOUNT', () => _(msg`Account Re-Authentication`))
.with('TWO_FACTOR_AUTH', () => _(msg`Two-Factor Re-Authentication`))
.with('PASSWORD', () => _(msg`Password Re-Authentication`))
.with('PASSKEY', () => _(msg`Passkey Re-Authentication`))
.with('EXPLICIT_NONE', () => _(msg`Email`))
.with(null, () => null)
.with(undefined, () => null)
.exhaustive();
if (!authLevel) {
authLevel = match(extractedAuthMethods.derivedRecipientAccessAuth)
const accessAuthMethod = extractedAuthMethods.derivedRecipientAccessAuth.at(0);
authLevel = match(accessAuthMethod)
.with('ACCOUNT', () => _(msg`Account Authentication`))
.with(null, () => _(msg`Email`))
.with(undefined, () => _(msg`Email`))
.exhaustive();
}

View File

@ -47,9 +47,9 @@ export async function loader({ params, request }: Route.LoaderArgs) {
});
// Ensure typesafety when we add more options.
const isAccessAuthValid = match(derivedRecipientAccessAuth)
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
.with(DocumentAccessAuth.ACCOUNT, () => Boolean(session.user))
.with(null, () => true)
.with(undefined, () => true)
.exhaustive();
if (!isAccessAuthValid) {

View File

@ -68,9 +68,9 @@ export async function loader({ params, request }: Route.LoaderArgs) {
}),
]);
const isAccessAuthValid = match(derivedRecipientAccessAuth)
.with(DocumentAccessAuth.ACCOUNT, () => user !== null)
.with(null, () => true)
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
.with(DocumentAccessAuth.ACCOUNT, () => !!user)
.with(undefined, () => true)
.exhaustive();
if (!isAccessAuthValid) {

View File

@ -81,9 +81,9 @@ export async function loader({ params, request }: Route.LoaderArgs) {
documentAuth: document.authOptions,
});
const isAccessAuthValid = match(derivedRecipientAccessAuth)
.with(DocumentAccessAuth.ACCOUNT, () => user !== null)
.with(null, () => true)
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
.with(undefined, () => true)
.exhaustive();
if (!isAccessAuthValid) {

View File

@ -0,0 +1,325 @@
import { useEffect, useLayoutEffect, useState } from 'react';
import { SigningStatus } from '@prisma/client';
import { useRevalidator } from 'react-router';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { isCommunityPlan as isUserCommunityPlan } from '@documenso/ee/server-only/util/is-community-plan';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { BrandingLogo } from '~/components/general/branding-logo';
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
import { DocumentSigningRecipientProvider } from '~/components/general/document-signing/document-signing-recipient-provider';
import { ZSignDocumentEmbedDataSchema } from '~/types/embed-document-sign-schema';
import { injectCss } from '~/utils/css-vars';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import { MultiSignDocumentList } from '../../../../components/embed/multisign/multi-sign-document-list';
import { MultiSignDocumentSigningView } from '../../../../components/embed/multisign/multi-sign-document-signing-view';
import type { Route } from './+types/_index';
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getOptionalSession(request);
const url = new URL(request.url);
const tokens = url.searchParams.getAll('token');
const envelopes = await Promise.all(
tokens.map(async (token) => {
const document = await getDocumentAndSenderByToken({
token,
});
const recipient = await getRecipientByToken({ token });
return { document, recipient };
}),
);
// Check the first envelope for whitelabelling settings (assuming all docs are from same team)
const firstDocument = envelopes[0]?.document;
if (!firstDocument) {
return superLoaderJson({
envelopes,
user,
hidePoweredBy: false,
allowWhitelabelling: false,
});
}
const team = firstDocument.teamId
? await getTeamById({ teamId: firstDocument.teamId, userId: firstDocument.userId }).catch(
() => null,
)
: null;
const [isPlatformDocument, isEnterpriseDocument, isCommunityPlan] = await Promise.all([
isDocumentPlatform(firstDocument),
isUserEnterprise({
userId: firstDocument.userId,
teamId: firstDocument.teamId ?? undefined,
}),
isUserCommunityPlan({
userId: firstDocument.userId,
teamId: firstDocument.teamId ?? undefined,
}),
]);
const hidePoweredBy = team?.teamGlobalSettings?.brandingHidePoweredBy ?? false;
const allowWhitelabelling = isCommunityPlan || isPlatformDocument || isEnterpriseDocument;
return superLoaderJson({
envelopes,
user,
hidePoweredBy,
allowWhitelabelling,
});
}
export default function MultisignPage() {
const { envelopes, user, hidePoweredBy, allowWhitelabelling } =
useSuperLoaderData<typeof loader>();
const revalidator = useRevalidator();
const [selectedDocument, setSelectedDocument] = useState<
(typeof envelopes)[number]['document'] | null
>(null);
// Additional state for embed functionality
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [isNameLocked, setIsNameLocked] = useState(false);
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
const [showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
useState(false);
const [embedFullName, setEmbedFullName] = useState('');
// Check if all documents are completed
const isCompleted = envelopes.every(
(envelope) => envelope.recipient.signingStatus === SigningStatus.SIGNED,
);
const selectedRecipient = selectedDocument
? envelopes.find((e) => e.document.id === selectedDocument.id)?.recipient
: null;
const onSelectDocument = (document: (typeof envelopes)[number]['document']) => {
setSelectedDocument(document);
};
const onBackToDocumentList = () => {
setSelectedDocument(null);
// Revalidate to fetch fresh data when returning to document list
void revalidator.revalidate();
};
const onDocumentCompleted = (data: {
token: string;
documentId: number;
recipientId: number;
}) => {
// Send postMessage for individual document completion
if (window.parent) {
window.parent.postMessage(
{
action: 'document-completed',
data: {
token: data.token,
documentId: data.documentId,
recipientId: data.recipientId,
},
},
'*',
);
}
};
const onDocumentRejected = (data: {
token: string;
documentId: number;
recipientId: number;
reason: string;
}) => {
// Send postMessage for document rejection
if (window.parent) {
window.parent.postMessage(
{
action: 'document-rejected',
data: {
token: data.token,
documentId: data.documentId,
recipientId: data.recipientId,
reason: data.reason,
},
},
'*',
);
}
};
const onDocumentError = () => {
// Send postMessage for document error
if (window.parent) {
window.parent.postMessage(
{
action: 'document-error',
data: null,
},
'*',
);
}
};
const onDocumentReady = () => {
// Send postMessage when document is ready
if (window.parent) {
window.parent.postMessage(
{
action: 'document-ready',
data: null,
},
'*',
);
}
};
const onAllDocumentsCompleted = () => {
// Send postMessage for all documents completion
if (window.parent) {
window.parent.postMessage(
{
action: 'all-documents-completed',
data: {
documents: envelopes.map((envelope) => ({
token: envelope.recipient.token,
documentId: envelope.document.id,
recipientId: envelope.recipient.id,
action:
envelope.recipient.signingStatus === SigningStatus.SIGNED
? 'document-completed'
: 'document-rejected',
reason:
envelope.recipient.signingStatus === SigningStatus.REJECTED
? envelope.recipient.rejectionReason
: undefined,
})),
},
},
'*',
);
}
};
useEffect(() => {
if (
envelopes.every((envelope) => envelope.recipient.signingStatus !== SigningStatus.NOT_SIGNED)
) {
onAllDocumentsCompleted();
}
}, [envelopes]);
useLayoutEffect(() => {
const hash = window.location.hash.slice(1);
try {
const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
if (!isCompleted && data.name) {
setEmbedFullName(data.name);
}
// Since a recipient can be provided a name we can lock it without requiring
// a to be provided by the parent application, unlike direct templates.
setIsNameLocked(!!data.lockName);
setAllowDocumentRejection(!!data.allowDocumentRejection);
setShowOtherRecipientsCompletedFields(!!data.showOtherRecipientsCompletedFields);
if (data.darkModeDisabled) {
document.documentElement.classList.add('dark-mode-disabled');
}
if (allowWhitelabelling) {
injectCss({
css: data.css,
cssVars: data.cssVars,
});
}
} catch (err) {
console.error(err);
}
setHasFinishedInit(true);
// !: While the two setters are stable we still want to ensure we're avoiding
// !: re-renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// If a document is selected, show the signing view
if (selectedDocument && selectedRecipient) {
// Determine the full name to use - prioritize embed data, then user name, then recipient name
const fullNameToUse =
embedFullName ||
(user?.email === selectedRecipient.email ? user?.name : selectedRecipient.name) ||
'';
return (
<div className="p-4">
<DocumentSigningProvider
email={selectedRecipient.email}
fullName={fullNameToUse}
signature={user?.email === selectedRecipient.email ? user?.signature : undefined}
typedSignatureEnabled={selectedDocument.documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={selectedDocument.documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={selectedDocument.documentMeta?.drawSignatureEnabled}
>
<DocumentSigningAuthProvider
documentAuthOptions={selectedDocument.authOptions}
recipient={selectedRecipient}
user={user}
>
<DocumentSigningRecipientProvider recipient={selectedRecipient} targetSigner={null}>
<MultiSignDocumentSigningView
token={selectedRecipient.token}
recipientId={selectedRecipient.id}
onBack={onBackToDocumentList}
onDocumentCompleted={onDocumentCompleted}
onDocumentRejected={onDocumentRejected}
onDocumentError={onDocumentError}
onDocumentReady={onDocumentReady}
isNameLocked={isNameLocked}
/>
</DocumentSigningRecipientProvider>
</DocumentSigningAuthProvider>
</DocumentSigningProvider>
{!hidePoweredBy && (
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
<span>Powered by</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
)}
</div>
);
}
// Otherwise, show the document list
return (
<div className="p-4">
<MultiSignDocumentList envelopes={envelopes} onDocumentSelect={onSelectDocument} />
{!hidePoweredBy && (
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
<span>Powered by</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
)}
</div>
);
}

View File

@ -0,0 +1,17 @@
import { z } from 'zod';
import { ZBaseEmbedDataSchema } from './embed-base-schemas';
export const ZEmbedMultiSignDocumentSchema = ZBaseEmbedDataSchema.extend({
email: z
.union([z.literal(''), z.string().email()])
.optional()
.transform((value) => value || undefined),
lockEmail: z.boolean().optional().default(false),
name: z
.string()
.optional()
.transform((value) => value || undefined),
lockName: z.boolean().optional().default(false),
allowDocumentRejection: z.boolean().optional(),
});

View File

@ -100,5 +100,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "1.11.1"
"version": "1.12.0-rc.2"
}

View File

@ -40,43 +40,6 @@ services:
entrypoint: sh
command: -c 'mkdir -p /data/documenso && minio server /data --console-address ":9001" --address ":9002"'
triggerdotdev:
image: ghcr.io/triggerdotdev/trigger.dev:latest
container_name: triggerdotdev
environment:
- LOGIN_ORIGIN=http://localhost:3030
- APP_ORIGIN=http://localhost:3030
- PORT=3030
- REMIX_APP_PORT=3030
- MAGIC_LINK_SECRET=secret
- SESSION_SECRET=secret
- ENCRYPTION_KEY=deadbeefcafefeed
- DATABASE_URL=postgresql://trigger:password@triggerdotdev_database:5432/trigger
- DIRECT_URL=postgresql://trigger:password@triggerdotdev_database:5432/trigger
- RUNTIME_PLATFORM=docker-compose
ports:
- 3030:3030
depends_on:
- triggerdotdev_database
triggerdotdev_database:
container_name: triggerdotdev_database
image: postgres:15
volumes:
- triggerdotdev_database:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
interval: 10s
timeout: 5s
retries: 5
environment:
- POSTGRES_USER=trigger
- POSTGRES_PASSWORD=password
- POSTGRES_DB=trigger
ports:
- 54321:5432
volumes:
minio:
documenso_database:
triggerdotdev_database:

6
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "1.11.1",
"version": "1.12.0-rc.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "1.11.1",
"version": "1.12.0-rc.2",
"workspaces": [
"apps/*",
"packages/*"
@ -88,7 +88,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "1.11.1",
"version": "1.12.0-rc.2",
"dependencies": {
"@documenso/api": "*",
"@documenso/assets": "*",

View File

@ -1,6 +1,6 @@
{
"private": true,
"version": "1.11.1",
"version": "1.12.0-rc.2",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",
@ -84,4 +84,4 @@
"trigger.dev": {
"endpointId": "documenso-app"
}
}
}

View File

@ -821,7 +821,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
name,
role,
signingOrder,
actionAuth: authOptions?.actionAuth ?? null,
actionAuth: authOptions?.actionAuth ?? [],
},
],
requestMetadata: metadata,
@ -888,7 +888,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
name,
role,
signingOrder,
actionAuth: authOptions?.actionAuth,
actionAuth: authOptions?.actionAuth ?? [],
requestMetadata: metadata.requestMetadata,
}).catch(() => null);

View File

@ -177,8 +177,8 @@ export const ZCreateDocumentMutationSchema = z.object({
.default({}),
authOptions: z
.object({
globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(),
globalActionAuth: ZDocumentActionAuthTypesSchema.optional(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional().default([]),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]),
})
.optional()
.openapi({
@ -237,8 +237,8 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
.optional(),
authOptions: z
.object({
globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(),
globalActionAuth: ZDocumentActionAuthTypesSchema.optional(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional().default([]),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]),
})
.optional(),
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
@ -310,8 +310,8 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
.optional(),
authOptions: z
.object({
globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(),
globalActionAuth: ZDocumentActionAuthTypesSchema.optional(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional().default([]),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]),
})
.optional(),
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
@ -350,7 +350,7 @@ export const ZCreateRecipientMutationSchema = z.object({
signingOrder: z.number().nullish(),
authOptions: z
.object({
actionAuth: ZRecipientActionAuthTypesSchema.optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
})
.optional()
.openapi({

View File

@ -42,8 +42,8 @@ test('[DOCUMENT_AUTH]: should allow or deny access when required', async ({ page
{
createDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: 'ACCOUNT',
globalActionAuth: null,
globalAccessAuth: ['ACCOUNT'],
globalActionAuth: [],
}),
},
},

View File

@ -65,8 +65,8 @@ test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ pa
recipients: [recipientWithAccount],
updateDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: null,
globalActionAuth: 'ACCOUNT',
globalAccessAuth: [],
globalActionAuth: ['ACCOUNT'],
}),
},
});
@ -116,8 +116,8 @@ test.skip('[DOCUMENT_AUTH]: should deny signing document when required for globa
recipients: [recipientWithAccount],
updateDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: null,
globalActionAuth: 'ACCOUNT',
globalAccessAuth: [],
globalActionAuth: ['ACCOUNT'],
}),
},
});
@ -147,8 +147,8 @@ test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth'
recipients: [recipientWithAccount, seedTestEmail()],
updateDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: null,
globalActionAuth: 'ACCOUNT',
globalAccessAuth: [],
globalActionAuth: ['ACCOUNT'],
}),
},
});
@ -193,20 +193,20 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
recipientsCreateOptions: [
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: null,
accessAuth: [],
actionAuth: [],
}),
},
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: 'EXPLICIT_NONE',
accessAuth: [],
actionAuth: ['EXPLICIT_NONE'],
}),
},
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: 'ACCOUNT',
accessAuth: [],
actionAuth: ['ACCOUNT'],
}),
},
],
@ -218,7 +218,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
// This document has no global action auth, so only account should require auth.
const isAuthRequired = actionAuth === 'ACCOUNT';
const isAuthRequired = actionAuth.includes('ACCOUNT');
const signUrl = `/sign/${token}`;
@ -292,28 +292,28 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
recipientsCreateOptions: [
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: null,
accessAuth: [],
actionAuth: [],
}),
},
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: 'EXPLICIT_NONE',
accessAuth: [],
actionAuth: ['EXPLICIT_NONE'],
}),
},
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: 'ACCOUNT',
accessAuth: [],
actionAuth: ['ACCOUNT'],
}),
},
],
fields: [FieldType.DATE, FieldType.SIGNATURE],
updateDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: null,
globalActionAuth: 'ACCOUNT',
globalAccessAuth: [],
globalActionAuth: ['ACCOUNT'],
}),
},
});
@ -323,7 +323,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
// This document HAS global action auth, so account and inherit should require auth.
const isAuthRequired = actionAuth === 'ACCOUNT' || actionAuth === null;
const isAuthRequired = actionAuth.includes('ACCOUNT') || actionAuth.length === 0;
const signUrl = `/sign/${token}`;

View File

@ -40,7 +40,7 @@ test.describe('[EE_ONLY]', () => {
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step.
@ -82,7 +82,7 @@ test.describe('[EE_ONLY]', () => {
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step.
@ -143,7 +143,7 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
// Set access auth.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await page.getByRole('option').filter({ hasText: 'Require account' }).click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Action auth should NOT be visible.

View File

@ -37,7 +37,7 @@ test.describe('[EE_ONLY]', () => {
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step.
@ -79,7 +79,7 @@ test.describe('[EE_ONLY]', () => {
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step.
@ -140,7 +140,7 @@ test('[TEMPLATE_FLOW]: add settings', async ({ page }) => {
// Set access auth.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await page.getByRole('option').filter({ hasText: 'Require account' }).click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Action auth should NOT be visible.

View File

@ -58,8 +58,8 @@ test.describe('[EE_ONLY]', () => {
// Add advanced settings for a single recipient.
await page.getByLabel('Show advanced settings').check();
await page.getByRole('combobox').first().click();
await page.getByLabel('Require passkey').click();
await page.getByTestId('documentActionSelectValue').click();
await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
// Navigate to the next step and back.
await page.getByRole('button', { name: 'Continue' }).click();

View File

@ -48,13 +48,13 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
// Set template document access.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await page.getByRole('option').filter({ hasText: 'Require account' }).click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Set EE action auth.
if (isBillingEnabled) {
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
}
@ -85,8 +85,8 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
// Apply require passkey for Recipient 1.
if (isBillingEnabled) {
await page.getByLabel('Show advanced settings').check();
await page.getByRole('combobox').first().click();
await page.getByLabel('Require passkey').click();
await page.getByTestId('documentActionSelectValue').click();
await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
}
await page.getByRole('button', { name: 'Continue' }).click();
@ -119,10 +119,12 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
});
expect(document.title).toEqual('TEMPLATE_TITLE');
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
isBillingEnabled ? 'PASSKEY' : null,
);
expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT');
if (isBillingEnabled) {
expect(documentAuth.documentAuthOption.globalActionAuth).toContain('PASSKEY');
}
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
expect(document.documentMeta?.message).toEqual('MESSAGE');
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
@ -143,11 +145,11 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
});
if (isBillingEnabled) {
expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
expect(recipientOneAuth.derivedRecipientActionAuth).toContain('PASSKEY');
}
expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
expect(recipientOneAuth.derivedRecipientAccessAuth).toContain('ACCOUNT');
expect(recipientTwoAuth.derivedRecipientAccessAuth).toContain('ACCOUNT');
});
/**
@ -183,13 +185,13 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
// Set template document access.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await page.getByRole('option').filter({ hasText: 'Require account' }).click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Set EE action auth.
if (isBillingEnabled) {
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
}
@ -220,8 +222,8 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
// Apply require passkey for Recipient 1.
if (isBillingEnabled) {
await page.getByLabel('Show advanced settings').check();
await page.getByRole('combobox').first().click();
await page.getByLabel('Require passkey').click();
await page.getByTestId('documentActionSelectValue').click();
await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
}
await page.getByRole('button', { name: 'Continue' }).click();
@ -256,10 +258,12 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
});
expect(document.title).toEqual('TEMPLATE_TITLE');
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
isBillingEnabled ? 'PASSKEY' : null,
);
expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT');
if (isBillingEnabled) {
expect(documentAuth.documentAuthOption.globalActionAuth).toContain('PASSKEY');
}
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
expect(document.documentMeta?.message).toEqual('MESSAGE');
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
@ -280,11 +284,11 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
});
if (isBillingEnabled) {
expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
expect(recipientOneAuth.derivedRecipientActionAuth).toContain('PASSKEY');
}
expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
expect(recipientOneAuth.derivedRecipientAccessAuth).toContain('ACCOUNT');
expect(recipientTwoAuth.derivedRecipientAccessAuth).toContain('ACCOUNT');
});
/**

View File

@ -172,8 +172,8 @@ test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) =>
userId: user.id,
createTemplateOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: 'ACCOUNT',
globalActionAuth: null,
globalAccessAuth: ['ACCOUNT'],
globalActionAuth: [],
}),
},
});

View File

@ -0,0 +1,75 @@
import { useEffect, useState } from 'react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
export const useElementBounds = (elementOrSelector: HTMLElement | string, withScroll = false) => {
const [bounds, setBounds] = useState({
top: 0,
left: 0,
height: 0,
width: 0,
});
const calculateBounds = () => {
const $el =
typeof elementOrSelector === 'string'
? document.querySelector<HTMLElement>(elementOrSelector)
: elementOrSelector;
if (!$el) {
throw new Error('Element not found');
}
if (withScroll) {
return getBoundingClientRect($el);
}
const { top, left, width, height } = $el.getBoundingClientRect();
return {
top,
left,
width,
height,
};
};
useEffect(() => {
setBounds(calculateBounds());
}, [calculateBounds]);
useEffect(() => {
const onResize = () => {
setBounds(calculateBounds());
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, [calculateBounds]);
useEffect(() => {
const $el =
typeof elementOrSelector === 'string'
? document.querySelector<HTMLElement>(elementOrSelector)
: elementOrSelector;
if (!$el) {
return;
}
const observer = new ResizeObserver(() => {
setBounds(calculateBounds());
});
observer.observe($el);
return () => {
observer.disconnect();
};
}, [calculateBounds]);
return bounds;
};

View File

@ -19,6 +19,10 @@ export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = {
key: DocumentAuth.TWO_FACTOR_AUTH,
value: 'Require 2FA',
},
[DocumentAuth.PASSWORD]: {
key: DocumentAuth.PASSWORD,
value: 'Require password',
},
[DocumentAuth.EXPLICIT_NONE]: {
key: DocumentAuth.EXPLICIT_NONE,
value: 'None (Overrides global settings)',

View File

@ -157,7 +157,7 @@ export const run = async ({
// Normalize and flatten layers that could cause issues with the signature
normalizeSignatureAppearances(pdfDoc);
flattenForm(pdfDoc);
await flattenForm(pdfDoc);
flattenAnnotations(pdfDoc);
// Add rejection stamp if the document is rejected
@ -188,7 +188,7 @@ export const run = async ({
// Re-flatten the form to handle our checkbox and radio fields that
// create native arcoFields
flattenForm(pdfDoc);
await flattenForm(pdfDoc);
const pdfBytes = await pdfDoc.save();
const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) });

View File

@ -0,0 +1,20 @@
import { compare } from '@node-rs/bcrypt';
import { prisma } from '@documenso/prisma';
type VerifyPasswordOptions = {
userId: number;
password: string;
};
export const verifyPassword = async ({ userId, password }: VerifyPasswordOptions) => {
const user = await prisma.user.findUnique({
where: { id: userId },
});
if (!user || !user.password) {
return false;
}
return await compare(password, user.password);
};

View File

@ -23,6 +23,7 @@ import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sendPendingEmail } from './send-pending-email';
@ -140,6 +141,11 @@ export const completeDocumentWithToken = async ({
},
});
const authOptions = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
@ -154,6 +160,7 @@ export const completeDocumentWithToken = async ({
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
actionAuth: authOptions.derivedRecipientActionAuth,
},
}),
});

View File

@ -39,8 +39,8 @@ export type CreateDocumentOptions = {
title: string;
externalId?: string;
visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes;
globalActionAuth?: TDocumentActionAuthTypes;
globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[];
formValues?: TDocumentFormValues;
recipients: TCreateDocumentV2Request['recipients'];
};
@ -113,14 +113,16 @@ export const createDocumentV2 = async ({
}
const authOptions = createDocumentAuthOptions({
globalAccessAuth: data?.globalAccessAuth || null,
globalActionAuth: data?.globalActionAuth || null,
globalAccessAuth: data?.globalAccessAuth || [],
globalActionAuth: data?.globalActionAuth || [],
});
const recipientsHaveActionAuth = data.recipients?.some((recipient) => recipient.actionAuth);
const recipientsHaveActionAuth = data.recipients?.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (authOptions.globalActionAuth || recipientsHaveActionAuth) {
if (authOptions.globalActionAuth.length > 0 || recipientsHaveActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
@ -171,8 +173,8 @@ export const createDocumentV2 = async ({
await Promise.all(
(data.recipients || []).map(async (recipient) => {
const recipientAuthOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth || null,
actionAuth: recipient.actionAuth || null,
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
});
await tx.recipient.create({

View File

@ -17,6 +17,7 @@ export const getDocumentCertificateAuditLogs = async ({
in: [
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
],
@ -36,6 +37,9 @@ export const getDocumentCertificateAuditLogs = async ({
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs.filter(
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
),
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED]: auditLogs.filter(
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
),
[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs.filter(
(log) =>
log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT &&

View File

@ -5,6 +5,7 @@ import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token';
import { verifyPassword } from '../2fa/verify-password';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth';
import { DocumentAuth } from '../../types/document-auth';
@ -60,23 +61,26 @@ export const isRecipientAuthorized = async ({
recipientAuth: recipient.authOptions,
});
const authMethod: TDocumentAuth | null =
const authMethods: TDocumentAuth[] =
type === 'ACCESS' ? derivedRecipientAccessAuth : derivedRecipientActionAuth;
// Early true return when auth is not required.
if (!authMethod || authMethod === DocumentAuth.EXPLICIT_NONE) {
if (
authMethods.length === 0 ||
authMethods.some((method) => method === DocumentAuth.EXPLICIT_NONE)
) {
return true;
}
// Create auth options when none are passed for account.
if (!authOptions && authMethod === DocumentAuth.ACCOUNT) {
if (!authOptions && authMethods.some((method) => method === DocumentAuth.ACCOUNT)) {
authOptions = {
type: DocumentAuth.ACCOUNT,
};
}
// Authentication required does not match provided method.
if (!authOptions || authOptions.type !== authMethod || !userId) {
if (!authOptions || !authMethods.includes(authOptions.type) || !userId) {
return false;
}
@ -117,6 +121,15 @@ export const isRecipientAuthorized = async ({
window: 10, // 5 minutes worth of tokens
});
})
.with({ type: DocumentAuth.PASSWORD }, async ({ password }) => {
return await verifyPassword({
userId,
password,
});
})
.with({ type: DocumentAuth.EXPLICIT_NONE }, () => {
return true;
})
.exhaustive();
};
@ -160,7 +173,7 @@ const verifyPasskey = async ({
}: VerifyPasskeyOptions): Promise<void> => {
const passkey = await prisma.passkey.findFirst({
where: {
credentialId: Buffer.from(authenticationResponse.id, 'base64'),
credentialId: new Uint8Array(Buffer.from(authenticationResponse.id, 'base64')),
userId,
},
});

View File

@ -128,7 +128,7 @@ export const sealDocument = async ({
// Normalize and flatten layers that could cause issues with the signature
normalizeSignatureAppearances(doc);
flattenForm(doc);
await flattenForm(doc);
flattenAnnotations(doc);
// Add rejection stamp if the document is rejected
@ -153,7 +153,7 @@ export const sealDocument = async ({
}
// Re-flatten post-insertion to handle fields that create arcoFields
flattenForm(doc);
await flattenForm(doc);
const pdfBytes = await doc.save();

View File

@ -21,8 +21,8 @@ export type UpdateDocumentOptions = {
title?: string;
externalId?: string | null;
visibility?: DocumentVisibility | null;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[];
useLegacyFieldInsertion?: boolean;
};
requestMetadata: ApiRequestMetadata;
@ -119,7 +119,6 @@ export const updateDocument = async ({
// If no data just return the document since this function is normally chained after a meta update.
if (!data || Object.values(data).length === 0) {
console.log('no data');
return document;
}
@ -137,7 +136,7 @@ export const updateDocument = async ({
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
// Check if user has permission to set the global action auth.
if (newGlobalActionAuth) {
if (newGlobalActionAuth && newGlobalActionAuth.length > 0) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,

View File

@ -3,7 +3,6 @@ import { FieldType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TRecipientActionAuth } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { isRecipientAuthorized } from './is-recipient-authorized';
export type ValidateFieldAuthOptions = {
@ -26,14 +25,9 @@ export const validateFieldAuth = async ({
userId,
authOptions,
}: ValidateFieldAuthOptions) => {
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: documentAuthOptions,
recipientAuth: recipient.authOptions,
});
// Override all non-signature fields to not require any auth.
if (field.type !== FieldType.SIGNATURE) {
return null;
return undefined;
}
const isValid = await isRecipientAuthorized({
@ -50,5 +44,5 @@ export const validateFieldAuth = async ({
});
}
return derivedRecipientActionAuth;
return authOptions?.type;
};

View File

@ -15,7 +15,7 @@ import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type ViewedDocumentOptions = {
token: string;
recipientAccessAuth?: TDocumentAccessAuthTypes | null;
recipientAccessAuth?: TDocumentAccessAuthTypes[];
requestMetadata?: RequestMetadata;
};
@ -63,7 +63,7 @@ export const viewedDocument = async ({
recipientId: recipient.id,
recipientName: recipient.name,
recipientRole: recipient.role,
accessAuth: recipientAccessAuth || undefined,
accessAuth: recipientAccessAuth ?? [],
},
}),
});

View File

@ -27,7 +27,6 @@ export const createEmbeddingPresignToken = async ({
// In development mode, allow setting expiresIn to 0 for testing
// In production, enforce a minimum expiration time
const isDevelopment = env('NODE_ENV') !== 'production';
console.log('isDevelopment', isDevelopment);
const minExpirationMinutes = isDevelopment ? 0 : 5;
// Ensure expiresIn is at least the minimum allowed value

View File

@ -1,3 +1,4 @@
import fontkit from '@pdf-lib/fontkit';
import type { PDFField, PDFWidgetAnnotation } from 'pdf-lib';
import {
PDFCheckBox,
@ -13,6 +14,8 @@ import {
translate,
} from 'pdf-lib';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
export const removeOptionalContentGroups = (document: PDFDocument) => {
const context = document.context;
const catalog = context.lookup(context.trailerInfo.Root);
@ -21,12 +24,20 @@ export const removeOptionalContentGroups = (document: PDFDocument) => {
}
};
export const flattenForm = (document: PDFDocument) => {
export const flattenForm = async (document: PDFDocument) => {
removeOptionalContentGroups(document);
const form = document.getForm();
form.updateFieldAppearances();
const fontNoto = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(
async (res) => res.arrayBuffer(),
);
document.registerFontkit(fontkit);
const font = await document.embedFont(fontNoto);
form.updateFieldAppearances(font);
for (const field of form.getFields()) {
for (const widget of field.acroField.getWidgets()) {

View File

@ -1,8 +1,15 @@
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
import fontkit from '@pdf-lib/fontkit';
import { FieldType } from '@prisma/client';
import type { PDFDocument, PDFFont } from 'pdf-lib';
import { RotationTypes, TextAlignment, degrees, radiansToDegrees, rgb } from 'pdf-lib';
import type { PDFDocument, PDFFont, PDFTextField } from 'pdf-lib';
import {
RotationTypes,
TextAlignment,
degrees,
radiansToDegrees,
rgb,
setFontAndSize,
} from 'pdf-lib';
import { P, match } from 'ts-pattern';
import {
@ -442,6 +449,10 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
adjustedFieldY = adjustedPosition.yPos;
}
// Set properties for the text field
setTextFieldFontSize(textField, font, fontSize);
textField.setText(textToInsert);
// Set the position and size of the text field
textField.addToPage(page, {
x: adjustedFieldX,
@ -450,6 +461,8 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
height: adjustedFieldHeight,
rotate: degrees(pageRotationInDegrees),
font,
// Hide borders.
borderWidth: 0,
borderColor: undefined,
@ -457,10 +470,6 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
...(isDebugMode ? { borderWidth: 1, borderColor: rgb(0, 0, 1) } : {}),
});
// Set properties for the text field
textField.setFontSize(fontSize);
textField.setText(textToInsert);
});
return pdf;
@ -629,3 +638,21 @@ function breakLongString(text: string, maxWidth: number, font: PDFFont, fontSize
return lines.join('\n');
}
const setTextFieldFontSize = (textField: PDFTextField, font: PDFFont, fontSize: number) => {
textField.defaultUpdateAppearances(font);
textField.updateAppearances(font);
try {
textField.setFontSize(fontSize);
} catch (err) {
let da = textField.acroField.getDefaultAppearance() ?? '';
da += `\n ${setFontAndSize(font.name, fontSize)}`;
textField.acroField.setDefaultAppearance(da);
}
textField.defaultUpdateAppearances(font);
textField.updateAppearances(font);
};

View File

@ -11,7 +11,7 @@ export const normalizePdf = async (pdf: Buffer) => {
}
removeOptionalContentGroups(pdfDoc);
flattenForm(pdfDoc);
await flattenForm(pdfDoc);
flattenAnnotations(pdfDoc);
return Buffer.from(await pdfDoc.save());

View File

@ -22,8 +22,8 @@ export interface CreateDocumentRecipientsOptions {
name: string;
role: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes | null;
actionAuth?: TRecipientActionAuthTypes | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
}[];
requestMetadata: ApiRequestMetadata;
}
@ -71,7 +71,9 @@ export const createDocumentRecipients = async ({
});
}
const recipientsHaveActionAuth = recipientsToCreate.some((recipient) => recipient.actionAuth);
const recipientsHaveActionAuth = recipientsToCreate.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
@ -110,8 +112,8 @@ export const createDocumentRecipients = async ({
return await Promise.all(
normalizedRecipients.map(async (recipient) => {
const authOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth || null,
actionAuth: recipient.actionAuth || null,
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
});
const createdRecipient = await tx.recipient.create({
@ -140,8 +142,8 @@ export const createDocumentRecipients = async ({
recipientName: createdRecipient.name,
recipientId: createdRecipient.id,
recipientRole: createdRecipient.role,
accessAuth: recipient.accessAuth || undefined,
actionAuth: recipient.actionAuth || undefined,
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
},
}),
});

View File

@ -19,8 +19,8 @@ export interface CreateTemplateRecipientsOptions {
name: string;
role: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes | null;
actionAuth?: TRecipientActionAuthTypes | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
}[];
}
@ -60,7 +60,9 @@ export const createTemplateRecipients = async ({
});
}
const recipientsHaveActionAuth = recipientsToCreate.some((recipient) => recipient.actionAuth);
const recipientsHaveActionAuth = recipientsToCreate.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
@ -99,8 +101,8 @@ export const createTemplateRecipients = async ({
return await Promise.all(
normalizedRecipients.map(async (recipient) => {
const authOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth || null,
actionAuth: recipient.actionAuth || null,
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
});
const createdRecipient = await tx.recipient.create({

View File

@ -4,6 +4,7 @@ import { msg } from '@lingui/core/macro';
import type { Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { mailer } from '@documenso/email/mailer';
@ -96,7 +97,9 @@ export const setDocumentRecipients = async ({
throw new Error('Document already complete');
}
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
@ -245,8 +248,8 @@ export const setDocumentRecipients = async ({
metadata: requestMetadata,
data: {
...baseAuditLog,
accessAuth: recipient.accessAuth || undefined,
actionAuth: recipient.actionAuth || undefined,
accessAuth: recipient.accessAuth || [],
actionAuth: recipient.actionAuth || [],
},
}),
});
@ -361,8 +364,8 @@ type RecipientData = {
name: string;
role: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes | null;
actionAuth?: TRecipientActionAuthTypes | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
};
type RecipientDataWithClientId = Recipient & {
@ -372,15 +375,15 @@ type RecipientDataWithClientId = Recipient & {
const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
const newRecipientAccessAuth = newRecipientData.accessAuth || null;
const newRecipientActionAuth = newRecipientData.actionAuth || null;
const newRecipientAccessAuth = newRecipientData.accessAuth || [];
const newRecipientActionAuth = newRecipientData.actionAuth || [];
return (
recipient.email !== newRecipientData.email ||
recipient.name !== newRecipientData.name ||
recipient.role !== newRecipientData.role ||
recipient.signingOrder !== newRecipientData.signingOrder ||
authOptions.accessAuth !== newRecipientAccessAuth ||
authOptions.actionAuth !== newRecipientActionAuth
!isDeepEqual(authOptions.accessAuth, newRecipientAccessAuth) ||
!isDeepEqual(authOptions.actionAuth, newRecipientActionAuth)
);
};

View File

@ -26,7 +26,7 @@ export type SetTemplateRecipientsOptions = {
name: string;
role: RecipientRole;
signingOrder?: number | null;
actionAuth?: TRecipientActionAuthTypes | null;
actionAuth?: TRecipientActionAuthTypes[];
}[];
};
@ -64,7 +64,9 @@ export const setTemplateRecipients = async ({
throw new Error('Template not found');
}
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {

View File

@ -1,6 +1,7 @@
import type { Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@ -72,7 +73,9 @@ export const updateDocumentRecipients = async ({
});
}
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
@ -218,8 +221,8 @@ type RecipientData = {
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes | null;
actionAuth?: TRecipientActionAuthTypes | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
};
const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => {
@ -233,7 +236,7 @@ const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: Recipie
recipient.name !== newRecipientData.name ||
recipient.role !== newRecipientData.role ||
recipient.signingOrder !== newRecipientData.signingOrder ||
authOptions.accessAuth !== newRecipientAccessAuth ||
authOptions.actionAuth !== newRecipientActionAuth
!isDeepEqual(authOptions.accessAuth, newRecipientAccessAuth) ||
!isDeepEqual(authOptions.actionAuth, newRecipientActionAuth)
);
};

View File

@ -20,7 +20,7 @@ export type UpdateRecipientOptions = {
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
actionAuth?: TRecipientActionAuthTypes | null;
actionAuth?: TRecipientActionAuthTypes[];
userId: number;
teamId?: number;
requestMetadata?: RequestMetadata;
@ -90,7 +90,7 @@ export const updateRecipient = async ({
throw new Error('Recipient not found');
}
if (actionAuth) {
if (actionAuth && actionAuth.length > 0) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
@ -117,7 +117,7 @@ export const updateRecipient = async ({
signingOrder,
authOptions: createRecipientAuthOptions({
accessAuth: recipientAuthOptions.accessAuth,
actionAuth: actionAuth ?? null,
actionAuth: actionAuth ?? [],
}),
},
});

View File

@ -22,8 +22,8 @@ export interface UpdateTemplateRecipientsOptions {
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes | null;
actionAuth?: TRecipientActionAuthTypes | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
}[];
}
@ -63,7 +63,9 @@ export const updateTemplateRecipients = async ({
});
}
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {

View File

@ -70,7 +70,7 @@ export type CreateDocumentFromDirectTemplateOptions = {
type CreatedDirectRecipientField = {
field: Field & { signature?: Signature | null };
derivedRecipientActionAuth: TRecipientActionAuthTypes | null;
derivedRecipientActionAuth?: TRecipientActionAuthTypes;
};
export const ZCreateDocumentFromDirectTemplateResponseSchema = z.object({
@ -151,9 +151,9 @@ export const createDocumentFromDirectTemplate = async ({
const directRecipientName = user?.name || initialDirectRecipientName;
// Ensure typesafety when we add more options.
const isAccessAuthValid = match(derivedRecipientAccessAuth)
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
.with(DocumentAccessAuth.ACCOUNT, () => user && user?.email === directRecipientEmail)
.with(null, () => true)
.with(undefined, () => true)
.exhaustive();
if (!isAccessAuthValid) {
@ -460,7 +460,7 @@ export const createDocumentFromDirectTemplate = async ({
const createdDirectRecipientFields: CreatedDirectRecipientField[] = [
...createdDirectRecipient.fields.map((field) => ({
field,
derivedRecipientActionAuth: null,
derivedRecipientActionAuth: undefined,
})),
...createdDirectRecipientSignatureFields,
];
@ -567,6 +567,7 @@ export const createDocumentFromDirectTemplate = async ({
recipientId: createdDirectRecipient.id,
recipientName: createdDirectRecipient.name,
recipientRole: createdDirectRecipient.role,
actionAuth: createdDirectRecipient.authOptions?.actionAuth ?? [],
},
}),
];

View File

@ -15,8 +15,8 @@ export type UpdateTemplateOptions = {
title?: string;
externalId?: string | null;
visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[];
publicTitle?: string;
publicDescription?: string;
type?: Template['type'];
@ -74,7 +74,7 @@ export const updateTemplate = async ({
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
// Check if user has permission to set the global action auth.
if (newGlobalActionAuth) {
if (newGlobalActionAuth && newGlobalActionAuth.length > 0) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,

View File

@ -123,8 +123,8 @@ export const ZDocumentAuditLogFieldDiffSchema = z.union([
]);
export const ZGenericFromToSchema = z.object({
from: z.string().nullable(),
to: z.string().nullable(),
from: z.union([z.string(), z.array(z.string())]).nullable(),
to: z.union([z.string(), z.array(z.string())]).nullable(),
});
export const ZRecipientDiffActionAuthSchema = ZGenericFromToSchema.extend({
@ -296,7 +296,7 @@ export const ZDocumentAuditLogEventDocumentFieldInsertedSchema = z.object({
},
z
.object({
type: ZRecipientActionAuthTypesSchema,
type: ZRecipientActionAuthTypesSchema.optional(),
})
.optional(),
),
@ -384,7 +384,7 @@ export const ZDocumentAuditLogEventDocumentFieldPrefilledSchema = z.object({
},
z
.object({
type: ZRecipientActionAuthTypesSchema,
type: ZRecipientActionAuthTypesSchema.optional(),
})
.optional(),
),
@ -428,7 +428,13 @@ export const ZDocumentAuditLogEventDocumentMetaUpdatedSchema = z.object({
export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED),
data: ZBaseRecipientDataSchema.extend({
accessAuth: z.string().optional(),
accessAuth: z.preprocess((unknownValue) => {
if (!unknownValue) {
return [];
}
return Array.isArray(unknownValue) ? unknownValue : [unknownValue];
}, z.array(ZRecipientAccessAuthTypesSchema)),
}),
});
@ -438,7 +444,13 @@ export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({
export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED),
data: ZBaseRecipientDataSchema.extend({
actionAuth: z.string().optional(),
actionAuth: z.preprocess((unknownValue) => {
if (!unknownValue) {
return [];
}
return Array.isArray(unknownValue) ? unknownValue : [unknownValue];
}, z.array(ZRecipientActionAuthTypesSchema)),
}),
});
@ -516,8 +528,20 @@ export const ZDocumentAuditLogEventFieldUpdatedSchema = z.object({
export const ZDocumentAuditLogEventRecipientAddedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED),
data: ZBaseRecipientDataSchema.extend({
accessAuth: ZRecipientAccessAuthTypesSchema.optional(),
actionAuth: ZRecipientActionAuthTypesSchema.optional(),
accessAuth: z.preprocess((unknownValue) => {
if (!unknownValue) {
return [];
}
return Array.isArray(unknownValue) ? unknownValue : [unknownValue];
}, z.array(ZRecipientAccessAuthTypesSchema)),
actionAuth: z.preprocess((unknownValue) => {
if (!unknownValue) {
return [];
}
return Array.isArray(unknownValue) ? unknownValue : [unknownValue];
}, z.array(ZRecipientActionAuthTypesSchema)),
}),
});

View File

@ -9,8 +9,10 @@ export const ZDocumentAuthTypesSchema = z.enum([
'ACCOUNT',
'PASSKEY',
'TWO_FACTOR_AUTH',
'PASSWORD',
'EXPLICIT_NONE',
]);
export const DocumentAuth = ZDocumentAuthTypesSchema.Enum;
const ZDocumentAuthAccountSchema = z.object({
@ -27,6 +29,11 @@ const ZDocumentAuthPasskeySchema = z.object({
tokenReference: z.string().min(1),
});
const ZDocumentAuthPasswordSchema = z.object({
type: z.literal(DocumentAuth.PASSWORD),
password: z.string().min(1),
});
const ZDocumentAuth2FASchema = z.object({
type: z.literal(DocumentAuth.TWO_FACTOR_AUTH),
token: z.string().min(4).max(10),
@ -40,6 +47,7 @@ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
ZDocumentAuthExplicitNoneSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
ZDocumentAuthPasswordSchema,
]);
/**
@ -61,9 +69,15 @@ export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
ZDocumentAuthPasswordSchema,
]);
export const ZDocumentActionAuthTypesSchema = z
.enum([DocumentAuth.ACCOUNT, DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH])
.enum([
DocumentAuth.ACCOUNT,
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
DocumentAuth.PASSWORD,
])
.describe(
'The type of authentication required for the recipient to sign the document. This field is restricted to Enterprise plan users only.',
);
@ -89,6 +103,7 @@ export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
ZDocumentAuthPasswordSchema,
ZDocumentAuthExplicitNoneSchema,
]);
export const ZRecipientActionAuthTypesSchema = z
@ -96,6 +111,7 @@ export const ZRecipientActionAuthTypesSchema = z
DocumentAuth.ACCOUNT,
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
DocumentAuth.PASSWORD,
DocumentAuth.EXPLICIT_NONE,
])
.describe('The type of authentication required for the recipient to sign the document.');
@ -110,18 +126,26 @@ export const RecipientActionAuth = ZRecipientActionAuthTypesSchema.Enum;
*/
export const ZDocumentAuthOptionsSchema = z.preprocess(
(unknownValue) => {
if (unknownValue) {
return unknownValue;
if (!unknownValue || typeof unknownValue !== 'object') {
return {
globalAccessAuth: [],
globalActionAuth: [],
};
}
const globalAccessAuth =
'globalAccessAuth' in unknownValue ? processAuthValue(unknownValue.globalAccessAuth) : [];
const globalActionAuth =
'globalActionAuth' in unknownValue ? processAuthValue(unknownValue.globalActionAuth) : [];
return {
globalAccessAuth: null,
globalActionAuth: null,
globalAccessAuth,
globalActionAuth,
};
},
z.object({
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable(),
globalActionAuth: ZDocumentActionAuthTypesSchema.nullable(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema),
}),
);
@ -130,21 +154,46 @@ export const ZDocumentAuthOptionsSchema = z.preprocess(
*/
export const ZRecipientAuthOptionsSchema = z.preprocess(
(unknownValue) => {
if (unknownValue) {
return unknownValue;
if (!unknownValue || typeof unknownValue !== 'object') {
return {
accessAuth: [],
actionAuth: [],
};
}
const accessAuth =
'accessAuth' in unknownValue ? processAuthValue(unknownValue.accessAuth) : [];
const actionAuth =
'actionAuth' in unknownValue ? processAuthValue(unknownValue.actionAuth) : [];
return {
accessAuth: null,
actionAuth: null,
accessAuth,
actionAuth,
};
},
z.object({
accessAuth: ZRecipientAccessAuthTypesSchema.nullable(),
actionAuth: ZRecipientActionAuthTypesSchema.nullable(),
accessAuth: z.array(ZRecipientAccessAuthTypesSchema),
actionAuth: z.array(ZRecipientActionAuthTypesSchema),
}),
);
/**
* Utility function to process the auth value.
*
* Converts the old singular auth value to an array of auth values.
*/
const processAuthValue = (value: unknown) => {
if (value === null || value === undefined) {
return [];
}
if (Array.isArray(value)) {
return value;
}
return [value];
};
export type TDocumentAuth = z.infer<typeof ZDocumentAuthTypesSchema>;
export type TDocumentAuthMethods = z.infer<typeof ZDocumentAuthMethodsSchema>;
export type TDocumentAuthOptions = z.infer<typeof ZDocumentAuthOptionsSchema>;

View File

@ -2,6 +2,7 @@ import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { DocumentAuditLog, DocumentMeta, Field, Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
import type {
@ -106,7 +107,7 @@ export const diffRecipientChanges = (
const newActionAuth =
newAuthOptions?.actionAuth === undefined ? oldActionAuth : newAuthOptions.actionAuth;
if (oldAccessAuth !== newAccessAuth) {
if (!isDeepEqual(oldAccessAuth, newAccessAuth)) {
diffs.push({
type: RECIPIENT_DIFF_TYPE.ACCESS_AUTH,
from: oldAccessAuth ?? '',
@ -114,7 +115,7 @@ export const diffRecipientChanges = (
});
}
if (oldActionAuth !== newActionAuth) {
if (!isDeepEqual(oldActionAuth, newActionAuth)) {
diffs.push({
type: RECIPIENT_DIFF_TYPE.ACTION_AUTH,
from: oldActionAuth ?? '',

View File

@ -27,17 +27,21 @@ export const extractDocumentAuthMethods = ({
const documentAuthOption = ZDocumentAuthOptionsSchema.parse(documentAuth);
const recipientAuthOption = ZRecipientAuthOptionsSchema.parse(recipientAuth);
const derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null =
recipientAuthOption.accessAuth || documentAuthOption.globalAccessAuth;
const derivedRecipientAccessAuth: TRecipientAccessAuthTypes[] =
recipientAuthOption.accessAuth.length > 0
? recipientAuthOption.accessAuth
: documentAuthOption.globalAccessAuth;
const derivedRecipientActionAuth: TRecipientActionAuthTypes | null =
recipientAuthOption.actionAuth || documentAuthOption.globalActionAuth;
const derivedRecipientActionAuth: TRecipientActionAuthTypes[] =
recipientAuthOption.actionAuth.length > 0
? recipientAuthOption.actionAuth
: documentAuthOption.globalActionAuth;
const recipientAccessAuthRequired = derivedRecipientAccessAuth !== null;
const recipientAccessAuthRequired = derivedRecipientAccessAuth.length > 0;
const recipientActionAuthRequired =
derivedRecipientActionAuth !== DocumentAuth.EXPLICIT_NONE &&
derivedRecipientActionAuth !== null;
derivedRecipientActionAuth.length > 0 &&
!derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE);
return {
derivedRecipientAccessAuth,
@ -54,8 +58,8 @@ export const extractDocumentAuthMethods = ({
*/
export const createDocumentAuthOptions = (options: TDocumentAuthOptions): TDocumentAuthOptions => {
return {
globalAccessAuth: options?.globalAccessAuth ?? null,
globalActionAuth: options?.globalActionAuth ?? null,
globalAccessAuth: options?.globalAccessAuth ?? [],
globalActionAuth: options?.globalActionAuth ?? [],
};
};
@ -66,7 +70,7 @@ export const createRecipientAuthOptions = (
options: TRecipientAuthOptions,
): TRecipientAuthOptions => {
return {
accessAuth: options?.accessAuth ?? null,
actionAuth: options?.actionAuth ?? null,
accessAuth: options?.accessAuth ?? [],
actionAuth: options?.actionAuth ?? [],
};
};

View File

@ -206,8 +206,8 @@ export const ZCreateDocumentV2RequestSchema = z.object({
title: ZDocumentTitleSchema,
externalId: ZDocumentExternalIdSchema.optional(),
visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(),
globalActionAuth: ZDocumentActionAuthTypesSchema.optional(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
formValues: ZDocumentFormValuesSchema.optional(),
recipients: z
.array(

View File

@ -42,8 +42,8 @@ export const ZUpdateDocumentRequestSchema = z.object({
title: ZDocumentTitleSchema.optional(),
externalId: ZDocumentExternalIdSchema.nullish(),
visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullish(),
globalActionAuth: ZDocumentActionAuthTypesSchema.nullish(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
useLegacyFieldInsertion: z.boolean().optional(),
})
.optional(),

View File

@ -1,7 +1,9 @@
import { router } from '../trpc';
import { applyMultiSignSignatureRoute } from './apply-multi-sign-signature';
import { createEmbeddingDocumentRoute } from './create-embedding-document';
import { createEmbeddingPresignTokenRoute } from './create-embedding-presign-token';
import { createEmbeddingTemplateRoute } from './create-embedding-template';
import { getMultiSignDocumentRoute } from './get-multi-sign-document';
import { updateEmbeddingDocumentRoute } from './update-embedding-document';
import { updateEmbeddingTemplateRoute } from './update-embedding-template';
import { verifyEmbeddingPresignTokenRoute } from './verify-embedding-presign-token';
@ -13,4 +15,6 @@ export const embeddingPresignRouter = router({
createEmbeddingTemplate: createEmbeddingTemplateRoute,
updateEmbeddingDocument: updateEmbeddingDocumentRoute,
updateEmbeddingTemplate: updateEmbeddingTemplateRoute,
applyMultiSignSignature: applyMultiSignSignatureRoute,
getMultiSignDocument: getMultiSignDocumentRoute,
});

View File

@ -0,0 +1,102 @@
import { FieldType, ReadStatus, SigningStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { procedure } from '../trpc';
import {
ZApplyMultiSignSignatureRequestSchema,
ZApplyMultiSignSignatureResponseSchema,
} from './apply-multi-sign-signature.types';
export const applyMultiSignSignatureRoute = procedure
.input(ZApplyMultiSignSignatureRequestSchema)
.output(ZApplyMultiSignSignatureResponseSchema)
.mutation(async ({ input, ctx: { metadata } }) => {
try {
const { tokens, signature, isBase64 } = input;
// Get all documents and recipients for the tokens
const envelopes = await Promise.all(
tokens.map(async (token) => {
const document = await getDocumentByToken({ token });
const recipient = await getRecipientByToken({ token });
return { document, recipient };
}),
);
// Check if all documents have been viewed
const hasUnviewedDocuments = envelopes.some(
(envelope) => envelope.recipient.readStatus !== ReadStatus.OPENED,
);
if (hasUnviewedDocuments) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'All documents must be viewed before signing',
});
}
// If we require action auth we should abort here for now
for (const envelope of envelopes) {
const derivedRecipientActionAuth = extractDocumentAuthMethods({
documentAuth: envelope.document.authOptions,
recipientAuth: envelope.recipient.authOptions,
});
if (
derivedRecipientActionAuth.recipientAccessAuthRequired ||
derivedRecipientActionAuth.recipientActionAuthRequired
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'Documents that require additional authentication cannot be multi signed at the moment',
});
}
}
// Sign all signature fields for each document
await Promise.all(
envelopes.map(async (envelope) => {
if (envelope.recipient.signingStatus === SigningStatus.REJECTED) {
return;
}
const signatureFields = await prisma.field.findMany({
where: {
documentId: envelope.document.id,
recipientId: envelope.recipient.id,
type: FieldType.SIGNATURE,
inserted: false,
},
});
await Promise.all(
signatureFields.map(async (field) =>
signFieldWithToken({
token: envelope.recipient.token,
fieldId: field.id,
value: signature,
isBase64,
requestMetadata: metadata.requestMetadata,
}),
),
);
}),
);
return { success: true };
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to apply multi-sign signature',
});
}
});

View File

@ -0,0 +1,18 @@
import { z } from 'zod';
export const ZApplyMultiSignSignatureRequestSchema = z.object({
tokens: z.array(z.string()).min(1, { message: 'At least one token is required' }),
signature: z.string().min(1, { message: 'Signature is required' }),
isBase64: z.boolean().optional().default(false),
});
export const ZApplyMultiSignSignatureResponseSchema = z.object({
success: z.boolean(),
});
export type TApplyMultiSignSignatureRequestSchema = z.infer<
typeof ZApplyMultiSignSignatureRequestSchema
>;
export type TApplyMultiSignSignatureResponseSchema = z.infer<
typeof ZApplyMultiSignSignatureResponseSchema
>;

View File

@ -0,0 +1,62 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
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 { procedure } from '../trpc';
import {
ZGetMultiSignDocumentRequestSchema,
ZGetMultiSignDocumentResponseSchema,
} from './get-multi-sign-document.types';
export const getMultiSignDocumentRoute = procedure
.input(ZGetMultiSignDocumentRequestSchema)
.output(ZGetMultiSignDocumentResponseSchema)
.query(async ({ input, ctx: { metadata } }) => {
try {
const { token } = input;
const [document, fields, recipient] = await Promise.all([
getDocumentAndSenderByToken({
token,
requireAccessAuth: false,
}).catch(() => null),
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
getCompletedFieldsForToken({ token }).catch(() => []),
]);
if (!document || !recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document or recipient not found',
});
}
await viewedDocument({
token,
requestMetadata: metadata.requestMetadata,
});
// Transform fields to match our schema
const transformedFields = fields.map((field) => ({
...field,
recipient,
}));
return {
...document,
folder: null,
fields: transformedFields,
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to get document details',
});
}
});

View File

@ -0,0 +1,50 @@
import { z } from 'zod';
import { ZDocumentLiteSchema } from '@documenso/lib/types/document';
import { ZRecipientLiteSchema } from '@documenso/lib/types/recipient';
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
import DocumentMetaSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
import FieldSchema from '@documenso/prisma/generated/zod/modelSchema/FieldSchema';
import SignatureSchema from '@documenso/prisma/generated/zod/modelSchema/SignatureSchema';
export const ZGetMultiSignDocumentRequestSchema = z.object({
token: z.string().min(1, { message: 'Token is required' }),
});
export const ZGetMultiSignDocumentResponseSchema = ZDocumentLiteSchema.extend({
documentData: DocumentDataSchema.pick({
type: true,
id: true,
data: true,
initialData: true,
}),
documentMeta: DocumentMetaSchema.pick({
signingOrder: true,
distributionMethod: true,
id: true,
subject: true,
message: true,
timezone: true,
password: true,
dateFormat: true,
documentId: true,
redirectUrl: true,
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
allowDictateNextSigner: true,
language: true,
emailSettings: true,
}).nullable(),
fields: z.array(
FieldSchema.extend({
recipient: ZRecipientLiteSchema,
signature: SignatureSchema.nullable(),
}),
),
});
export type TGetMultiSignDocumentRequestSchema = z.infer<typeof ZGetMultiSignDocumentRequestSchema>;
export type TGetMultiSignDocumentResponseSchema = z.infer<
typeof ZGetMultiSignDocumentResponseSchema
>;

View File

@ -26,8 +26,8 @@ export const ZCreateRecipientSchema = z.object({
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
accessAuth: ZRecipientAccessAuthTypesSchema.optional().nullable(),
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
accessAuth: z.array(ZRecipientAccessAuthTypesSchema).optional().default([]),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
});
export const ZUpdateRecipientSchema = z.object({
@ -36,8 +36,8 @@ export const ZUpdateRecipientSchema = z.object({
name: z.string().optional(),
role: z.nativeEnum(RecipientRole).optional(),
signingOrder: z.number().optional(),
accessAuth: ZRecipientAccessAuthTypesSchema.optional().nullable(),
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
accessAuth: z.array(ZRecipientAccessAuthTypesSchema).optional().default([]),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
});
export const ZCreateDocumentRecipientRequestSchema = z.object({
@ -106,7 +106,7 @@ export const ZSetDocumentRecipientsRequestSchema = z
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}),
),
})
@ -190,7 +190,7 @@ export const ZSetTemplateRecipientsRequestSchema = z
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}),
),
})

View File

@ -133,8 +133,8 @@ export const ZUpdateTemplateRequestSchema = z.object({
title: z.string().min(1).optional(),
externalId: z.string().nullish(),
visibility: z.nativeEnum(DocumentVisibility).optional(),
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(),
globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional().default([]),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]),
publicTitle: z
.string()
.trim()

View File

@ -1,51 +1,75 @@
import { forwardRef } from 'react';
import React from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export const DocumentGlobalAuthAccessSelect = forwardRef<HTMLButtonElement, SelectProps>(
(props, ref) => {
const { _ } = useLingui();
export interface DocumentGlobalAuthAccessSelectProps {
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
disabled?: boolean;
placeholder?: string;
}
return (
<Select {...props}>
<SelectTrigger ref={ref} className="bg-background text-muted-foreground">
<SelectValue
data-testid="documentAccessSelectValue"
placeholder={_(msg`No restrictions`)}
/>
</SelectTrigger>
export const DocumentGlobalAuthAccessSelect = ({
value,
defaultValue,
onValueChange,
disabled,
placeholder,
}: DocumentGlobalAuthAccessSelectProps) => {
const { _ } = useLingui();
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>
<Trans>No restrictions</Trans>
</SelectItem>
// Convert auth types to MultiSelect options
const authOptions: Option[] = [
{
value: '-1',
label: _(msg`No restrictions`),
},
...Object.values(DocumentAccessAuth).map((authType) => ({
value: authType,
label: DOCUMENT_AUTH_TYPES[authType].value,
})),
];
{Object.values(DocumentAccessAuth).map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
);
},
);
// Convert string array to Option array for MultiSelect
const selectedOptions =
(value
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
// Convert default value to Option array
const defaultOptions =
(defaultValue
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
const handleChange = (options: Option[]) => {
const values = options.map((option) => option.value);
onValueChange?.(values);
};
return (
<MultiSelect
value={selectedOptions}
defaultOptions={defaultOptions}
options={authOptions}
onChange={handleChange}
disabled={disabled}
placeholder={placeholder || _(msg`Select access methods`)}
className="bg-background text-muted-foreground"
hideClearAllButton={false}
data-testid="documentAccessSelectValue"
/>
);
};
DocumentGlobalAuthAccessSelect.displayName = 'DocumentGlobalAuthAccessSelect';
@ -63,7 +87,11 @@ export const DocumentGlobalAuthAccessTooltip = () => (
</h2>
<p>
<Trans>The authentication required for recipients to view the document.</Trans>
<Trans>The authentication methods required for recipients to view the document.</Trans>
</p>
<p className="mt-2">
<Trans>Multiple access methods can be selected.</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">

View File

@ -1,54 +1,75 @@
import { forwardRef } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { DocumentActionAuth, DocumentAuth } from '@documenso/lib/types/document-auth';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export const DocumentGlobalAuthActionSelect = forwardRef<HTMLButtonElement, SelectProps>(
(props, ref) => {
const { _ } = useLingui();
export interface DocumentGlobalAuthActionSelectProps {
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
disabled?: boolean;
placeholder?: string;
}
return (
<Select {...props}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue
ref={ref}
data-testid="documentActionSelectValue"
placeholder={_(msg`No restrictions`)}
/>
</SelectTrigger>
export const DocumentGlobalAuthActionSelect = ({
value,
defaultValue,
onValueChange,
disabled,
placeholder,
}: DocumentGlobalAuthActionSelectProps) => {
const { _ } = useLingui();
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>
<Trans>No restrictions</Trans>
</SelectItem>
// Convert auth types to MultiSelect options
const authOptions: Option[] = [
{
value: '-1',
label: _(msg`No restrictions`),
},
...Object.values(DocumentActionAuth)
.filter((auth) => auth !== DocumentAuth.ACCOUNT)
.map((authType) => ({
value: authType,
label: DOCUMENT_AUTH_TYPES[authType].value,
})),
];
{Object.values(DocumentActionAuth)
.filter((auth) => auth !== DocumentAuth.ACCOUNT)
.map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
);
},
);
// Convert string array to Option array for MultiSelect
const selectedOptions =
(value
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
// Convert default value to Option array
const defaultOptions =
(defaultValue
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
const handleChange = (options: Option[]) => {
const values = options.map((option) => option.value);
onValueChange?.(values);
};
return (
<MultiSelect
value={selectedOptions}
defaultOptions={defaultOptions}
options={authOptions}
onChange={handleChange}
disabled={disabled}
placeholder={placeholder || _(msg`Select authentication methods`)}
className="bg-background text-muted-foreground"
hideClearAllButton={false}
data-testid="documentActionSelectValue"
/>
);
};
DocumentGlobalAuthActionSelect.displayName = 'DocumentGlobalAuthActionSelect';
@ -64,20 +85,19 @@ export const DocumentGlobalAuthActionTooltip = () => (
</h2>
<p>
<Trans>The authentication required for recipients to sign the signature field.</Trans>
<Trans>
The authentication methods required for recipients to sign the signature field.
</Trans>
</p>
<p>
<Trans>
This can be overriden by setting the authentication requirements directly on each
recipient in the next step.
These can be overriden by setting the authentication requirements directly on each
recipient in the next step. Multiple methods can be selected.
</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
{/* <li>
<strong>Require account</strong> - The recipient must be signed in
</li> */}
<li>
<Trans>
<strong>Require passkey</strong> - The recipient must have an account and passkey
@ -90,6 +110,14 @@ export const DocumentGlobalAuthActionTooltip = () => (
their settings
</Trans>
</li>
<li>
<Trans>
<strong>Require password</strong> - The recipient must have an account and password
configured via their settings, the password will be verified during signing
</Trans>
</li>
<li>
<Trans>
<strong>No restrictions</strong> - No authentication required

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { type Field, FieldType } from '@prisma/client';
import { createPortal } from 'react-dom';
@ -20,24 +20,37 @@ export function FieldContainerPortal({
children,
className = '',
}: FieldContainerPortalProps) {
const alternativePortalRoot = document.getElementById('document-field-portal-root');
const coords = useFieldPageCoords(field);
const isCheckboxOrRadioField = field.type === 'CHECKBOX' || field.type === 'RADIO';
const style = {
top: `${coords.y}px`,
left: `${coords.x}px`,
...(!isCheckboxOrRadioField && {
height: `${coords.height}px`,
width: `${coords.width}px`,
}),
};
const style = useMemo(() => {
const portalBounds = alternativePortalRoot?.getBoundingClientRect();
const bounds = {
top: `${coords.y}px`,
left: `${coords.x}px`,
...(!isCheckboxOrRadioField && {
height: `${coords.height}px`,
width: `${coords.width}px`,
}),
};
if (portalBounds) {
bounds.top = `${coords.y - portalBounds.top}px`;
bounds.left = `${coords.x - portalBounds.left}px`;
}
return bounds;
}, [coords, isCheckboxOrRadioField]);
return createPortal(
<div className={cn('absolute', className)} style={style}>
{children}
</div>,
document.body,
alternativePortalRoot ?? document.body,
);
}

View File

@ -3,97 +3,124 @@ import React from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { RecipientActionAuth } from '@documenso/lib/types/document-auth';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export type RecipientActionAuthSelectProps = SelectProps;
export interface RecipientActionAuthSelectProps {
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
disabled?: boolean;
placeholder?: string;
}
export const RecipientActionAuthSelect = (props: RecipientActionAuthSelectProps) => {
export const RecipientActionAuthSelect = ({
value,
defaultValue,
onValueChange,
disabled,
placeholder,
}: RecipientActionAuthSelectProps) => {
const { _ } = useLingui();
// Convert auth types to MultiSelect options
const authOptions: Option[] = [
{
value: '-1',
label: _(msg`Inherit authentication method`),
},
...Object.values(RecipientActionAuth)
.filter((auth) => auth !== RecipientActionAuth.ACCOUNT)
.map((authType) => ({
value: authType,
label: DOCUMENT_AUTH_TYPES[authType].value,
})),
];
// Convert string array to Option array for MultiSelect
const selectedOptions =
(value
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
// Convert default value to Option array
const defaultOptions =
(defaultValue
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
const handleChange = (options: Option[]) => {
const values = options.map((option) => option.value);
onValueChange?.(values);
};
return (
<Select {...props}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue placeholder={_(msg`Inherit authentication method`)} />
<div className="relative">
<MultiSelect
value={selectedOptions}
defaultOptions={defaultOptions}
options={authOptions}
onChange={handleChange}
disabled={disabled}
placeholder={placeholder || _(msg`Select authentication methods`)}
className="bg-background text-muted-foreground"
maxSelected={4} // Allow selecting up to 4 auth methods
hideClearAllButton={false}
/>
<Tooltip>
<TooltipTrigger className="-mr-1 ml-auto">
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<Tooltip>
<TooltipTrigger className="absolute right-2 top-1/2 -translate-y-1/2">
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md p-4">
<h2>
<strong>
<Trans>Recipient action authentication</Trans>
</strong>
</h2>
<TooltipContent className="text-foreground max-w-md p-4">
<h2>
<strong>
<Trans>Recipient action authentication</Trans>
</strong>
</h2>
<p>
<Trans>The authentication required for recipients to sign fields</Trans>
</p>
<p>
<Trans>The authentication methods required for recipients to sign fields</Trans>
</p>
<p className="mt-2">
<Trans>This will override any global settings.</Trans>
</p>
<p className="mt-2">
<Trans>
These will override any global settings. Multiple methods can be selected.
</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<Trans>
<strong>Inherit authentication method</strong> - Use the global action signing
authentication method configured in the "General Settings" step
</Trans>
</li>
{/* <li>
<strong>Require account</strong> - The recipient must be
signed in
</li> */}
<li>
<Trans>
<strong>Require passkey</strong> - The recipient must have an account and passkey
configured via their settings
</Trans>
</li>
<li>
<Trans>
<strong>Require 2FA</strong> - The recipient must have an account and 2FA enabled
via their settings
</Trans>
</li>
<li>
<Trans>
<strong>None</strong> - No authentication required
</Trans>
</li>
</ul>
</TooltipContent>
</Tooltip>
</SelectTrigger>
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value="-1">
<Trans>Inherit authentication method</Trans>
</SelectItem>
{Object.values(RecipientActionAuth)
.filter((auth) => auth !== RecipientActionAuth.ACCOUNT)
.map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<Trans>
<strong>Inherit authentication method</strong> - Use the global action signing
authentication method configured in the "General Settings" step
</Trans>
</li>
<li>
<Trans>
<strong>Require passkey</strong> - The recipient must have an account and passkey
configured via their settings
</Trans>
</li>
<li>
<Trans>
<strong>Require 2FA</strong> - The recipient must have an account and 2FA enabled
via their settings
</Trans>
</li>
<li>
<Trans>
<strong>None</strong> - No authentication required
</Trans>
</li>
</ul>
</TooltipContent>
</Tooltip>
</div>
);
};

View File

@ -58,7 +58,7 @@ const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm', className)} {...props} />
<div ref={ref} className={cn('mt-2 text-sm', className)} {...props} />
));
AlertDescription.displayName = 'AlertDescription';

View File

@ -98,8 +98,8 @@ export const AddSettingsFormPartial = ({
title: document.title,
externalId: document.externalId || '',
visibility: document.visibility || '',
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined,
globalActionAuth: documentAuthOption?.globalActionAuth || undefined,
globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
globalActionAuth: documentAuthOption?.globalActionAuth || [],
meta: {
timezone:
@ -131,6 +131,12 @@ export const AddSettingsFormPartial = ({
)
.otherwise(() => false);
const onFormSubmit = form.handleSubmit(onSubmit);
const onGoNextClick = () => {
void onFormSubmit().catch(console.error);
};
// We almost always want to set the timezone to the user's local timezone to avoid confusion
// when the document is signed.
useEffect(() => {
@ -214,7 +220,11 @@ export const AddSettingsFormPartial = ({
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<Select
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
@ -244,7 +254,11 @@ export const AddSettingsFormPartial = ({
</FormLabel>
<FormControl>
<DocumentGlobalAuthAccessSelect {...field} onValueChange={field.onChange} />
<DocumentGlobalAuthAccessSelect
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@ -286,7 +300,11 @@ export const AddSettingsFormPartial = ({
</FormLabel>
<FormControl>
<DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} />
<DocumentGlobalAuthActionSelect
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@ -370,7 +388,7 @@ export const AddSettingsFormPartial = ({
<FormControl>
<Select
{...field}
value={field.value}
onValueChange={field.onChange}
disabled={documentHasBeenSent}
>
@ -406,7 +424,7 @@ export const AddSettingsFormPartial = ({
<Combobox
className="bg-background"
options={TIME_ZONES}
{...field}
value={field.value}
onChange={(value) => value && field.onChange(value)}
disabled={documentHasBeenSent}
/>
@ -461,7 +479,7 @@ export const AddSettingsFormPartial = ({
disabled={form.formState.isSubmitting}
canGoBack={stepIndex !== 0}
onGoBackClick={previousStep}
onGoNextClick={form.handleSubmit(onSubmit)}
onGoNextClick={onGoNextClick}
/>
</DocumentFlowFormContainerFooter>
</>

View File

@ -16,17 +16,6 @@ import {
ZDocumentMetaTimezoneSchema,
} from '@documenso/trpc/server/document-router/schema';
export const ZMapNegativeOneToUndefinedSchema = z
.string()
.optional()
.transform((val) => {
if (val === '-1') {
return undefined;
}
return val;
});
export const ZAddSettingsFormSchema = z.object({
title: z
.string()
@ -34,12 +23,8 @@ export const ZAddSettingsFormSchema = z.object({
.min(1, { message: msg`Title cannot be empty`.id }),
externalId: z.string().optional(),
visibility: z.nativeEnum(DocumentVisibility).optional(),
globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentAccessAuthTypesSchema.optional(),
),
globalActionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentActionAuthTypesSchema.optional(),
),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema),
meta: z.object({
timezone: ZDocumentMetaTimezoneSchema.optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: ZDocumentMetaDateFormatSchema.optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),

View File

@ -85,7 +85,7 @@ export const AddSignersFormPartial = ({
email: '',
role: RecipientRole.SIGNER,
signingOrder: 1,
actionAuth: undefined,
actionAuth: [],
},
];
@ -119,10 +119,14 @@ export const AddSignersFormPartial = ({
const recipientHasAuthOptions = recipients.find((recipient) => {
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
return recipientAuthOptions?.accessAuth || recipientAuthOptions?.actionAuth;
return (
recipientAuthOptions.accessAuth.length > 0 || recipientAuthOptions.actionAuth.length > 0
);
});
const formHasActionAuth = form.getValues('signers').find((signer) => signer.actionAuth);
const formHasActionAuth = form
.getValues('signers')
.find((signer) => signer.actionAuth.length > 0);
return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined;
}, [recipients, form]);
@ -190,7 +194,7 @@ export const AddSignersFormPartial = ({
name: '',
email: '',
role: RecipientRole.SIGNER,
actionAuth: undefined,
actionAuth: [],
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
});
};
@ -226,7 +230,7 @@ export const AddSignersFormPartial = ({
name: user?.name ?? '',
email: user?.email ?? '',
role: RecipientRole.SIGNER,
actionAuth: undefined,
actionAuth: [],
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
});
}

View File

@ -4,8 +4,6 @@ import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { ZMapNegativeOneToUndefinedSchema } from './add-settings.types';
export const ZAddSignersFormSchema = z
.object({
signers: z.array(
@ -19,9 +17,7 @@ export const ZAddSignersFormSchema = z
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZRecipientActionAuthTypesSchema.optional(),
),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}),
),
signingOrder: z.nativeEnum(DocumentSigningOrder),

View File

@ -78,6 +78,8 @@ interface MultiSelectProps {
>;
/** hide the clear all button. */
hideClearAllButton?: boolean;
/** test id for the select value. */
'data-testid'?: string;
}
export interface MultiSelectRef {
@ -170,6 +172,7 @@ const MultiSelect = ({
commandProps,
inputProps,
hideClearAllButton = false,
'data-testid': dataTestId,
}: MultiSelectProps) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false);
@ -403,6 +406,7 @@ const MultiSelect = ({
commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch
} // When onSearch is provided, we don&lsquo;t want to filter the options. You can still override it.
filter={commandFilter()}
data-testid={dataTestId}
>
<div
className={cn(

View File

@ -86,7 +86,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
{
formId: initialId,
role: RecipientRole.SIGNER,
actionAuth: undefined,
actionAuth: [],
...generateRecipientPlaceholder(1),
signingOrder: 1,
},
@ -136,10 +136,14 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
const recipientHasAuthOptions = recipients.find((recipient) => {
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
return recipientAuthOptions?.accessAuth || recipientAuthOptions?.actionAuth;
return (
recipientAuthOptions.accessAuth.length > 0 || recipientAuthOptions.actionAuth.length > 0
);
});
const formHasActionAuth = form.getValues('signers').find((signer) => signer.actionAuth);
const formHasActionAuth = form
.getValues('signers')
.find((signer) => signer.actionAuth.length > 0);
return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined;
}, [recipients, form]);
@ -179,6 +183,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
email: user.email ?? '',
role: RecipientRole.SIGNER,
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
actionAuth: [],
});
};
@ -188,6 +193,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
role: RecipientRole.SIGNER,
...generateRecipientPlaceholder(placeholderRecipientCount),
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
actionAuth: [],
});
setPlaceholderRecipientCount((count) => count + 1);

View File

@ -3,8 +3,6 @@ import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
export const ZAddTemplatePlacholderRecipientsFormSchema = z
.object({
signers: z.array(
@ -15,9 +13,7 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZRecipientActionAuthTypesSchema.optional(),
),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}),
),
signingOrder: z.nativeEnum(DocumentSigningOrder),

View File

@ -106,8 +106,8 @@ export const AddTemplateSettingsFormPartial = ({
title: template.title,
externalId: template.externalId || undefined,
visibility: template.visibility || '',
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined,
globalActionAuth: documentAuthOption?.globalActionAuth || undefined,
globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
globalActionAuth: documentAuthOption?.globalActionAuth || [],
meta: {
subject: template.templateMeta?.subject ?? '',
message: template.templateMeta?.message ?? '',
@ -237,7 +237,11 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel>
<FormControl>
<DocumentGlobalAuthAccessSelect {...field} onValueChange={field.onChange} />
<DocumentGlobalAuthAccessSelect
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@ -378,7 +382,11 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel>
<FormControl>
<DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} />
<DocumentGlobalAuthActionSelect
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}

View File

@ -18,18 +18,12 @@ import {
ZDocumentMetaTimezoneSchema,
} from '@documenso/trpc/server/document-router/schema';
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
export const ZAddTemplateSettingsFormSchema = z.object({
title: z.string().trim().min(1, { message: "Title can't be empty" }),
externalId: z.string().optional(),
visibility: z.nativeEnum(DocumentVisibility).optional(),
globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentAccessAuthTypesSchema.optional(),
),
globalActionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentActionAuthTypesSchema.optional(),
),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional().default([]),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]),
meta: z.object({
subject: z.string(),
message: z.string(),