Compare commits

..

9 Commits

80 changed files with 689 additions and 2054 deletions

View File

@ -18,11 +18,6 @@ The guide assumes you have a Documenso account. If you don't, you can create a f
Navigate to the [Documenso dashboard](https://app.documenso.com/documents) and click on the "Add a document" button. Select the document you want to upload and wait for the upload to complete. Navigate to the [Documenso dashboard](https://app.documenso.com/documents) and click on the "Add a document" button. Select the document you want to upload and wait for the upload to complete.
<Callout type="info">
The maximum file size for uploaded documents is 150MB in production. In staging, the limit is
50MB.
</Callout>
![Documenso dashboard](/document-signing/documenso-documents-dashboard.webp) ![Documenso dashboard](/document-signing/documenso-documents-dashboard.webp)
After the upload is complete, you will be redirected to the document's page. You can configure the document's settings and add recipients and fields here. After the upload is complete, you will be redirected to the document's page. You can configure the document's settings and add recipients and fields here.

View File

@ -6,7 +6,7 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { type Recipient, SigningStatus } from '@prisma/client'; import { type Recipient, SigningStatus } from '@prisma/client';
import { History } from 'lucide-react'; import { History } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import * as z from 'zod'; import * as z from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
@ -85,11 +85,6 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
formState: { isSubmitting }, formState: { isSubmitting },
} = form; } = form;
const selectedRecipients = useWatch({
control: form.control,
name: 'recipients',
});
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => { const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
try { try {
await resendDocument({ documentId: document.id, recipients }); await resendDocument({ documentId: document.id, recipients });
@ -156,7 +151,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
<FormControl> <FormControl>
<Checkbox <Checkbox
className="h-5 w-5 rounded-full border border-neutral-400" className="h-5 w-5 rounded-full"
value={recipient.id} value={recipient.id}
checked={value.includes(recipient.id)} checked={value.includes(recipient.id)}
onCheckedChange={(checked: boolean) => onCheckedChange={(checked: boolean) =>
@ -187,13 +182,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
</Button> </Button>
</DialogClose> </DialogClose>
<Button <Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
className="flex-1"
loading={isSubmitting}
type="submit"
form={FORM_ID}
disabled={isSubmitting || selectedRecipients.length === 0}
>
<Trans>Send reminder</Trans> <Trans>Send reminder</Trans>
</Button> </Button>
</div> </div>

View File

@ -15,6 +15,7 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { import {
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX, TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX, TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
isTemplateRecipientEmailPlaceholder,
} from '@documenso/lib/constants/template'; } from '@documenso/lib/constants/template';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
@ -45,22 +46,50 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
import type { Toast } from '@documenso/ui/primitives/use-toast'; import type { Toast } from '@documenso/ui/primitives/use-toast';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
const ZAddRecipientsForNewDocumentSchema = z.object({ const ZAddRecipientsForNewDocumentSchema = z
distributeDocument: z.boolean(), .object({
useCustomDocument: z.boolean().default(false), distributeDocument: z.boolean(),
customDocumentData: z useCustomDocument: z.boolean().default(false),
.any() customDocumentData: z
.refine((data) => data instanceof File || data === undefined) .any()
.optional(), .refine((data) => data instanceof File || data === undefined)
recipients: z.array( .optional(),
z.object({ recipients: z.array(
id: z.number(), z.object({
email: z.string().email(), id: z.number(),
name: z.string(), email: z.string().email(),
signingOrder: z.number().optional(), name: z.string(),
}), signingOrder: z.number().optional(),
), }),
}); ),
})
// Display exactly which rows are duplicates.
.superRefine((items, ctx) => {
const uniqueEmails = new Map<string, number>();
for (const [index, recipients] of items.recipients.entries()) {
const email = recipients.email.toLowerCase();
const firstFoundIndex = uniqueEmails.get(email);
if (firstFoundIndex === undefined) {
uniqueEmails.set(email, index);
continue;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', index, 'email'],
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', firstFoundIndex, 'email'],
});
}
});
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>; type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
@ -249,7 +278,14 @@ export function TemplateUseDialog({
)} )}
<FormControl> <FormControl>
<Input {...field} aria-label="Email" placeholder={_(msg`Email`)} /> <Input
{...field}
placeholder={
isTemplateRecipientEmailPlaceholder(field.value)
? ''
: _(msg`Email`)
}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -270,7 +306,6 @@ export function TemplateUseDialog({
<FormControl> <FormControl>
<Input <Input
{...field} {...field}
aria-label="Name"
placeholder={recipients[index].name || _(msg`Name`)} placeholder={recipients[index].name || _(msg`Name`)}
/> />
</FormControl> </FormControl>

View File

@ -3,11 +3,10 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { TeamGlobalSettings } from '@prisma/client'; import type { TeamGlobalSettings } from '@prisma/client';
import { DocumentVisibility, OrganisationType } from '@prisma/client'; import { DocumentVisibility } from '@prisma/client';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats'; import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document'; import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
@ -87,10 +86,8 @@ export const DocumentPreferencesForm = ({
}: DocumentPreferencesFormProps) => { }: DocumentPreferencesFormProps) => {
const { t } = useLingui(); const { t } = useLingui();
const { user, organisations } = useSession(); const { user, organisations } = useSession();
const currentOrganisation = useCurrentOrganisation();
const isPersonalLayoutMode = isPersonalLayout(organisations); const isPersonalLayoutMode = isPersonalLayout(organisations);
const isPersonalOrganisation = currentOrganisation.type === OrganisationType.PERSONAL;
const placeholderEmail = user.email ?? 'user@example.com'; const placeholderEmail = user.email ?? 'user@example.com';
@ -334,7 +331,7 @@ export const DocumentPreferencesForm = ({
)} )}
/> />
{!isPersonalLayoutMode && !isPersonalOrganisation && ( {!isPersonalLayoutMode && (
<FormField <FormField
control={form.control} control={form.control}
name="includeSenderDetails" name="includeSenderDetails"

View File

@ -160,14 +160,6 @@ export const DocumentSigningPageView = ({
return ( return (
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}> <DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
<div className="mx-auto w-full max-w-screen-xl sm:px-6"> <div className="mx-auto w-full max-w-screen-xl sm:px-6">
{document.team.teamGlobalSettings.brandingEnabled &&
document.team.teamGlobalSettings.brandingLogo && (
<img
src={`/api/branding/logo/team/${document.teamId}`}
alt={`${document.team.name}'s Logo`}
className="mb-4 h-12 w-12 md:mb-2"
/>
)}
<h1 <h1
className="block max-w-[20rem] truncate text-2xl font-semibold sm:mt-4 md:max-w-[30rem] md:text-3xl" className="block max-w-[20rem] truncate text-2xl font-semibold sm:mt-4 md:max-w-[30rem] md:text-3xl"
title={document.title} title={document.title}

View File

@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone'; import { useDropzone } from 'react-dropzone';
import { Link, useNavigate, useParams } from 'react-router'; import { Link, useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -108,51 +108,15 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
} }
}; };
const onFileDropRejected = (fileRejections: FileRejection[]) => { const onFileDropRejected = () => {
if (!fileRejections.length) {
return;
}
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
const { file, errors } = fileRejections[0];
if (!errors.length) {
return;
}
const errorNodes = errors.map((error, index) => (
<span key={index} className="block">
{match(error.code)
.with(ErrorCode.FileTooLarge, () => (
<Trans>File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB</Trans>
))
.with(ErrorCode.FileInvalidType, () => <Trans>Only PDF files are allowed</Trans>)
.with(ErrorCode.FileTooSmall, () => <Trans>File is too small</Trans>)
.with(ErrorCode.TooManyFiles, () => (
<Trans>Only one file can be uploaded at a time</Trans>
))
.otherwise(() => (
<Trans>Unknown error</Trans>
))}
</span>
));
const description = (
<>
<span className="font-medium">
{file.name} <Trans>couldn't be uploaded:</Trans>
</span>
{errorNodes}
</>
);
toast({ toast({
title: _(msg`Upload failed`), title: _(msg`Your document failed to upload.`),
description, description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
duration: 5000, duration: 5000,
variant: 'destructive', variant: 'destructive',
}); });
}; };
const { getRootProps, getInputProps, isDragActive } = useDropzone({ const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: { accept: {
'application/pdf': ['.pdf'], 'application/pdf': ['.pdf'],
@ -165,8 +129,8 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
void onFileDrop(acceptedFile); void onFileDrop(acceptedFile);
} }
}, },
onDropRejected: (fileRejections) => { onDropRejected: () => {
onFileDropRejected(fileRejections); void onFileDropRejected();
}, },
noClick: true, noClick: true,
noDragEventsBubbling: true, noDragEventsBubbling: true,

View File

@ -239,27 +239,7 @@ export const DocumentEditForm = ({
const onAddSignersFormAutoSave = async (data: TAddSignersFormSchema) => { const onAddSignersFormAutoSave = async (data: TAddSignersFormSchema) => {
try { try {
// For autosave, we need to return the recipients response for form state sync await saveSignersData(data);
const [, recipientsResponse] = await Promise.all([
updateDocument({
documentId: document.id,
meta: {
allowDictateNextSigner: data.allowDictateNextSigner,
signingOrder: data.signingOrder,
},
}),
setRecipients({
documentId: document.id,
recipients: data.signers.map((signer) => ({
...signer,
// Explicitly set to null to indicate we want to remove auth if required.
actionAuth: signer.actionAuth ?? [],
})),
}),
]);
return recipientsResponse;
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -268,8 +248,6 @@ export const DocumentEditForm = ({
description: _(msg`An error occurred while adding signers.`), description: _(msg`An error occurred while adding signers.`),
variant: 'destructive', variant: 'destructive',
}); });
throw err; // Re-throw so the autosave hook can handle the error
} }
}; };

View File

@ -54,7 +54,7 @@ export const FolderCard = ({
}; };
return ( return (
<Link to={formatPath()} data-folder-id={folder.id} data-folder-name={folder.name}> <Link to={formatPath()} key={folder.id}>
<Card className="hover:bg-muted/50 border-border h-full border transition-all"> <Card className="hover:bg-muted/50 border-border h-full border transition-all">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">

View File

@ -4,9 +4,8 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone'; import { useDropzone } from 'react-dropzone';
import { useNavigate, useParams } from 'react-router'; import { useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
@ -68,47 +67,10 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
} }
}; };
const onFileDropRejected = (fileRejections: FileRejection[]) => { const onFileDropRejected = () => {
if (!fileRejections.length) {
return;
}
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
const { file, errors } = fileRejections[0];
if (!errors.length) {
return;
}
const errorNodes = errors.map((error, index) => (
<span key={index} className="block">
{match(error.code)
.with(ErrorCode.FileTooLarge, () => (
<Trans>File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB</Trans>
))
.with(ErrorCode.FileInvalidType, () => <Trans>Only PDF files are allowed</Trans>)
.with(ErrorCode.FileTooSmall, () => <Trans>File is too small</Trans>)
.with(ErrorCode.TooManyFiles, () => (
<Trans>Only one file can be uploaded at a time</Trans>
))
.otherwise(() => (
<Trans>Unknown error</Trans>
))}
</span>
));
const description = (
<>
<span className="font-medium">
{file.name} <Trans>couldn't be uploaded:</Trans>
</span>
{errorNodes}
</>
);
toast({ toast({
title: _(msg`Upload failed`), title: _(msg`Your template failed to upload.`),
description, description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
duration: 5000, duration: 5000,
variant: 'destructive', variant: 'destructive',
}); });
@ -126,8 +88,8 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
void onFileDrop(acceptedFile); void onFileDrop(acceptedFile);
} }
}, },
onDropRejected: (fileRejections) => { onDropRejected: () => {
onFileDropRejected(fileRejections); void onFileDropRejected();
}, },
noClick: true, noClick: true,
noDragEventsBubbling: true, noDragEventsBubbling: true,

View File

@ -182,7 +182,7 @@ export const TemplateEditForm = ({
}; };
const saveTemplatePlaceholderData = async (data: TAddTemplatePlacholderRecipientsFormSchema) => { const saveTemplatePlaceholderData = async (data: TAddTemplatePlacholderRecipientsFormSchema) => {
const [, recipients] = await Promise.all([ return Promise.all([
updateTemplateSettings({ updateTemplateSettings({
templateId: template.id, templateId: template.id,
meta: { meta: {
@ -196,8 +196,6 @@ export const TemplateEditForm = ({
recipients: data.signers, recipients: data.signers,
}), }),
]); ]);
return recipients;
}; };
const onAddTemplatePlaceholderFormSubmit = async ( const onAddTemplatePlaceholderFormSubmit = async (
@ -220,7 +218,7 @@ export const TemplateEditForm = ({
data: TAddTemplatePlacholderRecipientsFormSchema, data: TAddTemplatePlacholderRecipientsFormSchema,
) => { ) => {
try { try {
return await saveTemplatePlaceholderData(data); await saveTemplatePlaceholderData(data);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -229,8 +227,6 @@ export const TemplateEditForm = ({
description: _(msg`An error occurred while auto-saving the template placeholders.`), description: _(msg`An error occurred while auto-saving the template placeholders.`),
variant: 'destructive', variant: 'destructive',
}); });
throw err;
} }
}; };

View File

@ -71,23 +71,6 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
}, },
}); });
const { mutateAsync: promoteToOwner, isPending: isPromotingToOwner } =
trpc.admin.organisationMember.promoteToOwner.useMutation({
onSuccess: () => {
toast({
title: t`Success`,
description: t`Member promoted to owner successfully`,
});
},
onError: () => {
toast({
title: t`Error`,
description: t`We couldn't promote the member to owner. Please try again.`,
variant: 'destructive',
});
},
});
const teamsColumns = useMemo(() => { const teamsColumns = useMemo(() => {
return [ return [
{ {
@ -118,26 +101,6 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
<Link to={`/admin/users/${row.original.user.id}`}>{row.original.user.email}</Link> <Link to={`/admin/users/${row.original.user.id}`}>{row.original.user.email}</Link>
), ),
}, },
{
header: t`Actions`,
cell: ({ row }) => (
<div className="flex justify-end space-x-2">
<Button
variant="outline"
disabled={row.original.userId === organisation?.ownerUserId}
loading={isPromotingToOwner}
onClick={async () =>
promoteToOwner({
organisationId,
userId: row.original.userId,
})
}
>
<Trans>Promote to owner</Trans>
</Button>
</div>
),
},
] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['members'][number]>[]; ] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['members'][number]>[];
}, [organisation]); }, [organisation]);

View File

