Compare commits

...

19 Commits

Author SHA1 Message Date
Mythie
231f51bd1f v1.10.0-rc.1 2025-03-22 17:34:33 +11:00
Mythie
a8de8368a2 fix: hide powered by on certificate for platform documents 2025-03-22 12:04:08 +11:00
Mythie
7dd331addf fix: allow blank rejection reasons 2025-03-22 12:01:18 +11:00
Mythie
c6743a7cec v1.10.0-rc.0 2025-03-22 03:23:23 +11:00
Mythie
efbc097191 fix: unblock last signer when using dictation 2025-03-22 02:34:12 +11:00
Lucas Smith
f1525991dc feat: dictate next signer (#1719)
Adds next recipient dictation functionality to document signing flow,
allowing assistants and signers to update the next recipient's
information during the signing process.

## Related Issue

N/A

## Changes Made

- Added form handling for next recipient dictation in signing dialogs
- Implemented UI for updating next recipient information
- Added e2e tests covering dictation scenarios:
  - Regular signing with dictation enabled
  - Assistant role with dictation
  - Parallel signing flow
  - Disabled dictation state

## Testing Performed

- Added comprehensive e2e tests covering:
  - Sequential signing with dictation
  - Assistant role dictation
  - Parallel signing without dictation
  - Form validation and state management
- Tested on Chrome and Firefox
- Verified recipient state updates in database
2025-03-21 13:27:04 +11:00
Mythie
fb173e4d0e chore: update docker build scripts 2025-03-20 10:52:33 +11:00
Catalin Pit
d422ffa873 chore: add terms and privacy policy link (#1707) 2025-03-19 19:29:09 +11:00
Catalin Pit
55b7697316 chore: update d script in package.json (#1703)
Add the `translate:compile` command to the `d` script.
2025-03-14 16:15:57 +11:00
Catalin Pit
67bbb6c6f4 fix: autosigning fields with direct links (#1696)
## Description

The changes in `sign-direct-template.tsx` automatically fill in field
values for text, number, and dropdown fields when default values are
present or if the fields are read-only. In `checkbox-field.tsx`, the
changes fix the checkbox signing by checking if the validation is met
and improving how it saves or removes checkbox choices.

## Testing Performed

I tested the code locally with a variety of documents/fields.

## Checklist

<!--- Please check the boxes that apply to this pull request. -->
<!--- You can add or remove items as needed. -->

- [x] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [ ] I have updated the documentation to reflect these changes, if
applicable.
- [x] I have followed the project's coding style guidelines.
- [ ] I have addressed the code review feedback from the previous
submission, if applicable.
2025-03-13 11:18:01 +02:00
Lucas Smith
63a4bab0fe feat: better document rejection (#1702)
Improves the existing document rejection process by actually marking a
document as completed cancelling further actions.

## Related Issue

N/A

## Changes Made

- Added a new rejection status for documents
- Updated a million areas that check for document completion
- Updated email sending, so rejection is confirmed for the rejecting
recipient while other recipients are notified that the document is now
cancelled.

## Testing Performed

- Ran the testing suite to ensure there are no regressions.
- Performed manual testing of current core flows.
2025-03-13 15:08:57 +11:00
Jenil Savani
9f17c1e48e fix: adjust desktop nav search button width and spacing (#1699) 2025-03-13 10:52:01 +11:00
Catalin Pit
91ae818213 fix: missing prefillfields property from the api v2 documentation (#1700) 2025-03-12 22:54:58 +11:00
David Nguyen
a0ace803cf fix: admin signing page crash 2025-03-12 16:53:09 +11:00
Ephraim Duncan
b3db3be8e9 fix: signing field disabled when pointer is out of canvas (#1652) 2025-03-12 16:44:21 +11:00
Jenil Savani
44cdbeecb4 fix: improve layout and truncate document information in logs page (#1656) 2025-03-12 16:31:03 +11:00
Tom
7214965c0c chore: update French translations (#1679) 2025-03-12 16:22:06 +11:00
Ephraim Duncan
8d6bf91d12 fix: persist theme cookie for a much longer time (#1693) 2025-03-12 16:09:37 +11:00
Ephraim Duncan
fec078081b fix: correct signer deletion (#1596) 2025-03-12 16:05:45 +11:00
86 changed files with 2006 additions and 424 deletions

View File

@@ -1,4 +1,9 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -9,64 +14,208 @@ import {
DialogHeader,
DialogTitle,
} 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';
export type NextSigner = {
name: string;
email: string;
};
type ConfirmationDialogProps = {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
onConfirm: (nextSigner?: NextSigner) => void;
hasUninsertedFields: 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({
isOpen,
onClose,
onConfirm,
hasUninsertedFields,
isSubmitting,
allowDictateNextSigner = false,
defaultNextSigner,
}: ConfirmationDialogProps) {
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
const form = useForm<TNextSignerFormSchema>({
resolver: zodResolver(ZNextSignerFormSchema),
defaultValues: {
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
},
});
const onOpenChange = () => {
if (isSubmitting) {
if (form.formState.isSubmitting) {
return;
}
form.reset({
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
});
setIsEditingNextSigner(false);
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 (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Complete Document</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Are you sure you want to complete the document? This action cannot be undone. Please
ensure that you have completed prefilling all relevant fields before proceeding.
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
disabled={form.formState.isSubmitting || isSubmitting}
className="border-none p-0"
>
<DialogHeader>
<DialogTitle>
<Trans>Complete Document</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Are you sure you want to complete the document? This action cannot be undone.
Please ensure that you have completed prefilling all relevant fields before
proceeding.
</Trans>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<DocumentSigningDisclosure />
</div>
<div className="mt-4 flex flex-col gap-4">
{allowDictateNextSigner && (
<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>
<DialogFooter className="mt-4">
<Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button
variant={hasUninsertedFields ? 'destructive' : 'default'}
onClick={onConfirm}
disabled={isSubmitting}
loading={isSubmitting}
>
{isSubmitting ? 'Submitting...' : hasUninsertedFields ? 'Proceed' : 'Continue'}
</Button>
</DialogFooter>
<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>
<DialogFooter className="mt-4">
<Button
type="button"
variant="secondary"
onClick={onClose}
disabled={form.formState.isSubmitting}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant={hasUninsertedFields ? 'destructive' : 'default'}
disabled={form.formState.isSubmitting || !isNextSignerValid}
loading={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? (
<Trans>Submitting...</Trans>
) : hasUninsertedFields ? (
<Trans>Proceed</Trans>
) : (
<Trans>Continue</Trans>
)}
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);

View File

@@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import { match } from 'ts-pattern';
import { P, match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
@@ -146,7 +146,7 @@ export const DocumentDeleteDialog = ({
</ul>
</AlertDescription>
))
.with(DocumentStatus.COMPLETED, () => (
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
<AlertDescription>
<p>
<Trans>By deleting this document, the following will occur:</Trans>

View File

@@ -531,6 +531,27 @@ export const SignUpForm = ({
</div>
</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>
);

View File

@@ -76,7 +76,7 @@ export const AppNavDesktop = ({
<Button
variant="outline"
className="text-muted-foreground flex w-96 items-center justify-between rounded-lg"
className="text-muted-foreground flex w-full max-w-96 items-center justify-between rounded-lg"
onClick={() => setIsCommandMenuOpen(true)}
>
<div className="flex items-center">

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import type { Field, Recipient, Signature } from '@prisma/client';
@@ -170,6 +170,55 @@ export const DirectTemplateSigningForm = ({
// 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 (
<DocumentSigningRecipientProvider recipient={directRecipient}>
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />

View File

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

View File

@@ -1,9 +1,12 @@
import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import type { Field } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { Button } from '@documenso/ui/primitives/button';
@@ -14,6 +17,15 @@ import {
DialogTitle,
DialogTrigger,
} 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';
@@ -22,11 +34,23 @@ export type DocumentSigningCompleteDialogProps = {
documentTitle: string;
fields: Field[];
fieldsValidated: () => void | Promise<void>;
onSignatureComplete: () => void | Promise<void>;
onSignatureComplete: (nextSigner?: { name: string; email: string }) => void | Promise<void>;
role: RecipientRole;
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 = ({
isSubmitting,
documentTitle,
@@ -35,19 +59,54 @@ export const DocumentSigningCompleteDialog = ({
onSignatureComplete,
role,
disabled = false,
allowDictateNextSigner = false,
defaultNextSigner,
}: DocumentSigningCompleteDialogProps) => {
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 handleOpenChange = (open: boolean) => {
if (isSubmitting || !isComplete) {
if (form.formState.isSubmitting || !isComplete) {
return;
}
if (open) {
form.reset({
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
});
}
setIsEditingNextSigner(false);
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 (
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
@@ -71,110 +130,184 @@ export const DocumentSigningCompleteDialog = ({
</DialogTrigger>
<DialogContent>
<DialogTitle>
<div className="text-foreground text-xl font-semibold">
{match(role)
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
.exhaustive()}
</div>
</DialogTitle>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
<DialogTitle>
<div className="text-foreground text-xl font-semibold">
{match(role)
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
.exhaustive()}
</div>
</DialogTitle>
<div className="text-muted-foreground max-w-[50ch]">
{match(role)
.with(RecipientRole.VIEWER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
<div className="text-muted-foreground max-w-[50ch]">
{match(role)
.with(RecipientRole.VIEWER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))
.with(RecipientRole.SIGNER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete signing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
))
.with(RecipientRole.SIGNER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete signing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))
.with(RecipientRole.APPROVER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete approving{' '}
<span className="inline-block max-w-[11rem] truncate align-baseline">
"{documentTitle}"
))
.with(RecipientRole.APPROVER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete approving{' '}
<span className="inline-block max-w-[11rem] truncate align-baseline">
"{documentTitle}"
</span>
.
</span>
<br /> Are you sure?
</Trans>
</span>
.
</span>
<br /> Are you sure?
</Trans>
</span>
))
.otherwise(() => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
))
.otherwise(() => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))}
</div>
))}
</div>
<DocumentSigningDisclosure className="mt-4" />
{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>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowDialog(false);
}}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
className="mt-2"
variant="outline"
size="sm"
onClick={() => setIsEditingNextSigner((prev) => !prev)}
>
<Trans>Update Recipient</Trans>
</Button>
</div>
)}
<Button
type="button"
className="flex-1"
disabled={!isComplete}
loading={isSubmitting}
onClick={onSignatureComplete}
>
{match(role)
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
.exhaustive()}
</Button>
</div>
</DialogFooter>
{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" />
<DialogFooter className="mt-4">
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => setShowDialog(false)}
disabled={form.formState.isSubmitting}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
className="flex-1"
disabled={!isComplete || !isNextSignerValid}
loading={form.formState.isSubmitting}
>
{match(role)
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
.exhaustive()}
</Button>
</div>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);

View File

@@ -1,11 +1,13 @@
import { useId, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
@@ -25,10 +27,20 @@ import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { AssistantConfirmationDialog } from '../../dialogs/assistant-confirmation-dialog';
import {
AssistantConfirmationDialog,
type NextSigner,
} from '../../dialogs/assistant-confirmation-dialog';
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
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 = {
document: DocumentAndSender;
recipient: Recipient;
@@ -75,7 +87,9 @@ export const DocumentSigningForm = ({
},
});
const { handleSubmit, formState } = useForm();
const { handleSubmit, formState } = useForm<TSigningFormSchema>({
resolver: zodResolver(ZSigningFormSchema),
});
// Keep the loading state going if successful since the redirect may take some time.
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
@@ -100,20 +114,36 @@ export const DocumentSigningForm = ({
validateFieldsInserted(fieldsRequiringValidation);
};
const onFormSubmit = async () => {
setValidateUninsertedFields(true);
const onFormSubmit = async (data: TSigningFormSchema) => {
try {
setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
if (hasSignatureField && !signatureValid) {
return;
if (hasSignatureField && !signatureValid) {
return;
}
if (!isFieldsValid) {
return;
}
const nextSigner =
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',
});
}
if (!isFieldsValid) {
return;
}
await completeDocument();
};
const onAssistantFormSubmit = () => {
@@ -124,11 +154,11 @@ export const DocumentSigningForm = ({
setIsConfirmationDialogOpen(true);
};
const handleAssistantConfirmDialogSubmit = async () => {
const handleAssistantConfirmDialogSubmit = async (nextSigner?: NextSigner) => {
setIsAssistantSubmitting(true);
try {
await completeDocument();
await completeDocument(undefined, nextSigner);
} catch (err) {
toast({
title: 'Error',
@@ -141,12 +171,18 @@ export const DocumentSigningForm = ({
}
};
const completeDocument = async (authOptions?: TRecipientActionAuth) => {
await completeDocumentWithToken({
const completeDocument = async (
authOptions?: TRecipientActionAuth,
nextSigner?: { email: string; name: string },
) => {
const payload = {
token: recipient.token,
documentId: document.id,
authOptions,
});
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
};
await completeDocumentWithToken(payload);
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
@@ -161,6 +197,31 @@ 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 (
<div
className={cn(
@@ -210,12 +271,19 @@ export const DocumentSigningForm = ({
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role}
disabled={!isRecipientsTurn}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
/>
</div>
</div>
@@ -306,6 +374,14 @@ export const DocumentSigningForm = ({
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
onConfirm={handleAssistantConfirmDialogSubmit}
isSubmitting={isAssistantSubmitting}
allowDictateNextSigner={
nextRecipient && document.documentMeta?.allowDictateNextSigner
}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
/>
</form>
</>
@@ -376,30 +452,40 @@ export const DocumentSigningForm = ({
</div>
)}
</div>
<div className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={async () => navigate(-1)}
>
<Trans>Cancel</Trans>
</Button>
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
role={recipient.role}
disabled={!isRecipientsTurn}
/>
</div>
</fieldset>
<div className="mt-6 flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={async () => navigate(-1)}
>
<Trans>Cancel</Trans>
</Button>
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting || isAssistantSubmitting}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role}
allowDictateNextSigner={
nextRecipient && document.documentMeta?.allowDictateNextSigner
}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
/>
</div>
</form>
</>
)}

View File

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

View File

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

View File

@@ -1,9 +1,10 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import type { DocumentStatus } from '@prisma/client';
import { DownloadIcon } from 'lucide-react';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -76,7 +77,7 @@ export const DocumentCertificateDownloadButton = ({
className={cn('w-full sm:w-auto', className)}
loading={isPending}
variant="outline"
disabled={documentStatus !== DocumentStatus.COMPLETED}
disabled={!isDocumentCompleted(documentStatus)}
onClick={() => void onDownloadCertificatesClick()}
>
{!isPending && <DownloadIcon className="mr-1.5 h-4 w-4" />}

View File

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

View File

@@ -9,6 +9,7 @@ import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button';
@@ -32,7 +33,7 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
const isRecipient = !!recipient;
const isPending = document.status === DocumentStatus.PENDING;
const isComplete = document.status === DocumentStatus.COMPLETED;
const isComplete = isDocumentCompleted(document);
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const role = recipient?.role;

View File

@@ -20,6 +20,7 @@ import { useNavigate } from 'react-router';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
@@ -63,7 +64,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
const isDraft = document.status === DocumentStatus.DRAFT;
const isPending = document.status === DocumentStatus.PENDING;
const isDeleted = document.deletedAt !== null;
const isComplete = document.status === DocumentStatus.COMPLETED;
const isComplete = isDocumentCompleted(document);
const isCurrentTeamDocument = team && document.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);

View File

@@ -17,6 +17,7 @@ import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { SignatureIcon } from '@documenso/ui/icons/signature';
@@ -48,7 +49,7 @@ export const DocumentPageViewRecipients = ({
<Trans>Recipients</Trans>
</h1>
{document.status !== DocumentStatus.COMPLETED && (
{!isDocumentCompleted(document.status) && (
<Link
to={`${documentRootPath}/${document.id}/edit?step=signers`}
title={_(msg`Modify recipients`)}

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button';
@@ -37,7 +38,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
const isRecipient = !!recipient;
const isDraft = row.status === DocumentStatus.DRAFT;
const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED;
const isComplete = isDocumentCompleted(row.status);
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const role = recipient?.role;
const isCurrentTeamDocument = team && row.team?.url === team.url;

View File

@@ -22,6 +22,7 @@ import { Link } from 'react-router';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
@@ -66,7 +67,7 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
// const isRecipient = !!recipient;
const isDraft = row.status === DocumentStatus.DRAFT;
const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED;
const isComplete = isDocumentCompleted(row.status);
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isCurrentTeamDocument = team && row.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);

View File

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

View File

@@ -103,7 +103,9 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
variant="outline"
loading={isResealDocumentLoading}
disabled={document.recipients.some(
(recipient) => recipient.signingStatus !== SigningStatus.SIGNED,
(recipient) =>
recipient.signingStatus !== SigningStatus.SIGNED &&
recipient.signingStatus !== SigningStatus.REJECTED,
)}
onClick={() => resealDocument({ id: document.id })}
>

View File

@@ -220,6 +220,9 @@ export default function DocumentPage() {
.with(DocumentStatus.COMPLETED, () => (
<Trans>This document has been signed by all recipients</Trans>
))
.with(DocumentStatus.REJECTED, () => (
<Trans>This document has been rejected by a recipient</Trans>
))
.with(DocumentStatus.DRAFT, () => (
<Trans>This document is currently a draft and has not been sent</Trans>
))

View File

@@ -1,5 +1,5 @@
import { Plural, Trans } from '@lingui/react/macro';
import { DocumentStatus as InternalDocumentStatus, TeamMemberRole } from '@prisma/client';
import { TeamMemberRole } from '@prisma/client';
import { ChevronLeft, Users2 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { match } from 'ts-pattern';
@@ -9,6 +9,7 @@ import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-ent
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
@@ -71,7 +72,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw redirect(documentRootPath);
}
if (document.status === InternalDocumentStatus.COMPLETED) {
if (isDocumentCompleted(document.status)) {
throw redirect(`${documentRootPath}/${documentId}`);
}

View File

@@ -129,7 +129,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
<Trans>Document</Trans>
</Link>
<div className="flex flex-col justify-between truncate sm:flex-row">
<div className="flex flex-col">
<div>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
@@ -137,7 +137,8 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
>
{document.title}
</h1>
</div>
<div className="mt-1 flex flex-col justify-between sm:flex-row">
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatusComponent
inheritColor
@@ -145,16 +146,15 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
className="text-muted-foreground"
/>
</div>
</div>
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
<DocumentCertificateDownloadButton
className="mr-2"
documentId={document.id}
documentStatus={document.status}
/>
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
<DocumentCertificateDownloadButton
className="mr-2"
documentId={document.id}
documentStatus={document.status}
/>
<DocumentAuditLogDownloadButton documentId={document.id} />
<DocumentAuditLogDownloadButton documentId={document.id} />
</div>
</div>
</div>
@@ -163,7 +163,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
{documentInformation.map((info, i) => (
<div className="text-foreground text-sm" key={i}>
<h3 className="font-semibold">{_(info.description)}</h3>
<p className="text-muted-foreground">{info.value}</p>
<p className="text-muted-foreground truncate">{info.value}</p>
</div>
))}

View File

@@ -50,6 +50,7 @@ export default function DocumentsPage() {
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
});

View File

@@ -1,11 +1,12 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { FieldType } from '@prisma/client';
import { FieldType, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { redirect } from 'react-router';
import { match } from 'ts-pattern';
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 {
RECIPIENT_ROLES_DESCRIPTION,
@@ -59,6 +60,8 @@ export async function loader({ request }: Route.LoaderArgs) {
throw redirect('/');
}
const isPlatformDocument = await isDocumentPlatform(document);
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
const auditLogs = await getDocumentCertificateAuditLogs({
@@ -70,6 +73,7 @@ export async function loader({ request }: Route.LoaderArgs) {
return {
document,
documentLanguage,
isPlatformDocument,
auditLogs,
messages,
};
@@ -85,7 +89,7 @@ export async function loader({ request }: Route.LoaderArgs) {
* Update: Maybe <Trans> tags work now after RR7 migration.
*/
export default function SigningCertificate({ loaderData }: Route.ComponentProps) {
const { document, documentLanguage, auditLogs, messages } = loaderData;
const { document, documentLanguage, isPlatformDocument, auditLogs, messages } = loaderData;
const { i18n, _ } = useLingui();
@@ -159,6 +163,13 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED &&
log.data.recipientId === recipientId,
),
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED]: auditLogs[
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED
].filter(
(log) =>
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED &&
log.data.recipientId === recipientId,
),
};
};
@@ -282,25 +293,42 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</span>
</p>
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Signed`)}:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]
? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: _(msg`Unknown`)}
</span>
</p>
{logs.DOCUMENT_RECIPIENT_REJECTED[0] ? (
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Rejected`)}:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_REJECTED[0]
? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_REJECTED[0].createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: _(msg`Unknown`)}
</span>
</p>
) : (
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Signed`)}:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]
? DateTime.fromJSDate(
logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt,
)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: _(msg`Unknown`)}
</span>
</p>
)}
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Reason`)}:</span>{' '}
<span className="inline-block">
{_(
isOwner(recipient.email)
? FRIENDLY_SIGNING_REASONS['__OWNER__']
: FRIENDLY_SIGNING_REASONS[recipient.role],
)}
{recipient.signingStatus === SigningStatus.REJECTED
? recipient.rejectionReason
: _(
isOwner(recipient.email)
? FRIENDLY_SIGNING_REASONS['__OWNER__']
: FRIENDLY_SIGNING_REASONS[recipient.role],
)}
</span>
</p>
</div>
@@ -313,15 +341,17 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</CardContent>
</Card>
<div className="my-8 flex-row-reverse">
<div className="flex items-end justify-end gap-x-4">
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
{_(msg`Signing certificate provided by`)}:
</p>
{isPlatformDocument && (
<div className="my-8 flex-row-reverse">
<div className="flex items-end justify-end gap-x-4">
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
{_(msg`Signing certificate provided by`)}:
</p>
<BrandingLogo className="max-h-6 print:max-h-4" />
<BrandingLogo className="max-h-6 print:max-h-4" />
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { DocumentSigningOrder, DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { Clock8 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
@@ -13,6 +13,7 @@ import { viewedDocument } from '@documenso/lib/server-only/document/viewed-docum
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 { 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 { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
@@ -72,7 +73,24 @@ export async function loader({ params, request }: Route.LoaderArgs) {
? await getRecipientsForAssistant({
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({
documentAuth: document.authOptions,
@@ -160,7 +178,7 @@ export default function SigningPage() {
recipientWithFields,
} = data;
if (document.deletedAt) {
if (document.deletedAt || document.status === DocumentStatus.REJECTED) {
return (
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24">
<SigningCard3D

View File

@@ -17,6 +17,7 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { env } from '@documenso/lib/utils/env';
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
@@ -205,12 +206,12 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
<DocumentShareButton documentId={document.id} token={recipient.token} />
{document.status === DocumentStatus.COMPLETED ? (
{isDocumentCompleted(document.status) ? (
<DocumentDownloadButton
className="flex-1"
fileName={document.title}
documentData={document.documentData}
disabled={document.status !== DocumentStatus.COMPLETED}
disabled={!isDocumentCompleted(document.status)}
/>
) : (
<DocumentDialog
@@ -268,7 +269,7 @@ export const PollUntilDocumentCompleted = ({ document }: PollUntilDocumentComple
const { revalidate } = useRevalidator();
useEffect(() => {
if (document.status === DocumentStatus.COMPLETED) {
if (isDocumentCompleted(document.status)) {
return;
}

View File

@@ -1,4 +1,4 @@
import { DocumentStatus, RecipientRole } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { data } from 'react-router';
import { match } from 'ts-pattern';
@@ -14,6 +14,7 @@ import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-re
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { EmbedSignDocumentClientPage } from '~/components/embed/embed-document-signing-page';
@@ -168,7 +169,7 @@ export default function EmbedSignDocumentPage() {
recipient={recipient}
fields={fields}
metadata={document.documentMeta}
isCompleted={document.status === DocumentStatus.COMPLETED}
isCompleted={isDocumentCompleted(document.status)}
hidePoweredBy={
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy
}

View File

@@ -12,6 +12,7 @@ const themeSessionStorage = createCookieSessionStorage({
secrets: ['insecure-secret-do-not-care'],
secure: useSecureCookies,
domain: getCookieDomain(),
maxAge: 60 * 60 * 24 * 365,
},
});

View File

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

View File

@@ -8,6 +8,7 @@ command -v docker >/dev/null 2>&1 || {
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
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//')"
GIT_SHA="$(git rev-parse HEAD)"
@@ -15,12 +16,39 @@ echo "Building docker image for monorepo at $MONOREPO_ROOT"
echo "App version: $APP_VERSION"
echo "Git SHA: $GIT_SHA"
# Build with temporary base tag
docker build -f "$SCRIPT_DIR/Dockerfile" \
--progress=plain \
-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" \
-t "documenso-base" \
"$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/../")"
# 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
PLATFORM="linux/amd64"
fi
# Get Git information
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)"
@@ -21,14 +21,41 @@ echo "Building docker image for monorepo at $MONOREPO_ROOT"
echo "App version: $APP_VERSION"
echo "Git SHA: $GIT_SHA"
# Build with temporary base tag
docker buildx build \
-f "$SCRIPT_DIR/Dockerfile" \
--platform=$PLATFORM \
--progress=plain \
-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" \
-t "documenso-base" \
"$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"

7
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{
"private": true,
"version": "1.9.0-rc.11",
"version": "1.10.0-rc.1",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",
@@ -14,7 +14,7 @@
"prepare": "husky && husky install || true",
"commitlint": "commitlint --edit",
"clean": "turbo run clean && rimraf node_modules",
"d": "npm run dx && npm run dev",
"d": "npm run dx && npm run translate:compile && npm run dev",
"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:down": "docker compose -f docker/development/compose.yml down",
@@ -80,4 +80,4 @@
"trigger.dev": {
"endpointId": "documenso-app"
}
}
}

View File

@@ -1,5 +1,5 @@
import type { Prisma } from '@prisma/client';
import { DocumentDataType, DocumentStatus, SigningStatus, TeamMemberRole } from '@prisma/client';
import { DocumentDataType, SigningStatus, TeamMemberRole } from '@prisma/client';
import { tsr } from '@ts-rest/serverless/fetch';
import { match } from 'ts-pattern';
@@ -50,6 +50,7 @@ import {
getPresignGetUrl,
getPresignPostUrl,
} from '@documenso/lib/universal/upload/server-actions';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
@@ -176,7 +177,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
};
}
if (document.status !== DocumentStatus.COMPLETED) {
if (!isDocumentCompleted(document.status)) {
return {
status: 400,
body: {
@@ -322,6 +323,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
dateFormat: dateFormat?.value,
redirectUrl: body.meta.redirectUrl,
signingOrder: body.meta.signingOrder,
allowDictateNextSigner: body.meta.allowDictateNextSigner,
language: body.meta.language,
typedSignatureEnabled: body.meta.typedSignatureEnabled,
distributionMethod: body.meta.distributionMethod,
@@ -669,7 +671,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
};
}
if (document.status === DocumentStatus.COMPLETED) {
if (isDocumentCompleted(document.status)) {
return {
status: 400,
body: {
@@ -772,7 +774,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
};
}
if (document.status === DocumentStatus.COMPLETED) {
if (isDocumentCompleted(document.status)) {
return {
status: 400,
body: {
@@ -863,7 +865,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
};
}
if (document.status === DocumentStatus.COMPLETED) {
if (isDocumentCompleted(document.status)) {
return {
status: 400,
body: {
@@ -922,7 +924,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
};
}
if (document.status === DocumentStatus.COMPLETED) {
if (isDocumentCompleted(document.status)) {
return {
status: 400,
body: {
@@ -987,7 +989,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
};
}
if (document.status === DocumentStatus.COMPLETED) {
if (isDocumentCompleted(document.status)) {
return {
status: 400,
body: { message: 'Document is already completed' },
@@ -1149,7 +1151,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
};
}
if (document.status === DocumentStatus.COMPLETED) {
if (isDocumentCompleted(document.status)) {
return {
status: 400,
body: {
@@ -1237,7 +1239,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
};
}
if (document.status === DocumentStatus.COMPLETED) {
if (isDocumentCompleted(document.status)) {
return {
status: 400,
body: {

View File

@@ -96,7 +96,7 @@ export const ZSendDocumentForSigningMutationSchema = z
'Whether to send completion emails when the document is fully signed. This will override the document email settings.',
}),
})
.or(z.literal('').transform(() => ({ sendEmail: true, sendCompletionEmails: undefined })));
.or(z.any().transform(() => ({ sendEmail: true, sendCompletionEmails: undefined })));
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
@@ -155,6 +155,7 @@ export const ZCreateDocumentMutationSchema = z.object({
}),
redirectUrl: z.string(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
allowDictateNextSigner: z.boolean().optional(),
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
typedSignatureEnabled: z.boolean().optional().default(true),
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(),
@@ -218,6 +219,7 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
dateFormat: z.string(),
redirectUrl: z.string(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
allowDictateNextSigner: z.boolean().optional(),
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
})
.partial()
@@ -285,6 +287,7 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
dateFormat: z.string(),
redirectUrl: ZUrlSchema,
signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean(),
language: z.enum(SUPPORTED_LANGUAGE_CODES),
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
typedSignatureEnabled: z.boolean(),

View File

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

View File

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

View File

@@ -8,12 +8,14 @@ export interface TemplateDocumentCancelProps {
inviterEmail: string;
documentName: string;
assetBaseUrl: string;
cancellationReason?: string;
}
export const TemplateDocumentCancel = ({
inviterName,
documentName,
assetBaseUrl,
cancellationReason,
}: TemplateDocumentCancelProps) => {
return (
<>
@@ -34,6 +36,12 @@ export const TemplateDocumentCancel = ({
<Text className="my-1 text-center text-base text-slate-400">
<Trans>You don't need to sign it anymore.</Trans>
</Text>
{cancellationReason && (
<Text className="mt-4 text-center text-base">
<Trans>Reason for cancellation: {cancellationReason}</Trans>
</Text>
)}
</Section>
</>
);

View File

@@ -14,6 +14,7 @@ export const DocumentCancelTemplate = ({
inviterEmail = 'lucas@documenso.com',
documentName = 'Open Source Pledge.pdf',
assetBaseUrl = 'http://localhost:3002',
cancellationReason,
}: DocumentCancelEmailTemplateProps) => {
const { _ } = useLingui();
const branding = useBranding();
@@ -48,6 +49,7 @@ export const DocumentCancelTemplate = ({
inviterEmail={inviterEmail}
documentName={documentName}
assetBaseUrl={assetBaseUrl}
cancellationReason={cancellationReason}
/>
</Section>
</Container>

View File

@@ -8,6 +8,9 @@ export const DOCUMENT_STATUS: {
[DocumentStatus.COMPLETED]: {
description: msg`Completed`,
},
[DocumentStatus.REJECTED]: {
description: msg`Rejected`,
},
[DocumentStatus.DRAFT]: {
description: msg`Draft`,
},

View File

@@ -1,5 +1,6 @@
import { JobClient } from './client/client';
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
import { SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION } from './definitions/emails/send-password-reset-success-email';
import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-email';
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
@@ -24,6 +25,7 @@ export const jobsClient = new JobClient([
SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION,
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION,
BULK_SEND_TEMPLATE_JOB_DEFINITION,
] as const);

View File

@@ -0,0 +1,105 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendDocumentCancelledEmailsJobDefinition } from './send-document-cancelled-emails';
export const run = async ({
payload,
io,
}: {
payload: TSendDocumentCancelledEmailsJobDefinition;
io: JobRunIO;
}) => {
const { documentId, cancellationReason } = payload;
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
user: true,
documentMeta: true,
recipients: true,
team: {
select: {
teamEmail: true,
name: true,
url: true,
teamGlobalSettings: true,
},
},
},
});
const { documentMeta, user: documentOwner } = document;
// Check if document cancellation emails are enabled
const isEmailEnabled = extractDerivedDocumentEmailSettings(documentMeta).documentDeleted;
if (!isEmailEnabled) {
return;
}
const i18n = await getI18nInstance(documentMeta?.language);
// Send cancellation emails to all recipients who have been sent the document or viewed it
const recipientsToNotify = document.recipients.filter(
(recipient) =>
(recipient.sendStatus === SendStatus.SENT || recipient.readStatus === ReadStatus.OPENED) &&
recipient.signingStatus !== SigningStatus.REJECTED,
);
await io.runTask('send-cancellation-emails', async () => {
await Promise.all(
recipientsToNotify.map(async (recipient) => {
const template = createElement(DocumentCancelTemplate, {
documentName: document.title,
inviterName: documentOwner.name || undefined,
inviterEmail: documentOwner.email,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
cancellationReason: cancellationReason || 'The document has been cancelled.',
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: documentMeta?.language, branding }),
renderEmailWithI18N(template, {
lang: documentMeta?.language,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`Document "${document.title}" Cancelled`),
html,
text,
});
}),
);
});
};

View File

@@ -0,0 +1,33 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_ID = 'send.document.cancelled.emails';
const SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_SCHEMA = z.object({
documentId: z.number(),
cancellationReason: z.string().optional(),
requestMetadata: z.any().optional(),
});
export type TSendDocumentCancelledEmailsJobDefinition = z.infer<
typeof SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_SCHEMA
>;
export const SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION = {
id: SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_ID,
name: 'Send Document Cancelled Emails',
version: '1.0.0',
trigger: {
name: SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_ID,
schema: SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-document-cancelled-emails.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_ID,
TSendDocumentCancelledEmailsJobDefinition
>;

View File

@@ -6,9 +6,11 @@ import { PDFDocument } from 'pdf-lib';
import { prisma } from '@documenso/prisma';
import { signPdf } from '@documenso/signing';
import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
import { flattenForm } from '../../../server-only/pdf/flatten-form';
import { insertFieldInPDF } from '../../../server-only/pdf/insert-field-in-pdf';
@@ -22,6 +24,7 @@ import {
import { getFileServerSide } from '../../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../../universal/upload/put-file.server';
import { fieldsContainUnsignedRequiredField } from '../../../utils/advanced-fields-helpers';
import { isDocumentCompleted } from '../../../utils/document';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSealDocumentJobDefinition } from './seal-document';
@@ -38,11 +41,6 @@ export const run = async ({
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
recipients: {
every: {
signingStatus: SigningStatus.SIGNED,
},
},
},
include: {
documentMeta: true,
@@ -59,6 +57,16 @@ export const run = async ({
},
});
const isComplete =
document.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
document.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
if (!isComplete) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Document is not complete',
});
}
// Seems silly but we need to do this in case the job is re-ran
// after it has already run through the update task further below.
// eslint-disable-next-line @typescript-eslint/require-await
@@ -91,9 +99,15 @@ export const run = async ({
},
});
if (recipients.some((recipient) => recipient.signingStatus !== SigningStatus.SIGNED)) {
throw new Error(`Document ${document.id} has unsigned recipients`);
}
// Determine if the document has been rejected by checking if any recipient has rejected it
const rejectedRecipient = recipients.find(
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
);
const isRejected = Boolean(rejectedRecipient);
// Get the rejection reason from the rejected recipient
const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
const fields = await prisma.field.findMany({
where: {
@@ -104,7 +118,8 @@ export const run = async ({
},
});
if (fieldsContainUnsignedRequiredField(fields)) {
// Skip the field check if the document is rejected
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Document ${document.id} has unsigned required fields`);
}
@@ -132,6 +147,11 @@ export const run = async ({
flattenForm(pdfDoc);
flattenAnnotations(pdfDoc);
// Add rejection stamp if the document is rejected
if (isRejected && rejectionReason) {
await addRejectionStampToPdf(pdfDoc, rejectionReason);
}
if (certificateData) {
const certificateDoc = await PDFDocument.load(certificateData);
@@ -160,8 +180,11 @@ export const run = async ({
const { name } = path.parse(document.title);
// Add suffix based on document status
const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf';
const documentData = await putPdfFileServerSide({
name: `${name}_signed.pdf`,
name: `${name}${suffix}`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBuffer),
});
@@ -177,6 +200,7 @@ export const run = async ({
event: 'App: Document Sealed',
properties: {
documentId: document.id,
isRejected,
},
});
}
@@ -194,7 +218,7 @@ export const run = async ({
id: document.id,
},
data: {
status: DocumentStatus.COMPLETED,
status: isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED,
completedAt: new Date(),
},
});
@@ -216,6 +240,7 @@ export const run = async ({
user: null,
data: {
transactionId: nanoid(),
...(isRejected ? { isRejected: true, rejectionReason: rejectionReason } : {}),
},
}),
});
@@ -223,9 +248,9 @@ export const run = async ({
});
await io.runTask('send-completed-email', async () => {
let shouldSendCompletedEmail = sendEmail && !isResealing;
let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected;
if (isResealing && documentStatus !== DocumentStatus.COMPLETED) {
if (isResealing && !isDocumentCompleted(document.status)) {
shouldSendCompletedEmail = sendEmail;
}
@@ -246,7 +271,9 @@ export const run = async ({
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_COMPLETED,
event: isRejected
? WebhookTriggerEvents.DOCUMENT_REJECTED
: WebhookTriggerEvents.DOCUMENT_COMPLETED,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
userId: updatedDocument.userId,
teamId: updatedDocument.teamId ?? undefined,

View File

@@ -13,6 +13,7 @@ export const getDocumentStats = async () => {
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.ALL]: 0,
};

View File

@@ -1,4 +1,4 @@
import { DocumentStatus } from '@prisma/client';
import { DocumentStatus, SubscriptionStatus } from '@prisma/client';
import { kyselyPrisma, sql } from '@documenso/prisma';
@@ -44,7 +44,6 @@ export async function getSigningVolume({
.on('td.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('td.deletedAt', 'is', null),
)
// @ts-expect-error - Raw SQL enum casting not properly typed by Kysely
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([
@@ -82,7 +81,6 @@ export async function getSigningVolume({
.selectFrom('Subscription as s')
.leftJoin('User as u', 's.userId', 'u.id')
.leftJoin('Team as t', 's.teamId', 't.id')
// @ts-expect-error - Raw SQL enum casting not properly typed by Kysely
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([

View File

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

View File

@@ -7,7 +7,10 @@ import {
WebhookTriggerEvents,
} from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import {
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 { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
@@ -30,6 +33,10 @@ export type CompleteDocumentWithTokenOptions = {
userId?: number;
authOptions?: TRecipientActionAuth;
requestMetadata?: RequestMetadata;
nextSigner?: {
email: string;
name: string;
};
};
const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => {
@@ -57,6 +64,7 @@ export const completeDocumentWithToken = async ({
token,
documentId,
requestMetadata,
nextSigner,
}: CompleteDocumentWithTokenOptions) => {
const document = await getDocument({ token, documentId });
@@ -146,7 +154,6 @@ export const completeDocumentWithToken = async ({
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
// actionAuth: derivedRecipientActionAuth || undefined,
},
}),
});
@@ -164,6 +171,9 @@ export const completeDocumentWithToken = async ({
select: {
id: true,
signingOrder: true,
name: true,
email: true,
role: true,
},
where: {
documentId: document.id,
@@ -186,9 +196,49 @@ export const completeDocumentWithToken = async ({
const [nextRecipient] = pendingRecipients;
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({
where: { id: nextRecipient.id },
data: { sendStatus: SendStatus.SENT },
data: {
sendStatus: SendStatus.SENT,
...(nextSigner && document.documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
email: nextSigner.email,
}
: {}),
},
});
await jobs.triggerJob({

View File

@@ -26,6 +26,7 @@ import {
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { isDocumentCompleted } from '../../utils/document';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
@@ -161,7 +162,7 @@ const handleDocumentOwnerDelete = async ({
}
// Soft delete completed documents.
if (document.status === DocumentStatus.COMPLETED) {
if (isDocumentCompleted(document.status)) {
return await prisma.$transaction(async (tx) => {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({

View File

@@ -356,6 +356,24 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
},
],
}))
.with(ExtendedDocumentStatus.REJECTED, () => ({
OR: [
{
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.REJECTED,
},
{
status: ExtendedDocumentStatus.REJECTED,
recipients: {
some: {
email: user.email,
signingStatus: SigningStatus.REJECTED,
},
},
},
],
}))
.exhaustive();
};
@@ -548,5 +566,38 @@ const findTeamDocumentsFilter = (
return filter;
})
.with(ExtendedDocumentStatus.REJECTED, () => {
const filter: Prisma.DocumentWhereInput = {
status: ExtendedDocumentStatus.REJECTED,
OR: [
{
teamId: team.id,
OR: visibilityFilters,
},
],
};
if (teamEmail && filter.OR) {
filter.OR.push(
{
recipients: {
some: {
email: teamEmail,
signingStatus: SigningStatus.REJECTED,
},
},
OR: visibilityFilters,
},
{
user: {
email: teamEmail,
},
OR: visibilityFilters,
},
);
}
return filter;
})
.exhaustive();
};

View File

@@ -16,6 +16,7 @@ export const getDocumentCertificateAuditLogs = async ({
type: {
in: [
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
],
@@ -29,6 +30,9 @@ export const getDocumentCertificateAuditLogs = async ({
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs.filter(
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
),
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED]: auditLogs.filter(
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
),
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs.filter(
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
),

View File

@@ -44,6 +44,7 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
};
@@ -64,6 +65,10 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta
if (stat.status === ExtendedDocumentStatus.PENDING) {
stats[ExtendedDocumentStatus.PENDING] += stat._count._all;
}
if (stat.status === ExtendedDocumentStatus.REJECTED) {
stats[ExtendedDocumentStatus.REJECTED] += stat._count._all;
}
});
Object.keys(stats).forEach((key) => {

View File

@@ -1,18 +1,12 @@
import { SigningStatus } from '@prisma/client';
import { WebhookTriggerEvents } from '@prisma/client';
import { jobs } from '@documenso/lib/jobs/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type RejectDocumentWithTokenOptions = {
token: string;
@@ -84,7 +78,16 @@ export async function rejectDocumentWithToken({
}),
]);
// Send email notifications
// Trigger the seal document job to process the document asynchronously
await jobs.triggerJob({
name: 'internal.seal-document',
payload: {
documentId,
requestMetadata,
},
});
// Send email notifications to the rejecting recipient
await jobs.triggerJob({
name: 'send.signing.rejected.emails',
payload: {
@@ -93,27 +96,14 @@ export async function rejectDocumentWithToken({
},
});
// Get the updated document with all recipients
const updatedDocument = await prisma.document.findFirst({
where: {
id: document.id,
// Send cancellation emails to other recipients
await jobs.triggerJob({
name: 'send.document.cancelled.emails',
payload: {
documentId,
cancellationReason: reason,
requestMetadata,
},
include: {
recipients: true,
documentMeta: true,
},
});
if (!updatedDocument) {
throw new Error('Document not found after update');
}
// Trigger webhook for document rejection
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_REJECTED,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
userId: document.userId,
teamId: document.teamId ?? undefined,
});
return updatedRecipient;

View File

@@ -20,6 +20,7 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { isDocumentCompleted } from '../../utils/document';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { getDocumentWhereInput } from './get-document-by-id';
@@ -88,7 +89,7 @@ export const resendDocument = async ({
throw new Error('Can not send draft document');
}
if (document.status === DocumentStatus.COMPLETED) {
if (isDocumentCompleted(document.status)) {
throw new Error('Can not send completed document');
}

View File

@@ -18,6 +18,7 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers';
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations';
import { flattenForm } from '../pdf/flatten-form';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
@@ -41,11 +42,6 @@ export const sealDocument = async ({
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
recipients: {
every: {
signingStatus: SigningStatus.SIGNED,
},
},
},
include: {
documentData: true,
@@ -78,7 +74,21 @@ export const sealDocument = async ({
},
});
if (recipients.some((recipient) => recipient.signingStatus !== SigningStatus.SIGNED)) {
// Determine if the document has been rejected by checking if any recipient has rejected it
const rejectedRecipient = recipients.find(
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
);
const isRejected = Boolean(rejectedRecipient);
// Get the rejection reason from the rejected recipient
const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
// If the document is not rejected, ensure all recipients have signed
if (
!isRejected &&
recipients.some((recipient) => recipient.signingStatus !== SigningStatus.SIGNED)
) {
throw new Error(`Document ${document.id} has unsigned recipients`);
}
@@ -91,7 +101,8 @@ export const sealDocument = async ({
},
});
if (fieldsContainUnsignedRequiredField(fields)) {
// Skip the field check if the document is rejected
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Document ${document.id} has unsigned required fields`);
}
@@ -119,6 +130,11 @@ export const sealDocument = async ({
flattenForm(doc);
flattenAnnotations(doc);
// Add rejection stamp if the document is rejected
if (isRejected && rejectionReason) {
await addRejectionStampToPdf(doc, rejectionReason);
}
if (certificateData) {
const certificate = await PDFDocument.load(certificateData);
@@ -142,8 +158,11 @@ export const sealDocument = async ({
const { name } = path.parse(document.title);
// Add suffix based on document status
const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf';
const { data: newData } = await putPdfFileServerSide({
name: `${name}_signed.pdf`,
name: `${name}${suffix}`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBuffer),
});
@@ -156,6 +175,7 @@ export const sealDocument = async ({
event: 'App: Document Sealed',
properties: {
documentId: document.id,
isRejected,
},
});
}
@@ -166,7 +186,7 @@ export const sealDocument = async ({
id: document.id,
},
data: {
status: DocumentStatus.COMPLETED,
status: isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED,
completedAt: new Date(),
},
});
@@ -188,6 +208,7 @@ export const sealDocument = async ({
user: null,
data: {
transactionId: nanoid(),
...(isRejected ? { isRejected: true, rejectionReason } : {}),
},
}),
});
@@ -209,7 +230,9 @@ export const sealDocument = async ({
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_COMPLETED,
event: isRejected
? WebhookTriggerEvents.DOCUMENT_REJECTED
: WebhookTriggerEvents.DOCUMENT_COMPLETED,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
userId: document.userId,
teamId: document.teamId ?? undefined,

View File

@@ -20,6 +20,7 @@ import {
} from '../../types/webhook-payload';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { isDocumentCompleted } from '../../utils/document';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -74,7 +75,7 @@ export const sendDocument = async ({
throw new Error('Document has no recipients');
}
if (document.status === DocumentStatus.COMPLETED) {
if (isDocumentCompleted(document.status)) {
throw new Error('Can not send completed document');
}

View File

@@ -0,0 +1,87 @@
import fontkit from '@pdf-lib/fontkit';
import type { PDFDocument } from 'pdf-lib';
import { TextAlignment, rgb, setFontAndSize } from 'pdf-lib';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
/**
* Adds a rejection stamp to each page of a PDF document.
* The stamp is placed in the center of the page.
*/
export async function addRejectionStampToPdf(
pdfDoc: PDFDocument,
reason: string,
): Promise<PDFDocument> {
const pages = pdfDoc.getPages();
pdfDoc.registerFontkit(fontkit);
const fontBytes = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(
async (res) => res.arrayBuffer(),
);
const font = await pdfDoc.embedFont(fontBytes, {
customName: 'Noto',
});
const form = pdfDoc.getForm();
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
const { width, height } = page.getSize();
// Draw the "REJECTED" text
const rejectedTitleText = 'DOCUMENT REJECTED';
const rejectedTitleFontSize = 36;
const rejectedTitleTextField = form.createTextField(`internal-document-rejected-title-${i}`);
if (!rejectedTitleTextField.acroField.getDefaultAppearance()) {
rejectedTitleTextField.acroField.setDefaultAppearance(
setFontAndSize('Noto', rejectedTitleFontSize).toString(),
);
}
rejectedTitleTextField.updateAppearances(font);
rejectedTitleTextField.setFontSize(rejectedTitleFontSize);
rejectedTitleTextField.setText(rejectedTitleText);
rejectedTitleTextField.setAlignment(TextAlignment.Center);
const rejectedTitleTextWidth =
font.widthOfTextAtSize(rejectedTitleText, rejectedTitleFontSize) * 1.2;
const rejectedTitleTextHeight = font.heightAtSize(rejectedTitleFontSize);
// Calculate the center position of the page
const centerX = width / 2;
const centerY = height / 2;
// Position the title text at the center of the page
const rejectedTitleTextX = centerX - rejectedTitleTextWidth / 2;
const rejectedTitleTextY = centerY - rejectedTitleTextHeight / 2;
// Add padding for the rectangle
const padding = 20;
// Draw the stamp background
page.drawRectangle({
x: rejectedTitleTextX - padding / 2,
y: rejectedTitleTextY - padding / 2,
width: rejectedTitleTextWidth + padding,
height: rejectedTitleTextHeight + padding,
borderColor: rgb(220 / 255, 38 / 255, 38 / 255),
borderWidth: 4,
});
rejectedTitleTextField.addToPage(page, {
x: rejectedTitleTextX,
y: rejectedTitleTextY,
width: rejectedTitleTextWidth,
height: rejectedTitleTextHeight,
textColor: rgb(220 / 255, 38 / 255, 38 / 255),
backgroundColor: undefined,
borderWidth: 0,
borderColor: undefined,
});
}
return pdfDoc;
}

View File

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

View File

@@ -29,7 +29,7 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
where: {
userId: user.id,
status: {
in: [DocumentStatus.PENDING, DocumentStatus.COMPLETED],
in: [DocumentStatus.PENDING, DocumentStatus.REJECTED, DocumentStatus.COMPLETED],
},
},
data: {

View File

@@ -417,7 +417,7 @@ msgstr "<0>{teamName}</0> a demandé à utiliser votre adresse e-mail pour leur
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
msgid "<0>Click to upload</0> or drag and drop"
msgstr "<0>Cliquez pour télécharger</0> ou faites glisser et déposez"
msgstr "<0>Cliquez pour importer</0> ou faites glisser et déposez"
#: packages/ui/primitives/template-flow/add-template-settings.tsx
msgid "<0>Email</0> - The recipient will be emailed the document to sign, approve, etc."
@@ -1082,7 +1082,7 @@ msgstr "Une erreur est survenue lors de la mise à jour de votre profil."
#: apps/remix/app/components/general/document/document-upload.tsx
msgid "An error occurred while uploading your document."
msgstr "Une erreur est survenue lors du téléchargement de votre document."
msgstr "Une erreur est survenue lors de l'importation de votre document."
#: apps/remix/app/components/general/generic-error-layout.tsx
msgid "An unexpected error occurred."
@@ -1539,7 +1539,7 @@ msgstr "Cliquez ici pour réessayer"
#: apps/remix/app/components/dialogs/team-member-invite-dialog.tsx
msgid "Click here to upload"
msgstr "Cliquez ici pour télécharger"
msgstr "Cliquez ici pour importer"
#: apps/remix/app/components/general/avatar-with-recipient.tsx
#: apps/remix/app/components/general/avatar-with-recipient.tsx
@@ -1727,11 +1727,11 @@ msgstr "Continuer vers la connexion"
#: apps/remix/app/components/forms/team-document-preferences-form.tsx
msgid "Controls the default language of an uploaded document. This will be used as the language in email communications with the recipients."
msgstr "Contrôle la langue par défaut d'un document téléchargé. Cela sera utilisé comme langue dans les communications par e-mail avec les destinataires."
msgstr "Contrôle la langue par défaut d'un document importé. Cela sera utilisé comme langue dans les communications par e-mail avec les destinataires."
#: apps/remix/app/components/forms/team-document-preferences-form.tsx
msgid "Controls the default visibility of an uploaded document."
msgstr "Contrôle la visibilité par défaut d'un document téléchargé."
msgstr "Contrôle la visibilité par défaut d'un document importé."
#: apps/remix/app/components/forms/team-document-preferences-form.tsx
msgid "Controls the formatting of the message that will be sent when inviting a recipient to sign a document. If a custom message has been provided while configuring the document, it will be used instead."
@@ -2394,11 +2394,11 @@ msgstr "Document mis à jour"
#: apps/remix/app/components/general/document/document-upload.tsx
msgid "Document upload disabled due to unpaid invoices"
msgstr "Téléchargement du document désactivé en raison de factures impayées"
msgstr "Importation de documents désactivé en raison de factures impayées"
#: apps/remix/app/components/general/document/document-upload.tsx
msgid "Document uploaded"
msgstr "Document téléchargé"
msgstr "Document importé"
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "Document Viewed"
@@ -2860,7 +2860,7 @@ msgstr "Pour toute question concernant cette divulgation, les signatures électr
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
msgid "For each recipient, provide their email (required) and name (optional) in separate columns. Download the template CSV below for the correct format."
msgstr "Pour chaque destinataire, fournissez leur email (obligatoire) et leur nom (facultatif) dans des colonnes séparées. Téléchargez le modèle CSV ci-dessous pour le format correct."
msgstr "Pour chaque destinataire, fournissez son e-mail (obligatoire) et son nom (facultatif) dans des colonnes séparées. Téléchargez le modèle CSV ci-dessous pour obtenir le format requis."
#: packages/lib/server-only/auth/send-forgot-password.ts
msgid "Forgot Password?"
@@ -2907,7 +2907,7 @@ msgstr "Authentification d'action de destinataire globale"
#: apps/remix/app/components/general/generic-error-layout.tsx
#: packages/ui/primitives/document-flow/document-flow-root.tsx
msgid "Go Back"
msgstr "Retourner"
msgstr "Retour"
#: apps/remix/app/routes/_unauthenticated+/verify-email._index.tsx
#: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx
@@ -2988,7 +2988,7 @@ msgstr "Salut, je suis Timur"
#: packages/email/templates/bulk-send-complete.tsx
msgid "Hi {userName},"
msgstr "Bonjour, {userName},"
msgstr "Bonjour {userName},"
#: packages/email/templates/reset-password.tsx
msgid "Hi, {userName} <0>({userEmail})</0>"
@@ -3010,7 +3010,7 @@ msgstr "Je suis un signataire de ce document"
#: packages/lib/constants/recipient-roles.ts
msgid "I am a viewer of this document"
msgstr "Je suis un visualiseur de ce document"
msgstr "Je suis un lecteur de ce document"
#: packages/lib/constants/recipient-roles.ts
msgid "I am an approver of this document"
@@ -3400,11 +3400,11 @@ msgstr "MAU (document terminé)"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx
msgid "Max"
msgstr ""
msgstr "Maximum"
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults."
msgstr "Taille maximale du fichier : 4 Mo. Maximum de 100 lignes par téléversement. Les valeurs vides utiliseront les valeurs par défaut du modèle."
msgstr "Taille maximale du fichier : 4 Mo. Maximum de 100 lignes par importation. Les valeurs vides utiliseront les valeurs par défaut du modèle."
#: packages/lib/constants/teams.ts
msgid "Member"
@@ -5215,7 +5215,7 @@ msgstr "Modèle supprimé"
#: apps/remix/app/components/dialogs/template-create-dialog.tsx
msgid "Template document uploaded"
msgstr "Document modèle téléchargé"
msgstr "Document modèle importé"
#: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx
msgid "Template duplicated"
@@ -5280,11 +5280,11 @@ msgstr "Couleur du texte"
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "Thank you for using Documenso to perform your electronic document signing. The purpose of this disclosure is to inform you about the process, legality, and your rights regarding the use of electronic signatures on our platform. By opting to use an electronic signature, you are agreeing to the terms and conditions outlined below."
msgstr "Merci d'utiliser Documenso pour signer vos documents électroniquement. L'objectif de cette divulgation est de vous informer sur le processus, la légalité et vos droits concernant l'utilisation des signatures électroniques sur notre plateforme. En choisissant d'utiliser une signature électronique, vous acceptez les termes et conditions énoncés ci-dessous."
msgstr "Merci d'utiliser Documenso pour signer vos documents électroniquement. L'objectif de cette clause est de vous informer sur le processus, la légalité et vos droits concernant l'utilisation de la signature électronique sur notre plateforme. En choisissant d'utiliser un sytème de signature électronique, vous acceptez les termes et conditions exposés ci-dessous."
#: packages/email/template-components/template-forgot-password.tsx
msgid "That's okay, it happens! Click the button below to reset your password."
msgstr "C'est d'accord, cela arrive ! Cliquez sur le bouton ci-dessous pour réinitialiser votre mot de passe."
msgstr "Ce n'est pas grave, cela arrive ! Cliquez sur le bouton ci-dessous pour réinitialiser votre mot de passe."
#: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx
msgid "The account has been deleted successfully."
@@ -5522,7 +5522,7 @@ msgstr "Le webhook a été créé avec succès."
#: apps/remix/app/components/tables/documents-table-empty-state.tsx
msgid "There are no active drafts at the current moment. You can upload a document to start drafting."
msgstr "Il n'y a pas de brouillons actifs pour le moment. Vous pouvez télécharger un document pour commencer à rédiger."
msgstr "Il n'y a pas de brouillons actifs pour le moment. Vous pouvez importer un document pour commencer un brouillon."
#: apps/remix/app/components/tables/documents-table-empty-state.tsx
msgid "There are no completed documents yet. Documents that you have created or received will appear here once completed."
@@ -6091,27 +6091,27 @@ msgstr "Améliorer"
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
msgid "Upload a CSV file to create multiple documents from this template. Each row represents one document with its recipient details."
msgstr "Téléchargez un fichier CSV pour créer plusieurs documents à partir de ce modèle. Chaque ligne représente un document avec ses détails de destinataire."
msgstr "Importer un fichier CSV pour créer plusieurs documents à partir de ce modèle. Chaque ligne représente un document avec les coordonnées de son destinataire."
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
msgid "Upload a custom document to use instead of the template's default document"
msgstr "Téléchargez un document personnalisé à utiliser à la place du document par défaut du modèle"
msgstr "Importer un document personnalisé à utiliser à la place du modèle par défaut"
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
msgid "Upload and Process"
msgstr "Télécharger et traiter"
msgstr "Importer et traiter"
#: apps/remix/app/components/forms/avatar-image.tsx
msgid "Upload Avatar"
msgstr "Télécharger un avatar"
msgstr "Importer un avatar"
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
msgid "Upload CSV"
msgstr "Télécharger le CSV"
msgstr "Importer le CSV"
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
msgid "Upload custom document"
msgstr "Télécharger un document personnalisé"
msgstr "Importer un document personnalisé"
#: packages/ui/primitives/signature-pad/signature-pad.tsx
msgid "Upload Signature"
@@ -6119,7 +6119,7 @@ msgstr "Importer une signature"
#: packages/ui/primitives/document-dropzone.tsx
msgid "Upload Template Document"
msgstr "Télécharger le document modèle"
msgstr "Importer le document modèle"
#: apps/remix/app/components/forms/team-branding-preferences-form.tsx
msgid "Upload your brand logo (max 5MB, JPG, PNG, or WebP)"
@@ -6132,15 +6132,15 @@ msgstr "Téléversé par"
#: apps/remix/app/components/forms/avatar-image.tsx
msgid "Uploaded file is too large"
msgstr "Le fichier téléchargé est trop volumineux"
msgstr "Le fichier importé est trop volumineux"
#: apps/remix/app/components/forms/avatar-image.tsx
msgid "Uploaded file is too small"
msgstr "Le fichier téléchargé est trop petit"
msgstr "Le fichier importé est trop petit"
#: apps/remix/app/components/forms/avatar-image.tsx
msgid "Uploaded file not an allowed file type"
msgstr "Le fichier téléchargé n'est pas un type de fichier autorisé"
msgstr "Le fichier importé n'est pas un type de fichier autorisé"
#: apps/remix/app/routes/_authenticated+/templates.$id._index.tsx
msgid "Use"
@@ -6232,7 +6232,7 @@ msgstr "Vérifiez votre adresse e-mail pour débloquer toutes les fonctionnalit
#: apps/remix/app/components/general/document/document-upload.tsx
msgid "Verify your email to upload documents."
msgstr "Vérifiez votre e-mail pour télécharger des documents."
msgstr "Vérifiez votre e-mail pour importer des documents."
#: packages/email/templates/confirm-team-email.tsx
msgid "Verify your team email address"
@@ -6329,11 +6329,11 @@ msgstr "Vu"
#: packages/lib/constants/recipient-roles.ts
msgid "Viewer"
msgstr "Visiteur"
msgstr "Lecteur"
#: packages/lib/constants/recipient-roles.ts
msgid "Viewers"
msgstr "Spectateurs"
msgstr "Lecteurs"
#: packages/lib/constants/recipient-roles.ts
msgid "Viewing"
@@ -6728,7 +6728,7 @@ msgstr "Vous êtes sur le point d'envoyer ce document aux destinataires. Êtes-v
#: apps/remix/app/routes/_authenticated+/settings+/billing.tsx
msgid "You are currently on the <0>Free Plan</0>."
msgstr ""
msgstr "Vous êtes actuellement sur l'<0>Abonnement Gratuit</0>."
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
msgid "You are currently updating <0>{teamMemberName}.</0>"
@@ -6809,11 +6809,11 @@ msgstr "Vous ne pouvez pas modifier un membre de l'équipe qui a un rôle plus
#: packages/ui/primitives/document-dropzone.tsx
msgid "You cannot upload documents at this time."
msgstr "Vous ne pouvez pas télécharger de documents pour le moment."
msgstr "Vous ne pouvez pas importer de documents pour le moment."
#: apps/remix/app/components/general/document/document-upload.tsx
msgid "You cannot upload encrypted PDFs"
msgstr "Vous ne pouvez pas télécharger de PDF cryptés"
msgstr "Vous ne pouvez pas importer de PDF cryptés"
#: apps/remix/app/components/general/billing-portal-button.tsx
msgid "You do not currently have a customer record, this should not happen. Please contact support for assistance."
@@ -6885,11 +6885,11 @@ msgstr "Vous n'avez pas encore de webhooks. Vos webhooks seront affichés ici un
#: apps/remix/app/routes/_authenticated+/templates._index.tsx
msgid "You have not yet created any templates. To create a template please upload one."
msgstr "Vous n'avez pas encore créé de modèles. Pour créer un modèle, veuillez en télécharger un."
msgstr "Vous n'avez pas encore créé de modèles. Pour créer un modèle, veuillez en importer un."
#: apps/remix/app/components/tables/documents-table-empty-state.tsx
msgid "You have not yet created or received any documents. To create a document please upload one."
msgstr "Vous n'avez pas encore créé ou reçu de documents. Pour créer un document, veuillez en télécharger un."
msgstr "Vous n'avez pas encore créé ou reçu de documents. Pour créer un document, veuillez en importer un."
#. placeholder {0}: quota.directTemplates
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
@@ -6898,7 +6898,7 @@ msgstr "Vous avez atteint la limite maximale de {0} modèles directs. <0>Mettez
#: apps/remix/app/components/general/document/document-upload.tsx
msgid "You have reached your document limit for this month. Please upgrade your plan."
msgstr "Vous avez atteint votre limite de documents pour ce mois. Veuillez passer à un plan supérieur."
msgstr "Vous avez atteint votre limite de documents pour ce mois. Veuillez passer à l'abonnement supérieur."
#: apps/remix/app/components/general/document/document-upload.tsx
#: packages/ui/primitives/document-dropzone.tsx
@@ -7024,11 +7024,11 @@ msgstr "Votre envoi groupé a été initié. Vous recevrez une notification par
#: packages/email/templates/bulk-send-complete.tsx
msgid "Your bulk send operation for template \"{templateName}\" has completed."
msgstr "Votre opération d'envoi groupé pour le modèle \"{templateName}\" est terminée."
msgstr "Votre envoi groupé pour le modèle \"{templateName}\" est terminé."
#: apps/remix/app/routes/_authenticated+/settings+/billing.tsx
msgid "Your current plan is past due. Please update your payment information."
msgstr ""
msgstr "Votre abonnement actuel est arrivé à échéance. Veuillez mettre à jour vos informations de paiement."
#: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx
msgid "Your direct signing templates"
@@ -7036,7 +7036,7 @@ msgstr "Vos modèles de signature directe"
#: apps/remix/app/components/general/document/document-upload.tsx
msgid "Your document failed to upload."
msgstr "Votre document a échoué à se télécharger."
msgstr "L'importation de votre document a échoué."
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
msgid "Your document has been created from the template successfully."
@@ -7060,11 +7060,11 @@ msgstr "Votre document a été dupliqué avec succès."
#: apps/remix/app/components/general/document/document-upload.tsx
msgid "Your document has been uploaded successfully."
msgstr "Votre document a été téléchargé avec succès."
msgstr "Votre document a été importé avec succès."
#: apps/remix/app/components/dialogs/template-create-dialog.tsx
msgid "Your document has been uploaded successfully. You will be redirected to the template page."
msgstr "Votre document a été téléchargé avec succès. Vous serez redirigé vers la page de modèle."
msgstr "Votre document a été importé avec succès. Vous serez redirigé vers la page de modèle."
#: apps/remix/app/components/forms/team-document-preferences-form.tsx
msgid "Your document preferences have been updated"

View File

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

View File

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

View File

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

View File

@@ -40,7 +40,13 @@ export type ApiRequestMetadata = {
};
export const extractRequestMetadata = (req: Request): RequestMetadata => {
const parsedIp = ZIpSchema.safeParse(req.headers.get('x-forwarded-for'));
const forwardedFor = req.headers.get('x-forwarded-for');
const ip = forwardedFor
?.split(',')
.map((ip) => ip.trim())
.at(0);
const parsedIp = ZIpSchema.safeParse(ip);
const ipAddress = parsedIp.success ? parsedIp.data : undefined;
const userAgent = req.headers.get('user-agent');

View File

@@ -0,0 +1,8 @@
import type { Document } from '@prisma/client';
import { DocumentStatus } from '@prisma/client';
export const isDocumentCompleted = (document: Pick<Document, 'status'> | DocumentStatus) => {
const status = typeof document === 'string' ? document : document.status;
return status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED;
};

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "DocumentStatus" ADD VALUE 'REJECTED';

View File

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

View File

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

View File

@@ -297,6 +297,7 @@ enum DocumentStatus {
DRAFT
PENDING
COMPLETED
REJECTED
}
enum DocumentSource {
@@ -389,20 +390,21 @@ enum DocumentDistributionMethod {
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';"])
model DocumentMeta {
id String @id @default(cuid())
subject String?
message String?
timezone String? @default("Etc/UTC") @db.Text
password String?
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
documentId Int @unique
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
redirectUrl String?
signingOrder DocumentSigningOrder @default(PARALLEL)
typedSignatureEnabled Boolean @default(true)
language String @default("en")
distributionMethod DocumentDistributionMethod @default(EMAIL)
emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
id String @id @default(cuid())
subject String?
message String?
timezone String? @default("Etc/UTC") @db.Text
password String?
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
documentId Int @unique
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
redirectUrl String?
signingOrder DocumentSigningOrder @default(PARALLEL)
allowDictateNextSigner Boolean @default(false)
typedSignatureEnabled Boolean @default(true)
language String @default("en")
distributionMethod DocumentDistributionMethod @default(EMAIL)
emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
}
enum ReadStatus {
@@ -659,15 +661,16 @@ enum TemplateType {
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';"])
model TemplateMeta {
id String @id @default(cuid())
subject String?
message String?
timezone String? @default("Etc/UTC") @db.Text
password String?
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
signingOrder DocumentSigningOrder? @default(PARALLEL)
typedSignatureEnabled Boolean @default(true)
distributionMethod DocumentDistributionMethod @default(EMAIL)
id String @id @default(cuid())
subject String?
message String?
timezone String? @default("Etc/UTC") @db.Text
password String?
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
signingOrder DocumentSigningOrder? @default(PARALLEL)
allowDictateNextSigner Boolean @default(false)
typedSignatureEnabled Boolean @default(true)
distributionMethod DocumentDistributionMethod @default(EMAIL)
templateId Int @unique
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)

View File

@@ -31,6 +31,7 @@ type DocumentToSeed = {
export const seedDocuments = async (documents: DocumentToSeed[]) => {
await Promise.all(
// eslint-disable-next-line @typescript-eslint/require-await
documents.map(async (document, i) =>
match(document.type)
.with(DocumentStatus.DRAFT, async () =>
@@ -50,8 +51,7 @@ export const seedDocuments = async (documents: DocumentToSeed[]) => {
key: i,
createDocumentOptions: document.documentOptions,
}),
)
.exhaustive(),
),
),
);
};

View File

@@ -1,5 +1,3 @@
import { DocumentStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents';
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
@@ -13,6 +11,7 @@ import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
import { disableUser } from '@documenso/lib/server-only/user/disable-user';
import { enableUser } from '@documenso/lib/server-only/user/enable-user';
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { adminProcedure, router } from '../trpc';
import {
@@ -70,7 +69,7 @@ export const adminRouter = router({
const document = await getEntireDocument({ id });
const isResealing = document.status === DocumentStatus.COMPLETED;
const isResealing = isDocumentCompleted(document.status);
return await sealDocument({ documentId: id, isResealing });
}),

View File

@@ -1,4 +1,4 @@
import { DocumentDataType, DocumentStatus } from '@prisma/client';
import { DocumentDataType } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import { DateTime } from 'luxon';
@@ -26,6 +26,7 @@ import { sendDocument } from '@documenso/lib/server-only/document/send-document'
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { authenticatedProcedure, procedure, router } from '../trpc';
import {
@@ -370,6 +371,7 @@ export const documentRouter = router({
redirectUrl: meta.redirectUrl,
distributionMethod: meta.distributionMethod,
signingOrder: meta.signingOrder,
allowDictateNextSigner: meta.allowDictateNextSigner,
emailSettings: meta.emailSettings,
requestMetadata: ctx.metadata,
});
@@ -659,7 +661,7 @@ export const documentRouter = router({
teamId,
});
if (document.status !== DocumentStatus.COMPLETED) {
if (!isDocumentCompleted(document.status)) {
throw new AppError('DOCUMENT_NOT_COMPLETE');
}

View File

@@ -144,6 +144,7 @@ export const ZFindDocumentsInternalResponseSchema = ZFindResultResponse.extend({
[ExtendedDocumentStatus.DRAFT]: z.number(),
[ExtendedDocumentStatus.PENDING]: z.number(),
[ExtendedDocumentStatus.COMPLETED]: z.number(),
[ExtendedDocumentStatus.REJECTED]: z.number(),
[ExtendedDocumentStatus.INBOX]: z.number(),
[ExtendedDocumentStatus.ALL]: z.number(),
}),
@@ -267,6 +268,7 @@ export const ZUpdateDocumentRequestSchema = z.object({
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
allowDictateNextSigner: z.boolean().optional(),
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(),
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),

View File

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

View File

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

View File

@@ -68,7 +68,12 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
'The data ID of an alternative PDF to use when creating the document. If not provided, the PDF attached to the template will be used.',
)
.optional(),
prefillFields: z.array(ZFieldMetaPrefillFieldsSchema).optional(),
prefillFields: z
.array(ZFieldMetaPrefillFieldsSchema)
.describe(
'The fields to prefill on the document before sending it out. Useful when you want to create a document from an existing template and pre-fill the fields with specific values.',
)
.optional(),
});
export const ZCreateDocumentFromTemplateResponseSchema = ZDocumentSchema;
@@ -160,6 +165,7 @@ export const ZUpdateTemplateRequestSchema = z.object({
language: ZDocumentMetaLanguageSchema.optional(),
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
allowDictateNextSigner: z.boolean().optional(),
})
.optional(),
});

View File

@@ -9,7 +9,7 @@ import { Trans } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client';
import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVerticalIcon, Plus, Trash } from 'lucide-react';
import { GripVerticalIcon, HelpCircle, Plus, Trash } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { prop, sortBy } from 'remeda';
@@ -29,6 +29,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '
import { FormErrorMessage } from '../form/form-error-message';
import { Input } from '../input';
import { useStep } from '../stepper';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import { useToast } from '../use-toast';
import type { TAddSignersFormSchema } from './add-signers.types';
import { ZAddSignersFormSchema } from './add-signers.types';
@@ -48,6 +49,7 @@ export type AddSignersFormProps = {
recipients: Recipient[];
fields: Field[];
signingOrder?: DocumentSigningOrder | null;
allowDictateNextSigner?: boolean;
isDocumentEnterprise: boolean;
onSubmit: (_data: TAddSignersFormSchema) => void;
isDocumentPdfLoaded: boolean;
@@ -58,6 +60,7 @@ export const AddSignersFormPartial = ({
recipients,
fields,
signingOrder,
allowDictateNextSigner,
isDocumentEnterprise,
onSubmit,
isDocumentPdfLoaded,
@@ -104,6 +107,7 @@ export const AddSignersFormPartial = ({
)
: defaultRecipients,
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
allowDictateNextSigner: allowDictateNextSigner ?? false,
},
});
@@ -201,10 +205,12 @@ export const AddSignersFormPartial = ({
return;
}
removeSigner(index);
const updatedSigners = signers.filter((_, idx) => idx !== index);
form.setValue('signers', normalizeSigningOrders(updatedSigners));
const formStateIndex = form.getValues('signers').findIndex((s) => s.formId === signer.formId);
if (formStateIndex !== -1) {
removeSigner(formStateIndex);
const updatedSigners = form.getValues('signers').filter((s) => s.formId !== signer.formId);
form.setValue('signers', normalizeSigningOrders(updatedSigners));
}
};
const onAddSelfSigner = () => {
@@ -352,6 +358,7 @@ export const AddSignersFormPartial = ({
form.setValue('signers', updatedSigners);
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL);
form.setValue('allowDictateNextSigner', false);
}, [form]);
return (
@@ -387,6 +394,11 @@ export const AddSignersFormPartial = ({
field.onChange(
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
);
// If sequential signing is turned off, disable dictate next signer
if (!checked) {
form.setValue('allowDictateNextSigner', false);
}
}}
disabled={isSubmitting || hasDocumentBeenSent}
/>
@@ -401,6 +413,50 @@ export const AddSignersFormPartial = ({
</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
onDragEnd={onDragEnd}
sensors={[

View File

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

View File

@@ -79,11 +79,13 @@ export const AddSubjectFormPartial = ({
? msg`Resend`
: msg`Send`,
[DocumentStatus.COMPLETED]: msg`Update`,
[DocumentStatus.REJECTED]: msg`Update`,
},
[DocumentDistributionMethod.NONE]: {
[DocumentStatus.DRAFT]: msg`Generate Links`,
[DocumentStatus.PENDING]: msg`View Document`,
[DocumentStatus.COMPLETED]: msg`View Document`,
[DocumentStatus.REJECTED]: msg`View Document`,
},
};

View File

@@ -271,7 +271,11 @@ export const SignaturePad = ({
event.preventDefault();
}
onMouseUp(event, false);
if (isPressed) {
onMouseUp(event, true);
} else {
onMouseUp(event, false);
}
};
const onClearClick = () => {

View File

@@ -9,7 +9,7 @@ import { Trans } from '@lingui/react/macro';
import type { TemplateDirectLink } from '@prisma/client';
import { DocumentSigningOrder, type Field, type Recipient, RecipientRole } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVerticalIcon, Link2Icon, Plus, Trash } from 'lucide-react';
import { GripVerticalIcon, HelpCircle, Link2Icon, Plus, Trash } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useSession } from '@documenso/lib/client-only/providers/session';
@@ -47,10 +47,11 @@ export type AddTemplatePlaceholderRecipientsFormProps = {
recipients: Recipient[];
fields: Field[];
signingOrder?: DocumentSigningOrder | null;
templateDirectLink: TemplateDirectLink | null;
allowDictateNextSigner?: boolean;
templateDirectLink?: TemplateDirectLink | null;
isEnterprise: boolean;
isDocumentPdfLoaded: boolean;
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
isDocumentPdfLoaded: boolean;
};
export const AddTemplatePlaceholderRecipientsFormPartial = ({
@@ -60,6 +61,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
templateDirectLink,
fields,
signingOrder,
allowDictateNextSigner,
isDocumentPdfLoaded,
onSubmit,
}: AddTemplatePlaceholderRecipientsFormProps) => {
@@ -112,6 +114,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
defaultValues: {
signers: generateDefaultFormSigners(),
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
allowDictateNextSigner: allowDictateNextSigner ?? false,
},
});
@@ -119,6 +122,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
form.reset({
signers: generateDefaultFormSigners(),
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
allowDictateNextSigner: allowDictateNextSigner ?? false,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -377,6 +381,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
form.setValue('signers', updatedSigners);
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL);
form.setValue('allowDictateNextSigner', false);
}, [form]);
return (
@@ -416,6 +421,11 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
field.onChange(
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
);
// If sequential signing is turned off, disable dictate next signer
if (!checked) {
form.setValue('allowDictateNextSigner', false);
}
}}
disabled={isSubmitting}
/>
@@ -431,6 +441,49 @@ 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 */}
<DragDropContext
onDragEnd={onDragEnd}

View File

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