Compare commits

..

15 Commits

30 changed files with 548 additions and 733 deletions

View File

@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { msg } from '@lingui/macro'; import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { useSession } from 'next-auth/react';
import { isValidLanguageCode } from '@documenso/lib/constants/i18n'; import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
import { import {
@ -55,6 +56,7 @@ export const EditDocumentForm = ({
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const team = useOptionalCurrentTeam(); const team = useOptionalCurrentTeam();
const { data: session } = useSession();
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false); const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
@ -73,7 +75,7 @@ export const EditDocumentForm = ({
const { recipients, fields } = document; const { recipients, fields } = document;
const { mutateAsync: updateDocument } = trpc.document.updateDocument.useMutation({ const { mutateAsync: updateDocument } = trpc.document.setSettingsForDocument.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => { onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData( utils.document.getDocumentWithDetailsById.setData(
@ -85,6 +87,19 @@ export const EditDocumentForm = ({
}, },
}); });
const { mutateAsync: setSigningOrderForDocument } =
trpc.document.setSigningOrderForDocument.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData(
{
documentId: initialDocument.id,
},
(oldData) => ({ ...(oldData || initialDocument), ...newData, id: Number(newData.id) }),
);
},
});
const { mutateAsync: addFields } = trpc.field.addFields.useMutation({ const { mutateAsync: addFields } = trpc.field.addFields.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: ({ fields: newFields }) => { onSuccess: ({ fields: newFields }) => {
@ -121,6 +136,18 @@ export const EditDocumentForm = ({
}, },
}); });
const { mutateAsync: selfSignDocument } = trpc.document.selfSignDocument.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData(
{
documentId: initialDocument.id,
},
(oldData) => ({ ...(oldData || initialDocument), ...newData }),
);
},
});
const { mutateAsync: setPasswordForDocument } = const { mutateAsync: setPasswordForDocument } =
trpc.document.setPasswordForDocument.useMutation(); trpc.document.setPasswordForDocument.useMutation();
@ -203,12 +230,9 @@ export const EditDocumentForm = ({
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => { const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
try { try {
await Promise.all([ await Promise.all([
updateDocument({ setSigningOrderForDocument({
documentId: document.id, documentId: document.id,
meta: { signingOrder: data.signingOrder,
signingOrder: data.signingOrder,
modifyNextSigner: data.modifyNextSigner,
},
}), }),
setRecipients({ setRecipients({
@ -259,10 +283,22 @@ export const EditDocumentForm = ({
} }
} }
// Router refresh is here to clear the router cache for when navigating to /documents. const hasSameOwnerAsRecipient =
router.refresh(); recipients.length === 1 && recipients[0].email === session?.user?.email;
setStep('subject'); if (hasSameOwnerAsRecipient) {
await selfSignDocument({
documentId: document.id,
teamId: team?.id,
});
router.push(`/sign/${recipients[0].token}`);
} else {
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh();
setStep('subject');
}
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -381,7 +417,6 @@ export const EditDocumentForm = ({
documentFlow={documentFlow.signers} documentFlow={documentFlow.signers}
recipients={recipients} recipients={recipients}
signingOrder={document.documentMeta?.signingOrder} signingOrder={document.documentMeta?.signingOrder}
modifyNextSigner={document.documentMeta?.modifyNextSigner}
fields={fields} fields={fields}
isDocumentEnterprise={isDocumentEnterprise} isDocumentEnterprise={isDocumentEnterprise}
onSubmit={onAddSignersFormSubmit} onSubmit={onAddSignersFormSubmit}

View File

@ -76,7 +76,6 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
? { ? {
...templateMeta, ...templateMeta,
signingOrder: templateMeta.signingOrder || DocumentSigningOrder.SEQUENTIAL, signingOrder: templateMeta.signingOrder || DocumentSigningOrder.SEQUENTIAL,
modifyNextSigner: templateMeta.modifyNextSigner ?? false,
documentId: 0, documentId: 0,
} }
: undefined; : undefined;

View File

@ -15,12 +15,7 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { import { type Field, FieldType, RecipientRole } from '@documenso/prisma/client';
DocumentSigningOrder,
type Field,
FieldType,
RecipientRole,
} from '@documenso/prisma/client';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
@ -45,12 +40,6 @@ export type SigningFormProps = {
isRecipientsTurn: boolean; isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[]; allRecipients?: RecipientWithFields[];
setSelectedSignerId?: (id: number | null) => void; setSelectedSignerId?: (id: number | null) => void;
isLastRecipient: boolean;
};
type SigningFormData = {
email?: string;
name?: string;
}; };
export const SigningForm = ({ export const SigningForm = ({
@ -61,7 +50,6 @@ export const SigningForm = ({
isRecipientsTurn, isRecipientsTurn,
allRecipients = [], allRecipients = [],
setSelectedSignerId, setSelectedSignerId,
isLastRecipient,
}: SigningFormProps) => { }: SigningFormProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
@ -89,7 +77,7 @@ export const SigningForm = ({
}, },
}); });
const { handleSubmit, formState } = useForm<SigningFormData>(); const { handleSubmit, formState } = useForm();
// Keep the loading state going if successful since the redirect may take some time. // Keep the loading state going if successful since the redirect may take some time.
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful; const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
@ -114,58 +102,20 @@ export const SigningForm = ({
validateFieldsInserted(fieldsRequiringValidation); validateFieldsInserted(fieldsRequiringValidation);
}; };
const completeDocument = async ( const onFormSubmit = async () => {
authOptions?: TRecipientActionAuth, setValidateUninsertedFields(true);
nextSigner?: { email: string; name: string },
) => {
const payload = {
token: recipient.token,
documentId: document.id,
authOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
};
await completeDocumentWithToken(payload); const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
analytics.capture('App: Recipient has completed signing', { if (hasSignatureField && !signatureValid) {
signerId: recipient.id, return;
documentId: document.id,
timestamp: new Date().toISOString(),
});
redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${recipient.token}/complete`);
};
const onFormSubmit = async (data: SigningFormData) => {
try {
setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
if (hasSignatureField && !signatureValid) {
throw new Error('Please provide a valid signature');
}
if (!isFieldsValid) {
throw new Error('Please complete all required fields');
}
const nextSigner =
data.email && data.name
? {
email: data.email,
name: data.name,
}
: undefined;
await completeDocument(undefined, nextSigner);
} catch (error) {
toast({
title: 'Error',
description: 'An error occurred while completing the document. Please try again.',
variant: 'destructive',
});
} }
if (!isFieldsValid) {
return;
}
await completeDocument();
}; };
const onAssistantFormSubmit = () => { const onAssistantFormSubmit = () => {
@ -193,6 +143,22 @@ export const SigningForm = ({
} }
}; };
const completeDocument = async (authOptions?: TRecipientActionAuth) => {
await completeDocumentWithToken({
token: recipient.token,
documentId: document.id,
authOptions,
});
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: document.id,
timestamp: new Date().toISOString(),
});
redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${recipient.token}/complete`);
};
return ( return (
<div <div
className={cn( className={cn(
@ -242,21 +208,12 @@ export const SigningForm = ({
<SignDialog <SignDialog
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
onSignatureComplete={async (nextSigner) => { onSignatureComplete={handleSubmit(onFormSubmit)}
await handleSubmit(async (formData) =>
onFormSubmit({ ...formData, ...nextSigner }),
)();
}}
documentTitle={document.title} documentTitle={document.title}
fields={fields} fields={fields}
fieldsValidated={fieldsValidated} fieldsValidated={fieldsValidated}
role={recipient.role} role={recipient.role}
disabled={!isRecipientsTurn} disabled={!isRecipientsTurn}
canModifyNextSigner={
document.documentMeta?.modifyNextSigner &&
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL &&
!isLastRecipient
}
/> />
</div> </div>
</div> </div>
@ -426,21 +383,12 @@ export const SigningForm = ({
<SignDialog <SignDialog
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
onSignatureComplete={async (nextSigner) => { onSignatureComplete={handleSubmit(onFormSubmit)}
await handleSubmit(async (formData) =>
onFormSubmit({ ...formData, ...nextSigner }),
)();
}}
documentTitle={document.title} documentTitle={document.title}
fields={fields} fields={fields}
fieldsValidated={fieldsValidated} fieldsValidated={fieldsValidated}
role={recipient.role} role={recipient.role}
disabled={!isRecipientsTurn} disabled={!isRecipientsTurn}
canModifyNextSigner={
document.documentMeta?.modifyNextSigner &&
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL &&
!isLastRecipient
}
/> />
</div> </div>
</fieldset> </fieldset>

View File

@ -9,7 +9,6 @@ import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-re
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document'; import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token'; 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 { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getIsLastRecipient } from '@documenso/lib/server-only/recipient/get-is-last-recipient';
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn'; import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
@ -45,7 +44,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders); const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
const [document, recipient, fields, completedFields, isLastRecipient] = await Promise.all([ const [document, recipient, fields, completedFields] = await Promise.all([
getDocumentAndSenderByToken({ getDocumentAndSenderByToken({
token, token,
userId: user?.id, userId: user?.id,
@ -54,7 +53,6 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
getRecipientByToken({ token }).catch(() => null), getRecipientByToken({ token }).catch(() => null),
getFieldsForToken({ token }), getFieldsForToken({ token }),
getCompletedFieldsForToken({ token }), getCompletedFieldsForToken({ token }),
getIsLastRecipient({ token }),
]); ]);
if ( if (
@ -171,7 +169,6 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
completedFields={completedFields} completedFields={completedFields}
isRecipientsTurn={isRecipientsTurn} isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients} allRecipients={allRecipients}
isLastRecipient={isLastRecipient}
/> />
</DocumentAuthProvider> </DocumentAuthProvider>
</SigningProvider> </SigningProvider>

View File

@ -37,6 +37,11 @@ export const RecipientProvider = ({
recipient, recipient,
targetSigner = null, targetSigner = null,
}: RecipientProviderProps) => { }: RecipientProviderProps) => {
// console.log({
// recipient,
// targetSigner,
// isAssistantMode: !!targetSigner,
// });
return ( return (
<RecipientContext.Provider <RecipientContext.Provider
value={{ value={{

View File

@ -1,36 +1,18 @@
'use client';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/macro'; import { Trans } from '@lingui/macro';
import { ArrowRight } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import type { Field } from '@documenso/prisma/client'; import type { Field } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client'; import { RecipientRole } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { import {
Dialog, Dialog,
DialogClose,
DialogContent, DialogContent,
DialogFooter, DialogFooter,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@documenso/ui/primitives/dialog'; } 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 { SigningDisclosure } from '~/components/general/signing-disclosure'; import { SigningDisclosure } from '~/components/general/signing-disclosure';
@ -39,26 +21,12 @@ export type SignDialogProps = {
documentTitle: string; documentTitle: string;
fields: Field[]; fields: Field[];
fieldsValidated: () => void | Promise<void>; fieldsValidated: () => void | Promise<void>;
onSignatureComplete: (nextSigner?: { email?: string; name?: string }) => void | Promise<void>; onSignatureComplete: () => void | Promise<void>;
role: RecipientRole; role: RecipientRole;
disabled?: boolean; disabled?: boolean;
canModifyNextSigner?: boolean;
}; };
const formSchema = z.object({ export const SignDialog = ({
modifyNextSigner: z.boolean().default(false),
nextSigner: z
.object({
email: z.string().email({ message: 'Please enter a valid email address' }).optional(),
name: z.string().optional(),
})
.optional()
.default({}),
});
type TFormSchema = z.infer<typeof formSchema>;
export function SignDialog({
isSubmitting, isSubmitting,
documentTitle, documentTitle,
fields, fields,
@ -66,9 +34,7 @@ export function SignDialog({
onSignatureComplete, onSignatureComplete,
role, role,
disabled = false, disabled = false,
canModifyNextSigner = false, }: SignDialogProps) => {
}: SignDialogProps) {
const [step, setStep] = useState(1);
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]); const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
@ -81,336 +47,104 @@ export function SignDialog({
setShowDialog(open); setShowDialog(open);
}; };
const totalSteps = 2;
const handleContinue = () => {
if (step < totalSteps) {
setStep(step + 1);
}
};
const form = useForm<TFormSchema>({
resolver: zodResolver(formSchema),
});
const onFormSubmit = async (data: TFormSchema) => {
try {
await fieldsValidated();
await onSignatureComplete({
email: data.nextSigner.email?.trim().toLowerCase(),
name: data.nextSigner.name?.trim(),
});
setShowDialog(false);
form.reset();
} catch (err) {
console.error(err);
}
};
return ( return (
<> <Dialog open={showDialog} onOpenChange={handleOpenChange}>
{!canModifyNextSigner ? ( <DialogTrigger asChild>
<Dialog open={showDialog} onOpenChange={handleOpenChange}> <Button
<DialogTrigger asChild> className="w-full"
<Button type="button"
className="w-full" size="lg"
type="button" onClick={fieldsValidated}
size="lg" loading={isSubmitting}
onClick={fieldsValidated} disabled={disabled}
loading={isSubmitting}
disabled={disabled}
>
{isComplete ? <Trans>Complete</Trans> : <Trans>Next field</Trans>}
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>
<div className="text-foreground text-xl font-semibold">
{role === RecipientRole.VIEWER && <Trans>Complete Viewing</Trans>}
{role === RecipientRole.SIGNER && <Trans>Complete Signing</Trans>}
{role === RecipientRole.APPROVER && <Trans>Complete Approval</Trans>}
</div>
</DialogTitle>
<div className="text-muted-foreground max-w-[50ch]">
{role === RecipientRole.VIEWER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
)}
{role === RecipientRole.SIGNER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete signing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
)}
{role === RecipientRole.APPROVER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete approving{' '}
<span className="inline-block max-w-[11rem] truncate align-baseline">
"{documentTitle}"
</span>
.
</span>
<br /> Are you sure?
</Trans>
</span>
)}
</div>
<SigningDisclosure className="mt-4" />
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowDialog(false);
}}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
className="flex-1"
disabled={!isComplete}
loading={isSubmitting}
onClick={async (e) => {
e.preventDefault();
await onSignatureComplete();
}}
>
{role === RecipientRole.VIEWER && <Trans>Mark as Viewed</Trans>}
{role === RecipientRole.SIGNER && <Trans>Sign</Trans>}
{role === RecipientRole.APPROVER && <Trans>Approve</Trans>}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
) : (
<Dialog
onOpenChange={(open) => {
if (open) setStep(1);
}}
> >
<DialogTrigger asChild> {isComplete ? <Trans>Complete</Trans> : <Trans>Next field</Trans>}
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>
<div className="text-foreground text-xl font-semibold">
{role === RecipientRole.VIEWER && <Trans>Complete Viewing</Trans>}
{role === RecipientRole.SIGNER && <Trans>Complete Signing</Trans>}
{role === RecipientRole.APPROVER && <Trans>Complete Approval</Trans>}
</div>
</DialogTitle>
<div className="text-muted-foreground max-w-[50ch]">
{role === RecipientRole.VIEWER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
)}
{role === RecipientRole.SIGNER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete signing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
)}
{role === RecipientRole.APPROVER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete approving{' '}
<span className="inline-block max-w-[11rem] truncate align-baseline">
"{documentTitle}"
</span>
.
</span>
<br /> Are you sure?
</Trans>
</span>
)}
</div>
<SigningDisclosure className="mt-4" />
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button <Button
className="w-full"
type="button" type="button"
size="lg" className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
onClick={fieldsValidated} variant="secondary"
loading={isSubmitting} onClick={() => {
disabled={disabled} setShowDialog(false);
}}
> >
{isComplete ? <Trans>Complete</Trans> : <Trans>Next field</Trans>} <Trans>Cancel</Trans>
</Button> </Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>
{step === 1 && (
<div className="text-foreground text-base font-semibold">
<Trans>
Modify Next Signer <span className="text-muted-foreground">(Optional)</span>
</Trans>
</div>
)}
{step === 2 && ( <Button
<div className="text-foreground text-xl font-semibold"> type="button"
{role === RecipientRole.VIEWER && <Trans>Complete Viewing</Trans>} className="flex-1"
{role === RecipientRole.SIGNER && <Trans>Complete Signing</Trans>} disabled={!isComplete}
{role === RecipientRole.APPROVER && <Trans>Complete Approval</Trans>} loading={isSubmitting}
</div> onClick={onSignatureComplete}
)} >
</DialogTitle> {role === RecipientRole.VIEWER && <Trans>Mark as Viewed</Trans>}
{role === RecipientRole.SIGNER && <Trans>Sign</Trans>}
{step === 1 && ( {role === RecipientRole.APPROVER && <Trans>Approve</Trans>}
<Form {...form}> </Button>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="flex flex-col gap-y-4"> </div>
<FormField </DialogFooter>
control={form.control} </DialogContent>
name="modifyNextSigner" </Dialog>
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormLabel className="font-normal">
<Trans>Modify next signer details</Trans>
</FormLabel>
</FormItem>
)}
/>
{form.watch('modifyNextSigner') && (
<>
<FormField
control={form.control}
name="nextSigner.email"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Next Signer Email</Trans>
</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nextSigner.name"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Next Signer Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</form>
</Form>
)}
{step === 2 && (
<>
<div className="text-muted-foreground max-w-[50ch]">
{role === RecipientRole.VIEWER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
)}
{role === RecipientRole.SIGNER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete signing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
)}
{role === RecipientRole.APPROVER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete approving{' '}
<span className="inline-block max-w-[11rem] truncate align-baseline">
"{documentTitle}"
</span>
.
</span>
<br /> Are you sure?
</Trans>
</span>
)}
</div>
<SigningDisclosure className="mt-4" />
</>
)}
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div className="flex justify-center space-x-1.5 max-sm:order-1">
{[...Array(totalSteps)].map((_, index) => (
<button
key={index}
onClick={() => setStep(index + 1)}
className={cn(
'bg-primary h-1.5 w-1.5 rounded-full',
index + 1 === step ? 'bg-primary' : 'opacity-20',
)}
type="button"
aria-label={`Go to step ${index + 1}`}
/>
))}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="ghost">
Cancel
</Button>
</DialogClose>
{step === 1 && (
<Button className="group" type="button" onClick={handleContinue}>
Next
<ArrowRight
className="-me-1 ms-2 opacity-60 transition-transform group-hover:translate-x-0.5"
size={16}
strokeWidth={2}
aria-hidden="true"
/>
</Button>
)}
{step === 2 && (
<Button
type="button"
className="flex-1"
disabled={!isComplete}
loading={isSubmitting}
onClick={form.handleSubmit(onFormSubmit)}
>
{role === RecipientRole.VIEWER && <Trans>Mark as Viewed</Trans>}
{role === RecipientRole.SIGNER && <Trans>Sign</Trans>}
{role === RecipientRole.APPROVER && <Trans>Approve</Trans>}
</Button>
)}
</DialogFooter>
</div>
</DialogContent>
</Dialog>
)}
</>
); );
} };

View File

@ -49,7 +49,6 @@ export type SigningPageViewProps = {
completedFields: CompletedField[]; completedFields: CompletedField[];
isRecipientsTurn: boolean; isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[]; allRecipients?: RecipientWithFields[];
isLastRecipient: boolean;
}; };
export const SigningPageView = ({ export const SigningPageView = ({
@ -59,7 +58,6 @@ export const SigningPageView = ({
completedFields, completedFields,
isRecipientsTurn, isRecipientsTurn,
allRecipients = [], allRecipients = [],
isLastRecipient,
}: SigningPageViewProps) => { }: SigningPageViewProps) => {
const { documentData, documentMeta } = document; const { documentData, documentMeta } = document;
@ -161,7 +159,6 @@ export const SigningPageView = ({
redirectUrl={documentMeta?.redirectUrl} redirectUrl={documentMeta?.redirectUrl}
isRecipientsTurn={isRecipientsTurn} isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients} allRecipients={allRecipients}
isLastRecipient={isLastRecipient}
setSelectedSignerId={setSelectedSignerId} setSelectedSignerId={setSelectedSignerId}
/> />
</div> </div>

View File

@ -10,6 +10,7 @@ export type EmbedDocumentCompletedPageProps = {
}; };
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => { export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
console.log({ signature });
return ( return (
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6"> <div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<h3 className="text-foreground text-2xl font-semibold"> <h3 className="text-foreground text-2xl font-semibold">

View File

@ -336,6 +336,16 @@ export const DocumentHistorySheet = ({
]} ]}
/> />
)) ))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.SELF_SIGN }, ({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Signed by',
value: data.recipientEmail,
},
]}
/>
))
.with( .with(
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED }, { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED },
({ data }) => ( ({ data }) => (

View File

@ -2,7 +2,9 @@ import { expect, test } from '@playwright/test';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import path from 'node:path'; import path from 'node:path';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email'; import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recipient-by-id';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { import {
DocumentSigningOrder, DocumentSigningOrder,
@ -612,7 +614,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
expect(previousRecipient?.signingStatus).toBe(SigningStatus.SIGNED); expect(previousRecipient?.signingStatus).toBe(SigningStatus.SIGNED);
} }
await page.goto(`/sign/${recipient?.token}`); await page.goto(`/sign/${recipient!.token}`);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await page.locator(`#field-${recipientField.id}`).getByRole('button').click(); await page.locator(`#field-${recipientField.id}`).getByRole('button').click();
@ -630,24 +632,22 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
await page.getByRole('button', { name: 'Complete' }).click(); await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click(); await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`/sign/${recipient?.token}/complete`); await page.waitForURL(`/sign/${recipient!.token}/complete`);
await expect(page.getByText('Document Signed')).toBeVisible(); await expect(page.getByText('Document Signed')).toBeVisible();
const updatedRecipient = await prisma.recipient.findFirst({ const updatedRecipient = await getRecipientById({
where: { id: recipient?.id }, documentId: document.id,
id: recipient!.id,
}); });
expect(updatedRecipient?.signingStatus).toBe(SigningStatus.SIGNED); expect(updatedRecipient?.signingStatus).toBe(SigningStatus.SIGNED);
} }
// Wait for the document to be signed. // Wait for the document to be signed.
await page.waitForTimeout(5000); await expect(async () => {
const signedDocument = await getDocumentById({ id: document.id, userId: user.id });
const finalDocument = await prisma.document.findFirst({ expect(signedDocument?.status).toBe(DocumentStatus.COMPLETED);
where: { id: createdDocument?.id }, }).toPass();
});
expect(finalDocument?.status).toBe(DocumentStatus.COMPLETED);
}); });
test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode', async ({ test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode', async ({
@ -655,7 +655,7 @@ test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode',
}) => { }) => {
const user = await seedUser(); const user = await seedUser();
const { document, recipients } = await seedPendingDocumentWithFullFields({ const { recipients } = await seedPendingDocumentWithFullFields({
owner: user, owner: user,
recipients: ['user1@example.com', 'user2@example.com', 'user3@example.com'], recipients: ['user1@example.com', 'user2@example.com', 'user3@example.com'],
fields: [FieldType.SIGNATURE], fields: [FieldType.SIGNATURE],
@ -682,3 +682,85 @@ test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode',
await expect(page).not.toHaveURL(`/sign/${activeRecipient?.token}/waiting`); await expect(page).not.toHaveURL(`/sign/${activeRecipient?.token}/waiting`);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
}); });
test('[DOCUMENT_FLOW]: should be able to self sign a document', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
const documentTitle = `Self-Signing-${Date.now()}.pdf`;
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.getByLabel('Title').fill(documentTitle);
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByRole('button', { name: 'Add myself' }).click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Sign', exact: true }).click();
const documentRecipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
},
});
const { token, email, id: recipientId } = documentRecipients[0];
expect(documentRecipients.length).toBe(1);
expect(email).toBe(user.email);
await page.waitForURL(`/sign/${token}`);
await expect(page.getByRole('heading', { name: documentTitle })).toBeVisible();
const { status } = await getDocumentByToken(token);
expect(status).toBe(DocumentStatus.PENDING);
const fields = await prisma.field.findMany({
where: { recipientId, documentId: document.id },
});
const recipientField = fields[0];
expect(recipientField).not.toBeNull();
await page.locator(`#field-${recipientField.id}`).getByRole('button').click();
const canvas = page.locator('canvas#signature');
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
await page.mouse.up();
}
await page.getByRole('button', { name: 'Sign', exact: true }).click();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`/sign/${token}/complete`);
await expect(page.getByText('Document Signed')).toBeVisible();
const updatedRecipient = await getRecipientById({ documentId: document.id, id: recipientId });
expect(updatedRecipient?.signingStatus).toBe(SigningStatus.SIGNED);
await expect(async () => {
const signedDocument = await getDocumentById({ id: document.id, userId: user.id });
expect(signedDocument?.status).toBe(DocumentStatus.COMPLETED);
}).toPass();
});

View File

@ -28,7 +28,6 @@ export type CreateDocumentMetaOptions = {
distributionMethod?: DocumentDistributionMethod; distributionMethod?: DocumentDistributionMethod;
typedSignatureEnabled?: boolean; typedSignatureEnabled?: boolean;
language?: SupportedLanguageCodes; language?: SupportedLanguageCodes;
modifyNextSigner?: boolean;
requestMetadata: ApiRequestMetadata; requestMetadata: ApiRequestMetadata;
}; };
@ -47,7 +46,6 @@ export const upsertDocumentMeta = async ({
distributionMethod, distributionMethod,
typedSignatureEnabled, typedSignatureEnabled,
language, language,
modifyNextSigner,
requestMetadata, requestMetadata,
}: CreateDocumentMetaOptions) => { }: CreateDocumentMetaOptions) => {
const document = await prisma.document.findFirst({ const document = await prisma.document.findFirst({
@ -100,7 +98,6 @@ export const upsertDocumentMeta = async ({
distributionMethod, distributionMethod,
typedSignatureEnabled, typedSignatureEnabled,
language, language,
modifyNextSigner,
}, },
update: { update: {
subject, subject,
@ -114,7 +111,6 @@ export const upsertDocumentMeta = async ({
distributionMethod, distributionMethod,
typedSignatureEnabled, typedSignatureEnabled,
language, language,
modifyNextSigner,
}, },
}); });

View File

@ -28,10 +28,6 @@ export type CompleteDocumentWithTokenOptions = {
userId?: number; userId?: number;
authOptions?: TRecipientActionAuth; authOptions?: TRecipientActionAuth;
requestMetadata?: RequestMetadata; requestMetadata?: RequestMetadata;
nextSigner?: {
email: string;
name: string;
};
}; };
const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => { const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => {
@ -55,53 +51,10 @@ const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptio
}); });
}; };
export const delegateNextSigner = async ({
documentId,
currentRecipientId,
nextSigner,
}: {
documentId: number;
currentRecipientId: number;
nextSigner: { email: string; name: string };
}) => {
const document = await prisma.document.findUnique({
where: { id: documentId },
include: {
recipients: {
orderBy: [{ signingOrder: 'asc' }, { id: 'asc' }],
},
},
});
if (!document) {
throw new Error('Document not found');
}
const currentRecipient = document.recipients.find((r) => r.id === currentRecipientId);
const nextRecipient = document.recipients.find(
(r) => r.signingOrder === (currentRecipient?.signingOrder ?? 0) + 1,
);
if (!nextRecipient) {
throw new Error('Next recipient not found');
}
await prisma.recipient.update({
where: { id: nextRecipient.id },
data: {
email: nextSigner.email,
name: nextSigner.name,
},
});
return nextRecipient;
};
export const completeDocumentWithToken = async ({ export const completeDocumentWithToken = async ({
token, token,
documentId, documentId,
requestMetadata, requestMetadata,
nextSigner,
}: CompleteDocumentWithTokenOptions) => { }: CompleteDocumentWithTokenOptions) => {
const document = await getDocument({ token, documentId }); const document = await getDocument({ token, documentId });
@ -159,18 +112,6 @@ export const completeDocumentWithToken = async ({
// throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values'); // throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
// } // }
if (
nextSigner &&
document.documentMeta?.modifyNextSigner &&
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL
) {
await delegateNextSigner({
documentId: document.id,
currentRecipientId: recipient.id,
nextSigner,
});
}
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await tx.recipient.update({ await tx.recipient.update({
where: { where: {

View File

@ -0,0 +1,177 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SendStatus, SigningStatus } from '@documenso/prisma/client';
import { jobs } from '../../jobs/client';
import { getFile } from '../../universal/upload/get-file';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
export type SelfSignDocumentOptions = {
documentId: number;
userId: number;
teamId?: number;
requestMetadata?: ApiRequestMetadata;
};
export const selfSignDocument = async ({
documentId,
userId,
teamId,
requestMetadata,
}: SelfSignDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
const document = await prisma.document.findUnique({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
recipients: {
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
},
documentMeta: true,
documentData: true,
},
});
if (!document) {
throw new Error('Document not found');
}
if (document.recipients.length === 0) {
throw new Error('Document has no recipients');
}
if (document.recipients.length !== 1 || document.recipients[0].email !== user.email) {
throw new Error('Invalid document for self-signing');
}
if (document.status === DocumentStatus.COMPLETED) {
throw new Error('Can not sign completed document');
}
const { documentData } = document;
if (!documentData || !documentData.data) {
throw new Error('Document data not found');
}
if (document.formValues) {
const file = await getFile(documentData);
const prefilled = await insertFormValuesInPdf({
pdf: Buffer.from(file),
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
formValues: document.formValues as Record<string, string | number | boolean>,
});
const newDocumentData = await putPdfFile({
name: document.title,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
});
const result = await prisma.document.update({
where: {
id: document.id,
},
data: {
documentDataId: newDocumentData.id,
},
});
Object.assign(document, result);
}
const recipientHasNoActionToTake =
document.recipients[0].role === RecipientRole.CC ||
document.recipients[0].signingStatus === SigningStatus.SIGNED;
if (recipientHasNoActionToTake) {
await jobs.triggerJob({
name: 'internal.seal-document',
payload: {
documentId,
requestMetadata: requestMetadata?.requestMetadata,
},
});
return await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
recipients: true,
},
});
}
const updatedDocument = await prisma.$transaction(async (tx) => {
if (document.status === DocumentStatus.DRAFT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.SELF_SIGN,
documentId: document.id,
requestMetadata: requestMetadata?.requestMetadata,
user,
data: {
recipientId: document.recipients[0].id,
recipientEmail: document.recipients[0].email,
recipientName: document.recipients[0].name,
recipientRole: document.recipients[0].role,
},
}),
});
}
await tx.recipient.update({
where: {
id: document.recipients[0].id,
},
data: {
sendStatus: SendStatus.SENT,
},
});
return await tx.document.update({
where: {
id: documentId,
},
data: {
status: DocumentStatus.PENDING,
},
include: {
recipients: true,
},
});
});
return updatedDocument;
};

View File

@ -94,7 +94,7 @@ export const sendDocument = async ({
const { documentData } = document; const { documentData } = document;
if (!documentData.data) { if (!documentData || !documentData.data) {
throw new Error('Document data not found'); throw new Error('Document data not found');
} }

View File

@ -1,46 +0,0 @@
import { prisma } from '@documenso/prisma';
import { DocumentSigningOrder, RecipientRole, SigningStatus } from '@documenso/prisma/client';
export type GetIsLastRecipientOptions = {
token: string;
};
export async function getIsLastRecipient({ token }: GetIsLastRecipientOptions) {
const document = await prisma.document.findFirstOrThrow({
where: {
recipients: {
some: {
token,
},
},
},
include: {
documentMeta: true,
recipients: {
where: {
role: {
not: RecipientRole.CC,
},
},
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
},
},
});
if (document.documentMeta?.signingOrder !== DocumentSigningOrder.SEQUENTIAL) {
const unsignedRecipients = document.recipients.filter(
(recipient) => recipient.signingStatus !== SigningStatus.SIGNED,
);
return unsignedRecipients.length <= 1;
}
const { recipients } = document;
const currentRecipientIndex = recipients.findIndex((r) => r.token === token);
if (currentRecipientIndex === -1) {
return false;
}
return currentRecipientIndex === recipients.length - 1;
}

View File

@ -13,6 +13,7 @@ import { ZRecipientAccessAuthTypesSchema, ZRecipientActionAuthTypesSchema } from
export const ZDocumentAuditLogTypeSchema = z.enum([ export const ZDocumentAuditLogTypeSchema = z.enum([
// Document actions. // Document actions.
'EMAIL_SENT', 'EMAIL_SENT',
'SELF_SIGN',
// Document modification events. // Document modification events.
'FIELD_CREATED', 'FIELD_CREATED',
@ -181,6 +182,14 @@ export const ZDocumentAuditLogEventEmailSentSchema = z.object({
}), }),
}); });
/**
* Event: Self sign
*/
export const ZDocumentAuditLogSelfSignSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.SELF_SIGN),
data: ZBaseRecipientDataSchema,
});
/** /**
* Event: Document completed. * Event: Document completed.
*/ */
@ -566,6 +575,7 @@ export const ZDocumentAuditLogBaseSchema = z.object({
export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and( export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
z.union([ z.union([
ZDocumentAuditLogEventEmailSentSchema, ZDocumentAuditLogEventEmailSentSchema,
ZDocumentAuditLogSelfSignSchema,
ZDocumentAuditLogEventDocumentCompletedSchema, ZDocumentAuditLogEventDocumentCompletedSchema,
ZDocumentAuditLogEventDocumentCreatedSchema, ZDocumentAuditLogEventDocumentCreatedSchema,
ZDocumentAuditLogEventDocumentDeletedSchema, ZDocumentAuditLogEventDocumentDeletedSchema,

View File

@ -55,7 +55,6 @@ export const ZDocumentSchema = DocumentSchema.pick({
typedSignatureEnabled: true, typedSignatureEnabled: true,
language: true, language: true,
emailSettings: true, emailSettings: true,
modifyNextSigner: true,
}).nullable(), }).nullable(),
recipients: ZRecipientLiteSchema.array(), recipients: ZRecipientLiteSchema.array(),
fields: ZFieldSchema.array(), fields: ZFieldSchema.array(),

View File

@ -369,16 +369,6 @@ export const formatDocumentAuditLogAction = (
identified: result, identified: result,
}; };
}) })
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, ({ data }) => {
const userName = prefix || _(msg`Recipient`);
const result = msg`${userName} rejected the document`;
return {
anonymous: result,
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({
anonymous: data.isResending ? msg`Email resent` : msg`Email sent`, anonymous: data.isResending ? msg`Email resent` : msg`Email sent`,
identified: data.isResending identified: data.isResending
@ -389,6 +379,14 @@ export const formatDocumentAuditLogAction = (
anonymous: msg`Document completed`, anonymous: msg`Document completed`,
identified: msg`Document completed`, identified: msg`Document completed`,
})) }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.SELF_SIGN }, () => ({
anonymous: msg`Self-signed document`,
identified: msg`${prefix} self-signed the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, ({ data }) => ({
anonymous: msg`Document rejected`,
identified: msg`${prefix} rejected the document: ${data.reason}`,
}))
.exhaustive(); .exhaustive();
return { return {

View File

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "DocumentMeta" ADD COLUMN "modifyNextSigner" BOOLEAN NOT NULL DEFAULT false;

View File

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "TemplateMeta" ADD COLUMN "modifyNextSigner" BOOLEAN NOT NULL DEFAULT false;

View File

@ -390,7 +390,6 @@ model DocumentMeta {
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
redirectUrl String? redirectUrl String?
signingOrder DocumentSigningOrder @default(PARALLEL) signingOrder DocumentSigningOrder @default(PARALLEL)
modifyNextSigner Boolean @default(false)
typedSignatureEnabled Boolean @default(true) typedSignatureEnabled Boolean @default(true)
language String @default("en") language String @default("en")
distributionMethod DocumentDistributionMethod @default(EMAIL) distributionMethod DocumentDistributionMethod @default(EMAIL)
@ -660,7 +659,6 @@ model TemplateMeta {
signingOrder DocumentSigningOrder? @default(PARALLEL) signingOrder DocumentSigningOrder? @default(PARALLEL)
typedSignatureEnabled Boolean @default(true) typedSignatureEnabled Boolean @default(true)
distributionMethod DocumentDistributionMethod @default(EMAIL) distributionMethod DocumentDistributionMethod @default(EMAIL)
modifyNextSigner Boolean @default(false)
templateId Int @unique templateId Int @unique
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade) template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)

View File

@ -5,18 +5,6 @@ import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { prisma } from '..'; import { prisma } from '..';
import { DocumentDataType, DocumentSource, Role, TeamMemberRole } from '../client'; import { DocumentDataType, DocumentSource, Role, TeamMemberRole } from '../client';
import { seedPendingDocument } from './documents';
import { seedDirectTemplate, seedTemplate } from './templates';
const createDocumentData = async ({ documentData }: { documentData: string }) => {
return prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: documentData,
initialData: documentData,
},
});
};
export const seedDatabase = async () => { export const seedDatabase = async () => {
const examplePdf = fs const examplePdf = fs
@ -51,80 +39,35 @@ export const seedDatabase = async () => {
update: {}, update: {},
}); });
for (let i = 1; i <= 4; i++) { const examplePdfData = await prisma.documentData.upsert({
const documentData = await createDocumentData({ documentData: examplePdf }); where: {
id: 'clmn0kv5k0000pe04vcqg5zla',
await prisma.document.create({
data: {
source: DocumentSource.DOCUMENT,
title: `Example Document ${i}`,
documentDataId: documentData.id,
userId: exampleUser.id,
recipients: {
create: {
name: String(adminUser.name),
email: adminUser.email,
token: Math.random().toString(36).slice(2, 9),
},
},
},
});
}
for (let i = 1; i <= 4; i++) {
const documentData = await createDocumentData({ documentData: examplePdf });
await prisma.document.create({
data: {
source: DocumentSource.DOCUMENT,
title: `Document ${i}`,
documentDataId: documentData.id,
userId: adminUser.id,
recipients: {
create: {
name: String(exampleUser.name),
email: exampleUser.email,
token: Math.random().toString(36).slice(2, 9),
},
},
},
});
}
await seedPendingDocument(exampleUser, [adminUser], {
key: 'example-pending',
createDocumentOptions: {
title: 'Pending Document',
}, },
create: {
id: 'clmn0kv5k0000pe04vcqg5zla',
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
update: {},
}); });
await seedPendingDocument(adminUser, [exampleUser], { await prisma.document.create({
key: 'admin-pending', data: {
createDocumentOptions: { source: DocumentSource.DOCUMENT,
title: 'Pending Document', title: 'Example Document',
documentDataId: examplePdfData.id,
userId: exampleUser.id,
recipients: {
create: {
name: String(adminUser.name),
email: adminUser.email,
token: Math.random().toString(36).slice(2, 9),
},
},
}, },
}); });
await Promise.all([
seedTemplate({
title: 'Template 1',
userId: exampleUser.id,
}),
seedDirectTemplate({
title: 'Direct Template 1',
userId: exampleUser.id,
}),
seedTemplate({
title: 'Template 1',
userId: adminUser.id,
}),
seedDirectTemplate({
title: 'Direct Template 1',
userId: adminUser.id,
}),
]);
const testUsers = [ const testUsers = [
'test@documenso.com', 'test@documenso.com',
'test2@documenso.com', 'test2@documenso.com',

View File

@ -20,6 +20,7 @@ import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/
import { moveDocumentToTeam } from '@documenso/lib/server-only/document/move-document-to-team'; import { moveDocumentToTeam } from '@documenso/lib/server-only/document/move-document-to-team';
import { resendDocument } from '@documenso/lib/server-only/document/resend-document'; import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword'; import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword';
import { selfSignDocument } from '@documenso/lib/server-only/document/self-sign-document';
import { sendDocument } from '@documenso/lib/server-only/document/send-document'; import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { updateDocument } from '@documenso/lib/server-only/document/update-document'; import { updateDocument } from '@documenso/lib/server-only/document/update-document';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto'; import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
@ -50,6 +51,7 @@ import {
ZMoveDocumentToTeamSchema, ZMoveDocumentToTeamSchema,
ZResendDocumentMutationSchema, ZResendDocumentMutationSchema,
ZSearchDocumentsMutationSchema, ZSearchDocumentsMutationSchema,
ZSelfSignDocumentMutationSchema,
ZSetPasswordForDocumentMutationSchema, ZSetPasswordForDocumentMutationSchema,
ZSetSigningOrderForDocumentMutationSchema, ZSetSigningOrderForDocumentMutationSchema,
ZSuccessResponseSchema, ZSuccessResponseSchema,
@ -266,14 +268,15 @@ export const documentRouter = router({
/** /**
* @public * @public
*
* Todo: Refactor to updateDocument.
*/ */
updateDocument: authenticatedProcedure setSettingsForDocument: authenticatedProcedure
.meta({ .meta({
openapi: { openapi: {
method: 'POST', method: 'POST',
path: '/document/update', path: '/document/update',
summary: 'Update document', summary: 'Update document',
description: 'Update an existing document',
tags: ['Document'], tags: ['Document'],
}, },
}) })
@ -285,9 +288,9 @@ export const documentRouter = router({
const userId = ctx.user.id; const userId = ctx.user.id;
if (Object.keys(meta).length > 0) { if (Object.values(meta).length > 0) {
await upsertDocumentMeta({ await upsertDocumentMeta({
userId, userId: ctx.user.id,
teamId, teamId,
documentId, documentId,
subject: meta.subject, subject: meta.subject,
@ -300,7 +303,6 @@ export const documentRouter = router({
distributionMethod: meta.distributionMethod, distributionMethod: meta.distributionMethod,
signingOrder: meta.signingOrder, signingOrder: meta.signingOrder,
emailSettings: meta.emailSettings, emailSettings: meta.emailSettings,
modifyNextSigner: meta.modifyNextSigner,
requestMetadata: ctx.metadata, requestMetadata: ctx.metadata,
}); });
} }
@ -467,6 +469,28 @@ export const documentRouter = router({
}); });
}), }),
selfSignDocument: authenticatedProcedure
.input(ZSelfSignDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { documentId, teamId } = input;
return await selfSignDocument({
userId: ctx.user.id,
documentId,
teamId,
requestMetadata: ctx.metadata,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to self sign this document. Please try again later.',
});
}
}),
/** /**
* @public * @public
* *

View File

@ -251,7 +251,6 @@ export const ZUpdateDocumentRequestSchema = z.object({
language: ZDocumentMetaLanguageSchema.optional(), language: ZDocumentMetaLanguageSchema.optional(),
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(), typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(), emailSettings: ZDocumentEmailSettingsSchema.optional(),
modifyNextSigner: z.boolean().optional(),
}) })
.optional(), .optional(),
}); });
@ -294,6 +293,11 @@ export const ZDistributeDocumentRequestSchema = z.object({
.optional(), .optional(),
}); });
export const ZSelfSignDocumentMutationSchema = z.object({
documentId: z.number(),
teamId: z.number().optional(),
});
export const ZDistributeDocumentResponseSchema = ZDocumentLiteSchema; export const ZDistributeDocumentResponseSchema = ZDocumentLiteSchema;
export const ZSetPasswordForDocumentMutationSchema = z.object({ export const ZSetPasswordForDocumentMutationSchema = z.object({

View File

@ -437,14 +437,13 @@ export const recipientRouter = router({
completeDocumentWithToken: procedure completeDocumentWithToken: procedure
.input(ZCompleteDocumentWithTokenMutationSchema) .input(ZCompleteDocumentWithTokenMutationSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { token, documentId, authOptions, nextSigner } = input; const { token, documentId, authOptions } = input;
return await completeDocumentWithToken({ return await completeDocumentWithToken({
token, token,
documentId, documentId,
authOptions, authOptions,
userId: ctx.user?.id, userId: ctx.user?.id,
nextSigner,
requestMetadata: extractNextApiRequestMetadata(ctx.req), requestMetadata: extractNextApiRequestMetadata(ctx.req),
}); });
}), }),

View File

@ -212,12 +212,6 @@ export const ZCompleteDocumentWithTokenMutationSchema = z.object({
token: z.string(), token: z.string(),
documentId: z.number(), documentId: z.number(),
authOptions: ZRecipientActionAuthSchema.optional(), authOptions: ZRecipientActionAuthSchema.optional(),
nextSigner: z
.object({
email: z.string().email(),
name: z.string(),
})
.optional(),
}); });
export type TCompleteDocumentWithTokenMutationSchema = z.infer< export type TCompleteDocumentWithTokenMutationSchema = z.infer<

View File

@ -21,6 +21,7 @@ import {
Type, Type,
User, User,
} from 'lucide-react'; } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { prop, sortBy } from 'remeda'; import { prop, sortBy } from 'remeda';
@ -120,6 +121,7 @@ export const AddFieldsFormPartial = ({
teamId, teamId,
}: AddFieldsFormProps) => { }: AddFieldsFormProps) => {
const { toast } = useToast(); const { toast } = useToast();
const { data: session } = useSession();
const { _ } = useLingui(); const { _ } = useLingui();
const [isMissingSignatureDialogVisible, setIsMissingSignatureDialogVisible] = useState(false); const [isMissingSignatureDialogVisible, setIsMissingSignatureDialogVisible] = useState(false);
@ -558,6 +560,10 @@ export const AddFieldsFormPartial = ({
); );
}, [recipientsByRole]); }, [recipientsByRole]);
const hasSameOwnerAsRecipient =
recipientsByRole.SIGNER.length === 1 &&
recipientsByRole.SIGNER[0].email === session?.user?.email;
const handleAdvancedSettings = () => { const handleAdvancedSettings = () => {
setShowAdvancedSettings((prev) => !prev); setShowAdvancedSettings((prev) => !prev);
}; };
@ -1132,6 +1138,7 @@ export const AddFieldsFormPartial = ({
documentFlow.onBackStep?.(); documentFlow.onBackStep?.();
}} }}
goBackLabel={canRenderBackButtonAsRemove ? msg`Remove` : undefined} goBackLabel={canRenderBackButtonAsRemove ? msg`Remove` : undefined}
goNextLabel={hasSameOwnerAsRecipient ? msg`Sign` : undefined}
onGoNextClick={handleGoNextClick} onGoNextClick={handleGoNextClick}
/> />
</DocumentFlowFormContainerFooter> </DocumentFlowFormContainerFooter>

View File

@ -49,7 +49,6 @@ export type AddSignersFormProps = {
recipients: Recipient[]; recipients: Recipient[];
fields: Field[]; fields: Field[];
signingOrder?: DocumentSigningOrder | null; signingOrder?: DocumentSigningOrder | null;
modifyNextSigner?: boolean | null;
isDocumentEnterprise: boolean; isDocumentEnterprise: boolean;
onSubmit: (_data: TAddSignersFormSchema) => void; onSubmit: (_data: TAddSignersFormSchema) => void;
isDocumentPdfLoaded: boolean; isDocumentPdfLoaded: boolean;
@ -60,7 +59,6 @@ export const AddSignersFormPartial = ({
recipients, recipients,
fields, fields,
signingOrder, signingOrder,
modifyNextSigner,
isDocumentEnterprise, isDocumentEnterprise,
onSubmit, onSubmit,
isDocumentPdfLoaded, isDocumentPdfLoaded,
@ -109,7 +107,6 @@ export const AddSignersFormPartial = ({
) )
: defaultRecipients, : defaultRecipients,
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL, signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
modifyNextSigner: modifyNextSigner ?? false,
}, },
}); });
@ -407,35 +404,6 @@ export const AddSignersFormPartial = ({
</FormItem> </FormItem>
)} )}
/> />
{isSigningOrderSequential && (
<FormField
control={form.control}
name="modifyNextSigner"
render={({ field }) => (
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
id="modifyNextSigner"
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
}}
disabled={isSubmitting || hasDocumentBeenSent}
/>
</FormControl>
<FormLabel
htmlFor="modifyNextSigner"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Modify next signer</Trans>
</FormLabel>
</FormItem>
)}
/>
)}
<DragDropContext <DragDropContext
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
sensors={[ sensors={[

View File

@ -25,7 +25,6 @@ export const ZAddSignersFormSchema = z
}), }),
), ),
signingOrder: z.nativeEnum(DocumentSigningOrder), signingOrder: z.nativeEnum(DocumentSigningOrder),
modifyNextSigner: z.boolean(),
}) })
.refine( .refine(
(schema) => { (schema) => {

View File

@ -183,12 +183,12 @@ const FormMessage = React.forwardRef<
FormMessage.displayName = 'FormMessage'; FormMessage.displayName = 'FormMessage';
export { export {
useFormField,
Form, Form,
FormControl,
FormDescription,
FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormControl,
FormDescription,
FormMessage, FormMessage,
useFormField, FormField,
}; };