@ -23,12 +23,10 @@ export const loader = async () => {
try { try {
const certStatus = getCertificateStatus(); const certStatus = getCertificateStatus();
if (certStatus.isAvailable) { if (certStatus.isAvailable) {
checks.certificate = { status: 'ok' }; checks.certificate = { status: 'ok' };
} else { } else {
checks.certificate = { status: 'warning' }; checks.certificate = { status: 'warning' };
if (overallStatus === 'ok') { if (overallStatus === 'ok') {
overallStatus = 'warning'; overallStatus = 'warning';
} }

View File

@ -1,12 +1,10 @@
import { RecipientRole } from '@prisma/client'; import { RecipientRole } from '@prisma/client';
import { data } from 'react-router'; import { data } from 'react-router';
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token'; import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims'; import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
@ -25,8 +23,6 @@ import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/sign.$url'; import type { Route } from './+types/sign.$url';
export async function loader({ params, request }: Route.LoaderArgs) { export async function loader({ params, request }: Route.LoaderArgs) {
const { requestMetadata } = getOptionalLoaderContext();
if (!params.url) { if (!params.url) {
throw new Response('Not found', { status: 404 }); throw new Response('Not found', { status: 404 });
} }
@ -106,12 +102,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
); );
} }
await viewedDocument({
token,
requestMetadata,
recipientAccessAuth: derivedRecipientAccessAuth,
});
const allRecipients = const allRecipients =
recipient.role === RecipientRole.ASSISTANT recipient.role === RecipientRole.ASSISTANT
? await getRecipientsForAssistant({ ? await getRecipientsForAssistant({

View File

@ -101,5 +101,5 @@
"vite-plugin-babel-macros": "^1.0.6", "vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
}, },
"version": "1.12.8" "version": "1.12.2-rc.6"
} }

6
package-lock.json generated
View File

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

View File

@ -1,6 +1,6 @@
{ {
"private": true, "private": true,
"version": "1.12.8", "version": "1.12.2-rc.6",
"scripts": { "scripts": {
"build": "turbo run build", "build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix", "dev": "turbo run dev --filter=@documenso/remix",

View File

@ -310,11 +310,12 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
) )
.refine( .refine(
(schema) => { (schema) => {
const emails = schema.map((signer) => signer.email.toLowerCase());
const ids = schema.map((signer) => signer.id); const ids = schema.map((signer) => signer.id);
return new Set(ids).size === ids.length; return new Set(emails).size === emails.length && new Set(ids).size === ids.length;
}, },
{ message: 'Recipient IDs must be unique' }, { message: 'Recipient IDs and emails must be unique' },
), ),
meta: z meta: z
.object({ .object({

View File

@ -1,435 +0,0 @@
import { expect, test } from '@playwright/test';
import { nanoid } from '@documenso/lib/universal/id';
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../../fixtures/authentication';
test('[ADMIN]: promote member to owner', async ({ page }) => {
// Create an admin user who can access the admin panel
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Create an organisation owner
const { user: ownerUser, organisation } = await seedUser({
isPersonalOrganisation: false,
});
// Create organisation members with different roles
const memberEmail = `member-${nanoid()}@test.documenso.com`;
const managerEmail = `manager-${nanoid()}@test.documenso.com`;
const adminMemberEmail = `admin-member-${nanoid()}@test.documenso.com`;
const [memberUser, managerUser, adminMemberUser] = await seedOrganisationMembers({
members: [
{
email: memberEmail,
name: 'Test Member',
organisationRole: 'MEMBER',
},
{
email: managerEmail,
name: 'Test Manager',
organisationRole: 'MANAGER',
},
{
email: adminMemberEmail,
name: 'Test Admin Member',
organisationRole: 'ADMIN',
},
],
organisationId: organisation.id,
});
// Sign in as admin and navigate to the organisation admin page
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
// Verify we're on the admin organisation page
await expect(page.getByText(`Manage organisation`)).toBeVisible();
await expect(page.getByLabel('Organisation Name')).toHaveValue(organisation.name);
// Check that the organisation members table shows the correct roles
const ownerRow = page.getByRole('row', { name: ownerUser.email });
await expect(ownerRow).toBeVisible();
await expect(ownerRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
await expect(page.getByRole('row', { name: memberUser.email })).toBeVisible();
await expect(page.getByRole('row', { name: adminMemberUser.email })).toBeVisible();
await expect(page.getByRole('row', { name: managerUser.email })).toBeVisible();
// Test promoting a MEMBER to owner
const memberRow = page.getByRole('row', { name: memberUser.email });
// Find and click the "Promote to owner" button for the member
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
await expect(promoteButton).toBeVisible();
await expect(promoteButton).not.toBeDisabled();
await promoteButton.click();
// Verify success toast appears
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible();
// Reload the page to see the changes
await page.reload();
// Verify that the member is now the owner
const newOwnerRow = page.getByRole('row', { name: memberUser.email });
await expect(newOwnerRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
// Verify that the previous owner is no longer marked as owner
const previousOwnerRow = page.getByRole('row', { name: ownerUser.email });
await expect(previousOwnerRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
// Verify that the promote button is now disabled for the new owner
const newOwnerPromoteButton = newOwnerRow.getByRole('button', { name: 'Promote to owner' });
await expect(newOwnerPromoteButton).toBeDisabled();
// Test that we can't promote the current owner (button should be disabled)
await expect(newOwnerPromoteButton).toHaveAttribute('disabled');
});
test('[ADMIN]: promote manager to owner', async ({ page }) => {
// Create an admin user who can access the admin panel
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Create an organisation with owner and manager
const { organisation } = await seedUser({
isPersonalOrganisation: false,
});
const managerEmail = `manager-${nanoid()}@test.documenso.com`;
const [managerUser] = await seedOrganisationMembers({
members: [
{
email: managerEmail,
name: 'Test Manager',
organisationRole: 'MANAGER',
},
],
organisationId: organisation.id,
});
// Sign in as admin
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
// Promote the manager to owner
const managerRow = page.getByRole('row', { name: managerUser.email });
const promoteButton = managerRow.getByRole('button', { name: 'Promote to owner' });
await promoteButton.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible();
// Reload and verify the change
await page.reload();
await expect(managerRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
});
test('[ADMIN]: promote admin member to owner', async ({ page }) => {
// Create an admin user who can access the admin panel
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Create an organisation with owner and admin member
const { organisation } = await seedUser({
isPersonalOrganisation: false,
});
const adminMemberEmail = `admin-member-${nanoid()}@test.documenso.com`;
const [adminMemberUser] = await seedOrganisationMembers({
members: [
{
email: adminMemberEmail,
name: 'Test Admin Member',
organisationRole: 'ADMIN',
},
],
organisationId: organisation.id,
});
// Sign in as admin
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
// Promote the admin member to owner
const adminMemberRow = page.getByRole('row', { name: adminMemberUser.email });
const promoteButton = adminMemberRow.getByRole('button', { name: 'Promote to owner' });
await promoteButton.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
});
// Reload and verify the change
await page.reload();
await expect(adminMemberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
});
test('[ADMIN]: cannot promote non-existent user', async ({ page }) => {
// Create an admin user
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Create an organisation
const { organisation } = await seedUser({
isPersonalOrganisation: false,
});
// Sign in as admin
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
// Try to manually call the API with invalid data - this should be handled by the UI validation
// In a real scenario, the promote button wouldn't be available for non-existent users
// But we can test that the API properly handles invalid requests
// For now, just verify that non-existent users don't show up in the members table
await expect(page.getByRole('row', { name: 'Non Existent User' })).not.toBeVisible();
});
test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => {
// Create an admin user
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Create organisation with a member
const { organisation } = await seedUser({
isPersonalOrganisation: false,
});
const memberEmail = `member-${nanoid()}@test.documenso.com`;
const [memberUser] = await seedOrganisationMembers({
members: [
{
email: memberEmail,
name: 'Test Member',
organisationRole: 'MEMBER',
},
],
organisationId: organisation.id,
});
// Sign in as admin
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
// Before promotion - verify member has MEMBER role
let memberRow = page.getByRole('row', { name: memberUser.email });
await expect(memberRow).toBeVisible();
await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
// Promote member to owner
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
await promoteButton.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
});
// Reload page to see updated state
await page.reload();
// After promotion - verify member is now owner and has admin permissions
memberRow = page.getByRole('row', { name: memberUser.email });
await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
// Verify the promote button is now disabled for the new owner
const newOwnerPromoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
await expect(newOwnerPromoteButton).toBeDisabled();
// Sign in as the newly promoted user to verify they have owner permissions
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/o/${organisation.url}/settings/general`,
});
// Verify they can access organisation settings (owner permission)
await expect(page.getByText('Organisation Settings')).toBeVisible();
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
});
test('[ADMIN]: error handling for invalid organisation', async ({ page }) => {
// Create an admin user
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Sign in as admin and try to access non-existent organisation
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/non-existent-org-id`,
});
// Should show 404 error
await expect(page.getByRole('heading', { name: 'Organisation not found' })).toBeVisible({
timeout: 10_000,
});
});
test('[ADMIN]: multiple promotions in sequence', async ({ page }) => {
// Create an admin user
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Create organisation with multiple members
const { organisation } = await seedUser({
isPersonalOrganisation: false,
});
const member1Email = `member1-${nanoid()}@test.documenso.com`;
const member2Email = `member2-${nanoid()}@test.documenso.com`;
const [member1User, member2User] = await seedOrganisationMembers({
members: [
{
email: member1Email,
name: 'Test Member 1',
organisationRole: 'MEMBER',
},
{
email: member2Email,
name: 'Test Member 2',
organisationRole: 'MANAGER',
},
],
organisationId: organisation.id,
});
// Sign in as admin
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
// First promotion: Member 1 becomes owner
let member1Row = page.getByRole('row', { name: member1User.email });
let promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
await promoteButton1.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
});
await page.reload();
// Verify Member 1 is now owner and button is disabled
member1Row = page.getByRole('row', { name: member1User.email });
await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
await expect(promoteButton1).toBeDisabled();
// Second promotion: Member 2 becomes the new owner
const member2Row = page.getByRole('row', { name: member2User.email });
const promoteButton2 = member2Row.getByRole('button', { name: 'Promote to owner' });
await expect(promoteButton2).not.toBeDisabled();
await promoteButton2.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
});
await page.reload();
// Verify Member 2 is now owner and Member 1 is no longer owner
await expect(member2Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
// Verify Member 1's promote button is now enabled again
const newPromoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
await expect(newPromoteButton1).not.toBeDisabled();
});
test('[ADMIN]: verify organisation access after ownership change', async ({ page }) => {
// Create admin user
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Create organisation with owner and member
const { user: originalOwner, organisation } = await seedUser({
isPersonalOrganisation: false,
});
const memberEmail = `member-${nanoid()}@test.documenso.com`;
const [memberUser] = await seedOrganisationMembers({
members: [
{
email: memberEmail,
name: 'Test Member',
organisationRole: 'MEMBER',
},
],
organisationId: organisation.id,
});
// Sign in as admin and promote member to owner
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
const memberRow = page.getByRole('row', { name: memberUser.email });
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
await promoteButton.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
});
// Test that the new owner can access organisation settings
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/o/${organisation.url}/settings/general`,
});
// Should be able to access organisation settings
await expect(page.getByText('Organisation Settings')).toBeVisible();
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
await expect(page.getByRole('button', { name: 'Update organisation' })).toBeVisible();
// Should have delete permissions
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
// Test that the original owner no longer has owner-level access
await apiSignin({
page,
email: originalOwner.email,
redirectPath: `/o/${organisation.url}/settings/general`,
});
// Should still be able to access settings (as they should now be an admin)
await expect(page.getByText('Organisation Settings')).toBeVisible();
});

View File

@ -33,7 +33,7 @@ const setupDocumentAndNavigateToFieldsStep = async (page: Page) => {
}; };
const triggerAutosave = async (page: Page) => { const triggerAutosave = async (page: Page) => {
await page.locator('body').click({ position: { x: 0, y: 0 } }); await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur(); await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000); await page.waitForTimeout(5000);
@ -70,7 +70,7 @@ test.describe('AutoSave Fields Step', () => {
await triggerAutosave(page); await triggerAutosave(page);
await page.getByRole('combobox').first().click(); await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
@ -127,7 +127,7 @@ test.describe('AutoSave Fields Step', () => {
await triggerAutosave(page); await triggerAutosave(page);
await page.getByRole('combobox').first().click(); await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
@ -140,7 +140,7 @@ test.describe('AutoSave Fields Step', () => {
await triggerAutosave(page); await triggerAutosave(page);
await page.getByRole('combobox').first().click(); await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click(); await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
await page.getByText('Text').nth(1).click(); await page.getByText('Text').nth(1).click();
@ -191,7 +191,7 @@ test.describe('AutoSave Fields Step', () => {
await triggerAutosave(page); await triggerAutosave(page);
await page.getByRole('combobox').first().click(); await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
@ -204,7 +204,7 @@ test.describe('AutoSave Fields Step', () => {
await triggerAutosave(page); await triggerAutosave(page);
await page.getByRole('combobox').first().click(); await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click(); await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
await page.getByText('Signature').nth(1).click(); await page.getByText('Signature').nth(1).click();

View File

@ -24,7 +24,7 @@ const setupDocument = async (page: Page) => {
}; };
const triggerAutosave = async (page: Page) => { const triggerAutosave = async (page: Page) => {
await page.locator('body').click({ position: { x: 0, y: 0 } }); await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur(); await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000); await page.waitForTimeout(5000);

View File

@ -26,7 +26,7 @@ const setupDocumentAndNavigateToSignersStep = async (page: Page) => {
}; };
const triggerAutosave = async (page: Page) => { const triggerAutosave = async (page: Page) => {
await page.locator('body').click({ position: { x: 0, y: 0 } }); await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur(); await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000); await page.waitForTimeout(5000);
@ -92,7 +92,7 @@ test.describe('AutoSave Signers Step', () => {
await triggerAutosave(page); await triggerAutosave(page);
await page.getByRole('combobox').first().click(); await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Receives copy' }).click(); await page.getByRole('option', { name: 'Receives copy' }).click();
await triggerAutosave(page); await triggerAutosave(page);
@ -160,20 +160,9 @@ test.describe('AutoSave Signers Step', () => {
expect(retrievedDocumentData.documentMeta?.signingOrder).toBe('SEQUENTIAL'); expect(retrievedDocumentData.documentMeta?.signingOrder).toBe('SEQUENTIAL');
expect(retrievedDocumentData.documentMeta?.allowDictateNextSigner).toBe(true); expect(retrievedDocumentData.documentMeta?.allowDictateNextSigner).toBe(true);
expect(retrievedRecipients.length).toBe(3); expect(retrievedRecipients.length).toBe(3);
expect(retrievedRecipients[0].signingOrder).toBe(2);
const firstRecipient = retrievedRecipients.find( expect(retrievedRecipients[1].signingOrder).toBe(3);
(r) => r.email === 'recipient1@documenso.com', expect(retrievedRecipients[2].signingOrder).toBe(1);
);
const secondRecipient = retrievedRecipients.find(
(r) => r.email === 'recipient2@documenso.com',
);
const thirdRecipient = retrievedRecipients.find(
(r) => r.email === 'recipient3@documenso.com',
);
expect(firstRecipient?.signingOrder).toBe(2);
expect(secondRecipient?.signingOrder).toBe(3);
expect(thirdRecipient?.signingOrder).toBe(1);
}).toPass(); }).toPass();
}); });
}); });

View File

@ -42,7 +42,7 @@ export const setupDocumentAndNavigateToSubjectStep = async (page: Page) => {
}; };
export const triggerAutosave = async (page: Page) => { export const triggerAutosave = async (page: Page) => {
await page.locator('body').click({ position: { x: 0, y: 0 } }); await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur(); await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000); await page.waitForTimeout(5000);

View File

@ -1,56 +0,0 @@
import { expect, test } from '@playwright/test';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test('[DOCUMENT_FLOW]: Simple duplicate recipients test', async ({ page }) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
// Step 1: Settings - Continue with defaults
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Step 2: Add duplicate recipients
await page.getByPlaceholder('Email').fill('duplicate@example.com');
await page.getByPlaceholder('Name').fill('Duplicate 1');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(1).fill('duplicate@example.com');
await page.getByLabel('Name').nth(1).fill('Duplicate 2');
// Continue to fields
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Step 3: Add fields
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
await page.getByRole('combobox').first().click();
// Switch to second duplicate and add field
await page.getByText('Duplicate 2 (duplicate@example.com)').first().click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
// Continue to send
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
// Send document
await page.waitForTimeout(2500);
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
});

View File

@ -1,355 +0,0 @@
import { type Page, expect, test } from '@playwright/test';
import type { Document, Team } from '@prisma/client';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { signSignaturePad } from '../fixtures/signature';
/**
* Test helper to complete the document creation flow with duplicate recipients
*/
const completeDocumentFlowWithDuplicateRecipients = async (options: {
page: Page;
team: Team;
document: Document;
}) => {
const { page, team, document } = options;
// Step 1: Settings - Continue with defaults
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Step 2: Add duplicate recipients
await page.getByPlaceholder('Email').fill('duplicate@example.com');
await page.getByPlaceholder('Name').fill('Duplicate Recipient 1');
// Add second signer with same email
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(1).fill('duplicate@example.com');
await page.getByLabel('Name').nth(1).fill('Duplicate Recipient 2');
// Add third signer with different email for comparison
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(2).fill('unique@example.com');
await page.getByLabel('Name').nth(2).fill('Unique Recipient');
// Continue to fields
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Step 3: Add fields for each recipient
// Add signature field for first duplicate recipient
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click();
// Switch to second duplicate recipient and add their field
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
// Switch to unique recipient and add their field
await page.getByText('Unique Recipient (unique@example.com)').click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
// Continue to subject
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
// Step 4: Complete with subject and send
await page.waitForTimeout(2500);
await page.getByRole('button', { name: 'Send' }).click();
// Wait for send confirmation
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
};
test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
test('should allow creating document with duplicate recipient emails', async ({ page }) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
// Complete the flow
await completeDocumentFlowWithDuplicateRecipients({
page,
team,
document,
});
// Verify document was created successfully
await expect(page).toHaveURL(new RegExp(`/t/${team.url}/documents`));
});
test('should allow adding duplicate recipient after saving document initially', async ({
page,
}) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
// Step 1: Settings - Continue with defaults
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Step 2: Add initial recipient
await page.getByPlaceholder('Email').fill('test@example.com');
await page.getByPlaceholder('Name').fill('Test Recipient');
// Continue to fields and add a field
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
// Save the document by going to subject
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
// Navigate back to signers to add duplicate
await page.getByRole('button', { name: 'Go Back' }).click();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Add duplicate recipient
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(1).fill('test@example.com');
await page.getByLabel('Name').nth(1).fill('Test Recipient Duplicate');
// Continue and add field for duplicate
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.waitForTimeout(1000);
// Switch to duplicate recipient and add field
await page.getByRole('combobox').first().click();
await page.getByText('Test Recipient Duplicate (test@example.com)').first().click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
// Complete the flow
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
await page.waitForTimeout(2500);
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
});
test('should isolate fields per recipient token even with duplicate emails', async ({
page,
context,
}) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
// Complete the document flow
await completeDocumentFlowWithDuplicateRecipients({
page,
team,
document,
});
// Navigate to documents list and get the document
await expect(page).toHaveURL(new RegExp(`/t/${team.url}/documents`));
const recipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
},
});
expect(recipients).toHaveLength(3);
const tokens = recipients.map((r) => r.token);
expect(new Set(tokens).size).toBe(3); // All tokens should be unique
// Test each signing experience in separate browser contexts
for (const recipient of recipients) {
// Navigate to signing URL
await page.goto(`/sign/${recipient.token}`, {
waitUntil: 'networkidle',
});
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
// Verify only one signature field is visible for this recipient
expect(
await page.locator(`[data-field-type="SIGNATURE"]:not([data-readonly="true"])`).all(),
).toHaveLength(1);
// Verify recipient name is correct
await expect(page.getByLabel('Full Name')).toHaveValue(recipient.name);
// Sign the document
await signSignaturePad(page);
await page
.locator('[data-field-type="SIGNATURE"]:not([data-readonly="true"])')
.first()
.click();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
// Verify completion
await page.waitForURL(`/sign/${recipient?.token}/complete`);
await expect(page.getByText('Document Signed')).toBeVisible();
}
});
test('should handle duplicate recipient workflow with different field types', async ({
page,
}) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
// Step 1: Settings
await page.getByRole('button', { name: 'Continue' }).click();
// Step 2: Add duplicate recipients with different roles
await page.getByPlaceholder('Email').fill('signer@example.com');
await page.getByPlaceholder('Name').fill('Signer Role');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(1).fill('signer@example.com');
await page.getByLabel('Name').nth(1).fill('Approver Role');
// Change second recipient role if role selector is available
const roleDropdown = page.getByLabel('Role').nth(1);
if (await roleDropdown.isVisible()) {
await roleDropdown.click();
await page.getByText('Approver').click();
}
// Step 3: Add different field types for each duplicate
await page.getByRole('button', { name: 'Continue' }).click();
// Add signature for first recipient
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
// Add name field for second recipient
await page.getByRole('combobox').first().click();
await page.getByText('Approver Role (signer@example.com)').first().click();
await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
// Add date field for second recipient
await page.getByRole('button', { name: 'Date' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 150 } });
// Complete the document
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
});
test('should preserve field assignments when editing document with duplicates', async ({
page,
}) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
// Create document with duplicates and fields
await completeDocumentFlowWithDuplicateRecipients({
page,
team,
document,
});
// Navigate back to edit the document
await page.goto(`/t/${team.url}/documents/${document.id}/edit`);
// Go to fields step
await page.getByRole('button', { name: 'Continue' }).click(); // Settings
await page.getByRole('button', { name: 'Continue' }).click(); // Signers
// Verify fields are assigned to correct recipients
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Click on first duplicate recipient
await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click();
// Verify their field is visible and can be selected
const firstRecipientFields = await page
.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`)
.all();
expect(firstRecipientFields.length).toBeGreaterThan(0);
// Switch to second duplicate recipient
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
// Verify they have their own field
const secondRecipientFields = await page
.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`)
.all();
expect(secondRecipientFields.length).toBeGreaterThan(0);
// Add another field to the second duplicate
await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 250, y: 150 } });
// Save changes
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
await page.waitForTimeout(2500);
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
});
});

View File

@ -573,7 +573,6 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
y: 100 * i, y: 100 * i,
}, },
}); });
await page.getByText(`User ${i} (user${i}@example.com)`).click(); await page.getByText(`User ${i} (user${i}@example.com)`).click();
} }

View File

@ -277,13 +277,13 @@ test('[TEAMS]: document folder and its contents can be deleted', async ({ page }
await page.goto(`/t/${team.url}/documents`); await page.goto(`/t/${team.url}/documents`);
await expect(page.locator(`[data-folder-id="${folder.id}"]`)).not.toBeVisible(); await expect(page.locator('div').filter({ hasText: folder.name })).not.toBeVisible();
await expect(page.getByText(proposal.title)).not.toBeVisible(); await expect(page.getByText(proposal.title)).not.toBeVisible();
await page.goto(`/t/${team.url}/documents/f/${folder.id}`); await page.goto(`/t/${team.url}/documents/f/${folder.id}`);
await expect(page.getByText(report.title)).not.toBeVisible(); await expect(page.getByText(report.title)).not.toBeVisible();
await expect(page.locator(`[data-folder-id="${reportsFolder.id}"]`)).not.toBeVisible(); await expect(page.locator('div').filter({ hasText: reportsFolder.name })).not.toBeVisible();
}); });
test('[TEAMS]: create folder button is visible on templates page', async ({ page }) => { test('[TEAMS]: create folder button is visible on templates page', async ({ page }) => {
@ -318,7 +318,9 @@ test('[TEAMS]: can create a template folder', async ({ page }) => {
await expect(page.getByText('Team template folder')).toBeVisible(); await expect(page.getByText('Team template folder')).toBeVisible();
await page.goto(`/t/${team.url}/templates`); await page.goto(`/t/${team.url}/templates`);
await expect(page.locator(`[data-folder-name="Team template folder"]`)).toBeVisible(); await expect(
page.locator('div').filter({ hasText: 'Team template folder' }).nth(3),
).toBeVisible();
}); });
test('[TEAMS]: can create a template subfolder inside a template folder', async ({ page }) => { test('[TEAMS]: can create a template subfolder inside a template folder', async ({ page }) => {
@ -372,8 +374,11 @@ test('[TEAMS]: can create a template inside a template folder', async ({ page })
await page.getByRole('button', { name: 'New Template' }).click(); await page.getByRole('button', { name: 'New Template' }).click();
await page.getByText('Upload Template Document').click(); await page
.locator('div')
.filter({ hasText: /^Upload Template DocumentDrag & drop your PDF here\.$/ })
.nth(2)
.click();
await page.locator('input[type="file"]').nth(0).waitFor({ state: 'attached' }); await page.locator('input[type="file"]').nth(0).waitFor({ state: 'attached' });
await page await page
@ -532,7 +537,7 @@ test('[TEAMS]: template folder can be moved to another template folder', async (
await expect(page.getByText('Team Contract Templates')).toBeVisible(); await expect(page.getByText('Team Contract Templates')).toBeVisible();
}); });
test('[TEAMS]: template folder can be deleted', async ({ page }) => { test('[TEAMS]: template folder and its contents can be deleted', async ({ page }) => {
const { team, teamOwner } = await seedTeamDocuments(); const { team, teamOwner } = await seedTeamDocuments();
const folder = await seedBlankFolder(teamOwner, team.id, { const folder = await seedBlankFolder(teamOwner, team.id, {
@ -580,16 +585,13 @@ test('[TEAMS]: template folder can be deleted', async ({ page }) => {
await page.goto(`/t/${team.url}/templates`); await page.goto(`/t/${team.url}/templates`);
await page.waitForTimeout(1000); await expect(page.locator('div').filter({ hasText: folder.name })).not.toBeVisible();
await expect(page.getByText(template.title)).not.toBeVisible();
// !: This is no longer the case, when deleting a folder its contents will be moved to the root folder.
// await expect(page.locator(`[data-folder-id="${folder.id}"]`)).not.toBeVisible();
// await expect(page.getByText(template.title)).not.toBeVisible();
await page.goto(`/t/${team.url}/templates/f/${folder.id}`); await page.goto(`/t/${team.url}/templates/f/${folder.id}`);
await expect(page.getByText(reportTemplate.title)).not.toBeVisible(); await expect(page.getByText(reportTemplate.title)).not.toBeVisible();
await expect(page.locator(`[data-folder-id="${subfolder.id}"]`)).not.toBeVisible(); await expect(page.locator('div').filter({ hasText: subfolder.name })).not.toBeVisible();
}); });
test('[TEAMS]: can navigate between template folders', async ({ page }) => { test('[TEAMS]: can navigate between template folders', async ({ page }) => {
@ -841,15 +843,10 @@ test('[TEAMS]: documents inherit folder visibility', async ({ page }) => {
await page.getByText('Admin Only Folder').click(); await page.getByText('Admin Only Folder').click();
await page.waitForURL(new RegExp(`/t/${team.url}/documents/f/.+`)); const fileInput = page.locator('input[type="file"]').nth(1);
await fileInput.waitFor({ state: 'attached' });
// Upload document. await fileInput.setInputFiles(
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.getByRole('button', { name: 'Upload Document' }).click(),
]);
await fileChooser.setFiles(
path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'), path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'),
); );

View File

@ -30,8 +30,8 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
await page.getByRole('option', { name: 'Australia/Perth' }).click(); await page.getByRole('option', { name: 'Australia/Perth' }).click();
// Set default date // Set default date
await page.getByRole('combobox').filter({ hasText: 'yyyy-MM-dd hh:mm AM/PM' }).click(); await page.getByRole('combobox').filter({ hasText: 'yyyy-MM-dd hh:mm a' }).click();
await page.getByRole('option', { name: 'DD/MM/YYYY', exact: true }).click(); await page.getByRole('option', { name: 'DD/MM/YYYY' }).click();
await page.getByTestId('signature-types-trigger').click(); await page.getByTestId('signature-types-trigger').click();
await page.getByRole('option', { name: 'Draw' }).click(); await page.getByRole('option', { name: 'Draw' }).click();
@ -51,7 +51,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
expect(teamSettings.documentVisibility).toEqual(DocumentVisibility.MANAGER_AND_ABOVE); expect(teamSettings.documentVisibility).toEqual(DocumentVisibility.MANAGER_AND_ABOVE);
expect(teamSettings.documentLanguage).toEqual('de'); expect(teamSettings.documentLanguage).toEqual('de');
expect(teamSettings.documentTimezone).toEqual('Australia/Perth'); expect(teamSettings.documentTimezone).toEqual('Australia/Perth');
expect(teamSettings.documentDateFormat).toEqual('dd/MM/yyyy'); expect(teamSettings.documentDateFormat).toEqual('dd/MM/yyyy hh:mm a');
expect(teamSettings.includeSenderDetails).toEqual(false); expect(teamSettings.includeSenderDetails).toEqual(false);
expect(teamSettings.includeSigningCertificate).toEqual(false); expect(teamSettings.includeSigningCertificate).toEqual(false);
expect(teamSettings.typedSignatureEnabled).toEqual(true); expect(teamSettings.typedSignatureEnabled).toEqual(true);
@ -72,7 +72,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
// Override team date format settings // Override team date format settings
await page.getByTestId('document-date-format-trigger').click(); await page.getByTestId('document-date-format-trigger').click();
await page.getByRole('option', { name: 'MM/DD/YYYY', exact: true }).click(); await page.getByRole('option', { name: 'MM/DD/YYYY' }).click();
await page.getByRole('button', { name: 'Update' }).first().click(); await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible(); await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
@ -85,7 +85,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
expect(updatedTeamSettings.documentVisibility).toEqual(DocumentVisibility.EVERYONE); expect(updatedTeamSettings.documentVisibility).toEqual(DocumentVisibility.EVERYONE);
expect(updatedTeamSettings.documentLanguage).toEqual('pl'); expect(updatedTeamSettings.documentLanguage).toEqual('pl');
expect(updatedTeamSettings.documentTimezone).toEqual('Europe/London'); expect(updatedTeamSettings.documentTimezone).toEqual('Europe/London');
expect(updatedTeamSettings.documentDateFormat).toEqual('MM/dd/yyyy'); expect(updatedTeamSettings.documentDateFormat).toEqual('MM/dd/yyyy hh:mm a');
expect(updatedTeamSettings.includeSenderDetails).toEqual(false); expect(updatedTeamSettings.includeSenderDetails).toEqual(false);
expect(updatedTeamSettings.includeSigningCertificate).toEqual(false); expect(updatedTeamSettings.includeSigningCertificate).toEqual(false);
expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true); expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true);
@ -108,7 +108,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
expect(documentMeta.drawSignatureEnabled).toEqual(false); expect(documentMeta.drawSignatureEnabled).toEqual(false);
expect(documentMeta.language).toEqual('pl'); expect(documentMeta.language).toEqual('pl');
expect(documentMeta.timezone).toEqual('Europe/London'); expect(documentMeta.timezone).toEqual('Europe/London');
expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy'); expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy hh:mm a');
}); });
test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => { test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {

View File

@ -1,283 +0,0 @@
import { type Page, expect, test } from '@playwright/test';
import type { Team, Template } from '@prisma/client';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { prisma } from '@documenso/prisma';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
/**
* Test helper to complete template creation with duplicate recipients
*/
const completeTemplateFlowWithDuplicateRecipients = async (options: {
page: Page;
team: Team;
template: Template;
}) => {
const { page, team, template } = options;
// Step 1: Settings - Continue with defaults
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Step 2: Add duplicate recipients with real emails for testing
await page.getByPlaceholder('Email').fill('duplicate@example.com');
await page.getByPlaceholder('Name').fill('First Instance');
// Add second signer with same email
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(1).fill('duplicate@example.com');
await page.getByPlaceholder('Name').nth(1).fill('Second Instance');
// Add third signer with different email
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(2).fill('unique@example.com');
await page.getByPlaceholder('Name').nth(2).fill('Different Recipient');
// Continue to fields
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Step 3: Add fields for each recipient instance
// Add signature field for first instance
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
// Switch to second instance and add their field
await page.getByRole('combobox').first().click();
await page.getByText('Second Instance').first().click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
// Switch to different recipient and add their field
await page.getByRole('combobox').first().click();
await page.getByText('Different Recipient').first().click();
await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
// Save template
await page.getByRole('button', { name: 'Save Template' }).click();
// Wait for creation confirmation
await page.waitForURL(`/t/${team.url}/templates`);
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
};
test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
test('should allow creating template with duplicate recipient emails', async ({ page }) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Complete the template flow
await completeTemplateFlowWithDuplicateRecipients({ page, team, template });
// Verify template was created successfully
await expect(page).toHaveURL(`/t/${team.url}/templates`);
});
test('should create document from template with duplicate recipients using same email', async ({
page,
context,
}) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Complete template creation
await completeTemplateFlowWithDuplicateRecipients({ page, team, template });
// Navigate to template and create document
await page.goto(`/t/${team.url}/templates`);
await page
.getByRole('row', { name: template.title })
.getByRole('button', { name: 'Use Template' })
.click();
// Fill recipient information with same email for both instances
await expect(page.getByRole('heading', { name: 'Create document' })).toBeVisible();
// Set same email for both recipient instances
const emailInputs = await page.locator('[aria-label="Email"]').all();
const nameInputs = await page.locator('[aria-label="Name"]').all();
// First instance
await emailInputs[0].fill('same@example.com');
await nameInputs[0].fill('John Doe - Role 1');
// Second instance (same email)
await emailInputs[1].fill('same@example.com');
await nameInputs[1].fill('John Doe - Role 2');
// Different recipient
await emailInputs[2].fill('different@example.com');
await nameInputs[2].fill('Jane Smith');
await page.getByLabel('Send document').click();
// Create document
await page.getByRole('button', { name: 'Create and send' }).click();
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
// Get the document ID from URL for database queries
const url = page.url();
const documentIdMatch = url.match(/\/documents\/(\d+)/);
const documentId = documentIdMatch ? parseInt(documentIdMatch[1]) : null;
expect(documentId).not.toBeNull();
// Get recipients directly from database
const recipients = await prisma.recipient.findMany({
where: {
documentId: documentId!,
},
});
expect(recipients).toHaveLength(3);
// Verify all tokens are unique
const tokens = recipients.map((r) => r.token);
expect(new Set(tokens).size).toBe(3);
// Test signing experience for duplicate email recipients
const duplicateRecipients = recipients.filter((r) => r.email === 'same@example.com');
expect(duplicateRecipients).toHaveLength(2);
for (const recipient of duplicateRecipients) {
// Navigate to signing URL
await page.goto(`/sign/${recipient.token}`, {
waitUntil: 'networkidle',
});
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
// Verify correct recipient name is shown
await expect(page.getByLabel('Full Name')).toHaveValue(recipient.name);
// Verify only one signature field is visible for this recipient
expect(
await page.locator(`[data-field-type="SIGNATURE"]:not([data-readonly="true"])`).all(),
).toHaveLength(1);
}
});
test('should handle template with different types of duplicate emails', async ({ page }) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Step 1: Settings
await page.getByRole('button', { name: 'Continue' }).click();
// Step 2: Add multiple recipients with duplicate emails
await page.getByPlaceholder('Email').fill('duplicate@example.com');
await page.getByPlaceholder('Name').fill('Duplicate Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(1).fill('duplicate@example.com');
await page.getByPlaceholder('Name').nth(1).fill('Duplicate Recipient 2');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(2).fill('different@example.com');
await page.getByPlaceholder('Name').nth(2).fill('Different Recipient');
// Continue and add fields
await page.getByRole('button', { name: 'Continue' }).click();
// Add fields for each recipient
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
await page.getByRole('combobox').first().click();
await page.getByText('Duplicate Recipient 2').first().click();
await page.getByRole('button', { name: 'Date' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
await page.getByRole('combobox').first().click();
await page.getByText('Different Recipient').first().click();
await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 200 } });
// Save template
await page.getByRole('button', { name: 'Save Template' }).click();
await page.waitForURL(`/t/${team.url}/templates`);
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
});
test('should validate field assignments per recipient in template editing', async ({ page }) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Create template with duplicates
await completeTemplateFlowWithDuplicateRecipients({ page, team, template });
// Navigate back to edit the template
await page.goto(`/t/${team.url}/templates/${template.id}/edit`);
// Go to fields step
await page.getByRole('button', { name: 'Continue' }).click(); // Settings
await page.getByRole('button', { name: 'Continue' }).click(); // Signers
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Verify fields are correctly assigned to each recipient instance
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'First Instance' }).first().click();
let visibleFields = await page.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`).all();
expect(visibleFields.length).toBeGreaterThan(0);
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'Second Instance' }).first().click();
visibleFields = await page.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`).all();
expect(visibleFields.length).toBeGreaterThan(0);
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'Different Recipient' }).first().click();
const nameFields = await page.locator(`[data-field-type="NAME"]:not(:disabled)`).all();
expect(nameFields.length).toBeGreaterThan(0);
// Add additional field to verify proper assignment
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'First Instance' }).first().click();
await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 300 } });
await page.waitForTimeout(2500);
// Save changes
await page.getByRole('button', { name: 'Save Template' }).click();
await page.waitForURL(`/t/${team.url}/templates`);
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
});
});

View File

@ -33,7 +33,7 @@ const setupTemplateAndNavigateToFieldsStep = async (page: Page) => {
}; };
const triggerAutosave = async (page: Page) => { const triggerAutosave = async (page: Page) => {
await page.locator('body').click({ position: { x: 0, y: 0 } }); await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur(); await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000); await page.waitForTimeout(5000);
@ -70,7 +70,7 @@ test.describe('AutoSave Fields Step', () => {
await triggerAutosave(page); await triggerAutosave(page);
await page.getByRole('combobox').first().click(); await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
@ -129,7 +129,7 @@ test.describe('AutoSave Fields Step', () => {
await triggerAutosave(page); await triggerAutosave(page);
await page.getByRole('combobox').first().click(); await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
@ -142,7 +142,7 @@ test.describe('AutoSave Fields Step', () => {
await triggerAutosave(page); await triggerAutosave(page);
await page.getByRole('combobox').first().click(); await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click(); await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
await page.getByText('Text').nth(1).click(); await page.getByText('Text').nth(1).click();
@ -195,7 +195,7 @@ test.describe('AutoSave Fields Step', () => {
await triggerAutosave(page); await triggerAutosave(page);
await page.getByRole('combobox').first().click(); await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
@ -208,7 +208,7 @@ test.describe('AutoSave Fields Step', () => {
await triggerAutosave(page); await triggerAutosave(page);
await page.getByRole('combobox').first().click(); await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click(); await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
await page.getByText('Signature').nth(1).click(); await page.getByText('Signature').nth(1).click();

View File

@ -23,7 +23,7 @@ const setupTemplate = async (page: Page) => {
}; };
const triggerAutosave = async (page: Page) => { const triggerAutosave = async (page: Page) => {
await page.locator('body').click({ position: { x: 0, y: 0 } }); await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur(); await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000); await page.waitForTimeout(5000);

View File

@ -26,7 +26,7 @@ const setupTemplateAndNavigateToSignersStep = async (page: Page) => {
}; };
const triggerAutosave = async (page: Page) => { const triggerAutosave = async (page: Page) => {
await page.locator('body').click({ position: { x: 0, y: 0 } }); await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur(); await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000); await page.waitForTimeout(5000);

View File

@ -47,8 +47,8 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
// Set advanced options. // Set advanced options.
await page.getByRole('button', { name: 'Advanced Options' }).click(); await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.locator('button').filter({ hasText: 'YYYY-MM-DD hh:mm AM/PM' }).click(); await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
await page.getByLabel('DD/MM/YYYY HH:mm', { exact: true }).click(); await page.getByLabel('DD/MM/YYYY').click();
await page.locator('.time-zone-field').click(); await page.locator('.time-zone-field').click();
await page.getByRole('option', { name: 'Etc/UTC' }).click(); await page.getByRole('option', { name: 'Etc/UTC' }).click();
@ -96,7 +96,7 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
expect(document.title).toEqual('TEMPLATE_TITLE'); expect(document.title).toEqual('TEMPLATE_TITLE');
expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT'); expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT');
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy HH:mm'); expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
expect(document.documentMeta?.message).toEqual('MESSAGE'); expect(document.documentMeta?.message).toEqual('MESSAGE');
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com'); expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
expect(document.documentMeta?.subject).toEqual('SUBJECT'); expect(document.documentMeta?.subject).toEqual('SUBJECT');
@ -150,8 +150,8 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
// Set advanced options. // Set advanced options.
await page.getByRole('button', { name: 'Advanced Options' }).click(); await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.locator('button').filter({ hasText: 'YYYY-MM-DD hh:mm AM/PM' }).click(); await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
await page.getByLabel('DD/MM/YYYY HH:mm', { exact: true }).click(); await page.getByLabel('DD/MM/YYYY').click();
await page.locator('.time-zone-field').click(); await page.locator('.time-zone-field').click();
await page.getByRole('option', { name: 'Etc/UTC' }).click(); await page.getByRole('option', { name: 'Etc/UTC' }).click();
@ -200,7 +200,7 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
expect(document.title).toEqual('TEMPLATE_TITLE'); expect(document.title).toEqual('TEMPLATE_TITLE');
expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT'); expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT');
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy HH:mm'); expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
expect(document.documentMeta?.message).toEqual('MESSAGE'); expect(document.documentMeta?.message).toEqual('MESSAGE');
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com'); expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
expect(document.documentMeta?.subject).toEqual('SUBJECT'); expect(document.documentMeta?.subject).toEqual('SUBJECT');

View File

@ -17,7 +17,7 @@ export default defineConfig({
testDir: './e2e', testDir: './e2e',
/* Run tests in files in parallel */ /* Run tests in files in parallel */
fullyParallel: false, fullyParallel: false,
workers: 2, workers: 4,
maxFailures: process.env.CI ? 1 : undefined, maxFailures: process.env.CI ? 1 : undefined,
/* Fail the build on CI if you accidentally left test.only in the source code. */ /* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
@ -33,7 +33,7 @@ export default defineConfig({
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on', trace: 'on',
video: 'on-first-retry', video: 'retain-on-failure',
/* Add explicit timeouts for actions */ /* Add explicit timeouts for actions */
actionTimeout: 15_000, actionTimeout: 15_000,
@ -48,7 +48,7 @@ export default defineConfig({
name: 'chromium', name: 'chromium',
use: { use: {
...devices['Desktop Chrome'], ...devices['Desktop Chrome'],
viewport: { width: 1920, height: 1200 }, viewport: { width: 1920, height: 1080 },
}, },
}, },

View File

@ -1,56 +1,23 @@
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
type SaveRequest<T, R> = { export const useAutoSave = <T>(onSave: (data: T) => Promise<void>) => {
data: T;
onResponse?: (response: R) => void;
};
export const useAutoSave = <T, R = void>(
onSave: (data: T) => Promise<R>,
options: { delay?: number } = {},
) => {
const { delay = 2000 } = options;
const saveTimeoutRef = useRef<NodeJS.Timeout>(); const saveTimeoutRef = useRef<NodeJS.Timeout>();
const saveQueueRef = useRef<SaveRequest<T, R>[]>([]);
const isProcessingRef = useRef(false);
const processQueue = async () => { const saveFormData = async (data: T) => {
if (isProcessingRef.current || saveQueueRef.current.length === 0) { try {
return; await onSave(data);
} catch (error) {
console.error('Auto-save failed:', error);
} }
isProcessingRef.current = true;
while (saveQueueRef.current.length > 0) {
const request = saveQueueRef.current.shift()!;
try {
const response = await onSave(request.data);
request.onResponse?.(response);
} catch (error) {
console.error('Auto-save failed:', error);
}
}
isProcessingRef.current = false;
}; };
const saveFormData = async (data: T, onResponse?: (response: R) => void) => { const scheduleSave = useCallback((data: T) => {
saveQueueRef.current.push({ data, onResponse }); if (saveTimeoutRef.current) {
await processQueue(); clearTimeout(saveTimeoutRef.current);
}; }
const scheduleSave = useCallback( saveTimeoutRef.current = setTimeout(() => void saveFormData(data), 2000);
(data: T, onResponse?: (response: R) => void) => { }, []);
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(() => void saveFormData(data, onResponse), delay);
},
[delay],
);
useEffect(() => { useEffect(() => {
return () => { return () => {

View File

@ -7,25 +7,14 @@ export const DEFAULT_DOCUMENT_DATE_FORMAT = 'yyyy-MM-dd hh:mm a';
export const VALID_DATE_FORMAT_VALUES = [ export const VALID_DATE_FORMAT_VALUES = [
DEFAULT_DOCUMENT_DATE_FORMAT, DEFAULT_DOCUMENT_DATE_FORMAT,
'yyyy-MM-dd', 'yyyy-MM-dd',
'dd/MM/yyyy',
'MM/dd/yyyy',
'yy-MM-dd',
'MMMM dd, yyyy',
'EEEE, MMMM dd, yyyy',
'dd/MM/yyyy hh:mm a', 'dd/MM/yyyy hh:mm a',
'dd/MM/yyyy HH:mm',
'MM/dd/yyyy hh:mm a', 'MM/dd/yyyy hh:mm a',
'MM/dd/yyyy HH:mm',
'dd.MM.yyyy',
'dd.MM.yyyy HH:mm', 'dd.MM.yyyy HH:mm',
'yyyy-MM-dd HH:mm', 'yyyy-MM-dd HH:mm',
'yy-MM-dd hh:mm a', 'yy-MM-dd hh:mm a',
'yy-MM-dd HH:mm',
'yyyy-MM-dd HH:mm:ss', 'yyyy-MM-dd HH:mm:ss',
'MMMM dd, yyyy hh:mm a', 'MMMM dd, yyyy hh:mm a',
'MMMM dd, yyyy HH:mm',
'EEEE, MMMM dd, yyyy hh:mm a', 'EEEE, MMMM dd, yyyy hh:mm a',
'EEEE, MMMM dd, yyyy HH:mm',
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX", "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
] as const; ] as const;
@ -33,80 +22,10 @@ export type ValidDateFormat = (typeof VALID_DATE_FORMAT_VALUES)[number];
export const DATE_FORMATS = [ export const DATE_FORMATS = [
{ {
key: 'yyyy-MM-dd_HH:mm_12H', key: 'yyyy-MM-dd_hh:mm_a',
label: 'YYYY-MM-DD hh:mm AM/PM', label: 'YYYY-MM-DD HH:mm a',
value: DEFAULT_DOCUMENT_DATE_FORMAT, value: DEFAULT_DOCUMENT_DATE_FORMAT,
}, },
{
key: 'yyyy-MM-dd_HH:mm',
label: 'YYYY-MM-DD HH:mm',
value: 'yyyy-MM-dd HH:mm',
},
{
key: 'DDMMYYYY_TIME',
label: 'DD/MM/YYYY HH:mm',
value: 'dd/MM/yyyy HH:mm',
},
{
key: 'DDMMYYYY_TIME_12H',
label: 'DD/MM/YYYY HH:mm AM/PM',
value: 'dd/MM/yyyy hh:mm a',
},
{
key: 'MMDDYYYY_TIME',
label: 'MM/DD/YYYY HH:mm',
value: 'MM/dd/yyyy HH:mm',
},
{
key: 'MMDDYYYY_TIME_12H',
label: 'MM/DD/YYYY HH:mm AM/PM',
value: 'MM/dd/yyyy hh:mm a',
},
{
key: 'DDMMYYYYHHMM',
label: 'DD.MM.YYYY HH:mm',
value: 'dd.MM.yyyy HH:mm',
},
{
key: 'YYMMDD_TIME',
label: 'YY-MM-DD HH:mm',
value: 'yy-MM-dd HH:mm',
},
{
key: 'YYMMDD_TIME_12H',
label: 'YY-MM-DD HH:mm AM/PM',
value: 'yy-MM-dd hh:mm a',
},
{
key: 'YYYY_MM_DD_HH_MM_SS',
label: 'YYYY-MM-DD HH:mm:ss',
value: 'yyyy-MM-dd HH:mm:ss',
},
{
key: 'MonthDateYear_TIME',
label: 'Month Date, Year HH:mm',
value: 'MMMM dd, yyyy HH:mm',
},
{
key: 'MonthDateYear_TIME_12H',
label: 'Month Date, Year HH:mm AM/PM',
value: 'MMMM dd, yyyy hh:mm a',
},
{
key: 'DayMonthYear_TIME',
label: 'Day, Month Year HH:mm',
value: 'EEEE, MMMM dd, yyyy HH:mm',
},
{
key: 'DayMonthYear_TIME_12H',
label: 'Day, Month Year HH:mm AM/PM',
value: 'EEEE, MMMM dd, yyyy hh:mm a',
},
{
key: 'ISO8601',
label: 'ISO 8601',
value: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
},
{ {
key: 'YYYYMMDD', key: 'YYYYMMDD',
label: 'YYYY-MM-DD', label: 'YYYY-MM-DD',
@ -115,32 +34,47 @@ export const DATE_FORMATS = [
{ {
key: 'DDMMYYYY', key: 'DDMMYYYY',
label: 'DD/MM/YYYY', label: 'DD/MM/YYYY',
value: 'dd/MM/yyyy', value: 'dd/MM/yyyy hh:mm a',
}, },
{ {
key: 'MMDDYYYY', key: 'MMDDYYYY',
label: 'MM/DD/YYYY', label: 'MM/DD/YYYY',
value: 'MM/dd/yyyy', value: 'MM/dd/yyyy hh:mm a',
}, },
{ {
key: 'DDMMYYYY_DOT', key: 'DDMMYYYYHHMM',
label: 'DD.MM.YYYY', label: 'DD.MM.YYYY HH:mm',
value: 'dd.MM.yyyy', value: 'dd.MM.yyyy HH:mm',
},
{
key: 'YYYYMMDDHHmm',
label: 'YYYY-MM-DD HH:mm',
value: 'yyyy-MM-dd HH:mm',
}, },
{ {
key: 'YYMMDD', key: 'YYMMDD',
label: 'YY-MM-DD', label: 'YY-MM-DD',
value: 'yy-MM-dd', value: 'yy-MM-dd hh:mm a',
},
{
key: 'YYYYMMDDhhmmss',
label: 'YYYY-MM-DD HH:mm:ss',
value: 'yyyy-MM-dd HH:mm:ss',
}, },
{ {
key: 'MonthDateYear', key: 'MonthDateYear',
label: 'Month Date, Year', label: 'Month Date, Year',
value: 'MMMM dd, yyyy', value: 'MMMM dd, yyyy hh:mm a',
}, },
{ {
key: 'DayMonthYear', key: 'DayMonthYear',
label: 'Day, Month Year', label: 'Day, Month Year',
value: 'EEEE, MMMM dd, yyyy', value: 'EEEE, MMMM dd, yyyy hh:mm a',
},
{
key: 'ISO8601',
label: 'ISO 8601',
value: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
}, },
] satisfies { ] satisfies {
key: string; key: string;

View File

@ -2,25 +2,18 @@ import * as fs from 'node:fs';
import { env } from '@documenso/lib/utils/env'; import { env } from '@documenso/lib/utils/env';
export const getCertificateStatus = () => { export type CertificateStatus = {
if (env('NEXT_PRIVATE_SIGNING_TRANSPORT') !== 'local') { isAvailable: boolean;
return { isAvailable: true }; };
}
if (env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS')) {
return { isAvailable: true };
}
export const getCertificateStatus = (): CertificateStatus => {
const defaultPath = const defaultPath =
env('NODE_ENV') === 'production' ? '/opt/documenso/cert.p12' : './example/cert.p12'; env('NODE_ENV') === 'production' ? '/opt/documenso/cert.p12' : './example/cert.p12';
const filePath = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH') || defaultPath; const filePath = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH') || defaultPath;
try { try {
fs.accessSync(filePath, fs.constants.F_OK | fs.constants.R_OK); fs.accessSync(filePath, fs.constants.F_OK | fs.constants.R_OK);
const stats = fs.statSync(filePath); const stats = fs.statSync(filePath);
return { isAvailable: stats.size > 0 }; return { isAvailable: stats.size > 0 };
} catch { } catch {
return { isAvailable: false }; return { isAvailable: false };

View File

@ -91,12 +91,6 @@ export const getDocumentAndSenderByToken = async ({
select: { select: {
name: true, name: true,
teamEmail: true, teamEmail: true,
teamGlobalSettings: {
select: {
brandingEnabled: true,
brandingLogo: true,
},
},
}, },
}, },
}, },

View File

@ -0,0 +1,84 @@
import { deletedAccountServiceAccount } from '@documenso/lib/server-only/user/service-accounts/deleted-account';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
type HandleDocumentOwnershipOnDeletionOptions = {
documentIds: number[];
organisationOwnerId: number;
};
export const handleDocumentOwnershipOnDeletion = async ({
documentIds,
organisationOwnerId,
}: HandleDocumentOwnershipOnDeletionOptions) => {
if (documentIds.length === 0) {
return;
}
const serviceAccount = await deletedAccountServiceAccount();
const serviceAccountTeam = serviceAccount.ownedOrganisations[0].teams[0];
await prisma.document.deleteMany({
where: {
id: {
in: documentIds,
},
status: DocumentStatus.DRAFT,
},
});
const organisationOwner = await prisma.user.findUnique({
where: {
id: organisationOwnerId,
},
select: {
id: true,
ownedOrganisations: {
select: {
id: true,
teams: {
select: {
id: true,
},
},
},
},
},
});
if (organisationOwner && organisationOwner.ownedOrganisations.length > 0) {
const ownerPersonalTeam = organisationOwner.ownedOrganisations[0].teams[0];
await prisma.document.updateMany({
where: {
id: {
in: documentIds,
},
status: {
in: [DocumentStatus.PENDING, DocumentStatus.REJECTED, DocumentStatus.COMPLETED],
},
},
data: {
userId: organisationOwner.id,
teamId: ownerPersonalTeam.id,
deletedAt: new Date(),
},
});
} else {
await prisma.document.updateMany({
where: {
id: {
in: documentIds,
},
status: {
in: [DocumentStatus.PENDING, DocumentStatus.REJECTED, DocumentStatus.COMPLETED],
},
},
data: {
userId: serviceAccount.id,
teamId: serviceAccountTeam.id,
deletedAt: new Date(),
},
});
}
};

View File

@ -84,7 +84,9 @@ export const setFieldsForDocument = async ({
const linkedFields = fields.map((field) => { const linkedFields = fields.map((field) => {
const existing = existingFields.find((existingField) => existingField.id === field.id); const existing = existingFields.find((existingField) => existingField.id === field.id);
const recipient = document.recipients.find((recipient) => recipient.id === field.recipientId); const recipient = document.recipients.find(
(recipient) => recipient.email.toLowerCase() === field.signerEmail.toLowerCase(),
);
// Each field MUST have a recipient associated with it. // Each field MUST have a recipient associated with it.
if (!recipient) { if (!recipient) {
@ -224,8 +226,10 @@ export const setFieldsForDocument = async ({
}, },
recipient: { recipient: {
connect: { connect: {
id: field.recipientId, documentId_email: {
documentId, documentId,
email: fieldSignerEmail,
},
}, },
}, },
}, },
@ -326,7 +330,6 @@ type FieldData = {
id?: number | null; id?: number | null;
type: FieldType; type: FieldType;
signerEmail: string; signerEmail: string;
recipientId: number;
pageNumber: number; pageNumber: number;
pageX: number; pageX: number;
pageY: number; pageY: number;

View File

@ -26,7 +26,6 @@ export type SetFieldsForTemplateOptions = {
id?: number | null; id?: number | null;
type: FieldType; type: FieldType;
signerEmail: string; signerEmail: string;
recipientId: number;
pageNumber: number; pageNumber: number;
pageX: number; pageX: number;
pageY: number; pageY: number;
@ -170,8 +169,10 @@ export const setFieldsForTemplate = async ({
}, },
recipient: { recipient: {
connect: { connect: {
id: field.recipientId, templateId_email: {
templateId, templateId,
email: field.signerEmail.toLowerCase(),
},
}, },
}, },
}, },

View File

@ -85,6 +85,20 @@ export const createDocumentRecipients = async ({
email: recipient.email.toLowerCase(), email: recipient.email.toLowerCase(),
})); }));
const duplicateRecipients = normalizedRecipients.filter((newRecipient) => {
const existingRecipient = document.recipients.find(
(existingRecipient) => existingRecipient.email === newRecipient.email,
);
return existingRecipient !== undefined;
});
if (duplicateRecipients.length > 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Duplicate recipient(s) found for ${duplicateRecipients.map((recipient) => recipient.email).join(', ')}`,
});
}
const createdRecipients = await prisma.$transaction(async (tx) => { const createdRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all( return await Promise.all(
normalizedRecipients.map(async (recipient) => { normalizedRecipients.map(async (recipient) => {

View File

@ -71,6 +71,20 @@ export const createTemplateRecipients = async ({
email: recipient.email.toLowerCase(), email: recipient.email.toLowerCase(),
})); }));
const duplicateRecipients = normalizedRecipients.filter((newRecipient) => {
const existingRecipient = template.recipients.find(
(existingRecipient) => existingRecipient.email === newRecipient.email,
);
return existingRecipient !== undefined;
});
if (duplicateRecipients.length > 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Duplicate recipient(s) found for ${duplicateRecipients.map((recipient) => recipient.email).join(', ')}`,
});
}
const createdRecipients = await prisma.$transaction(async (tx) => { const createdRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all( return await Promise.all(
normalizedRecipients.map(async (recipient) => { normalizedRecipients.map(async (recipient) => {

View File

@ -122,12 +122,16 @@ export const setDocumentRecipients = async ({
const removedRecipients = existingRecipients.filter( const removedRecipients = existingRecipients.filter(
(existingRecipient) => (existingRecipient) =>
!normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id), !normalizedRecipients.find(
(recipient) =>
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
),
); );
const linkedRecipients = normalizedRecipients.map((recipient) => { const linkedRecipients = normalizedRecipients.map((recipient) => {
const existing = existingRecipients.find( const existing = existingRecipients.find(
(existingRecipient) => existingRecipient.id === recipient.id, (existingRecipient) =>
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
); );
const canPersistedRecipientBeModified = const canPersistedRecipientBeModified =

View File

@ -94,7 +94,10 @@ export const setTemplateRecipients = async ({
const removedRecipients = existingRecipients.filter( const removedRecipients = existingRecipients.filter(
(existingRecipient) => (existingRecipient) =>
!normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id), !normalizedRecipients.find(
(recipient) =>
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
),
); );
if (template.directLink !== null) { if (template.directLink !== null) {
@ -121,7 +124,8 @@ export const setTemplateRecipients = async ({
const linkedRecipients = normalizedRecipients.map((recipient) => { const linkedRecipients = normalizedRecipients.map((recipient) => {
const existing = existingRecipients.find( const existing = existingRecipients.find(
(existingRecipient) => existingRecipient.id === recipient.id, (existingRecipient) =>
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
); );
return { return {

View File

@ -91,6 +91,17 @@ export const updateDocumentRecipients = async ({
}); });
} }
const duplicateRecipientWithSameEmail = document.recipients.find(
(existingRecipient) =>
existingRecipient.email === recipient.email && existingRecipient.id !== recipient.id,
);
if (duplicateRecipientWithSameEmail) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Duplicate recipient with the same email found: ${duplicateRecipientWithSameEmail.email}`,
});
}
if (!canRecipientBeModified(originalRecipient, document.fields)) { if (!canRecipientBeModified(originalRecipient, document.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot modify a recipient who has already interacted with the document', message: 'Cannot modify a recipient who has already interacted with the document',

View File

@ -80,6 +80,17 @@ export const updateTemplateRecipients = async ({
}); });
} }
const duplicateRecipientWithSameEmail = template.recipients.find(
(existingRecipient) =>
existingRecipient.email === recipient.email && existingRecipient.id !== recipient.id,
);
if (duplicateRecipientWithSameEmail) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Duplicate recipient with the same email found: ${duplicateRecipientWithSameEmail.email}`,
});
}
return { return {
originalRecipient, originalRecipient,
recipientUpdateData: recipient, recipientUpdateData: recipient,

View File

@ -19,8 +19,6 @@ export type CreateDocumentFromTemplateLegacyOptions = {
}[]; }[];
}; };
// !TODO: Make this work
/** /**
* Legacy server function for /api/v1 * Legacy server function for /api/v1
*/ */
@ -60,15 +58,6 @@ export const createDocumentFromTemplateLegacy = async ({
}, },
}); });
const recipientsToCreate = template.recipients.map((recipient) => ({
id: recipient.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
}));
const document = await prisma.document.create({ const document = await prisma.document.create({
data: { data: {
qrToken: prefixedId('qr'), qrToken: prefixedId('qr'),
@ -81,12 +70,12 @@ export const createDocumentFromTemplateLegacy = async ({
documentDataId: documentData.id, documentDataId: documentData.id,
useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false, useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false,
recipients: { recipients: {
create: recipientsToCreate.map((recipient) => ({ create: template.recipients.map((recipient) => ({
email: recipient.email, email: recipient.email,
name: recipient.name, name: recipient.name,
role: recipient.role, role: recipient.role,
signingOrder: recipient.signingOrder, signingOrder: recipient.signingOrder,
token: recipient.token, token: nanoid(),
})), })),
}, },
documentMeta: { documentMeta: {
@ -106,11 +95,9 @@ export const createDocumentFromTemplateLegacy = async ({
await prisma.field.createMany({ await prisma.field.createMany({
data: template.fields.map((field) => { data: template.fields.map((field) => {
const recipient = recipientsToCreate.find((recipient) => recipient.id === field.recipientId); const recipient = template.recipients.find((recipient) => recipient.id === field.recipientId);
const documentRecipient = document.recipients.find( const documentRecipient = document.recipients.find((doc) => doc.email === recipient?.email);
(documentRecipient) => documentRecipient.token === recipient?.token,
);
if (!documentRecipient) { if (!documentRecipient) {
throw new Error('Recipient not found.'); throw new Error('Recipient not found.');
@ -131,34 +118,30 @@ export const createDocumentFromTemplateLegacy = async ({
}), }),
}); });
// Replicate the old logic, get by index and create if we exceed the number of existing recipients.
if (recipients && recipients.length > 0) { if (recipients && recipients.length > 0) {
await Promise.all( document.recipients = await Promise.all(
recipients.map(async (recipient, index) => { recipients.map(async (recipient, index) => {
const existingRecipient = document.recipients.at(index); const existingRecipient = document.recipients.at(index);
if (existingRecipient) { return await prisma.recipient.upsert({
return await prisma.recipient.update({ where: {
where: { documentId_email: {
id: existingRecipient.id,
documentId: document.id, documentId: document.id,
email: existingRecipient?.email ?? recipient.email,
}, },
data: { },
name: recipient.name, update: {
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
},
});
}
return await prisma.recipient.create({
data: {
documentId: document.id,
name: recipient.name, name: recipient.name,
email: recipient.email, email: recipient.email,
role: recipient.role, role: recipient.role,
signingOrder: recipient.signingOrder, signingOrder: recipient.signingOrder,
},
create: {
documentId: document.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(), token: nanoid(),
}, },
}); });
@ -166,18 +149,5 @@ export const createDocumentFromTemplateLegacy = async ({
); );
} }
// Gross but we need to do the additional fetch since we mutate above. return document;
const updatedRecipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
},
orderBy: {
id: 'asc',
},
});
return {
...document,
recipients: updatedRecipients,
};
}; };

View File

@ -53,7 +53,7 @@ import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type FinalRecipient = Pick< type FinalRecipient = Pick<
Recipient, Recipient,
'name' | 'email' | 'role' | 'authOptions' | 'signingOrder' | 'token' 'name' | 'email' | 'role' | 'authOptions' | 'signingOrder'
> & { > & {
templateRecipientId: number; templateRecipientId: number;
fields: Field[]; fields: Field[];
@ -350,7 +350,6 @@ export const createDocumentFromTemplate = async ({
role: templateRecipient.role, role: templateRecipient.role,
signingOrder: foundRecipient?.signingOrder ?? templateRecipient.signingOrder, signingOrder: foundRecipient?.signingOrder ?? templateRecipient.signingOrder,
authOptions: templateRecipient.authOptions, authOptions: templateRecipient.authOptions,
token: nanoid(),
}; };
}); });
@ -442,7 +441,7 @@ export const createDocumentFromTemplate = async ({
? SigningStatus.SIGNED ? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED, : SigningStatus.NOT_SIGNED,
signingOrder: recipient.signingOrder, signingOrder: recipient.signingOrder,
token: recipient.token, token: nanoid(),
}; };
}), }),
}, },
@ -501,8 +500,8 @@ export const createDocumentFromTemplate = async ({
} }
} }
Object.values(finalRecipients).forEach(({ token, fields }) => { Object.values(finalRecipients).forEach(({ email, fields }) => {
const recipient = document.recipients.find((recipient) => recipient.token === token); const recipient = document.recipients.find((recipient) => recipient.email === email);
if (!recipient) { if (!recipient) {
throw new Error('Recipient not found.'); throw new Error('Recipient not found.');

View File

@ -5,6 +5,20 @@ export const deletedAccountServiceAccount = async () => {
where: { where: {
email: 'deleted-account@documenso.com', email: 'deleted-account@documenso.com',
}, },
select: {
id: true,
email: true,
ownedOrganisations: {
select: {
id: true,
teams: {
select: {
id: true,
},
},
},
},
},
}); });
if (!serviceAccount) { if (!serviceAccount) {

View File

@ -1,5 +0,0 @@
-- DropIndex
DROP INDEX "Recipient_documentId_email_key";
-- DropIndex
DROP INDEX "Recipient_templateId_email_key";

View File

@ -527,6 +527,8 @@ model Recipient {
fields Field[] fields Field[]
signatures Signature[] signatures Signature[]
@@unique([documentId, email])
@@unique([templateId, email])
@@index([documentId]) @@index([documentId])
@@index([templateId]) @@index([templateId])
@@index([token]) @@index([token])

View File

@ -1,124 +0,0 @@
import { OrganisationGroupType, OrganisationMemberRole } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import {
ZPromoteMemberToOwnerRequestSchema,
ZPromoteMemberToOwnerResponseSchema,
} from './promote-member-to-owner.types';
export const promoteMemberToOwnerRoute = adminProcedure
.input(ZPromoteMemberToOwnerRequestSchema)
.output(ZPromoteMemberToOwnerResponseSchema)
.mutation(async ({ input, ctx }) => {
const { organisationId, userId } = input;
ctx.logger.info({
input: {
organisationId,
userId,
},
});
// First, verify the organisation exists and get member details with groups
const organisation = await prisma.organisation.findUnique({
where: {
id: organisationId,
},
include: {
groups: {
where: {
type: OrganisationGroupType.INTERNAL_ORGANISATION,
},
},
members: {
where: {
userId,
},
include: {
organisationGroupMembers: {
include: {
group: true,
},
},
},
},
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
// Verify the user is a member of the organisation
const [member] = organisation.members;
if (!member) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User is not a member of this organisation',
});
}
// Verify the user is not already the owner
if (organisation.ownerUserId === userId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'User is already the owner of this organisation',
});
}
// Get current organisation role
const currentOrganisationRole = getHighestOrganisationRoleInGroup(
member.organisationGroupMembers.flatMap((member) => member.group),
);
// Find the current and target organisation groups
const currentMemberGroup = organisation.groups.find(
(group) => group.organisationRole === currentOrganisationRole,
);
const adminGroup = organisation.groups.find(
(group) => group.organisationRole === OrganisationMemberRole.ADMIN,
);
if (!currentMemberGroup) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Current member group not found',
});
}
if (!adminGroup) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Admin group not found',
});
}
// Update the organisation owner and member role in a transaction
await prisma.$transaction(async (tx) => {
// Update the organisation to set the new owner
await tx.organisation.update({
where: {
id: organisationId,
},
data: {
ownerUserId: userId,
},
});
// Only update role if the user is not already an admin then add them to the admin group
if (currentOrganisationRole !== OrganisationMemberRole.ADMIN) {
await tx.organisationGroupMember.create({
data: {
id: generateDatabaseId('group_member'),
organisationMemberId: member.id,
groupId: adminGroup.id,
},
});
}
});
});

View File

@ -1,11 +0,0 @@
import { z } from 'zod';
export const ZPromoteMemberToOwnerRequestSchema = z.object({
organisationId: z.string().min(1),
userId: z.number().min(1),
});
export const ZPromoteMemberToOwnerResponseSchema = z.void();
export type TPromoteMemberToOwnerRequest = z.infer<typeof ZPromoteMemberToOwnerRequestSchema>;
export type TPromoteMemberToOwnerResponse = z.infer<typeof ZPromoteMemberToOwnerResponseSchema>;

View File

@ -12,7 +12,6 @@ import { findDocumentsRoute } from './find-documents';
import { findSubscriptionClaimsRoute } from './find-subscription-claims'; import { findSubscriptionClaimsRoute } from './find-subscription-claims';
import { getAdminOrganisationRoute } from './get-admin-organisation'; import { getAdminOrganisationRoute } from './get-admin-organisation';
import { getUserRoute } from './get-user'; import { getUserRoute } from './get-user';
import { promoteMemberToOwnerRoute } from './promote-member-to-owner';
import { resealDocumentRoute } from './reseal-document'; import { resealDocumentRoute } from './reseal-document';
import { resetTwoFactorRoute } from './reset-two-factor-authentication'; import { resetTwoFactorRoute } from './reset-two-factor-authentication';
import { updateAdminOrganisationRoute } from './update-admin-organisation'; import { updateAdminOrganisationRoute } from './update-admin-organisation';
@ -28,9 +27,6 @@ export const adminRouter = router({
create: createAdminOrganisationRoute, create: createAdminOrganisationRoute,
update: updateAdminOrganisationRoute, update: updateAdminOrganisationRoute,
}, },
organisationMember: {
promoteToOwner: promoteMemberToOwnerRoute,
},
claims: { claims: {
find: findSubscriptionClaimsRoute, find: findSubscriptionClaimsRoute,
create: createSubscriptionClaimRoute, create: createSubscriptionClaimRoute,

View File

@ -78,7 +78,14 @@ export const ZCreateDocumentTemporaryRequestSchema = z.object({
.optional(), .optional(),
}), }),
) )
.refine(
(recipients) => {
const emails = recipients.map((recipient) => recipient.email);
return new Set(emails).size === emails.length;
},
{ message: 'Recipients must have unique emails' },
)
.optional(), .optional(),
meta: z meta: z
.object({ .object({

View File

@ -47,7 +47,14 @@ export const ZCreateEmbeddingDocumentRequestSchema = z.object({
.optional(), .optional(),
}), }),
) )
.refine(
(recipients) => {
const emails = recipients.map((recipient) => recipient.email);
return new Set(emails).size === emails.length;
},
{ message: 'Recipients must have unique emails' },
)
.optional(), .optional(),
meta: z meta: z
.object({ .object({

View File

@ -30,27 +30,36 @@ export const ZUpdateEmbeddingDocumentRequestSchema = z.object({
documentId: z.number(), documentId: z.number(),
title: ZDocumentTitleSchema, title: ZDocumentTitleSchema,
externalId: ZDocumentExternalIdSchema.optional(), externalId: ZDocumentExternalIdSchema.optional(),
recipients: z.array( recipients: z
z.object({ .array(
id: z.number().optional(), z.object({
email: z.string().toLowerCase().email().min(1), id: z.number().optional(),
name: z.string(), email: z.string().toLowerCase().email().min(1),
role: z.nativeEnum(RecipientRole), name: z.string(),
signingOrder: z.number().optional(), role: z.nativeEnum(RecipientRole),
fields: ZFieldAndMetaSchema.and( signingOrder: z.number().optional(),
z.object({ fields: ZFieldAndMetaSchema.and(
id: z.number().optional(), z.object({
pageNumber: ZFieldPageNumberSchema, id: z.number().optional(),
pageX: ZFieldPageXSchema, pageNumber: ZFieldPageNumberSchema,
pageY: ZFieldPageYSchema, pageX: ZFieldPageXSchema,
width: ZFieldWidthSchema, pageY: ZFieldPageYSchema,
height: ZFieldHeightSchema, width: ZFieldWidthSchema,
}), height: ZFieldHeightSchema,
) }),
.array() )
.optional(), .array()
}), .optional(),
), }),
)
.refine(
(recipients) => {
const emails = recipients.map((recipient) => recipient.email);
return new Set(emails).size === emails.length;
},
{ message: 'Recipients must have unique emails' },
),
meta: z meta: z
.object({ .object({
subject: ZDocumentMetaSubjectSchema.optional(), subject: ZDocumentMetaSubjectSchema.optional(),

View File

@ -274,7 +274,6 @@ export const fieldRouter = router({
fields: fields.map((field) => ({ fields: fields.map((field) => ({
id: field.nativeId, id: field.nativeId,
signerEmail: field.signerEmail, signerEmail: field.signerEmail,
recipientId: field.recipientId,
type: field.type, type: field.type,
pageNumber: field.pageNumber, pageNumber: field.pageNumber,
pageX: field.pageX, pageX: field.pageX,
@ -514,7 +513,6 @@ export const fieldRouter = router({
fields: fields.map((field) => ({ fields: fields.map((field) => ({
id: field.nativeId, id: field.nativeId,
signerEmail: field.signerEmail, signerEmail: field.signerEmail,
recipientId: field.recipientId,
type: field.type, type: field.type,
pageNumber: field.pageNumber, pageNumber: field.pageNumber,
pageX: field.pageX, pageX: field.pageX,

View File

@ -114,7 +114,6 @@ export const ZSetDocumentFieldsRequestSchema = z.object({
nativeId: z.number().optional(), nativeId: z.number().optional(),
type: z.nativeEnum(FieldType), type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1), signerEmail: z.string().min(1),
recipientId: z.number().min(1),
pageNumber: z.number().min(1), pageNumber: z.number().min(1),
pageX: z.number().min(0), pageX: z.number().min(0),
pageY: z.number().min(0), pageY: z.number().min(0),
@ -137,7 +136,6 @@ export const ZSetFieldsForTemplateRequestSchema = z.object({
nativeId: z.number().optional(), nativeId: z.number().optional(),
type: z.nativeEnum(FieldType), type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1), signerEmail: z.string().min(1),
recipientId: z.number().min(1),
pageNumber: z.number().min(1), pageNumber: z.number().min(1),
pageX: z.number().min(0), pageX: z.number().min(0),
pageY: z.number().min(0), pageY: z.number().min(0),

View File

@ -3,6 +3,7 @@ import {
ORGANISATION_USER_ACCOUNT_TYPE, ORGANISATION_USER_ACCOUNT_TYPE,
} from '@documenso/lib/constants/organisations'; } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { handleDocumentOwnershipOnDeletion } from '@documenso/lib/server-only/document/handle-document-ownership-on-deletion';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
@ -32,6 +33,24 @@ export const deleteOrganisationRoute = authenticatedProcedure
userId: user.id, userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_ORGANISATION'], roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_ORGANISATION'],
}), }),
select: {
id: true,
owner: {
select: {
id: true,
},
},
teams: {
select: {
id: true,
documents: {
select: {
id: true,
},
},
},
},
},
}); });
if (!organisation) { if (!organisation) {
@ -40,6 +59,15 @@ export const deleteOrganisationRoute = authenticatedProcedure
}); });
} }
const documentIds = organisation.teams.flatMap((team) => team.documents.map((doc) => doc.id));
if (documentIds && documentIds.length > 0) {
await handleDocumentOwnershipOnDeletion({
documentIds,
organisationOwnerId: organisation.owner.id,
});
}
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await tx.account.deleteMany({ await tx.account.deleteMany({
where: { where: {

View File

@ -1,5 +1,3 @@
import { OrganisationType } from '@prisma/client';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations'; import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
@ -106,19 +104,6 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
}); });
} }
const isPersonalOrganisation = organisation.type === OrganisationType.PERSONAL;
const currentIncludeSenderDetails =
organisation.organisationGlobalSettings.includeSenderDetails;
const isChangingIncludeSenderDetails =
includeSenderDetails !== undefined && includeSenderDetails !== currentIncludeSenderDetails;
if (isPersonalOrganisation && isChangingIncludeSenderDetails) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Personal organisations cannot update the sender details',
});
}
await prisma.organisation.update({ await prisma.organisation.update({
where: { where: {
id: organisationId, id: organisationId,

View File

@ -50,7 +50,16 @@ export const ZCreateDocumentRecipientResponseSchema = ZRecipientLiteSchema;
export const ZCreateDocumentRecipientsRequestSchema = z.object({ export const ZCreateDocumentRecipientsRequestSchema = z.object({
documentId: z.number(), documentId: z.number(),
recipients: z.array(ZCreateRecipientSchema), recipients: z.array(ZCreateRecipientSchema).refine(
(recipients) => {
const emails = recipients.map((recipient) => recipient.email.toLowerCase());
return new Set(emails).size === emails.length;
},
{
message: 'Recipients must have unique emails',
},
),
}); });
export const ZCreateDocumentRecipientsResponseSchema = z.object({ export const ZCreateDocumentRecipientsResponseSchema = z.object({
@ -66,7 +75,18 @@ export const ZUpdateDocumentRecipientResponseSchema = ZRecipientSchema;
export const ZUpdateDocumentRecipientsRequestSchema = z.object({ export const ZUpdateDocumentRecipientsRequestSchema = z.object({
documentId: z.number(), documentId: z.number(),
recipients: z.array(ZUpdateRecipientSchema), recipients: z.array(ZUpdateRecipientSchema).refine(
(recipients) => {
const emails = recipients
.filter((recipient) => recipient.email !== undefined)
.map((recipient) => recipient.email?.toLowerCase());
return new Set(emails).size === emails.length;
},
{
message: 'Recipients must have unique emails',
},
),
}); });
export const ZUpdateDocumentRecipientsResponseSchema = z.object({ export const ZUpdateDocumentRecipientsResponseSchema = z.object({
@ -77,19 +97,29 @@ export const ZDeleteDocumentRecipientRequestSchema = z.object({
recipientId: z.number(), recipientId: z.number(),
}); });
export const ZSetDocumentRecipientsRequestSchema = z.object({ export const ZSetDocumentRecipientsRequestSchema = z
documentId: z.number(), .object({
recipients: z.array( documentId: z.number(),
z.object({ recipients: z.array(
nativeId: z.number().optional(), z.object({
email: z.string().toLowerCase().email().min(1).max(254), nativeId: z.number().optional(),
name: z.string().max(255), email: z.string().toLowerCase().email().min(1).max(254),
role: z.nativeEnum(RecipientRole), name: z.string().max(255),
signingOrder: z.number().optional(), role: z.nativeEnum(RecipientRole),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), signingOrder: z.number().optional(),
}), actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
), }),
}); ),
})
.refine(
(schema) => {
const emails = schema.recipients.map((recipient) => recipient.email.toLowerCase());
return new Set(emails).size === emails.length;
},
// Dirty hack to handle errors when .root is populated for an array type
{ message: 'Recipients must have unique emails', path: ['recipients__root'] },
);
export const ZSetDocumentRecipientsResponseSchema = z.object({ export const ZSetDocumentRecipientsResponseSchema = z.object({
recipients: ZRecipientLiteSchema.array(), recipients: ZRecipientLiteSchema.array(),
@ -104,7 +134,16 @@ export const ZCreateTemplateRecipientResponseSchema = ZRecipientLiteSchema;
export const ZCreateTemplateRecipientsRequestSchema = z.object({ export const ZCreateTemplateRecipientsRequestSchema = z.object({
templateId: z.number(), templateId: z.number(),
recipients: z.array(ZCreateRecipientSchema), recipients: z.array(ZCreateRecipientSchema).refine(
(recipients) => {
const emails = recipients.map((recipient) => recipient.email);
return new Set(emails).size === emails.length;
},
{
message: 'Recipients must have unique emails',
},
),
}); });
export const ZCreateTemplateRecipientsResponseSchema = z.object({ export const ZCreateTemplateRecipientsResponseSchema = z.object({
@ -120,7 +159,18 @@ export const ZUpdateTemplateRecipientResponseSchema = ZRecipientSchema;
export const ZUpdateTemplateRecipientsRequestSchema = z.object({ export const ZUpdateTemplateRecipientsRequestSchema = z.object({
templateId: z.number(), templateId: z.number(),
recipients: z.array(ZUpdateRecipientSchema), recipients: z.array(ZUpdateRecipientSchema).refine(
(recipients) => {
const emails = recipients
.filter((recipient) => recipient.email !== undefined)
.map((recipient) => recipient.email);
return new Set(emails).size === emails.length;
},
{
message: 'Recipients must have unique emails',
},
),
}); });
export const ZUpdateTemplateRecipientsResponseSchema = z.object({ export const ZUpdateTemplateRecipientsResponseSchema = z.object({
@ -131,30 +181,43 @@ export const ZDeleteTemplateRecipientRequestSchema = z.object({
recipientId: z.number(), recipientId: z.number(),
}); });
export const ZSetTemplateRecipientsRequestSchema = z.object({ export const ZSetTemplateRecipientsRequestSchema = z
templateId: z.number(), .object({
recipients: z.array( templateId: z.number(),
z.object({ recipients: z.array(
nativeId: z.number().optional(), z.object({
email: z nativeId: z.number().optional(),
.string() email: z
.toLowerCase() .string()
.refine( .toLowerCase()
(email) => { .refine(
return ( (email) => {
isTemplateRecipientEmailPlaceholder(email) || return (
z.string().email().safeParse(email).success isTemplateRecipientEmailPlaceholder(email) ||
); z.string().email().safeParse(email).success
}, );
{ message: 'Please enter a valid email address' }, },
), { message: 'Please enter a valid email address' },
name: z.string(), ),
role: z.nativeEnum(RecipientRole), name: z.string(),
signingOrder: z.number().optional(), role: z.nativeEnum(RecipientRole),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), signingOrder: z.number().optional(),
}), actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
), }),
}); ),
})
.refine(
(schema) => {
// Filter out placeholder emails and only check uniqueness for actual emails
const nonPlaceholderEmails = schema.recipients
.map((recipient) => recipient.email)
.filter((email) => !isTemplateRecipientEmailPlaceholder(email));
return new Set(nonPlaceholderEmails).size === nonPlaceholderEmails.length;
},
// Dirty hack to handle errors when .root is populated for an array type
{ message: 'Recipients must have unique emails', path: ['recipients__root'] },
);
export const ZSetTemplateRecipientsResponseSchema = z.object({ export const ZSetTemplateRecipientsResponseSchema = z.object({
recipients: ZRecipientLiteSchema.array(), recipients: ZRecipientLiteSchema.array(),

View File

@ -1,4 +1,8 @@
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { handleDocumentOwnershipOnDeletion } from '@documenso/lib/server-only/document/handle-document-ownership-on-deletion';
import { deleteTeam } from '@documenso/lib/server-only/team/delete-team'; import { deleteTeam } from '@documenso/lib/server-only/team/delete-team';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc'; import { authenticatedProcedure } from '../trpc';
import { ZDeleteTeamRequestSchema, ZDeleteTeamResponseSchema } from './delete-team.types'; import { ZDeleteTeamRequestSchema, ZDeleteTeamResponseSchema } from './delete-team.types';
@ -11,12 +15,53 @@ export const deleteTeamRoute = authenticatedProcedure
const { teamId } = input; const { teamId } = input;
const { user } = ctx; const { user } = ctx;
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
});
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId: team?.organisationId,
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_ORGANISATION'],
}),
select: {
id: true,
owner: {
select: {
id: true,
},
},
teams: {
select: {
id: true,
documents: {
select: {
id: true,
},
},
},
},
},
});
ctx.logger.info({ ctx.logger.info({
input: { input: {
teamId, teamId,
}, },
}); });
const documentIds = organisation?.teams.flatMap((team) => team.documents.map((doc) => doc.id));
if (documentIds && documentIds.length > 0 && organisation) {
await handleDocumentOwnershipOnDeletion({
documentIds,
organisationOwnerId: organisation.owner.id,
});
}
await deleteTeam({ await deleteTeam({
userId: user.id, userId: user.id,
teamId, teamId,

View File

@ -1,10 +1,7 @@
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { OrganisationType } from '@prisma/client';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams'; import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
@ -100,35 +97,6 @@ export const updateTeamSettingsRoute = authenticatedProcedure
} }
} }
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId: team.organisationId,
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
select: {
type: true,
organisationGlobalSettings: {
select: {
includeSenderDetails: true,
},
},
},
});
const isPersonalOrganisation = organisation?.type === OrganisationType.PERSONAL;
const currentIncludeSenderDetails =
organisation?.organisationGlobalSettings.includeSenderDetails;
const isChangingIncludeSenderDetails =
includeSenderDetails !== undefined && includeSenderDetails !== currentIncludeSenderDetails;
if (isPersonalOrganisation && isChangingIncludeSenderDetails) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Personal teams cannot update the sender details',
});
}
await prisma.team.update({ await prisma.team.update({
where: { where: {
id: teamId, id: teamId,

View File

@ -101,7 +101,12 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
name: z.string().max(255).optional(), name: z.string().max(255).optional(),
}), }),
) )
.describe('The information of the recipients to create the document with.'), .describe('The information of the recipients to create the document with.')
.refine((recipients) => {
const emails = recipients.map((signer) => signer.email);
return new Set(emails).size === emails.length;
}, 'Recipients must have unique emails'),
distributeDocument: z distributeDocument: z
.boolean() .boolean()
.describe('Whether to create the document as pending and distribute it to recipients.') .describe('Whether to create the document as pending and distribute it to recipients.')

View File

@ -105,7 +105,6 @@ export const DocumentReadOnlyFields = ({
<FieldRootContainer <FieldRootContainer
field={field} field={field}
key={field.id} key={field.id}
readonly={true}
color={ color={
showRecipientColors showRecipientColors
? getRecipientColorStyles( ? getRecipientColorStyles(

View File

@ -70,16 +70,9 @@ export type FieldRootContainerProps = {
color?: RecipientColorStyles; color?: RecipientColorStyles;
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
readonly?: boolean;
}; };
export function FieldRootContainer({ export function FieldRootContainer({ field, children, color, className }: FieldRootContainerProps) {
field,
children,
color,
className,
readonly,
}: FieldRootContainerProps) {
const [isValidating, setIsValidating] = useState(false); const [isValidating, setIsValidating] = useState(false);
const ref = React.useRef<HTMLDivElement>(null); const ref = React.useRef<HTMLDivElement>(null);
@ -110,7 +103,6 @@ export function FieldRootContainer({
ref={ref} ref={ref}
data-field-type={field.type} data-field-type={field.type}
data-inserted={field.inserted ? 'true' : 'false'} data-inserted={field.inserted ? 'true' : 'false'}
data-readonly={readonly ? 'true' : 'false'}
className={cn( className={cn(
'field--FieldRootContainer field-card-container dark-mode-disabled group relative z-20 flex h-full w-full items-center rounded-[2px] bg-white/90 ring-2 ring-gray-200 transition-all', 'field--FieldRootContainer field-card-container dark-mode-disabled group relative z-20 flex h-full w-full items-center rounded-[2px] bg-white/90 ring-2 ring-gray-200 transition-all',
color?.base, color?.base,

View File

@ -39,9 +39,7 @@ export interface BadgeProps
VariantProps<typeof badgeVariants> {} VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, size, ...props }: BadgeProps) { function Badge({ className, variant, size, ...props }: BadgeProps) {
return ( return <div className={cn(badgeVariants({ variant, size }), className)} {...props} />;
<div role="status" className={cn(badgeVariants({ variant, size }), className)} {...props} />
);
} }
export { Badge, badgeVariants }; export { Badge, badgeVariants };

View File

@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
@ -47,7 +46,7 @@ import { Form } from '../form/form';
import { RecipientSelector } from '../recipient-selector'; import { RecipientSelector } from '../recipient-selector';
import { useStep } from '../stepper'; import { useStep } from '../stepper';
import { useToast } from '../use-toast'; import { useToast } from '../use-toast';
import { type TAddFieldsFormSchema, ZAddFieldsFormSchema } from './add-fields.types'; import type { TAddFieldsFormSchema } from './add-fields.types';
import { import {
DocumentFlowFormContainerActions, DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent, DocumentFlowFormContainerContent,
@ -76,7 +75,6 @@ export type FieldFormType = {
pageWidth: number; pageWidth: number;
pageHeight: number; pageHeight: number;
signerEmail: string; signerEmail: string;
recipientId: number;
fieldMeta?: FieldMeta; fieldMeta?: FieldMeta;
}; };
@ -129,11 +127,9 @@ export const AddFieldsFormPartial = ({
pageHeight: Number(field.height), pageHeight: Number(field.height),
signerEmail: signerEmail:
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '', recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
recipientId: field.recipientId,
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined, fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
})), })),
}, },
resolver: zodResolver(ZAddFieldsFormSchema),
}); });
useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt)); useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt));
@ -327,7 +323,6 @@ export const AddFieldsFormPartial = ({
const field = { const field = {
formId: nanoid(12), formId: nanoid(12),
nativeId: undefined,
type: selectedField, type: selectedField,
pageNumber, pageNumber,
pageX, pageX,
@ -335,7 +330,6 @@ export const AddFieldsFormPartial = ({
pageWidth: fieldPageWidth, pageWidth: fieldPageWidth,
pageHeight: fieldPageHeight, pageHeight: fieldPageHeight,
signerEmail: selectedSigner.email, signerEmail: selectedSigner.email,
recipientId: selectedSigner.id,
fieldMeta: undefined, fieldMeta: undefined,
}; };
@ -420,7 +414,6 @@ export const AddFieldsFormPartial = ({
nativeId: undefined, nativeId: undefined,
formId: nanoid(12), formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail, signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
recipientId: selectedSigner?.id ?? lastActiveField.recipientId,
pageX: lastActiveField.pageX + 3, pageX: lastActiveField.pageX + 3,
pageY: lastActiveField.pageY + 3, pageY: lastActiveField.pageY + 3,
}; };
@ -445,7 +438,6 @@ export const AddFieldsFormPartial = ({
nativeId: undefined, nativeId: undefined,
formId: nanoid(12), formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail, signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
recipientId: selectedSigner?.id ?? lastActiveField.recipientId,
pageNumber, pageNumber,
}; };
@ -478,7 +470,6 @@ export const AddFieldsFormPartial = ({
nativeId: undefined, nativeId: undefined,
formId: nanoid(12), formId: nanoid(12),
signerEmail: selectedSigner?.email ?? copiedField.signerEmail, signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
recipientId: selectedSigner?.id ?? copiedField.recipientId,
pageX: copiedField.pageX + 3, pageX: copiedField.pageX + 3,
pageY: copiedField.pageY + 3, pageY: copiedField.pageY + 3,
}); });
@ -672,7 +663,7 @@ export const AddFieldsFormPartial = ({
{isDocumentPdfLoaded && {isDocumentPdfLoaded &&
localFields.map((field, index) => { localFields.map((field, index) => {
const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId); const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail);
const hasFieldError = const hasFieldError =
emptyCheckboxFields.find((f) => f.formId === field.formId) || emptyCheckboxFields.find((f) => f.formId === field.formId) ||
emptyRadioFields.find((f) => f.formId === field.formId) || emptyRadioFields.find((f) => f.formId === field.formId) ||

View File

@ -10,7 +10,6 @@ export const ZAddFieldsFormSchema = z.object({
nativeId: z.number().optional(), nativeId: z.number().optional(),
type: z.nativeEnum(FieldType), type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1), signerEmail: z.string().min(1),
recipientId: z.number().min(1),
pageNumber: z.number().min(1), pageNumber: z.number().min(1),
pageX: z.number().min(0), pageX: z.number().min(0),
pageY: z.number().min(0), pageY: z.number().min(0),

View File

@ -53,10 +53,6 @@ import {
import { SigningOrderConfirmation } from './signing-order-confirmation'; import { SigningOrderConfirmation } from './signing-order-confirmation';
import type { DocumentFlowStep } from './types'; import type { DocumentFlowStep } from './types';
type AutoSaveResponse = {
recipients: Recipient[];
};
export type AddSignersFormProps = { export type AddSignersFormProps = {
documentFlow: DocumentFlowStep; documentFlow: DocumentFlowStep;
recipients: Recipient[]; recipients: Recipient[];
@ -64,7 +60,7 @@ export type AddSignersFormProps = {
signingOrder?: DocumentSigningOrder | null; signingOrder?: DocumentSigningOrder | null;
allowDictateNextSigner?: boolean; allowDictateNextSigner?: boolean;
onSubmit: (_data: TAddSignersFormSchema) => void; onSubmit: (_data: TAddSignersFormSchema) => void;
onAutoSave: (_data: TAddSignersFormSchema) => Promise<AutoSaveResponse>; onAutoSave: (_data: TAddSignersFormSchema) => Promise<void>;
isDocumentPdfLoaded: boolean; isDocumentPdfLoaded: boolean;
}; };
@ -212,44 +208,7 @@ export const AddSignersFormPartial = ({
const formData = form.getValues(); const formData = form.getValues();
scheduleSave(formData, (response) => { scheduleSave(formData);
// Sync the response recipients back to form state to prevent duplicates
if (response?.recipients) {
const currentSigners = form.getValues('signers');
const updatedSigners = currentSigners.map((signer) => {
// Find the matching recipient from the response using nativeId
const matchingRecipient = response.recipients.find(
(recipient) => recipient.id === signer.nativeId,
);
if (matchingRecipient) {
// Update the signer with the server-returned data, especially the ID
return {
...signer,
nativeId: matchingRecipient.id,
};
}
// For new signers without nativeId, match by email and update with server ID
if (!signer.nativeId) {
const newRecipient = response.recipients.find(
(recipient) => recipient.email === signer.email,
);
if (newRecipient) {
return {
...signer,
nativeId: newRecipient.id,
};
}
}
return signer;
});
// Update the form state with the synced data
form.setValue('signers', updatedSigners, { shouldValidate: false });
}
});
}; };
const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email); const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email);
@ -755,6 +714,7 @@ export const AddSignersFormPartial = ({
handleRecipientAutoCompleteSelect(index, suggestion) handleRecipientAutoCompleteSelect(index, suggestion)
} }
onSearchQueryChange={(query) => { onSearchQueryChange={(query) => {
console.log('onSearchQueryChange', query);
field.onChange(query); field.onChange(query);
setRecipientSearchQuery(query); setRecipientSearchQuery(query);
}} }}

View File

@ -4,23 +4,33 @@ import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth'; import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
export const ZAddSignersFormSchema = z.object({ export const ZAddSignersFormSchema = z
signers: z.array( .object({
z.object({ signers: z.array(
formId: z.string().min(1), z.object({
nativeId: z.number().optional(), formId: z.string().min(1),
email: z nativeId: z.number().optional(),
.string() email: z
.email({ message: msg`Invalid email`.id }) .string()
.min(1), .email({ message: msg`Invalid email`.id })
name: z.string(), .min(1),
role: z.nativeEnum(RecipientRole), name: z.string(),
signingOrder: z.number().optional(), role: z.nativeEnum(RecipientRole),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), signingOrder: z.number().optional(),
}), actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
), }),
signingOrder: z.nativeEnum(DocumentSigningOrder), ),
allowDictateNextSigner: z.boolean().default(false), signingOrder: z.nativeEnum(DocumentSigningOrder),
}); allowDictateNextSigner: z.boolean().default(false),
})
.refine(
(schema) => {
const emails = schema.signers.map((signer) => signer.email.toLowerCase());
return new Set(emails).size === emails.length;
},
// Dirty hack to handle errors when .root is populated for an array type
{ message: msg`Signers must have unique emails`.id, path: ['signers__root'] },
);
export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>; export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>;

View File

@ -299,8 +299,6 @@ export const FieldItem = ({
}} }}
ref={$el} ref={$el}
data-field-id={field.nativeId} data-field-id={field.nativeId}
data-field-type={field.type}
data-recipient-id={field.recipientId}
> >
<FieldContent field={field} /> <FieldContent field={field} />

View File

@ -8,14 +8,19 @@ import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
export const ZDocumentFlowFormSchema = z.object({ export const ZDocumentFlowFormSchema = z.object({
title: z.string().min(1), title: z.string().min(1),
signers: z.array( signers: z
z.object({ .array(
formId: z.string().min(1), z.object({
nativeId: z.number().optional(), formId: z.string().min(1),
email: z.string().min(1).email(), nativeId: z.number().optional(),
name: z.string(), email: z.string().min(1).email(),
}), name: z.string(),
), }),
)
.refine((signers) => {
const emails = signers.map((signer) => signer.email);
return new Set(emails).size === emails.length;
}, 'Signers must have unique emails'),
fields: z.array( fields: z.array(
z.object({ z.object({
@ -23,7 +28,6 @@ export const ZDocumentFlowFormSchema = z.object({
nativeId: z.number().optional(), nativeId: z.number().optional(),
type: z.nativeEnum(FieldType), type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1).optional(), signerEmail: z.string().min(1).optional(),
recipientId: z.number().min(1),
pageNumber: z.number().min(1), pageNumber: z.number().min(1),
pageX: z.number().min(0), pageX: z.number().min(0),
pageY: z.number().min(0), pageY: z.number().min(0),

View File

@ -1,9 +1,9 @@
import type { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { KeyboardIcon, UploadCloudIcon } from 'lucide-react'; import { KeyboardIcon, UploadCloudIcon } from 'lucide-react';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { Trans } from '@lingui/react/macro';
import { DocumentSignatureType } from '@documenso/lib/constants/document'; import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { isBase64Image } from '@documenso/lib/constants/signatures'; import { isBase64Image } from '@documenso/lib/constants/signatures';

View File

@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
@ -62,10 +61,7 @@ import type { FieldFormType } from '../document-flow/add-fields';
import { FieldAdvancedSettings } from '../document-flow/field-item-advanced-settings'; import { FieldAdvancedSettings } from '../document-flow/field-item-advanced-settings';
import { Form } from '../form/form'; import { Form } from '../form/form';
import { useStep } from '../stepper'; import { useStep } from '../stepper';
import { import type { TAddTemplateFieldsFormSchema } from './add-template-fields.types';
type TAddTemplateFieldsFormSchema,
ZAddTemplateFieldsFormSchema,
} from './add-template-fields.types';
const MIN_HEIGHT_PX = 12; const MIN_HEIGHT_PX = 12;
const MIN_WIDTH_PX = 36; const MIN_WIDTH_PX = 36;
@ -116,7 +112,7 @@ export const AddTemplateFieldsFormPartial = ({
pageY: Number(field.positionY), pageY: Number(field.positionY),
pageWidth: Number(field.width), pageWidth: Number(field.width),
pageHeight: Number(field.height), pageHeight: Number(field.height),
recipientId: field.recipientId ?? -1, signerId: field.recipientId ?? -1,
signerEmail: signerEmail:
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '', recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
signerToken: signerToken:
@ -124,7 +120,6 @@ export const AddTemplateFieldsFormPartial = ({
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined, fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
})), })),
}, },
resolver: zodResolver(ZAddTemplateFieldsFormSchema),
}); });
const onFormSubmit = form.handleSubmit(onSubmit); const onFormSubmit = form.handleSubmit(onSubmit);
@ -175,7 +170,7 @@ export const AddTemplateFieldsFormPartial = ({
nativeId: undefined, nativeId: undefined,
formId: nanoid(12), formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail, signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
recipientId: selectedSigner?.id ?? lastActiveField.recipientId, signerId: selectedSigner?.id ?? lastActiveField.signerId,
signerToken: selectedSigner?.token ?? lastActiveField.signerToken, signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
pageX: lastActiveField.pageX + 3, pageX: lastActiveField.pageX + 3,
pageY: lastActiveField.pageY + 3, pageY: lastActiveField.pageY + 3,
@ -202,7 +197,7 @@ export const AddTemplateFieldsFormPartial = ({
nativeId: undefined, nativeId: undefined,
formId: nanoid(12), formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail, signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
recipientId: selectedSigner?.id ?? lastActiveField.recipientId, signerId: selectedSigner?.id ?? lastActiveField.signerId,
signerToken: selectedSigner?.token ?? lastActiveField.signerToken, signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
pageNumber, pageNumber,
}; };
@ -245,7 +240,7 @@ export const AddTemplateFieldsFormPartial = ({
formId: nanoid(12), formId: nanoid(12),
nativeId: undefined, nativeId: undefined,
signerEmail: selectedSigner?.email ?? copiedField.signerEmail, signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
recipientId: selectedSigner?.id ?? copiedField.recipientId, signerId: selectedSigner?.id ?? copiedField.signerId,
signerToken: selectedSigner?.token ?? copiedField.signerToken, signerToken: selectedSigner?.token ?? copiedField.signerToken,
pageX: copiedField.pageX + 3, pageX: copiedField.pageX + 3,
pageY: copiedField.pageY + 3, pageY: copiedField.pageY + 3,
@ -376,7 +371,7 @@ export const AddTemplateFieldsFormPartial = ({
pageWidth: fieldPageWidth, pageWidth: fieldPageWidth,
pageHeight: fieldPageHeight, pageHeight: fieldPageHeight,
signerEmail: selectedSigner.email, signerEmail: selectedSigner.email,
recipientId: selectedSigner.id, signerId: selectedSigner.id,
signerToken: selectedSigner.token ?? '', signerToken: selectedSigner.token ?? '',
fieldMeta: undefined, fieldMeta: undefined,
}; };
@ -602,14 +597,14 @@ export const AddTemplateFieldsFormPartial = ({
)} )}
{localFields.map((field, index) => { {localFields.map((field, index) => {
const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId); const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail);
return ( return (
<FieldItem <FieldItem
key={index} key={index}
recipientIndex={recipientIndex === -1 ? 0 : recipientIndex} recipientIndex={recipientIndex === -1 ? 0 : recipientIndex}
field={field} field={field}
disabled={selectedSigner?.id !== field.recipientId} disabled={selectedSigner?.email !== field.signerEmail}
minHeight={MIN_HEIGHT_PX} minHeight={MIN_HEIGHT_PX}
minWidth={MIN_WIDTH_PX} minWidth={MIN_WIDTH_PX}
defaultHeight={DEFAULT_HEIGHT_PX} defaultHeight={DEFAULT_HEIGHT_PX}

View File

@ -10,8 +10,8 @@ export const ZAddTemplateFieldsFormSchema = z.object({
nativeId: z.number().optional(), nativeId: z.number().optional(),
type: z.nativeEnum(FieldType), type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1), signerEmail: z.string().min(1),
recipientId: z.number().min(1),
signerToken: z.string(), signerToken: z.string(),
signerId: z.number().optional(),
pageNumber: z.number().min(1), pageNumber: z.number().min(1),
pageX: z.number().min(0), pageX: z.number().min(0),
pageY: z.number().min(0), pageY: z.number().min(0),

View File

@ -48,10 +48,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types'; import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types'; import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
type AutoSaveResponse = {
recipients: Recipient[];
};
export type AddTemplatePlaceholderRecipientsFormProps = { export type AddTemplatePlaceholderRecipientsFormProps = {
documentFlow: DocumentFlowStep; documentFlow: DocumentFlowStep;
recipients: Recipient[]; recipients: Recipient[];
@ -60,7 +56,7 @@ export type AddTemplatePlaceholderRecipientsFormProps = {
allowDictateNextSigner?: boolean; allowDictateNextSigner?: boolean;
templateDirectLink?: TemplateDirectLink | null; templateDirectLink?: TemplateDirectLink | null;
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void; onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
onAutoSave: (_data: TAddTemplatePlacholderRecipientsFormSchema) => Promise<AutoSaveResponse>; onAutoSave: (_data: TAddTemplatePlacholderRecipientsFormSchema) => Promise<void>;
isDocumentPdfLoaded: boolean; isDocumentPdfLoaded: boolean;
}; };
@ -150,44 +146,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
const formData = form.getValues(); const formData = form.getValues();
scheduleSave(formData, (response) => { scheduleSave(formData);
// Sync the response recipients back to form state to prevent duplicates
if (response?.recipients) {
const currentSigners = form.getValues('signers');
const updatedSigners = currentSigners.map((signer) => {
// Find the matching recipient from the response using nativeId
const matchingRecipient = response.recipients.find(
(recipient) => recipient.id === signer.nativeId,
);
if (matchingRecipient) {
// Update the signer with the server-returned data, especially the ID
return {
...signer,
nativeId: matchingRecipient.id,
};
}
// For new signers without nativeId, match by email and update with server ID
if (!signer.nativeId) {
const newRecipient = response.recipients.find(
(recipient) => recipient.email === signer.email,
);
if (newRecipient) {
return {
...signer,
nativeId: newRecipient.id,
};
}
}
return signer;
});
// Update the form state with the synced data
form.setValue('signers', updatedSigners, { shouldValidate: false });
}
});
}; };
// useEffect(() => { // useEffect(() => {

View File

@ -1,6 +1,7 @@
import { DocumentSigningOrder, RecipientRole } from '@prisma/client'; import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
import { TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX } from '@documenso/lib/constants/template';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth'; import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
export const ZAddTemplatePlacholderRecipientsFormSchema = z export const ZAddTemplatePlacholderRecipientsFormSchema = z
@ -19,7 +20,17 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z
signingOrder: z.nativeEnum(DocumentSigningOrder), signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean().default(false), allowDictateNextSigner: z.boolean().default(false),
}) })
.refine(
(schema) => {
const nonPlaceholderEmails = schema.signers
.map((signer) => signer.email.toLowerCase())
.filter((email) => !TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX.test(email));
return new Set(nonPlaceholderEmails).size === nonPlaceholderEmails.length;
},
// Dirty hack to handle errors when .root is populated for an array type
{ message: 'Signers must have unique emails', path: ['signers__root'] },
)
.refine( .refine(
/* /*
Since placeholder emails are empty, we need to check that the names are unique. Since placeholder emails are empty, we need to check that the names are unique.

View File

@ -216,7 +216,12 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input className="bg-background" {...field} maxLength={255} onBlur={handleAutoSave} /> <Input
className="bg-background"
{...field}
maxLength={255}
onBlur={handleAutoSave}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -624,7 +629,12 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input className="bg-background" {...field} maxLength={255} onBlur={handleAutoSave} /> <Input
className="bg-background"
{...field}
maxLength={255}
onBlur={handleAutoSave}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@ -715,7 +725,12 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input className="bg-background" {...field} maxLength={255} onBlur={handleAutoSave} /> <Input
className="bg-background"
{...field}
maxLength={255}
onBlur={handleAutoSave}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />