Compare commits

..

2 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan
c560b9e9e3 feat: add restore deleted document dialog 2025-03-13 22:09:07 +00:00
Ephraim Atta-Duncan
27cd8f9c25 feat: deleted documents bin 2025-03-13 19:45:11 +00:00
57 changed files with 746 additions and 1455 deletions

View File

@@ -1,9 +1,4 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
@@ -14,208 +9,64 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} 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 { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure'; import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
export type NextSigner = {
name: string;
email: string;
};
type ConfirmationDialogProps = { type ConfirmationDialogProps = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onConfirm: (nextSigner?: NextSigner) => void; onConfirm: () => void;
hasUninsertedFields: boolean; hasUninsertedFields: boolean;
isSubmitting: boolean; isSubmitting: boolean;
allowDictateNextSigner?: boolean;
defaultNextSigner?: NextSigner;
}; };
const ZNextSignerFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
});
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
export function AssistantConfirmationDialog({ export function AssistantConfirmationDialog({
isOpen, isOpen,
onClose, onClose,
onConfirm, onConfirm,
hasUninsertedFields, hasUninsertedFields,
isSubmitting, isSubmitting,
allowDictateNextSigner = false,
defaultNextSigner,
}: ConfirmationDialogProps) { }: ConfirmationDialogProps) {
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
const form = useForm<TNextSignerFormSchema>({
resolver: zodResolver(ZNextSignerFormSchema),
defaultValues: {
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
},
});
const onOpenChange = () => { const onOpenChange = () => {
if (form.formState.isSubmitting) { if (isSubmitting) {
return; return;
} }
form.reset({
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
});
setIsEditingNextSigner(false);
onClose(); onClose();
}; };
const onFormSubmit = async (data: TNextSignerFormSchema) => {
if (allowDictateNextSigner && data.name && data.email) {
await onConfirm({
name: data.name,
email: data.email,
});
} else {
await onConfirm();
}
};
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
return ( return (
<Dialog open={isOpen} onOpenChange={onOpenChange}> <Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
disabled={form.formState.isSubmitting || isSubmitting}
className="border-none p-0"
>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<Trans>Complete Document</Trans> <Trans>Complete Document</Trans>
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
<Trans> <Trans>
Are you sure you want to complete the document? This action cannot be undone. Are you sure you want to complete the document? This action cannot be undone. Please
Please ensure that you have completed prefilling all relevant fields before ensure that you have completed prefilling all relevant fields before proceeding.
proceeding.
</Trans> </Trans>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="mt-4 flex flex-col gap-4"> <div className="flex flex-col gap-4">
{allowDictateNextSigner && ( <DocumentSigningDisclosure />
<div className="space-y-4">
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
</p>
<Button
type="button"
className="mt-2"
variant="outline"
size="sm"
onClick={() => setIsEditingNextSigner((prev) => !prev)}
>
<Trans>Update Recipient</Trans>
</Button>
</div>
)}
{isEditingNextSigner && (
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder="Enter the next signer's name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder="Enter the next signer's email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
)}
<DocumentSigningDisclosure className="mt-4" />
</div> </div>
<DialogFooter className="mt-4"> <DialogFooter className="mt-4">
<Button <Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
type="button" Cancel
variant="secondary"
onClick={onClose}
disabled={form.formState.isSubmitting}
>
<Trans>Cancel</Trans>
</Button> </Button>
<Button <Button
type="submit"
variant={hasUninsertedFields ? 'destructive' : 'default'} variant={hasUninsertedFields ? 'destructive' : 'default'}
disabled={form.formState.isSubmitting || !isNextSignerValid} onClick={onConfirm}
loading={form.formState.isSubmitting} disabled={isSubmitting}
loading={isSubmitting}
> >
{form.formState.isSubmitting ? ( {isSubmitting ? 'Submitting...' : hasUninsertedFields ? 'Proceed' : 'Continue'}
<Trans>Submitting...</Trans>
) : hasUninsertedFields ? (
<Trans>Proceed</Trans>
) : (
<Trans>Continue</Trans>
)}
</Button> </Button>
</DialogFooter> </DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -162,7 +162,16 @@ export const DocumentDeleteDialog = ({
</ul> </ul>
</AlertDescription> </AlertDescription>
)) ))
.exhaustive()} // DocumentStatus.REJECTED isnt working currently so this is a fallback to prevent 500 error.
// The union should work but currently its not
.otherwise(() => (
<AlertDescription>
<Trans>
Please note that this action is <strong>irreversible</strong>. Once confirmed,
this document will be permanently deleted.
</Trans>
</AlertDescription>
))}
</Alert> </Alert>
) : ( ) : (
<Alert variant="warning" className="-mt-1"> <Alert variant="warning" className="-mt-1">

View File

@@ -0,0 +1,119 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DocumentRestoreDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
onRestore?: () => Promise<void> | void;
documentTitle: string;
teamId?: number;
canManageDocument: boolean;
};
export const DocumentRestoreDialog = ({
id,
open,
onOpenChange,
onRestore,
documentTitle,
canManageDocument,
}: DocumentRestoreDialogProps) => {
const { toast } = useToast();
const { refreshLimits } = useLimits();
const { _ } = useLingui();
const { mutateAsync: restoreDocument, isPending } =
trpcReact.document.restoreDocument.useMutation({
onSuccess: async () => {
void refreshLimits();
toast({
title: _(msg`Document restored`),
description: _(msg`"${documentTitle}" has been successfully restored`),
duration: 5000,
});
await onRestore?.();
onOpenChange(false);
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`This document could not be restored at this time. Please try again.`),
variant: 'destructive',
duration: 7500,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Restore Document</Trans>
</DialogTitle>
<DialogDescription>
{canManageDocument ? (
<Trans>
You are about to restore <strong>"{documentTitle}"</strong>
</Trans>
) : (
<Trans>
You are about to unhide <strong>"{documentTitle}"</strong>
</Trans>
)}
</DialogDescription>
</DialogHeader>
<Alert variant="neutral" className="-mt-1">
<AlertDescription>
{canManageDocument ? (
<Trans>
The document will be restored to your account and will be available in your
documents list.
</Trans>
) : (
<Trans>
The document will be unhidden from your account and will be available in your
documents list.
</Trans>
)}
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
loading={isPending}
onClick={() => void restoreDocument({ documentId: id })}
>
<Trans>Restore</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -531,27 +531,6 @@ export const SignUpForm = ({
</div> </div>
</form> </form>
</Form> </Form>
<p className="text-muted-foreground mt-6 text-xs">
<Trans>
By proceeding, you agree to our{' '}
<Link
to="https://documen.so/terms"
target="_blank"
className="text-documenso-700 duration-200 hover:opacity-70"
>
Terms of Service
</Link>{' '}
and{' '}
<Link
to="https://documen.so/privacy"
target="_blank"
className="text-documenso-700 duration-200 hover:opacity-70"
>
Privacy Policy
</Link>
.
</Trans>
</p>
</div> </div>
</div> </div>
); );

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { Field, Recipient, Signature } from '@prisma/client'; import type { Field, Recipient, Signature } from '@prisma/client';
@@ -170,55 +170,6 @@ export const DirectTemplateSigningForm = ({
// Do not reset to false since we do a redirect. // Do not reset to false since we do a redirect.
}; };
useEffect(() => {
const updatedFields = [...localFields];
localFields.forEach((field) => {
const index = updatedFields.findIndex((f) => f.id === field.id);
let value = '';
match(field.type)
.with(FieldType.TEXT, () => {
const meta = field.fieldMeta ? ZTextFieldMeta.safeParse(field.fieldMeta) : null;
if (meta?.success) {
value = meta.data.text ?? '';
}
})
.with(FieldType.NUMBER, () => {
const meta = field.fieldMeta ? ZNumberFieldMeta.safeParse(field.fieldMeta) : null;
if (meta?.success) {
value = meta.data.value ?? '';
}
})
.with(FieldType.DROPDOWN, () => {
const meta = field.fieldMeta ? ZDropdownFieldMeta.safeParse(field.fieldMeta) : null;
if (meta?.success) {
value = meta.data.defaultValue ?? '';
}
});
if (value) {
const signedValue = {
token: directRecipient.token,
fieldId: field.id,
value,
};
updatedFields[index] = {
...field,
customText: value,
inserted: true,
signedValue,
};
}
});
setLocalFields(updatedFields);
}, []);
return ( return (
<DocumentSigningRecipientProvider recipient={directRecipient}> <DocumentSigningRecipientProvider recipient={directRecipient}>
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} /> <DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />

View File

@@ -97,10 +97,6 @@ export const DocumentSigningCheckboxField = ({
const onSign = async (authOptions?: TRecipientActionAuth) => { const onSign = async (authOptions?: TRecipientActionAuth) => {
try { try {
if (!isLengthConditionMet) {
return;
}
const payload: TSignFieldWithTokenMutationSchema = { const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token, token: recipient.token,
fieldId: field.id, fieldId: field.id,
@@ -198,30 +194,18 @@ export const DocumentSigningCheckboxField = ({
setCheckedValues(updatedValues); setCheckedValues(updatedValues);
const removePayload: TRemovedSignedFieldWithTokenMutationSchema = { await removeSignedFieldWithToken({
token: recipient.token, token: recipient.token,
fieldId: field.id, fieldId: field.id,
}; });
if (onUnsignField) { if (updatedValues.length > 0) {
await onUnsignField(removePayload); await signFieldWithToken({
} else {
await removeSignedFieldWithToken(removePayload);
}
if (updatedValues.length > 0 && shouldAutoSignField) {
const signPayload: TSignFieldWithTokenMutationSchema = {
token: recipient.token, token: recipient.token,
fieldId: field.id, fieldId: field.id,
value: toCheckboxValue(updatedValues), value: toCheckboxValue(updatedValues),
isBase64: true, isBase64: true,
}; });
if (onSignField) {
await onSignField(signPayload);
} else {
await signFieldWithToken(signPayload);
}
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);

View File

@@ -1,12 +1,9 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { Field } from '@prisma/client'; import type { Field } from '@prisma/client';
import { RecipientRole } from '@prisma/client'; import { RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { z } from 'zod';
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -17,15 +14,6 @@ import {
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 { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure'; import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
@@ -34,23 +22,11 @@ export type DocumentSigningCompleteDialogProps = {
documentTitle: string; documentTitle: string;
fields: Field[]; fields: Field[];
fieldsValidated: () => void | Promise<void>; fieldsValidated: () => void | Promise<void>;
onSignatureComplete: (nextSigner?: { name: string; email: string }) => void | Promise<void>; onSignatureComplete: () => void | Promise<void>;
role: RecipientRole; role: RecipientRole;
disabled?: boolean; disabled?: boolean;
allowDictateNextSigner?: boolean;
defaultNextSigner?: {
name: string;
email: string;
};
}; };
const ZNextSignerFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
});
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
export const DocumentSigningCompleteDialog = ({ export const DocumentSigningCompleteDialog = ({
isSubmitting, isSubmitting,
documentTitle, documentTitle,
@@ -59,54 +35,19 @@ export const DocumentSigningCompleteDialog = ({
onSignatureComplete, onSignatureComplete,
role, role,
disabled = false, disabled = false,
allowDictateNextSigner = false,
defaultNextSigner,
}: DocumentSigningCompleteDialogProps) => { }: DocumentSigningCompleteDialogProps) => {
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
const form = useForm<TNextSignerFormSchema>({
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
defaultValues: {
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
},
});
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]); const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
const handleOpenChange = (open: boolean) => { const handleOpenChange = (open: boolean) => {
if (form.formState.isSubmitting || !isComplete) { if (isSubmitting || !isComplete) {
return; return;
} }
if (open) {
form.reset({
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
});
}
setIsEditingNextSigner(false);
setShowDialog(open); setShowDialog(open);
}; };
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 });
} else {
await onSignatureComplete();
}
} catch (error) {
console.error('Error completing signature:', error);
}
};
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
return ( return (
<Dialog open={showDialog} onOpenChange={handleOpenChange}> <Dialog open={showDialog} onOpenChange={handleOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
@@ -130,9 +71,6 @@ export const DocumentSigningCompleteDialog = ({
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
<DialogTitle> <DialogTitle>
<div className="text-foreground text-xl font-semibold"> <div className="text-foreground text-xl font-semibold">
{match(role) {match(role)
@@ -205,95 +143,27 @@ export const DocumentSigningCompleteDialog = ({
))} ))}
</div> </div>
{allowDictateNextSigner && (
<div className="mt-4 flex flex-col gap-4">
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
</p>
<Button
type="button"
className="mt-2"
variant="outline"
size="sm"
onClick={() => setIsEditingNextSigner((prev) => !prev)}
>
<Trans>Update Recipient</Trans>
</Button>
</div>
)}
{isEditingNextSigner && (
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder="Enter the next signer's name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder="Enter the next signer's email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
)}
<DocumentSigningDisclosure className="mt-4" /> <DocumentSigningDisclosure className="mt-4" />
<DialogFooter className="mt-4"> <DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4"> <div className="flex w-full flex-1 flex-nowrap gap-4">
<Button <Button
type="button" type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10" className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary" variant="secondary"
onClick={() => setShowDialog(false)} onClick={() => {
disabled={form.formState.isSubmitting} setShowDialog(false);
}}
> >
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</Button> </Button>
<Button <Button
type="submit" type="button"
className="flex-1" className="flex-1"
disabled={!isComplete || !isNextSignerValid} disabled={!isComplete}
loading={form.formState.isSubmitting} loading={isSubmitting}
onClick={onSignatureComplete}
> >
{match(role) {match(role)
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>) .with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
@@ -305,9 +175,6 @@ export const DocumentSigningCompleteDialog = ({
</Button> </Button>
</div> </div>
</DialogFooter> </DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -1,13 +1,11 @@
import { useId, useMemo, useState } from 'react'; import { useId, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client'; import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { z } from 'zod';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
@@ -27,20 +25,10 @@ import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group
import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { import { AssistantConfirmationDialog } from '../../dialogs/assistant-confirmation-dialog';
AssistantConfirmationDialog,
type NextSigner,
} from '../../dialogs/assistant-confirmation-dialog';
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog'; import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
import { useRequiredDocumentSigningContext } from './document-signing-provider'; import { useRequiredDocumentSigningContext } from './document-signing-provider';
export const ZSigningFormSchema = z.object({
name: z.string().min(1, 'Name is required').optional(),
email: z.string().email('Invalid email address').optional(),
});
export type TSigningFormSchema = z.infer<typeof ZSigningFormSchema>;
export type DocumentSigningFormProps = { export type DocumentSigningFormProps = {
document: DocumentAndSender; document: DocumentAndSender;
recipient: Recipient; recipient: Recipient;
@@ -87,9 +75,7 @@ export const DocumentSigningForm = ({
}, },
}); });
const { handleSubmit, formState } = useForm<TSigningFormSchema>({ const { handleSubmit, formState } = useForm();
resolver: zodResolver(ZSigningFormSchema),
});
// 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,8 +100,7 @@ export const DocumentSigningForm = ({
validateFieldsInserted(fieldsRequiringValidation); validateFieldsInserted(fieldsRequiringValidation);
}; };
const onFormSubmit = async (data: TSigningFormSchema) => { const onFormSubmit = async () => {
try {
setValidateUninsertedFields(true); setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation); const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
@@ -128,22 +113,7 @@ export const DocumentSigningForm = ({
return; return;
} }
const nextSigner = await completeDocument();
data.email && data.name
? {
email: data.email,
name: data.name,
}
: undefined;
await completeDocument(undefined, nextSigner);
} catch (error) {
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'An error occurred while signing',
variant: 'destructive',
});
}
}; };
const onAssistantFormSubmit = () => { const onAssistantFormSubmit = () => {
@@ -154,11 +124,11 @@ export const DocumentSigningForm = ({
setIsConfirmationDialogOpen(true); setIsConfirmationDialogOpen(true);
}; };
const handleAssistantConfirmDialogSubmit = async (nextSigner?: NextSigner) => { const handleAssistantConfirmDialogSubmit = async () => {
setIsAssistantSubmitting(true); setIsAssistantSubmitting(true);
try { try {
await completeDocument(undefined, nextSigner); await completeDocument();
} catch (err) { } catch (err) {
toast({ toast({
title: 'Error', title: 'Error',
@@ -171,18 +141,12 @@ export const DocumentSigningForm = ({
} }
}; };
const completeDocument = async ( const completeDocument = async (authOptions?: TRecipientActionAuth) => {
authOptions?: TRecipientActionAuth, await completeDocumentWithToken({
nextSigner?: { email: string; name: string },
) => {
const payload = {
token: recipient.token, token: recipient.token,
documentId: document.id, documentId: document.id,
authOptions, authOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}), });
};
await completeDocumentWithToken(payload);
analytics.capture('App: Recipient has completed signing', { analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id, signerId: recipient.id,
@@ -197,31 +161,6 @@ export const DocumentSigningForm = ({
} }
}; };
const nextRecipient = useMemo(() => {
if (
!document.documentMeta?.signingOrder ||
document.documentMeta.signingOrder !== 'SEQUENTIAL'
) {
return undefined;
}
const sortedRecipients = allRecipients.sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
if (a.signingOrder === null) return 1;
if (b.signingOrder === null) return -1;
if (a.signingOrder === b.signingOrder) return a.id - b.id;
return a.signingOrder - b.signingOrder;
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
console.log('nextRecipient', nextRecipient);
return ( return (
<div <div
className={cn( className={cn(
@@ -271,19 +210,12 @@ export const DocumentSigningForm = ({
<DocumentSigningCompleteDialog <DocumentSigningCompleteDialog
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
documentTitle={document.title} documentTitle={document.title}
fields={fields} fields={fields}
fieldsValidated={fieldsValidated} fieldsValidated={fieldsValidated}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role} role={recipient.role}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner} disabled={!isRecipientsTurn}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
/> />
</div> </div>
</div> </div>
@@ -374,14 +306,6 @@ export const DocumentSigningForm = ({
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)} onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
onConfirm={handleAssistantConfirmDialogSubmit} onConfirm={handleAssistantConfirmDialogSubmit}
isSubmitting={isAssistantSubmitting} isSubmitting={isAssistantSubmitting}
allowDictateNextSigner={
nextRecipient && document.documentMeta?.allowDictateNextSigner
}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
/> />
</form> </form>
</> </>
@@ -452,9 +376,8 @@ export const DocumentSigningForm = ({
</div> </div>
)} )}
</div> </div>
</fieldset>
<div className="mt-6 flex flex-col gap-4 md:flex-row"> <div className="flex flex-col gap-4 md:flex-row">
<Button <Button
type="button" type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10" className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
@@ -467,25 +390,16 @@ export const DocumentSigningForm = ({
</Button> </Button>
<DocumentSigningCompleteDialog <DocumentSigningCompleteDialog
isSubmitting={isSubmitting || isAssistantSubmitting} isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
documentTitle={document.title} documentTitle={document.title}
fields={fields} fields={fields}
fieldsValidated={fieldsValidated} fieldsValidated={fieldsValidated}
disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role} role={recipient.role}
allowDictateNextSigner={ disabled={!isRecipientsTurn}
nextRecipient && document.documentMeta?.allowDictateNextSigner
}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
/> />
</div> </div>
</fieldset>
</form> </form>
</> </>
)} )}

View File

@@ -40,9 +40,9 @@ import { DocumentReadOnlyFields } from '~/components/general/document/document-r
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider'; import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
export type DocumentSigningPageViewProps = { export type SigningPageViewProps = {
recipient: RecipientWithFields;
document: DocumentAndSender; document: DocumentAndSender;
recipient: RecipientWithFields;
fields: Field[]; fields: Field[];
completedFields: CompletedField[]; completedFields: CompletedField[];
isRecipientsTurn: boolean; isRecipientsTurn: boolean;
@@ -50,13 +50,13 @@ export type DocumentSigningPageViewProps = {
}; };
export const DocumentSigningPageView = ({ export const DocumentSigningPageView = ({
recipient,
document, document,
recipient,
fields, fields,
completedFields, completedFields,
isRecipientsTurn, isRecipientsTurn,
allRecipients = [], allRecipients = [],
}: DocumentSigningPageViewProps) => { }: SigningPageViewProps) => {
const { documentData, documentMeta } = document; const { documentData, documentMeta } = document;
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id); const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);

View File

@@ -38,11 +38,6 @@ export const DocumentSigningRecipientProvider = ({
recipient, recipient,
targetSigner = null, targetSigner = null,
}: DocumentSigningRecipientProviderProps) => { }: DocumentSigningRecipientProviderProps) => {
// console.log({
// recipient,
// targetSigner,
// isAssistantMode: !!targetSigner,
// });
return ( return (
<DocumentSigningRecipientContext.Provider <DocumentSigningRecipientContext.Provider
value={{ value={{

View File

@@ -31,7 +31,10 @@ import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
const ZRejectDocumentFormSchema = z.object({ const ZRejectDocumentFormSchema = z.object({
reason: z.string().max(500, msg`Reason must be less than 500 characters`), reason: z
.string()
.min(5, msg`Please provide a reason`)
.max(500, msg`Reason must be less than 500 characters`),
}); });
type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>; type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;

View File

@@ -71,7 +71,7 @@ export const DocumentEditForm = ({
const { recipients, fields } = document; const { recipients, fields } = document;
const { mutateAsync: updateDocumentSettings } = trpc.document.setSettingsForDocument.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(
@@ -176,7 +176,7 @@ export const DocumentEditForm = ({
try { try {
const { timezone, dateFormat, redirectUrl, language } = data.meta; const { timezone, dateFormat, redirectUrl, language } = data.meta;
await updateDocumentSettings({ await updateDocument({
documentId: document.id, documentId: document.id,
data: { data: {
title: data.title, title: data.title,
@@ -213,13 +213,6 @@ export const DocumentEditForm = ({
signingOrder: data.signingOrder, signingOrder: data.signingOrder,
}), }),
updateDocumentSettings({
documentId: document.id,
meta: {
allowDictateNextSigner: data.allowDictateNextSigner,
},
}),
setRecipients({ setRecipients({
documentId: document.id, documentId: document.id,
recipients: data.signers.map((signer) => ({ recipients: data.signers.map((signer) => ({
@@ -249,7 +242,7 @@ export const DocumentEditForm = ({
fields: data.fields, fields: data.fields,
}); });
await updateDocumentSettings({ await updateDocument({
documentId: document.id, documentId: document.id,
meta: { meta: {
@@ -372,7 +365,6 @@ export const DocumentEditForm = ({
documentFlow={documentFlow.signers} documentFlow={documentFlow.signers}
recipients={recipients} recipients={recipients}
signingOrder={document.documentMeta?.signingOrder} signingOrder={document.documentMeta?.signingOrder}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
fields={fields} fields={fields}
isDocumentEnterprise={isDocumentEnterprise} isDocumentEnterprise={isDocumentEnterprise}
onSubmit={onAddSignersFormSubmit} onSubmit={onAddSignersFormSubmit}

View File

@@ -25,7 +25,7 @@ export const DocumentPageViewInformation = ({
const { _, i18n } = useLingui(); const { _, i18n } = useLingui();
const documentInformation = useMemo(() => { const documentInformation = useMemo(() => {
return [ const documentInfo = [
{ {
description: msg`Uploaded by`, description: msg`Uploaded by`,
value: value:
@@ -44,6 +44,19 @@ export const DocumentPageViewInformation = ({
.toRelative(), .toRelative(),
}, },
]; ];
if (document.deletedAt) {
documentInfo.push({
description: msg`Deleted`,
value:
document.deletedAt &&
DateTime.fromJSDate(document.deletedAt)
.setLocale(i18n.locales?.[0] || i18n.locale)
.toFormat('MMMM d, yyyy'),
});
}
return documentInfo;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMounted, document, userId]); }, [isMounted, document, userId]);

View File

@@ -3,7 +3,7 @@ import type { HTMLAttributes } from 'react';
import type { MessageDescriptor } from '@lingui/core'; import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { CheckCircle2, Clock, File, XCircle } from 'lucide-react'; import { CheckCircle2, Clock, File, Trash, XCircle } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react'; import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
@@ -36,11 +36,11 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
icon: File, icon: File,
color: 'text-yellow-500 dark:text-yellow-200', color: 'text-yellow-500 dark:text-yellow-200',
}, },
REJECTED: { DELETED: {
label: msg`Rejected`, label: msg`Deleted`,
labelExtended: msg`Document rejected`, labelExtended: msg`Document deleted`,
icon: XCircle, icon: Trash,
color: 'text-red-500 dark:text-red-300', color: 'text-red-700 dark:text-red-500',
}, },
INBOX: { INBOX: {
label: msg`Inbox`, label: msg`Inbox`,
@@ -53,6 +53,12 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
labelExtended: msg`Document All`, labelExtended: msg`Document All`,
color: 'text-muted-foreground', color: 'text-muted-foreground',
}, },
REJECTED: {
label: msg`Rejected`,
labelExtended: msg`Document rejected`,
icon: XCircle,
color: 'text-red-500 dark:text-red-300',
},
}; };
export type DocumentStatusProps = HTMLAttributes<HTMLSpanElement> & { export type DocumentStatusProps = HTMLAttributes<HTMLSpanElement> & {

View File

@@ -161,7 +161,6 @@ export const TemplateEditForm = ({
templateId: template.id, templateId: template.id,
meta: { meta: {
signingOrder: data.signingOrder, signingOrder: data.signingOrder,
allowDictateNextSigner: data.allowDictateNextSigner,
}, },
}), }),
@@ -272,7 +271,6 @@ export const TemplateEditForm = ({
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
signingOrder={template.templateMeta?.signingOrder} signingOrder={template.templateMeta?.signingOrder}
allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner}
templateDirectLink={template.directLink} templateDirectLink={template.directLink}
onSubmit={onAddTemplatePlaceholderFormSubmit} onSubmit={onAddTemplatePlaceholderFormSubmit}
isEnterprise={isEnterprise} isEnterprise={isEnterprise}

View File

@@ -15,6 +15,7 @@ import {
MoreHorizontal, MoreHorizontal,
MoveRight, MoveRight,
Pencil, Pencil,
RotateCcw,
Share, Share,
Trash2, Trash2,
} from 'lucide-react'; } from 'lucide-react';
@@ -39,6 +40,7 @@ import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialo
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog'; import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
import { DocumentMoveDialog } from '~/components/dialogs/document-move-dialog'; import { DocumentMoveDialog } from '~/components/dialogs/document-move-dialog';
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog'; import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
import { DocumentRestoreDialog } from '~/components/dialogs/document-restore-dialog';
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog'; import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
import { useOptionalCurrentTeam } from '~/providers/team'; import { useOptionalCurrentTeam } from '~/providers/team';
@@ -58,18 +60,20 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
const { _ } = useLingui(); const { _ } = useLingui();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isRestoreDialogOpen, setRestoreDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false); const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
const [isMoveDialogOpen, setMoveDialogOpen] = useState(false); const [isMoveDialogOpen, setMoveDialogOpen] = useState(false);
const recipient = row.recipients.find((recipient) => recipient.email === user.email); const recipient = row.recipients.find((recipient) => recipient.email === user.email);
const isOwner = row.user.id === user.id; const isOwner = row.user.id === user.id;
// const isRecipient = !!recipient; const isRecipient = !!recipient;
const isDraft = row.status === DocumentStatus.DRAFT; const isDraft = row.status === DocumentStatus.DRAFT;
const isPending = row.status === DocumentStatus.PENDING; const isPending = row.status === DocumentStatus.PENDING;
const isComplete = isDocumentCompleted(row.status); const isComplete = isDocumentCompleted(row.status);
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isCurrentTeamDocument = team && row.team?.url === team.url; const isCurrentTeamDocument = team && row.team?.url === team.url;
const isDocumentDeleted = row.deletedAt !== null;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument); const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
const documentsPath = formatDocumentsPath(team?.url); const documentsPath = formatDocumentsPath(team?.url);
@@ -171,10 +175,17 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
Void Void
</DropdownMenuItem> */} </DropdownMenuItem> */}
{isDocumentDeleted || (isRecipient && !canManageDocument) ? (
<DropdownMenuItem disabled={isRecipient} onClick={() => setRestoreDialogOpen(true)}>
<RotateCcw className="mr-2 h-4 w-4" />
<Trans>Restore</Trans>
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)}> <DropdownMenuItem onClick={() => setDeleteDialogOpen(true)}>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)} {canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
</DropdownMenuItem> </DropdownMenuItem>
)}
<DropdownMenuLabel> <DropdownMenuLabel>
<Trans>Share</Trans> <Trans>Share</Trans>
@@ -220,6 +231,15 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
canManageDocument={canManageDocument} canManageDocument={canManageDocument}
/> />
<DocumentRestoreDialog
id={row.id}
open={isRestoreDialogOpen}
onOpenChange={setRestoreDialogOpen}
documentTitle={row.title}
teamId={team?.id}
canManageDocument={canManageDocument}
/>
<DocumentMoveDialog <DocumentMoveDialog
documentId={row.id} documentId={row.id}
open={isMoveDialogOpen} open={isMoveDialogOpen}

View File

@@ -1,6 +1,6 @@
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Bird, CheckCircle2 } from 'lucide-react'; import { Bird, CheckCircle2, Trash } from 'lucide-react';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
@@ -30,6 +30,11 @@ export const DocumentsTableEmptyState = ({ status }: DocumentsTableEmptyStatePro
message: msg`You have not yet created or received any documents. To create a document please upload one.`, message: msg`You have not yet created or received any documents. To create a document please upload one.`,
icon: Bird, icon: Bird,
})) }))
.with(ExtendedDocumentStatus.DELETED, () => ({
title: msg`Nothing in the trash`,
message: msg`There are no documents in the trash.`,
icon: Trash,
}))
.otherwise(() => ({ .otherwise(() => ({
title: msg`Nothing to do`, title: msg`Nothing to do`,
message: msg`All documents have been processed. Any new documents that are sent or received will show here.`, message: msg`All documents have been processed. Any new documents that are sent or received will show here.`,

View File

@@ -9,7 +9,6 @@ import { match } from 'ts-pattern';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema'; import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
@@ -76,8 +75,7 @@ export const DocumentsTable = ({ data, isLoading, isLoadingError }: DocumentsTab
}, },
{ {
header: _(msg`Actions`), header: _(msg`Actions`),
cell: ({ row }) => cell: ({ row }) => (
(!row.original.deletedAt || isDocumentCompleted(row.original.status)) && (
<div className="flex items-center gap-x-4"> <div className="flex items-center gap-x-4">
<DocumentsTableActionButton row={row.original} /> <DocumentsTableActionButton row={row.original} />
<DocumentsTableActionDropdown row={row.original} /> <DocumentsTableActionDropdown row={row.original} />

View File

@@ -1,8 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { useSearchParams } from 'react-router'; import { Link, useSearchParams } from 'react-router';
import { Link } from 'react-router';
import { z } from 'zod'; import { z } from 'zod';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
@@ -51,6 +50,7 @@ export default function DocumentsPage() {
[ExtendedDocumentStatus.PENDING]: 0, [ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0, [ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0, [ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.DELETED]: 0,
[ExtendedDocumentStatus.INBOX]: 0, [ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0, [ExtendedDocumentStatus.ALL]: 0,
}); });
@@ -114,13 +114,17 @@ export default function DocumentsPage() {
</div> </div>
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1"> <div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto"> <Tabs
value={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL}
className="overflow-x-auto"
>
<TabsList> <TabsList>
{[ {[
ExtendedDocumentStatus.INBOX, ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING, ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED, ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.DRAFT, ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.DELETED,
ExtendedDocumentStatus.ALL, ExtendedDocumentStatus.ALL,
].map((value) => ( ].map((value) => (
<TabsTrigger <TabsTrigger

View File

@@ -6,7 +6,6 @@ import { redirect } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js'; import { UAParser } from 'ua-parser-js';
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n'; import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n';
import { import {
RECIPIENT_ROLES_DESCRIPTION, RECIPIENT_ROLES_DESCRIPTION,
@@ -60,8 +59,6 @@ export async function loader({ request }: Route.LoaderArgs) {
throw redirect('/'); throw redirect('/');
} }
const isPlatformDocument = await isDocumentPlatform(document);
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language); const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
const auditLogs = await getDocumentCertificateAuditLogs({ const auditLogs = await getDocumentCertificateAuditLogs({
@@ -73,7 +70,6 @@ export async function loader({ request }: Route.LoaderArgs) {
return { return {
document, document,
documentLanguage, documentLanguage,
isPlatformDocument,
auditLogs, auditLogs,
messages, messages,
}; };
@@ -89,7 +85,7 @@ export async function loader({ request }: Route.LoaderArgs) {
* Update: Maybe <Trans> tags work now after RR7 migration. * Update: Maybe <Trans> tags work now after RR7 migration.
*/ */
export default function SigningCertificate({ loaderData }: Route.ComponentProps) { export default function SigningCertificate({ loaderData }: Route.ComponentProps) {
const { document, documentLanguage, isPlatformDocument, auditLogs, messages } = loaderData; const { document, documentLanguage, auditLogs, messages } = loaderData;
const { i18n, _ } = useLingui(); const { i18n, _ } = useLingui();
@@ -341,7 +337,6 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</CardContent> </CardContent>
</Card> </Card>
{isPlatformDocument && (
<div className="my-8 flex-row-reverse"> <div className="my-8 flex-row-reverse">
<div className="flex items-end justify-end gap-x-4"> <div className="flex items-end justify-end gap-x-4">
<p className="flex-shrink-0 text-sm font-medium print:text-xs"> <p className="flex-shrink-0 text-sm font-medium print:text-xs">
@@ -351,7 +346,6 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
<BrandingLogo className="max-h-6 print:max-h-4" /> <BrandingLogo className="max-h-6 print:max-h-4" />
</div> </div>
</div> </div>
)}
</div> </div>
); );
} }

View File

@@ -1,5 +1,5 @@
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { DocumentSigningOrder, DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client'; import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { Clock8 } from 'lucide-react'; import { Clock8 } from 'lucide-react';
import { Link, redirect } from 'react-router'; import { Link, redirect } from 'react-router';
import { getOptionalLoaderContext } from 'server/utils/get-loader-session'; import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
@@ -13,7 +13,6 @@ import { viewedDocument } from '@documenso/lib/server-only/document/viewed-docum
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 { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn'; import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
import { getNextPendingRecipient } from '@documenso/lib/server-only/recipient/get-next-pending-recipient';
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';
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant'; import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
@@ -73,24 +72,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
? await getRecipientsForAssistant({ ? await getRecipientsForAssistant({
token, token,
}) })
: [recipient]; : [];
if (
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL &&
recipient.role !== RecipientRole.ASSISTANT
) {
const nextPendingRecipient = await getNextPendingRecipient({
documentId: document.id,
currentRecipientId: recipient.id,
});
if (nextPendingRecipient) {
allRecipients.push({
...nextPendingRecipient,
fields: [],
});
}
}
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions, documentAuth: document.authOptions,

View File

@@ -99,6 +99,5 @@
"vite": "^6.1.0", "vite": "^6.1.0",
"vite-plugin-babel-macros": "^1.0.6", "vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
}, }
"version": "1.10.0-rc.1"
} }

View File

@@ -8,7 +8,6 @@ command -v docker >/dev/null 2>&1 || {
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")" SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")" MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
# Get Git information
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)" GIT_SHA="$(git rev-parse HEAD)"
@@ -16,39 +15,12 @@ echo "Building docker image for monorepo at $MONOREPO_ROOT"
echo "App version: $APP_VERSION" echo "App version: $APP_VERSION"
echo "Git SHA: $GIT_SHA" echo "Git SHA: $GIT_SHA"
# Build with temporary base tag
docker build -f "$SCRIPT_DIR/Dockerfile" \ docker build -f "$SCRIPT_DIR/Dockerfile" \
--progress=plain \ --progress=plain \
-t "documenso-base" \ -t "documenso/documenso:latest" \
-t "documenso/documenso:$GIT_SHA" \
-t "documenso/documenso:$APP_VERSION" \
-t "ghcr.io/documenso/documenso:latest" \
-t "ghcr.io/documenso/documenso:$GIT_SHA" \
-t "ghcr.io/documenso/documenso:$APP_VERSION" \
"$MONOREPO_ROOT" "$MONOREPO_ROOT"
# Handle repository tagging
if [ ! -z "$DOCKER_REPOSITORY" ]; then
echo "Using custom repository: $DOCKER_REPOSITORY"
# Add tags for custom repository
docker tag "documenso-base" "$DOCKER_REPOSITORY:latest"
docker tag "documenso-base" "$DOCKER_REPOSITORY:$GIT_SHA"
# Add version tag if available
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
docker tag "documenso-base" "$DOCKER_REPOSITORY:$APP_VERSION"
fi
else
echo "Using default repositories: dockerhub and ghcr.io"
# Add tags for both default repositories
docker tag "documenso-base" "documenso/documenso:latest"
docker tag "documenso-base" "documenso/documenso:$GIT_SHA"
docker tag "documenso-base" "ghcr.io/documenso/documenso:latest"
docker tag "documenso-base" "ghcr.io/documenso/documenso:$GIT_SHA"
# Add version tags if available
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
docker tag "documenso-base" "documenso/documenso:$APP_VERSION"
docker tag "documenso-base" "ghcr.io/documenso/documenso:$APP_VERSION"
fi
fi
# Remove the temporary base tag
docker rmi "documenso-base"

View File

@@ -9,11 +9,11 @@ SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")" MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
# Get the platform from environment variable or set to linux/amd64 if not set # Get the platform from environment variable or set to linux/amd64 if not set
# quote the string to prevent word splitting
if [ -z "$PLATFORM" ]; then if [ -z "$PLATFORM" ]; then
PLATFORM="linux/amd64" PLATFORM="linux/amd64"
fi fi
# Get Git information
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)" GIT_SHA="$(git rev-parse HEAD)"
@@ -21,41 +21,14 @@ echo "Building docker image for monorepo at $MONOREPO_ROOT"
echo "App version: $APP_VERSION" echo "App version: $APP_VERSION"
echo "Git SHA: $GIT_SHA" echo "Git SHA: $GIT_SHA"
# Build with temporary base tag
docker buildx build \ docker buildx build \
-f "$SCRIPT_DIR/Dockerfile" \ -f "$SCRIPT_DIR/Dockerfile" \
--platform=$PLATFORM \ --platform=$PLATFORM \
--progress=plain \ --progress=plain \
-t "documenso-base" \ -t "documenso/documenso:latest" \
-t "documenso/documenso:$GIT_SHA" \
-t "documenso/documenso:$APP_VERSION" \
-t "ghcr.io/documenso/documenso:latest" \
-t "ghcr.io/documenso/documenso:$GIT_SHA" \
-t "ghcr.io/documenso/documenso:$APP_VERSION" \
"$MONOREPO_ROOT" "$MONOREPO_ROOT"
# Handle repository tagging
if [ ! -z "$DOCKER_REPOSITORY" ]; then
echo "Using custom repository: $DOCKER_REPOSITORY"
# Add tags for custom repository
docker tag "documenso-base" "$DOCKER_REPOSITORY:latest"
docker tag "documenso-base" "$DOCKER_REPOSITORY:$GIT_SHA"
# Add version tag if available
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
docker tag "documenso-base" "$DOCKER_REPOSITORY:$APP_VERSION"
fi
else
echo "Using default repositories: dockerhub and ghcr.io"
# Add tags for both default repositories
docker tag "documenso-base" "documenso/documenso:latest"
docker tag "documenso-base" "documenso/documenso:$GIT_SHA"
docker tag "documenso-base" "ghcr.io/documenso/documenso:latest"
docker tag "documenso-base" "ghcr.io/documenso/documenso:$GIT_SHA"
# Add version tags if available
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
docker tag "documenso-base" "documenso/documenso:$APP_VERSION"
docker tag "documenso-base" "ghcr.io/documenso/documenso:$APP_VERSION"
fi
fi
# Remove the temporary base tag
docker rmi "documenso-base"

5
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@documenso/root", "name": "@documenso/root",
"version": "1.10.0-rc.1", "version": "1.9.0-rc.11",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@documenso/root", "name": "@documenso/root",
"version": "1.10.0-rc.1", "version": "1.9.0-rc.11",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*" "packages/*"
@@ -95,7 +95,6 @@
}, },
"apps/remix": { "apps/remix": {
"name": "@documenso/remix", "name": "@documenso/remix",
"version": "1.10.0-rc.1",
"dependencies": { "dependencies": {
"@documenso/api": "*", "@documenso/api": "*",
"@documenso/assets": "*", "@documenso/assets": "*",

View File

@@ -1,6 +1,6 @@
{ {
"private": true, "private": true,
"version": "1.10.0-rc.1", "version": "1.9.0-rc.11",
"scripts": { "scripts": {
"build": "turbo run build", "build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix", "dev": "turbo run dev --filter=@documenso/remix",
@@ -14,7 +14,7 @@
"prepare": "husky && husky install || true", "prepare": "husky && husky install || true",
"commitlint": "commitlint --edit", "commitlint": "commitlint --edit",
"clean": "turbo run clean && rimraf node_modules", "clean": "turbo run clean && rimraf node_modules",
"d": "npm run dx && npm run translate:compile && npm run dev", "d": "npm run dx && npm run dev",
"dx": "npm i && npm run dx:up && npm run prisma:migrate-dev && npm run prisma:seed", "dx": "npm i && npm run dx:up && npm run prisma:migrate-dev && npm run prisma:seed",
"dx:up": "docker compose -f docker/development/compose.yml up -d", "dx:up": "docker compose -f docker/development/compose.yml up -d",
"dx:down": "docker compose -f docker/development/compose.yml down", "dx:down": "docker compose -f docker/development/compose.yml down",

View File

@@ -323,7 +323,6 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
dateFormat: dateFormat?.value, dateFormat: dateFormat?.value,
redirectUrl: body.meta.redirectUrl, redirectUrl: body.meta.redirectUrl,
signingOrder: body.meta.signingOrder, signingOrder: body.meta.signingOrder,
allowDictateNextSigner: body.meta.allowDictateNextSigner,
language: body.meta.language, language: body.meta.language,
typedSignatureEnabled: body.meta.typedSignatureEnabled, typedSignatureEnabled: body.meta.typedSignatureEnabled,
distributionMethod: body.meta.distributionMethod, distributionMethod: body.meta.distributionMethod,

View File

@@ -155,7 +155,6 @@ export const ZCreateDocumentMutationSchema = z.object({
}), }),
redirectUrl: z.string(), redirectUrl: z.string(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(), signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
allowDictateNextSigner: z.boolean().optional(),
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(), language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
typedSignatureEnabled: z.boolean().optional().default(true), typedSignatureEnabled: z.boolean().optional().default(true),
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(), distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(),
@@ -219,7 +218,6 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
dateFormat: z.string(), dateFormat: z.string(),
redirectUrl: z.string(), redirectUrl: z.string(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(), signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
allowDictateNextSigner: z.boolean().optional(),
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(), language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
}) })
.partial() .partial()
@@ -287,7 +285,6 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
dateFormat: z.string(), dateFormat: z.string(),
redirectUrl: ZUrlSchema, redirectUrl: ZUrlSchema,
signingOrder: z.nativeEnum(DocumentSigningOrder), signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean(),
language: z.enum(SUPPORTED_LANGUAGE_CODES), language: z.enum(SUPPORTED_LANGUAGE_CODES),
distributionMethod: z.nativeEnum(DocumentDistributionMethod), distributionMethod: z.nativeEnum(DocumentDistributionMethod),
typedSignatureEnabled: z.boolean(), typedSignatureEnabled: z.boolean(),

View File

@@ -210,7 +210,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
}), }),
}, },
], ],
fields: [FieldType.DATE, FieldType.SIGNATURE], fields: [FieldType.DATE],
}); });
for (const recipient of recipients) { for (const recipient of recipients) {
@@ -307,7 +307,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
}), }),
}, },
], ],
fields: [FieldType.DATE, FieldType.SIGNATURE], fields: [FieldType.DATE],
updateDocumentOptions: { updateDocumentOptions: {
authOptions: createDocumentAuthOptions({ authOptions: createDocumentAuthOptions({
globalAccessAuth: null, globalAccessAuth: null,

View File

@@ -1,390 +0,0 @@
import { expect, test } from '@playwright/test';
import {
DocumentSigningOrder,
DocumentStatus,
FieldType,
RecipientRole,
SigningStatus,
} from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { signSignaturePad } from '../fixtures/signature';
test('[NEXT_RECIPIENT_DICTATION]: should allow updating next recipient when dictation is enabled', async ({
page,
}) => {
const user = await seedUser();
const firstSigner = await seedUser();
const secondSigner = await seedUser();
const thirdSigner = await seedUser();
const { recipients, document } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [firstSigner, secondSigner, thirdSigner],
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }, { signingOrder: 3 }],
updateDocumentOptions: {
documentMeta: {
upsert: {
create: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
update: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
},
},
},
});
const firstRecipient = recipients[0];
const { token, fields } = firstRecipient;
const signUrl = `/sign/${token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await signSignaturePad(page);
// Fill in all fields
for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.locator('#custom-text').fill('TEXT');
await page.getByRole('button', { name: 'Save' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
// Complete signing and update next recipient
await page.getByRole('button', { name: 'Complete' }).click();
// Verify next recipient info is shown
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('The next recipient to sign this document will be')).toBeVisible();
// Update next recipient
await page.locator('button').filter({ hasText: 'Update Recipient' }).click();
await page.waitForTimeout(1000);
// Use dialog context to ensure we're targeting the correct form fields
const dialog = page.getByRole('dialog');
await dialog.getByLabel('Name').fill('New Recipient');
await dialog.getByLabel('Email').fill('new.recipient@example.com');
// Submit and verify completion
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
// Verify document and recipient states
const updatedDocument = await prisma.document.findUniqueOrThrow({
where: { id: document.id },
include: {
recipients: {
orderBy: { signingOrder: 'asc' },
},
},
});
// Document should still be pending as there are more recipients
expect(updatedDocument.status).toBe(DocumentStatus.PENDING);
// First recipient should be completed
const updatedFirstRecipient = updatedDocument.recipients[0];
expect(updatedFirstRecipient.signingStatus).toBe(SigningStatus.SIGNED);
// Second recipient should be the new recipient
const updatedSecondRecipient = updatedDocument.recipients[1];
expect(updatedSecondRecipient.name).toBe('New Recipient');
expect(updatedSecondRecipient.email).toBe('new.recipient@example.com');
expect(updatedSecondRecipient.signingOrder).toBe(2);
expect(updatedSecondRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
});
test('[NEXT_RECIPIENT_DICTATION]: should not show dictation UI when disabled', async ({ page }) => {
const user = await seedUser();
const firstSigner = await seedUser();
const secondSigner = await seedUser();
const { recipients, document } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [firstSigner, secondSigner],
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }],
updateDocumentOptions: {
documentMeta: {
upsert: {
create: {
allowDictateNextSigner: false,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
update: {
allowDictateNextSigner: false,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
},
},
},
});
const firstRecipient = recipients[0];
const { token, fields } = firstRecipient;
const signUrl = `/sign/${token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await signSignaturePad(page);
// Fill in all fields
for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.locator('#custom-text').fill('TEXT');
await page.getByRole('button', { name: 'Save' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
// Complete signing
await page.getByRole('button', { name: 'Complete' }).click();
// Verify next recipient UI is not shown
await expect(
page.getByText('The next recipient to sign this document will be'),
).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Update Recipient' })).not.toBeVisible();
// Submit and verify completion
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
// Verify document and recipient states
const updatedDocument = await prisma.document.findUniqueOrThrow({
where: { id: document.id },
include: {
recipients: {
orderBy: { signingOrder: 'asc' },
},
},
});
// Document should still be pending as there are more recipients
expect(updatedDocument.status).toBe(DocumentStatus.PENDING);
// First recipient should be completed
const updatedFirstRecipient = updatedDocument.recipients[0];
expect(updatedFirstRecipient.signingStatus).toBe(SigningStatus.SIGNED);
// Second recipient should remain unchanged
const updatedSecondRecipient = updatedDocument.recipients[1];
expect(updatedSecondRecipient.email).toBe(secondSigner.email);
expect(updatedSecondRecipient.signingOrder).toBe(2);
expect(updatedSecondRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
});
test('[NEXT_RECIPIENT_DICTATION]: should work with parallel signing flow', async ({ page }) => {
const user = await seedUser();
const firstSigner = await seedUser();
const secondSigner = await seedUser();
const { recipients, document } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [firstSigner, secondSigner],
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }],
updateDocumentOptions: {
documentMeta: {
upsert: {
create: {
allowDictateNextSigner: false,
signingOrder: DocumentSigningOrder.PARALLEL,
},
update: {
allowDictateNextSigner: false,
signingOrder: DocumentSigningOrder.PARALLEL,
},
},
},
},
});
// Test both recipients can sign in parallel
for (const recipient of recipients) {
const { token, fields } = recipient;
const signUrl = `/sign/${token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await signSignaturePad(page);
// Fill in all fields
for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.locator('#custom-text').fill('TEXT');
await page.getByRole('button', { name: 'Save' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
// Complete signing
await page.getByRole('button', { name: 'Complete' }).click();
// Verify next recipient UI is not shown in parallel flow
await expect(
page.getByText('The next recipient to sign this document will be'),
).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Update Recipient' })).not.toBeVisible();
// Submit and verify completion
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
}
// Verify final document and recipient states
await expect(async () => {
const updatedDocument = await prisma.document.findUniqueOrThrow({
where: { id: document.id },
include: {
recipients: {
orderBy: { signingOrder: 'asc' },
},
},
});
// Document should be completed since all recipients have signed
expect(updatedDocument.status).toBe(DocumentStatus.COMPLETED);
// All recipients should be completed
for (const recipient of updatedDocument.recipients) {
expect(recipient.signingStatus).toBe(SigningStatus.SIGNED);
}
}).toPass();
});
test('[NEXT_RECIPIENT_DICTATION]: should allow assistant to dictate next signer', async ({
page,
}) => {
const user = await seedUser();
const assistant = await seedUser();
const signer = await seedUser();
const thirdSigner = await seedUser();
const { recipients, document } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [assistant, signer, thirdSigner],
recipientsCreateOptions: [
{ signingOrder: 1, role: RecipientRole.ASSISTANT },
{ signingOrder: 2, role: RecipientRole.SIGNER },
{ signingOrder: 3, role: RecipientRole.SIGNER },
],
updateDocumentOptions: {
documentMeta: {
upsert: {
create: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
update: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
},
},
},
});
const assistantRecipient = recipients[0];
const { token, fields } = assistantRecipient;
const signUrl = `/sign/${token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Assist Document' })).toBeVisible();
await page.getByRole('radio', { name: assistantRecipient.name }).click();
// Fill in all fields
for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.SIGNATURE) {
await signSignaturePad(page);
await page.getByRole('button', { name: 'Sign', exact: true }).click();
}
if (field.type === FieldType.TEXT) {
await page.locator('#custom-text').fill('TEXT');
await page.getByRole('button', { name: 'Save' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
// Complete assisting and update next recipient
await page.getByRole('button', { name: 'Continue' }).click();
// Verify next recipient info is shown
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('The next recipient to sign this document will be')).toBeVisible();
// Update next recipient
await page.locator('button').filter({ hasText: 'Update Recipient' }).click();
// Use dialog context to ensure we're targeting the correct form fields
const dialog = page.getByRole('dialog');
await dialog.getByLabel('Name').fill('New Signer');
await dialog.getByLabel('Email').fill('new.signer@example.com');
// Submit and verify completion
await page.getByRole('button', { name: /Continue|Proceed/i }).click();
await page.waitForURL(`${signUrl}/complete`);
// Verify document and recipient states
await expect(async () => {
const updatedDocument = await prisma.document.findUniqueOrThrow({
where: { id: document.id },
include: {
recipients: {
orderBy: { signingOrder: 'asc' },
},
},
});
// Document should still be pending as there are more recipients
expect(updatedDocument.status).toBe(DocumentStatus.PENDING);
// Assistant should be completed
const updatedAssistant = updatedDocument.recipients[0];
expect(updatedAssistant.signingStatus).toBe(SigningStatus.SIGNED);
expect(updatedAssistant.role).toBe(RecipientRole.ASSISTANT);
// Second recipient should be the new signer
const updatedSigner = updatedDocument.recipients[1];
expect(updatedSigner.name).toBe('New Signer');
expect(updatedSigner.email).toBe('new.signer@example.com');
expect(updatedSigner.signingOrder).toBe(2);
expect(updatedSigner.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(updatedSigner.role).toBe(RecipientRole.SIGNER);
// Third recipient should remain unchanged
const thirdRecipient = updatedDocument.recipients[2];
expect(thirdRecipient.email).toBe(thirdSigner.email);
expect(thirdRecipient.signingOrder).toBe(3);
expect(thirdRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(thirdRecipient.role).toBe(RecipientRole.SIGNER);
}).toPass();
});

View File

@@ -56,7 +56,6 @@ test('[PUBLIC_PROFILE]: create profile', async ({ page }) => {
// Go back to public profile page. // Go back to public profile page.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/settings/public-profile`); await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/settings/public-profile`);
await page.getByRole('switch').click(); await page.getByRole('switch').click();
await page.waitForTimeout(1000);
// Assert values. // Assert values.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`); await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
@@ -128,7 +127,6 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
// Go back to public profile page. // Go back to public profile page.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/settings/public-profile`); await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/settings/public-profile`);
await page.getByRole('switch').click(); await page.getByRole('switch').click();
await page.waitForTimeout(1000);
// Assert values. // Assert values.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`); await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);

View File

@@ -15,6 +15,7 @@ export const getDocumentStats = async () => {
[ExtendedDocumentStatus.COMPLETED]: 0, [ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0, [ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.ALL]: 0, [ExtendedDocumentStatus.ALL]: 0,
[ExtendedDocumentStatus.DELETED]: 0,
}; };
counts.forEach((stat) => { counts.forEach((stat) => {

View File

@@ -24,7 +24,6 @@ export type CreateDocumentMetaOptions = {
redirectUrl?: string; redirectUrl?: string;
emailSettings?: TDocumentEmailSettings; emailSettings?: TDocumentEmailSettings;
signingOrder?: DocumentSigningOrder; signingOrder?: DocumentSigningOrder;
allowDictateNextSigner?: boolean;
distributionMethod?: DocumentDistributionMethod; distributionMethod?: DocumentDistributionMethod;
typedSignatureEnabled?: boolean; typedSignatureEnabled?: boolean;
language?: SupportedLanguageCodes; language?: SupportedLanguageCodes;
@@ -42,7 +41,6 @@ export const upsertDocumentMeta = async ({
password, password,
redirectUrl, redirectUrl,
signingOrder, signingOrder,
allowDictateNextSigner,
emailSettings, emailSettings,
distributionMethod, distributionMethod,
typedSignatureEnabled, typedSignatureEnabled,
@@ -95,7 +93,6 @@ export const upsertDocumentMeta = async ({
documentId, documentId,
redirectUrl, redirectUrl,
signingOrder, signingOrder,
allowDictateNextSigner,
emailSettings, emailSettings,
distributionMethod, distributionMethod,
typedSignatureEnabled, typedSignatureEnabled,
@@ -109,7 +106,6 @@ export const upsertDocumentMeta = async ({
timezone, timezone,
redirectUrl, redirectUrl,
signingOrder, signingOrder,
allowDictateNextSigner,
emailSettings, emailSettings,
distributionMethod, distributionMethod,
typedSignatureEnabled, typedSignatureEnabled,

View File

@@ -7,10 +7,7 @@ import {
WebhookTriggerEvents, WebhookTriggerEvents,
} from '@prisma/client'; } from '@prisma/client';
import { import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
DOCUMENT_AUDIT_LOG_TYPE,
RECIPIENT_DIFF_TYPE,
} from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
@@ -33,10 +30,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) => {
@@ -64,7 +57,6 @@ 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 });
@@ -154,6 +146,7 @@ export const completeDocumentWithToken = async ({
recipientName: recipient.name, recipientName: recipient.name,
recipientId: recipient.id, recipientId: recipient.id,
recipientRole: recipient.role, recipientRole: recipient.role,
// actionAuth: derivedRecipientActionAuth || undefined,
}, },
}), }),
}); });
@@ -171,9 +164,6 @@ export const completeDocumentWithToken = async ({
select: { select: {
id: true, id: true,
signingOrder: true, signingOrder: true,
name: true,
email: true,
role: true,
}, },
where: { where: {
documentId: document.id, documentId: document.id,
@@ -196,49 +186,9 @@ export const completeDocumentWithToken = async ({
const [nextRecipient] = pendingRecipients; const [nextRecipient] = pendingRecipients;
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
if (nextSigner && document.documentMeta?.allowDictateNextSigner) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
documentId: document.id,
user: {
name: recipient.name,
email: recipient.email,
},
requestMetadata,
data: {
recipientEmail: nextRecipient.email,
recipientName: nextRecipient.name,
recipientId: nextRecipient.id,
recipientRole: nextRecipient.role,
changes: [
{
type: RECIPIENT_DIFF_TYPE.NAME,
from: nextRecipient.name,
to: nextSigner.name,
},
{
type: RECIPIENT_DIFF_TYPE.EMAIL,
from: nextRecipient.email,
to: nextSigner.email,
},
],
},
}),
});
}
await tx.recipient.update({ await tx.recipient.update({
where: { id: nextRecipient.id }, where: { id: nextRecipient.id },
data: { data: { sendStatus: SendStatus.SENT },
sendStatus: SendStatus.SENT,
...(nextSigner && document.documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
email: nextSigner.email,
}
: {}),
},
}); });
await jobs.triggerJob({ await jobs.triggerJob({

View File

@@ -136,18 +136,26 @@ export const findDocuments = async ({
}; };
} }
const deletedDateRange =
status === ExtendedDocumentStatus.DELETED
? {
gte: DateTime.now().minus({ days: 30 }).toJSDate(),
lte: DateTime.now().toJSDate(),
}
: null;
let deletedFilter: Prisma.DocumentWhereInput = { let deletedFilter: Prisma.DocumentWhereInput = {
AND: { AND: {
OR: [ OR: [
{ {
userId: user.id, userId: user.id,
deletedAt: null, deletedAt: deletedDateRange,
}, },
{ {
recipients: { recipients: {
some: { some: {
email: user.email, email: user.email,
documentDeletedAt: null, documentDeletedAt: deletedDateRange,
}, },
}, },
}, },
@@ -162,19 +170,19 @@ export const findDocuments = async ({
? [ ? [
{ {
teamId: team.id, teamId: team.id,
deletedAt: null, deletedAt: deletedDateRange,
}, },
{ {
user: { user: {
email: team.teamEmail.email, email: team.teamEmail.email,
}, },
deletedAt: null, deletedAt: deletedDateRange,
}, },
{ {
recipients: { recipients: {
some: { some: {
email: team.teamEmail.email, email: team.teamEmail.email,
documentDeletedAt: null, documentDeletedAt: deletedDateRange,
}, },
}, },
}, },
@@ -182,7 +190,7 @@ export const findDocuments = async ({
: [ : [
{ {
teamId: team.id, teamId: team.id,
deletedAt: null, deletedAt: deletedDateRange,
}, },
], ],
}, },
@@ -297,6 +305,14 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
}, },
}, },
}, },
{
status: ExtendedDocumentStatus.REJECTED,
recipients: {
some: {
email: user.email,
},
},
},
], ],
})) }))
.with(ExtendedDocumentStatus.INBOX, () => ({ .with(ExtendedDocumentStatus.INBOX, () => ({
@@ -368,7 +384,24 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
recipients: { recipients: {
some: { some: {
email: user.email, email: user.email,
signingStatus: SigningStatus.REJECTED, },
},
},
],
}))
.with(ExtendedDocumentStatus.DELETED, () => ({
OR: [
{
userId: user.id,
deletedAt: {
gte: DateTime.now().minus({ days: 30 }).toJSDate(),
not: null,
},
},
{
recipients: {
some: {
email: user.email,
}, },
}, },
}, },
@@ -410,7 +443,7 @@ const findTeamDocumentsFilter = (
status: ExtendedDocumentStatus, status: ExtendedDocumentStatus,
team: Team & { teamEmail: TeamEmail | null }, team: Team & { teamEmail: TeamEmail | null },
visibilityFilters: Prisma.DocumentWhereInput[], visibilityFilters: Prisma.DocumentWhereInput[],
) => { ): Prisma.DocumentWhereInput | null => {
const teamEmail = team.teamEmail?.email ?? null; const teamEmail = team.teamEmail?.email ?? null;
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput | null>(status) return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput | null>(status)
@@ -599,5 +632,32 @@ const findTeamDocumentsFilter = (
return filter; return filter;
}) })
.with(ExtendedDocumentStatus.DELETED, () => {
return {
OR: teamEmail
? [
{
teamId: team.id,
},
{
user: {
email: teamEmail,
},
},
{
recipients: {
some: {
email: teamEmail,
},
},
},
]
: [
{
teamId: team.id,
},
],
};
})
.exhaustive(); .exhaustive();
}; };

View File

@@ -1,7 +1,5 @@
import { TeamMemberRole } from '@prisma/client';
import type { Prisma, User } from '@prisma/client'; import type { Prisma, User } from '@prisma/client';
import { SigningStatus } from '@prisma/client'; import { DocumentVisibility, SigningStatus, TeamMemberRole } from '@prisma/client';
import { DocumentVisibility } from '@prisma/client';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@@ -17,7 +15,7 @@ export type GetStatsInput = {
search?: string; search?: string;
}; };
export const getStats = async ({ user, period, search = '', ...options }: GetStatsInput) => { export const getStats = async ({ user, period, search, ...options }: GetStatsInput) => {
let createdAt: Prisma.DocumentWhereInput['createdAt']; let createdAt: Prisma.DocumentWhereInput['createdAt'];
if (period) { if (period) {
@@ -30,7 +28,7 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta
}; };
} }
const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team const [ownerCounts, notSignedCounts, hasSignedCounts, deletedCounts] = await (options.team
? getTeamCounts({ ? getTeamCounts({
...options.team, ...options.team,
createdAt, createdAt,
@@ -45,6 +43,7 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta
[ExtendedDocumentStatus.PENDING]: 0, [ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0, [ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0, [ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.DELETED]: 0,
[ExtendedDocumentStatus.INBOX]: 0, [ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0, [ExtendedDocumentStatus.ALL]: 0,
}; };
@@ -71,6 +70,8 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta
} }
}); });
stats[ExtendedDocumentStatus.DELETED] = deletedCounts || 0;
Object.keys(stats).forEach((key) => { Object.keys(stats).forEach((key) => {
if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) { if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) {
stats[ExtendedDocumentStatus.ALL] += stats[key]; stats[ExtendedDocumentStatus.ALL] += stats[key];
@@ -167,6 +168,32 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
AND: [searchFilter], AND: [searchFilter],
}, },
}), }),
// Deleted count
prisma.document.count({
where: {
OR: [
{
userId: user.id,
deletedAt: {
gte: DateTime.now().minus({ days: 30 }).toJSDate(),
not: null,
},
},
{
recipients: {
some: {
email: user.email,
documentDeletedAt: {
gte: DateTime.now().minus({ days: 30 }).toJSDate(),
not: null,
},
},
},
},
],
AND: [searchFilter],
},
}),
]); ]);
}; };
@@ -336,5 +363,40 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
}), }),
notSignedCountsGroupByArgs ? prisma.document.groupBy(notSignedCountsGroupByArgs) : [], notSignedCountsGroupByArgs ? prisma.document.groupBy(notSignedCountsGroupByArgs) : [],
hasSignedCountsGroupByArgs ? prisma.document.groupBy(hasSignedCountsGroupByArgs) : [], hasSignedCountsGroupByArgs ? prisma.document.groupBy(hasSignedCountsGroupByArgs) : [],
prisma.document.count({
where: {
OR: [
{
teamId,
userId: userIdWhereClause,
deletedAt: {
gte: DateTime.now().minus({ days: 30 }).toJSDate(),
not: null,
},
},
{
user: {
email: teamEmail,
},
deletedAt: {
gte: DateTime.now().minus({ days: 30 }).toJSDate(),
not: null,
},
},
{
recipients: {
some: {
email: teamEmail,
documentDeletedAt: {
gte: DateTime.now().minus({ days: 30 }).toJSDate(),
not: null,
},
},
},
},
],
AND: [searchFilter],
},
}),
]); ]);
}; };

View File

@@ -0,0 +1,108 @@
import { WebhookTriggerEvents } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { triggerWebhook } from '@documenso/lib/server-only/webhooks/trigger/trigger-webhook';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '@documenso/lib/types/webhook-payload';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
export type RestoreDocumentOptions = {
id: number;
userId: number;
teamId?: number;
requestMetadata: ApiRequestMetadata;
};
export const restoreDocument = async ({
id,
userId,
teamId,
requestMetadata,
}: RestoreDocumentOptions) => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
const document = await prisma.document.findUnique({
where: {
id,
},
include: {
recipients: true,
documentMeta: true,
team: {
include: {
members: true,
},
},
},
});
if (!document || (teamId !== undefined && teamId !== document.teamId)) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const isUserOwner = document.userId === userId;
const isUserTeamMember = document.team?.members.some((member) => member.userId === userId);
if (!isUserOwner && !isUserTeamMember) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Not allowed to restore this document',
});
}
const restoredDocument = await prisma.$transaction(async (tx) => {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
documentId: document.id,
type: 'DOCUMENT_RESTORED',
metadata: requestMetadata,
data: {},
}),
});
return await tx.document.update({
where: {
id: document.id,
},
data: {
deletedAt: null,
},
});
});
await prisma.recipient.updateMany({
where: {
documentId: document.id,
documentDeletedAt: {
not: null,
},
},
data: {
documentDeletedAt: null,
},
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_RESTORED,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(document)),
userId,
teamId,
});
return restoredDocument;
};

View File

@@ -1,37 +0,0 @@
import { prisma } from '@documenso/prisma';
export const getNextPendingRecipient = async ({
documentId,
currentRecipientId,
}: {
documentId: number;
currentRecipientId: number;
}) => {
const recipients = await prisma.recipient.findMany({
where: {
documentId,
},
orderBy: [
{
signingOrder: {
sort: 'asc',
nulls: 'last',
},
},
{
id: 'asc',
},
],
});
const currentIndex = recipients.findIndex((r) => r.id === currentRecipientId);
if (currentIndex === -1 || currentIndex === recipients.length - 1) {
return null;
}
return {
...recipients[currentIndex + 1],
token: '',
};
};

View File

@@ -83,7 +83,6 @@ export type CreateDocumentFromTemplateOptions = {
language?: SupportedLanguageCodes; language?: SupportedLanguageCodes;
distributionMethod?: DocumentDistributionMethod; distributionMethod?: DocumentDistributionMethod;
typedSignatureEnabled?: boolean; typedSignatureEnabled?: boolean;
allowDictateNextSigner?: boolean;
emailSettings?: TDocumentEmailSettings; emailSettings?: TDocumentEmailSettings;
}; };
requestMetadata: ApiRequestMetadata; requestMetadata: ApiRequestMetadata;
@@ -405,10 +404,6 @@ export const createDocumentFromTemplate = async ({
template.team?.teamGlobalSettings?.documentLanguage, template.team?.teamGlobalSettings?.documentLanguage,
typedSignatureEnabled: typedSignatureEnabled:
override?.typedSignatureEnabled ?? template.templateMeta?.typedSignatureEnabled, override?.typedSignatureEnabled ?? template.templateMeta?.typedSignatureEnabled,
allowDictateNextSigner:
override?.allowDictateNextSigner ??
template.templateMeta?.allowDictateNextSigner ??
false,
}, },
}, },
recipients: { recipients: {

View File

@@ -39,6 +39,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_TITLE_UPDATED', // When the document title is updated. 'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated. 'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated.
'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team. 'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team.
'DOCUMENT_RESTORED', // When a deleted document is restored.
]); ]);
export const ZDocumentAuditLogEmailTypeSchema = z.enum([ export const ZDocumentAuditLogEmailTypeSchema = z.enum([
@@ -551,6 +552,14 @@ export const ZDocumentAuditLogEventDocumentMovedToTeamSchema = z.object({
}), }),
}); });
/**
* Event: Document restored.
*/
export const ZDocumentAuditLogEventDocumentRestoredSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RESTORED),
data: z.object({}),
});
export const ZDocumentAuditLogBaseSchema = z.object({ export const ZDocumentAuditLogBaseSchema = z.object({
id: z.string(), id: z.string(),
createdAt: z.date(), createdAt: z.date(),
@@ -588,6 +597,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventRecipientAddedSchema, ZDocumentAuditLogEventRecipientAddedSchema,
ZDocumentAuditLogEventRecipientUpdatedSchema, ZDocumentAuditLogEventRecipientUpdatedSchema,
ZDocumentAuditLogEventRecipientRemovedSchema, ZDocumentAuditLogEventRecipientRemovedSchema,
ZDocumentAuditLogEventDocumentRestoredSchema,
]), ]),
); );

View File

@@ -51,7 +51,6 @@ export const ZDocumentSchema = DocumentSchema.pick({
documentId: true, documentId: true,
redirectUrl: true, redirectUrl: true,
typedSignatureEnabled: true, typedSignatureEnabled: true,
allowDictateNextSigner: true,
language: true, language: true,
emailSettings: true, emailSettings: true,
}).nullable(), }).nullable(),

View File

@@ -45,7 +45,6 @@ export const ZTemplateSchema = TemplateSchema.pick({
dateFormat: true, dateFormat: true,
signingOrder: true, signingOrder: true,
typedSignatureEnabled: true, typedSignatureEnabled: true,
allowDictateNextSigner: true,
distributionMethod: true, distributionMethod: true,
templateId: true, templateId: true,
redirectUrl: true, redirectUrl: true,

View File

@@ -46,7 +46,6 @@ export const ZWebhookDocumentMetaSchema = z.object({
dateFormat: z.string(), dateFormat: z.string(),
redirectUrl: z.string().nullable(), redirectUrl: z.string().nullable(),
signingOrder: z.nativeEnum(DocumentSigningOrder), signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean(),
typedSignatureEnabled: z.boolean(), typedSignatureEnabled: z.boolean(),
language: z.string(), language: z.string(),
distributionMethod: z.nativeEnum(DocumentDistributionMethod), distributionMethod: z.nativeEnum(DocumentDistributionMethod),

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_RESTORED';

View File

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

View File

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

View File

@@ -178,6 +178,7 @@ enum WebhookTriggerEvents {
DOCUMENT_COMPLETED DOCUMENT_COMPLETED
DOCUMENT_REJECTED DOCUMENT_REJECTED
DOCUMENT_CANCELLED DOCUMENT_CANCELLED
DOCUMENT_RESTORED
} }
model Webhook { model Webhook {
@@ -400,7 +401,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)
allowDictateNextSigner 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)
@@ -668,7 +668,6 @@ model TemplateMeta {
password String? password String?
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
signingOrder DocumentSigningOrder? @default(PARALLEL) signingOrder DocumentSigningOrder? @default(PARALLEL)
allowDictateNextSigner Boolean @default(false)
typedSignatureEnabled Boolean @default(true) typedSignatureEnabled Boolean @default(true)
distributionMethod DocumentDistributionMethod @default(EMAIL) distributionMethod DocumentDistributionMethod @default(EMAIL)

View File

@@ -4,6 +4,7 @@ export const ExtendedDocumentStatus = {
...DocumentStatus, ...DocumentStatus,
INBOX: 'INBOX', INBOX: 'INBOX',
ALL: 'ALL', ALL: 'ALL',
DELETED: 'DELETED',
} as const; } as const;
export type ExtendedDocumentStatus = export type ExtendedDocumentStatus =

View File

@@ -21,6 +21,7 @@ import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stat
import { getStats } from '@documenso/lib/server-only/document/get-stats'; import { getStats } from '@documenso/lib/server-only/document/get-stats';
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 { restoreDocument } from '@documenso/lib/server-only/document/restore-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 { 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';
@@ -53,6 +54,7 @@ import {
ZMoveDocumentToTeamResponseSchema, ZMoveDocumentToTeamResponseSchema,
ZMoveDocumentToTeamSchema, ZMoveDocumentToTeamSchema,
ZResendDocumentMutationSchema, ZResendDocumentMutationSchema,
ZRestoreDocumentMutationSchema,
ZSearchDocumentsMutationSchema, ZSearchDocumentsMutationSchema,
ZSetSigningOrderForDocumentMutationSchema, ZSetSigningOrderForDocumentMutationSchema,
ZSuccessResponseSchema, ZSuccessResponseSchema,
@@ -371,7 +373,6 @@ export const documentRouter = router({
redirectUrl: meta.redirectUrl, redirectUrl: meta.redirectUrl,
distributionMethod: meta.distributionMethod, distributionMethod: meta.distributionMethod,
signingOrder: meta.signingOrder, signingOrder: meta.signingOrder,
allowDictateNextSigner: meta.allowDictateNextSigner,
emailSettings: meta.emailSettings, emailSettings: meta.emailSettings,
requestMetadata: ctx.metadata, requestMetadata: ctx.metadata,
}); });
@@ -416,6 +417,36 @@ export const documentRouter = router({
return ZGenericSuccessResponse; return ZGenericSuccessResponse;
}), }),
/**
* @public
*/
restoreDocument: authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/document/restore',
summary: 'Restore deleted document',
tags: ['Document'],
},
})
.input(ZRestoreDocumentMutationSchema)
.output(ZSuccessResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const { documentId } = input;
const userId = ctx.user.id;
await restoreDocument({
id: documentId,
userId,
teamId,
requestMetadata: ctx.metadata,
});
return ZGenericSuccessResponse;
}),
/** /**
* @public * @public
*/ */

View File

@@ -145,6 +145,7 @@ export const ZFindDocumentsInternalResponseSchema = ZFindResultResponse.extend({
[ExtendedDocumentStatus.PENDING]: z.number(), [ExtendedDocumentStatus.PENDING]: z.number(),
[ExtendedDocumentStatus.COMPLETED]: z.number(), [ExtendedDocumentStatus.COMPLETED]: z.number(),
[ExtendedDocumentStatus.REJECTED]: z.number(), [ExtendedDocumentStatus.REJECTED]: z.number(),
[ExtendedDocumentStatus.DELETED]: z.number(),
[ExtendedDocumentStatus.INBOX]: z.number(), [ExtendedDocumentStatus.INBOX]: z.number(),
[ExtendedDocumentStatus.ALL]: z.number(), [ExtendedDocumentStatus.ALL]: z.number(),
}), }),
@@ -268,7 +269,6 @@ export const ZUpdateDocumentRequestSchema = z.object({
dateFormat: ZDocumentMetaDateFormatSchema.optional(), dateFormat: ZDocumentMetaDateFormatSchema.optional(),
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(), distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(), signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
allowDictateNextSigner: z.boolean().optional(),
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(), redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(), language: ZDocumentMetaLanguageSchema.optional(),
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(), typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
@@ -349,6 +349,12 @@ export const ZDeleteDocumentMutationSchema = z.object({
export type TDeleteDocumentMutationSchema = z.infer<typeof ZDeleteDocumentMutationSchema>; export type TDeleteDocumentMutationSchema = z.infer<typeof ZDeleteDocumentMutationSchema>;
export const ZRestoreDocumentMutationSchema = z.object({
documentId: z.number(),
});
export type TRestoreDocumentMutationSchema = z.infer<typeof ZRestoreDocumentMutationSchema>;
export const ZSearchDocumentsMutationSchema = z.object({ export const ZSearchDocumentsMutationSchema = z.object({
query: z.string(), query: z.string(),
}); });

View File

@@ -436,13 +436,12 @@ 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,
nextSigner,
userId: ctx.user?.id, userId: ctx.user?.id,
requestMetadata: ctx.metadata.requestMetadata, requestMetadata: ctx.metadata.requestMetadata,
}); });

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().min(1),
})
.optional(),
}); });
export type TCompleteDocumentWithTokenMutationSchema = z.infer< export type TCompleteDocumentWithTokenMutationSchema = z.infer<

View File

@@ -165,7 +165,6 @@ export const ZUpdateTemplateRequestSchema = z.object({
language: ZDocumentMetaLanguageSchema.optional(), language: ZDocumentMetaLanguageSchema.optional(),
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(), typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(), signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
allowDictateNextSigner: z.boolean().optional(),
}) })
.optional(), .optional(),
}); });

View File

@@ -9,7 +9,7 @@ import { Trans } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client'; import type { Field, Recipient } from '@prisma/client';
import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client'; import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircle, Plus, Trash } from 'lucide-react'; import { GripVerticalIcon, Plus, Trash } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { prop, sortBy } from 'remeda'; import { prop, sortBy } from 'remeda';
@@ -29,7 +29,6 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '
import { FormErrorMessage } from '../form/form-error-message'; import { FormErrorMessage } from '../form/form-error-message';
import { Input } from '../input'; import { Input } from '../input';
import { useStep } from '../stepper'; import { useStep } from '../stepper';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import { useToast } from '../use-toast'; import { useToast } from '../use-toast';
import type { TAddSignersFormSchema } from './add-signers.types'; import type { TAddSignersFormSchema } from './add-signers.types';
import { ZAddSignersFormSchema } from './add-signers.types'; import { ZAddSignersFormSchema } from './add-signers.types';
@@ -49,7 +48,6 @@ export type AddSignersFormProps = {
recipients: Recipient[]; recipients: Recipient[];
fields: Field[]; fields: Field[];
signingOrder?: DocumentSigningOrder | null; signingOrder?: DocumentSigningOrder | null;
allowDictateNextSigner?: boolean;
isDocumentEnterprise: boolean; isDocumentEnterprise: boolean;
onSubmit: (_data: TAddSignersFormSchema) => void; onSubmit: (_data: TAddSignersFormSchema) => void;
isDocumentPdfLoaded: boolean; isDocumentPdfLoaded: boolean;
@@ -60,7 +58,6 @@ export const AddSignersFormPartial = ({
recipients, recipients,
fields, fields,
signingOrder, signingOrder,
allowDictateNextSigner,
isDocumentEnterprise, isDocumentEnterprise,
onSubmit, onSubmit,
isDocumentPdfLoaded, isDocumentPdfLoaded,
@@ -107,7 +104,6 @@ export const AddSignersFormPartial = ({
) )
: defaultRecipients, : defaultRecipients,
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL, signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
allowDictateNextSigner: allowDictateNextSigner ?? false,
}, },
}); });
@@ -358,7 +354,6 @@ export const AddSignersFormPartial = ({
form.setValue('signers', updatedSigners); form.setValue('signers', updatedSigners);
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL); form.setValue('signingOrder', DocumentSigningOrder.PARALLEL);
form.setValue('allowDictateNextSigner', false);
}, [form]); }, [form]);
return ( return (
@@ -394,11 +389,6 @@ export const AddSignersFormPartial = ({
field.onChange( field.onChange(
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL, checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
); );
// If sequential signing is turned off, disable dictate next signer
if (!checked) {
form.setValue('allowDictateNextSigner', false);
}
}} }}
disabled={isSubmitting || hasDocumentBeenSent} disabled={isSubmitting || hasDocumentBeenSent}
/> />
@@ -413,50 +403,6 @@ export const AddSignersFormPartial = ({
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="allowDictateNextSigner"
render={({ field: { value, ...field } }) => (
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="allowDictateNextSigner"
checked={value}
onCheckedChange={field.onChange}
disabled={isSubmitting || hasDocumentBeenSent || !isSigningOrderSequential}
/>
</FormControl>
<div className="flex items-center">
<FormLabel
htmlFor="allowDictateNextSigner"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Allow signers to dictate next signer</Trans>
</FormLabel>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground ml-1 cursor-help">
<HelpCircle className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 p-4">
<p>
<Trans>
When enabled, signers can choose who should sign next in the sequence
instead of following the predefined order.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
</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),
allowDictateNextSigner: z.boolean().default(false),
}) })
.refine( .refine(
(schema) => { (schema) => {

View File

@@ -9,7 +9,7 @@ import { Trans } from '@lingui/react/macro';
import type { TemplateDirectLink } from '@prisma/client'; import type { TemplateDirectLink } from '@prisma/client';
import { DocumentSigningOrder, type Field, type Recipient, RecipientRole } from '@prisma/client'; import { DocumentSigningOrder, type Field, type Recipient, RecipientRole } from '@prisma/client';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircle, Link2Icon, Plus, Trash } from 'lucide-react'; import { GripVerticalIcon, Link2Icon, Plus, Trash } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
@@ -47,11 +47,10 @@ export type AddTemplatePlaceholderRecipientsFormProps = {
recipients: Recipient[]; recipients: Recipient[];
fields: Field[]; fields: Field[];
signingOrder?: DocumentSigningOrder | null; signingOrder?: DocumentSigningOrder | null;
allowDictateNextSigner?: boolean; templateDirectLink: TemplateDirectLink | null;
templateDirectLink?: TemplateDirectLink | null;
isEnterprise: boolean; isEnterprise: boolean;
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
isDocumentPdfLoaded: boolean; isDocumentPdfLoaded: boolean;
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
}; };
export const AddTemplatePlaceholderRecipientsFormPartial = ({ export const AddTemplatePlaceholderRecipientsFormPartial = ({
@@ -61,7 +60,6 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
templateDirectLink, templateDirectLink,
fields, fields,
signingOrder, signingOrder,
allowDictateNextSigner,
isDocumentPdfLoaded, isDocumentPdfLoaded,
onSubmit, onSubmit,
}: AddTemplatePlaceholderRecipientsFormProps) => { }: AddTemplatePlaceholderRecipientsFormProps) => {
@@ -114,7 +112,6 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
defaultValues: { defaultValues: {
signers: generateDefaultFormSigners(), signers: generateDefaultFormSigners(),
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL, signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
allowDictateNextSigner: allowDictateNextSigner ?? false,
}, },
}); });
@@ -122,7 +119,6 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
form.reset({ form.reset({
signers: generateDefaultFormSigners(), signers: generateDefaultFormSigners(),
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL, signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
allowDictateNextSigner: allowDictateNextSigner ?? false,
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -381,7 +377,6 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
form.setValue('signers', updatedSigners); form.setValue('signers', updatedSigners);
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL); form.setValue('signingOrder', DocumentSigningOrder.PARALLEL);
form.setValue('allowDictateNextSigner', false);
}, [form]); }, [form]);
return ( return (
@@ -421,11 +416,6 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
field.onChange( field.onChange(
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL, checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
); );
// If sequential signing is turned off, disable dictate next signer
if (!checked) {
form.setValue('allowDictateNextSigner', false);
}
}} }}
disabled={isSubmitting} disabled={isSubmitting}
/> />
@@ -441,49 +431,6 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
)} )}
/> />
<FormField
control={form.control}
name="allowDictateNextSigner"
render={({ field: { value, ...field } }) => (
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="allowDictateNextSigner"
checked={value}
onCheckedChange={field.onChange}
disabled={isSubmitting || !isSigningOrderSequential}
/>
</FormControl>
<div className="flex items-center">
<FormLabel
htmlFor="allowDictateNextSigner"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Allow signers to dictate next signer</Trans>
</FormLabel>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground ml-1 cursor-help">
<HelpCircle className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 p-4">
<p>
<Trans>
When enabled, signers can choose who should sign next in the sequence
instead of following the predefined order.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
</FormItem>
)}
/>
{/* Drag and drop context */} {/* Drag and drop context */}
<DragDropContext <DragDropContext
onDragEnd={onDragEnd} onDragEnd={onDragEnd}

View File

@@ -21,7 +21,6 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z
}), }),
), ),
signingOrder: z.nativeEnum(DocumentSigningOrder), signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean().default(false),
}) })
.refine( .refine(
(schema) => { (schema) => {