Compare commits

...

10 Commits

Author SHA1 Message Date
b8fc47b719 v1.12.6 2025-09-25 22:10:20 +10:00
cfceebd78f feat: change organisation owner in admin panel (#2047)
Allows changing the owner of an organisation within the admin panel,
useful for support requests to change ownership from a testing account
to the main admin account.

<img width="890" height="431" alt="image"
src="https://github.com/user-attachments/assets/475bbbdd-0f26-4f74-aacf-3e793366551d"
/>
2025-09-25 17:13:47 +10:00
b9b3ddfb98 chore: update tests to use new date formats (#2045)
## Description

Update the tests to use the new date formats form this PR
https://github.com/documenso/documenso/pull/2038.
2025-09-25 16:55:31 +10:00
8590502338 fix: file upload error messages (#2041) 2025-09-24 16:06:41 +03:00
53f29daf50 fix: allow dates with and without time (#2038) 2025-09-24 14:46:04 +03:00
197d17ed7b v1.12.5 2025-09-23 21:00:48 +10:00
3c646d9475 feat: remove email requirement for recipients (#2040) 2025-09-23 17:13:52 +10:00
ed4dfc9b55 v1.12.4 2025-09-13 18:08:55 +10:00
32ce573de4 fix: incorrect certificate health logic (#2028) 2025-09-13 18:07:39 +10:00
2ecfdbdde5 v1.12.3 2025-09-12 23:02:59 +10:00
66 changed files with 1925 additions and 503 deletions

View File

@ -15,7 +15,6 @@ 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';
@ -46,50 +45,22 @@ 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 const ZAddRecipientsForNewDocumentSchema = z.object({
.object({ distributeDocument: z.boolean(),
distributeDocument: z.boolean(), useCustomDocument: z.boolean().default(false),
useCustomDocument: z.boolean().default(false), customDocumentData: z
customDocumentData: z .any()
.any() .refine((data) => data instanceof File || data === undefined)
.refine((data) => data instanceof File || data === undefined) .optional(),
.optional(), recipients: z.array(
recipients: z.array( z.object({
z.object({ id: z.number(),
id: z.number(), email: z.string().email(),
email: z.string().email(), name: z.string(),
name: z.string(), signingOrder: z.number().optional(),
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>;
@ -278,14 +249,7 @@ export function TemplateUseDialog({
)} )}
<FormControl> <FormControl>
<Input <Input {...field} aria-label="Email" placeholder={_(msg`Email`)} />
{...field}
placeholder={
isTemplateRecipientEmailPlaceholder(field.value)
? ''
: _(msg`Email`)
}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -306,6 +270,7 @@ 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

@ -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 { useDropzone } from 'react-dropzone'; import { ErrorCode, type FileRejection, 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,15 +108,51 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
} }
}; };
const onFileDropRejected = () => { const onFileDropRejected = (fileRejections: FileRejection[]) => {
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`Your document failed to upload.`), title: _(msg`Upload failed`),
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`), description,
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'],
@ -129,8 +165,8 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
void onFileDrop(acceptedFile); void onFileDrop(acceptedFile);
} }
}, },
onDropRejected: () => { onDropRejected: (fileRejections) => {
void onFileDropRejected(); onFileDropRejected(fileRejections);
}, },
noClick: true, noClick: true,
noDragEventsBubbling: true, noDragEventsBubbling: true,

View File

@ -239,7 +239,27 @@ export const DocumentEditForm = ({
const onAddSignersFormAutoSave = async (data: TAddSignersFormSchema) => { const onAddSignersFormAutoSave = async (data: TAddSignersFormSchema) => {
try { try {
await saveSignersData(data); // For autosave, we need to return the recipients response for form state sync
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);
@ -248,6 +268,8 @@ 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()} key={folder.id}> <Link to={formatPath()} data-folder-id={folder.id} data-folder-name={folder.name}>
<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,8 +4,9 @@ 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 { useDropzone } from 'react-dropzone'; import { ErrorCode, type FileRejection, 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';
@ -67,10 +68,47 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
} }
}; };
const onFileDropRejected = () => { const onFileDropRejected = (fileRejections: FileRejection[]) => {
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`Your template failed to upload.`), title: _(msg`Upload failed`),
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`), description,
duration: 5000, duration: 5000,
variant: 'destructive', variant: 'destructive',
}); });
@ -88,8 +126,8 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
void onFileDrop(acceptedFile); void onFileDrop(acceptedFile);
} }
}, },
onDropRejected: () => { onDropRejected: (fileRejections) => {
void onFileDropRejected(); onFileDropRejected(fileRejections);
}, },
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) => {
return Promise.all([ const [, recipients] = await Promise.all([
updateTemplateSettings({ updateTemplateSettings({
templateId: template.id, templateId: template.id,
meta: { meta: {
@ -196,6 +196,8 @@ export const TemplateEditForm = ({
recipients: data.signers, recipients: data.signers,
}), }),
]); ]);
return recipients;
}; };
const onAddTemplatePlaceholderFormSubmit = async ( const onAddTemplatePlaceholderFormSubmit = async (
@ -218,7 +220,7 @@ export const TemplateEditForm = ({
data: TAddTemplatePlacholderRecipientsFormSchema, data: TAddTemplatePlacholderRecipientsFormSchema,
) => { ) => {
try { try {
await saveTemplatePlaceholderData(data); return await saveTemplatePlaceholderData(data);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -227,6 +229,8 @@ 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,6 +71,23 @@ 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 [
{ {
@ -101,6 +118,26 @@ 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,10 +23,12 @@ 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

@ -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.2-rc.6" "version": "1.12.6"
} }

6
package-lock.json generated
View File

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

View File

@ -1,6 +1,6 @@
{ {
"private": true, "private": true,
"version": "1.12.2-rc.6", "version": "1.12.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,12 +310,11 @@ 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(emails).size === emails.length && new Set(ids).size === ids.length; return new Set(ids).size === ids.length;
}, },
{ message: 'Recipient IDs and emails must be unique' }, { message: 'Recipient IDs must be unique' },
), ),
meta: z meta: z
.object({ .object({

View File

@ -0,0 +1,435 @@
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('#document-flow-form-container').click(); await page.locator('body').click({ position: { x: 0, y: 0 } });
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').click(); await page.getByRole('combobox').first().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').click(); await page.getByRole('combobox').first().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').click(); await page.getByRole('combobox').first().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').click(); await page.getByRole('combobox').first().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').click(); await page.getByRole('combobox').first().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('#document-flow-form-container').click(); await page.locator('body').click({ position: { x: 0, y: 0 } });
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('#document-flow-form-container').click(); await page.locator('body').click({ position: { x: 0, y: 0 } });
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').click(); await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'Receives copy' }).click(); await page.getByRole('option', { name: 'Receives copy' }).click();
await triggerAutosave(page); await triggerAutosave(page);
@ -160,9 +160,20 @@ 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);
expect(retrievedRecipients[1].signingOrder).toBe(3); const firstRecipient = retrievedRecipients.find(
expect(retrievedRecipients[2].signingOrder).toBe(1); (r) => r.email === 'recipient1@documenso.com',
);
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('#document-flow-form-container').click(); await page.locator('body').click({ position: { x: 0, y: 0 } });
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

@ -0,0 +1,56 @@
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

@ -0,0 +1,355 @@
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

@ -504,7 +504,7 @@ test('[DOCUMENT_FLOW]: should be able to sign a document with custom date', asyn
}, },
}); });
const insertedDate = DateTime.fromFormat(field?.customText ?? '', 'yyyy-MM-dd hh:mm a'); const insertedDate = DateTime.fromFormat(field?.customText ?? '', 'yyyy-MM-dd HH:mm');
expect(Math.abs(insertedDate.diff(now).minutes)).toBeLessThanOrEqual(1); expect(Math.abs(insertedDate.diff(now).minutes)).toBeLessThanOrEqual(1);
@ -573,6 +573,7 @@ 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('div').filter({ hasText: folder.name })).not.toBeVisible(); await expect(page.locator(`[data-folder-id="${folder.id}"]`)).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('div').filter({ hasText: reportsFolder.name })).not.toBeVisible(); await expect(page.locator(`[data-folder-id="${reportsFolder.id}"]`)).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,9 +318,7 @@ 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( await expect(page.locator(`[data-folder-name="Team template folder"]`)).toBeVisible();
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 }) => {
@ -374,11 +372,8 @@ 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 await page.getByText('Upload Template Document').click();
.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
@ -537,7 +532,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 and its contents can be deleted', async ({ page }) => { test('[TEAMS]: template folder 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, {
@ -585,13 +580,16 @@ test('[TEAMS]: template folder and its contents can be deleted', async ({ page }
await page.goto(`/t/${team.url}/templates`); await page.goto(`/t/${team.url}/templates`);
await expect(page.locator('div').filter({ hasText: folder.name })).not.toBeVisible(); await page.waitForTimeout(1000);
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('div').filter({ hasText: subfolder.name })).not.toBeVisible(); await expect(page.locator(`[data-folder-id="${subfolder.id}"]`)).not.toBeVisible();
}); });
test('[TEAMS]: can navigate between template folders', async ({ page }) => { test('[TEAMS]: can navigate between template folders', async ({ page }) => {
@ -843,10 +841,15 @@ test('[TEAMS]: documents inherit folder visibility', async ({ page }) => {
await page.getByText('Admin Only Folder').click(); await page.getByText('Admin Only Folder').click();
const fileInput = page.locator('input[type="file"]').nth(1); await page.waitForURL(new RegExp(`/t/${team.url}/documents/f/.+`));
await fileInput.waitFor({ state: 'attached' });
await fileInput.setInputFiles( // Upload document.
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 a' }).click(); await page.getByRole('combobox').filter({ hasText: 'yyyy-MM-dd HH:mm' }).click();
await page.getByRole('option', { name: 'DD/MM/YYYY' }).click(); await page.getByRole('option', { name: 'DD/MM/YYYY', exact: true }).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 hh:mm a'); expect(teamSettings.documentDateFormat).toEqual('dd/MM/yyyy');
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' }).click(); await page.getByRole('option', { name: 'MM/DD/YYYY', exact: true }).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 hh:mm a'); expect(updatedTeamSettings.documentDateFormat).toEqual('MM/dd/yyyy');
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 hh:mm a'); expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy');
}); });
test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => { test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {

View File

@ -0,0 +1,283 @@
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('#document-flow-form-container').click(); await page.locator('body').click({ position: { x: 0, y: 0 } });
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').click(); await page.getByRole('combobox').first().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').click(); await page.getByRole('combobox').first().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').click(); await page.getByRole('combobox').first().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').click(); await page.getByRole('combobox').first().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').click(); await page.getByRole('combobox').first().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('#document-flow-form-container').click(); await page.locator('body').click({ position: { x: 0, y: 0 } });
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('#document-flow-form-container').click(); await page.locator('body').click({ position: { x: 0, y: 0 } });
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 a' }).click(); await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm' }).click();
await page.getByLabel('DD/MM/YYYY').click(); await page.getByLabel('DD/MM/YYYY HH:mm').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 a'); expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy HH:mm');
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 a' }).click(); await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm' }).click();
await page.getByLabel('DD/MM/YYYY').click(); await page.getByLabel('DD/MM/YYYY HH:mm').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 a'); expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy HH:mm');
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: 4, workers: 2,
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: 'retain-on-failure', video: 'on-first-retry',
/* 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: 1080 }, viewport: { width: 1920, height: 1200 },
}, },
}, },

View File

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

@ -2,19 +2,23 @@ import { DateTime } from 'luxon';
import { DEFAULT_DOCUMENT_TIME_ZONE } from './time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE } from './time-zones';
export const DEFAULT_DOCUMENT_DATE_FORMAT = 'yyyy-MM-dd hh:mm a'; export const DEFAULT_DOCUMENT_DATE_FORMAT = 'yyyy-MM-dd HH:mm';
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 hh:mm a', 'dd/MM/yyyy',
'MM/dd/yyyy hh:mm a', 'MM/dd/yyyy',
'dd.MM.yyyy',
'yy-MM-dd',
'MMMM dd, yyyy',
'EEEE, MMMM dd, yyyy',
'dd/MM/yyyy HH:mm',
'MM/dd/yyyy HH:mm',
'dd.MM.yyyy HH:mm', 'dd.MM.yyyy HH:mm',
'yyyy-MM-dd HH:mm', 'yy-MM-dd HH:mm',
'yy-MM-dd hh:mm a', 'MMMM dd, yyyy HH:mm',
'yyyy-MM-dd HH:mm:ss', 'EEEE, MMMM dd, yyyy HH:mm',
'MMMM dd, yyyy hh:mm a',
'EEEE, MMMM dd, yyyy hh:mm a',
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX", "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
] as const; ] as const;
@ -22,10 +26,47 @@ export type ValidDateFormat = (typeof VALID_DATE_FORMAT_VALUES)[number];
export const DATE_FORMATS = [ export const DATE_FORMATS = [
{ {
key: 'yyyy-MM-dd_hh:mm_a', key: 'yyyy-MM-dd_HH:mm',
label: 'YYYY-MM-DD HH:mm a', label: 'YYYY-MM-DD HH:mm',
value: DEFAULT_DOCUMENT_DATE_FORMAT, value: DEFAULT_DOCUMENT_DATE_FORMAT,
}, },
{
key: 'DDMMYYYY_TIME',
label: 'DD/MM/YYYY HH:mm',
value: 'dd/MM/yyyy HH:mm',
},
{
key: 'MMDDYYYY_TIME',
label: 'MM/DD/YYYY HH:mm',
value: 'MM/dd/yyyy HH:mm',
},
{
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: 'MonthDateYear_TIME',
label: 'Month Date, Year HH:mm',
value: 'MMMM dd, yyyy HH:mm',
},
{
key: 'DayMonthYear_TIME',
label: 'Day, Month Year HH:mm',
value: 'EEEE, MMMM dd, yyyy HH:mm',
},
{
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',
@ -34,47 +75,32 @@ export const DATE_FORMATS = [
{ {
key: 'DDMMYYYY', key: 'DDMMYYYY',
label: 'DD/MM/YYYY', label: 'DD/MM/YYYY',
value: 'dd/MM/yyyy hh:mm a', value: 'dd/MM/yyyy',
}, },
{ {
key: 'MMDDYYYY', key: 'MMDDYYYY',
label: 'MM/DD/YYYY', label: 'MM/DD/YYYY',
value: 'MM/dd/yyyy hh:mm a', value: 'MM/dd/yyyy',
}, },
{ {
key: 'DDMMYYYYHHMM', key: 'DDMMYYYY_DOT',
label: 'DD.MM.YYYY HH:mm', label: 'DD.MM.YYYY',
value: 'dd.MM.yyyy HH:mm', value: 'dd.MM.yyyy',
},
{
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 hh:mm a', value: 'yy-MM-dd',
},
{
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 hh:mm a', value: 'MMMM dd, yyyy',
}, },
{ {
key: 'DayMonthYear', key: 'DayMonthYear',
label: 'Day, Month Year', label: 'Day, Month Year',
value: 'EEEE, MMMM dd, yyyy hh:mm a', value: 'EEEE, MMMM dd, yyyy',
},
{
key: 'ISO8601',
label: 'ISO 8601',
value: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
}, },
] satisfies { ] satisfies {
key: string; key: string;

View File

@ -2,18 +2,25 @@ import * as fs from 'node:fs';
import { env } from '@documenso/lib/utils/env'; import { env } from '@documenso/lib/utils/env';
export type CertificateStatus = { export const getCertificateStatus = () => {
isAvailable: boolean; if (env('NEXT_PRIVATE_SIGNING_TRANSPORT') !== 'local') {
}; 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

@ -84,9 +84,7 @@ 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( const recipient = document.recipients.find((recipient) => recipient.id === field.recipientId);
(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) {
@ -226,10 +224,8 @@ export const setFieldsForDocument = async ({
}, },
recipient: { recipient: {
connect: { connect: {
documentId_email: { id: field.recipientId,
documentId, documentId,
email: fieldSignerEmail,
},
}, },
}, },
}, },
@ -330,6 +326,7 @@ 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,6 +26,7 @@ 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;
@ -169,10 +170,8 @@ export const setFieldsForTemplate = async ({
}, },
recipient: { recipient: {
connect: { connect: {
templateId_email: { id: field.recipientId,
templateId, templateId,
email: field.signerEmail.toLowerCase(),
},
}, },
}, },
}, },

View File

@ -85,20 +85,6 @@ 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,20 +71,6 @@ 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,16 +122,12 @@ export const setDocumentRecipients = async ({
const removedRecipients = existingRecipients.filter( const removedRecipients = existingRecipients.filter(
(existingRecipient) => (existingRecipient) =>
!normalizedRecipients.find( !normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
(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) => existingRecipient.id === recipient.id,
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
); );
const canPersistedRecipientBeModified = const canPersistedRecipientBeModified =

View File

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

View File

@ -91,17 +91,6 @@ 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,17 +80,6 @@ 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,6 +19,8 @@ export type CreateDocumentFromTemplateLegacyOptions = {
}[]; }[];
}; };
// !TODO: Make this work
/** /**
* Legacy server function for /api/v1 * Legacy server function for /api/v1
*/ */
@ -58,6 +60,15 @@ 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'),
@ -70,12 +81,12 @@ export const createDocumentFromTemplateLegacy = async ({
documentDataId: documentData.id, documentDataId: documentData.id,
useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false, useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false,
recipients: { recipients: {
create: template.recipients.map((recipient) => ({ create: recipientsToCreate.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: nanoid(), token: recipient.token,
})), })),
}, },
documentMeta: { documentMeta: {
@ -95,9 +106,11 @@ export const createDocumentFromTemplateLegacy = async ({
await prisma.field.createMany({ await prisma.field.createMany({
data: template.fields.map((field) => { data: template.fields.map((field) => {
const recipient = template.recipients.find((recipient) => recipient.id === field.recipientId); const recipient = recipientsToCreate.find((recipient) => recipient.id === field.recipientId);
const documentRecipient = document.recipients.find((doc) => doc.email === recipient?.email); const documentRecipient = document.recipients.find(
(documentRecipient) => documentRecipient.token === recipient?.token,
);
if (!documentRecipient) { if (!documentRecipient) {
throw new Error('Recipient not found.'); throw new Error('Recipient not found.');
@ -118,28 +131,32 @@ 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) {
document.recipients = await Promise.all( 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);
return await prisma.recipient.upsert({ if (existingRecipient) {
where: { return await prisma.recipient.update({
documentId_email: { where: {
id: existingRecipient.id,
documentId: document.id, documentId: document.id,
email: existingRecipient?.email ?? recipient.email,
}, },
}, data: {
update: { 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: { }
return await prisma.recipient.create({
data: {
documentId: document.id, documentId: document.id,
email: recipient.email,
name: recipient.name, name: recipient.name,
email: recipient.email,
role: recipient.role, role: recipient.role,
signingOrder: recipient.signingOrder, signingOrder: recipient.signingOrder,
token: nanoid(), token: nanoid(),
@ -149,5 +166,18 @@ export const createDocumentFromTemplateLegacy = async ({
); );
} }
return document; // Gross but we need to do the additional fetch since we mutate above.
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' 'name' | 'email' | 'role' | 'authOptions' | 'signingOrder' | 'token'
> & { > & {
templateRecipientId: number; templateRecipientId: number;
fields: Field[]; fields: Field[];
@ -350,6 +350,7 @@ 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(),
}; };
}); });
@ -441,7 +442,7 @@ export const createDocumentFromTemplate = async ({
? SigningStatus.SIGNED ? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED, : SigningStatus.NOT_SIGNED,
signingOrder: recipient.signingOrder, signingOrder: recipient.signingOrder,
token: nanoid(), token: recipient.token,
}; };
}), }),
}, },
@ -500,8 +501,8 @@ export const createDocumentFromTemplate = async ({
} }
} }
Object.values(finalRecipients).forEach(({ email, fields }) => { Object.values(finalRecipients).forEach(({ token, fields }) => {
const recipient = document.recipients.find((recipient) => recipient.email === email); const recipient = document.recipients.find((recipient) => recipient.token === token);
if (!recipient) { if (!recipient) {
throw new Error('Recipient not found.'); throw new Error('Recipient not found.');

View File

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

View File

@ -527,8 +527,6 @@ 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

@ -0,0 +1,124 @@
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

@ -0,0 +1,11 @@
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,6 +12,7 @@ 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';
@ -27,6 +28,9 @@ 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,14 +78,7 @@ 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,14 +47,7 @@ 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,36 +30,27 @@ export const ZUpdateEmbeddingDocumentRequestSchema = z.object({
documentId: z.number(), documentId: z.number(),
title: ZDocumentTitleSchema, title: ZDocumentTitleSchema,
externalId: ZDocumentExternalIdSchema.optional(), externalId: ZDocumentExternalIdSchema.optional(),
recipients: z recipients: z.array(
.array( z.object({
z.object({ id: z.number().optional(),
id: z.number().optional(), email: z.string().toLowerCase().email().min(1),
email: z.string().toLowerCase().email().min(1), name: z.string(),
name: z.string(), role: z.nativeEnum(RecipientRole),
role: z.nativeEnum(RecipientRole), signingOrder: z.number().optional(),
signingOrder: z.number().optional(), fields: ZFieldAndMetaSchema.and(
fields: ZFieldAndMetaSchema.and( z.object({
z.object({ id: z.number().optional(),
id: z.number().optional(), pageNumber: ZFieldPageNumberSchema,
pageNumber: ZFieldPageNumberSchema, pageX: ZFieldPageXSchema,
pageX: ZFieldPageXSchema, pageY: ZFieldPageYSchema,
pageY: ZFieldPageYSchema, width: ZFieldWidthSchema,
width: ZFieldWidthSchema, height: ZFieldHeightSchema,
height: ZFieldHeightSchema, }),
}), )
) .array()
.array() .optional(),
.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,6 +274,7 @@ 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,
@ -513,6 +514,7 @@ 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,6 +114,7 @@ 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),
@ -136,6 +137,7 @@ 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

@ -50,16 +50,7 @@ export const ZCreateDocumentRecipientResponseSchema = ZRecipientLiteSchema;
export const ZCreateDocumentRecipientsRequestSchema = z.object({ export const ZCreateDocumentRecipientsRequestSchema = z.object({
documentId: z.number(), documentId: z.number(),
recipients: z.array(ZCreateRecipientSchema).refine( recipients: z.array(ZCreateRecipientSchema),
(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({
@ -75,18 +66,7 @@ export const ZUpdateDocumentRecipientResponseSchema = ZRecipientSchema;
export const ZUpdateDocumentRecipientsRequestSchema = z.object({ export const ZUpdateDocumentRecipientsRequestSchema = z.object({
documentId: z.number(), documentId: z.number(),
recipients: z.array(ZUpdateRecipientSchema).refine( recipients: z.array(ZUpdateRecipientSchema),
(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({
@ -97,29 +77,19 @@ export const ZDeleteDocumentRecipientRequestSchema = z.object({
recipientId: z.number(), recipientId: z.number(),
}); });
export const ZSetDocumentRecipientsRequestSchema = z export const ZSetDocumentRecipientsRequestSchema = z.object({
.object({ documentId: z.number(),
documentId: z.number(), recipients: z.array(
recipients: z.array( z.object({
z.object({ nativeId: z.number().optional(),
nativeId: z.number().optional(), email: z.string().toLowerCase().email().min(1).max(254),
email: z.string().toLowerCase().email().min(1).max(254), name: z.string().max(255),
name: z.string().max(255), role: z.nativeEnum(RecipientRole),
role: z.nativeEnum(RecipientRole), signingOrder: z.number().optional(),
signingOrder: z.number().optional(), actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
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(),
@ -134,16 +104,7 @@ export const ZCreateTemplateRecipientResponseSchema = ZRecipientLiteSchema;
export const ZCreateTemplateRecipientsRequestSchema = z.object({ export const ZCreateTemplateRecipientsRequestSchema = z.object({
templateId: z.number(), templateId: z.number(),
recipients: z.array(ZCreateRecipientSchema).refine( recipients: z.array(ZCreateRecipientSchema),
(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({
@ -159,18 +120,7 @@ export const ZUpdateTemplateRecipientResponseSchema = ZRecipientSchema;
export const ZUpdateTemplateRecipientsRequestSchema = z.object({ export const ZUpdateTemplateRecipientsRequestSchema = z.object({
templateId: z.number(), templateId: z.number(),
recipients: z.array(ZUpdateRecipientSchema).refine( recipients: z.array(ZUpdateRecipientSchema),
(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({
@ -181,43 +131,30 @@ export const ZDeleteTemplateRecipientRequestSchema = z.object({
recipientId: z.number(), recipientId: z.number(),
}); });
export const ZSetTemplateRecipientsRequestSchema = z export const ZSetTemplateRecipientsRequestSchema = z.object({
.object({ templateId: z.number(),
templateId: z.number(), recipients: z.array(
recipients: z.array( z.object({
z.object({ nativeId: z.number().optional(),
nativeId: z.number().optional(), email: z
email: z .string()
.string() .toLowerCase()
.toLowerCase() .refine(
.refine( (email) => {
(email) => { return (
return ( isTemplateRecipientEmailPlaceholder(email) ||
isTemplateRecipientEmailPlaceholder(email) || z.string().email().safeParse(email).success
z.string().email().safeParse(email).success );
); },
}, { message: 'Please enter a valid email address' },
{ message: 'Please enter a valid email address' }, ),
), name: z.string(),
name: z.string(), role: z.nativeEnum(RecipientRole),
role: z.nativeEnum(RecipientRole), signingOrder: z.number().optional(),
signingOrder: z.number().optional(), actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
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

@ -101,12 +101,7 @@ 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,6 +105,7 @@ 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,9 +70,16 @@ export type FieldRootContainerProps = {
color?: RecipientColorStyles; color?: RecipientColorStyles;
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
readonly?: boolean;
}; };
export function FieldRootContainer({ field, children, color, className }: FieldRootContainerProps) { export function FieldRootContainer({
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);
@ -103,6 +110,7 @@ export function FieldRootContainer({ field, children, color, className }: FieldR
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,7 +39,9 @@ export interface BadgeProps
VariantProps<typeof badgeVariants> {} VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, size, ...props }: BadgeProps) { function Badge({ className, variant, size, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant, size }), className)} {...props} />; return (
<div role="status" className={cn(badgeVariants({ variant, size }), className)} {...props} />
);
} }
export { Badge, badgeVariants }; export { Badge, badgeVariants };

View File

@ -1,5 +1,6 @@
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';
@ -46,7 +47,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 } from './add-fields.types'; import { type TAddFieldsFormSchema, ZAddFieldsFormSchema } from './add-fields.types';
import { import {
DocumentFlowFormContainerActions, DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent, DocumentFlowFormContainerContent,
@ -75,6 +76,7 @@ export type FieldFormType = {
pageWidth: number; pageWidth: number;
pageHeight: number; pageHeight: number;
signerEmail: string; signerEmail: string;
recipientId: number;
fieldMeta?: FieldMeta; fieldMeta?: FieldMeta;
}; };
@ -127,9 +129,11 @@ 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));
@ -323,6 +327,7 @@ export const AddFieldsFormPartial = ({
const field = { const field = {
formId: nanoid(12), formId: nanoid(12),
nativeId: undefined,
type: selectedField, type: selectedField,
pageNumber, pageNumber,
pageX, pageX,
@ -330,6 +335,7 @@ export const AddFieldsFormPartial = ({
pageWidth: fieldPageWidth, pageWidth: fieldPageWidth,
pageHeight: fieldPageHeight, pageHeight: fieldPageHeight,
signerEmail: selectedSigner.email, signerEmail: selectedSigner.email,
recipientId: selectedSigner.id,
fieldMeta: undefined, fieldMeta: undefined,
}; };
@ -414,6 +420,7 @@ 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,
}; };
@ -438,6 +445,7 @@ 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,
}; };
@ -470,6 +478,7 @@ 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,
}); });
@ -663,7 +672,7 @@ export const AddFieldsFormPartial = ({
{isDocumentPdfLoaded && {isDocumentPdfLoaded &&
localFields.map((field, index) => { localFields.map((field, index) => {
const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail); const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
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,6 +10,7 @@ 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,6 +53,10 @@ 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[];
@ -60,7 +64,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<void>; onAutoSave: (_data: TAddSignersFormSchema) => Promise<AutoSaveResponse>;
isDocumentPdfLoaded: boolean; isDocumentPdfLoaded: boolean;
}; };
@ -208,7 +212,44 @@ export const AddSignersFormPartial = ({
const formData = form.getValues(); const formData = form.getValues();
scheduleSave(formData); scheduleSave(formData, (response) => {
// 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);

View File

@ -4,33 +4,23 @@ import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth'; import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
export const ZAddSignersFormSchema = z export const ZAddSignersFormSchema = z.object({
.object({ signers: z.array(
signers: z.array( z.object({
z.object({ formId: z.string().min(1),
formId: z.string().min(1), nativeId: z.number().optional(),
nativeId: z.number().optional(), email: z
email: z .string()
.string() .email({ message: msg`Invalid email`.id })
.email({ message: msg`Invalid email`.id }) .min(1),
.min(1), name: z.string(),
name: z.string(), role: z.nativeEnum(RecipientRole),
role: z.nativeEnum(RecipientRole), signingOrder: z.number().optional(),
signingOrder: z.number().optional(), actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), }),
}), ),
), signingOrder: z.nativeEnum(DocumentSigningOrder),
signingOrder: z.nativeEnum(DocumentSigningOrder), allowDictateNextSigner: z.boolean().default(false),
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,6 +299,8 @@ 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,19 +8,14 @@ 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 signers: z.array(
.array( z.object({
z.object({ formId: z.string().min(1),
formId: z.string().min(1), nativeId: z.number().optional(),
nativeId: z.number().optional(), email: z.string().min(1).email(),
email: z.string().min(1).email(), name: z.string(),
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({
@ -28,6 +23,7 @@ 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,5 +1,6 @@
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';
@ -61,7 +62,10 @@ 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 type { TAddTemplateFieldsFormSchema } from './add-template-fields.types'; import {
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;
@ -112,7 +116,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),
signerId: field.recipientId ?? -1, recipientId: field.recipientId ?? -1,
signerEmail: signerEmail:
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '', recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
signerToken: signerToken:
@ -120,6 +124,7 @@ 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);
@ -170,7 +175,7 @@ export const AddTemplateFieldsFormPartial = ({
nativeId: undefined, nativeId: undefined,
formId: nanoid(12), formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail, signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
signerId: selectedSigner?.id ?? lastActiveField.signerId, recipientId: selectedSigner?.id ?? lastActiveField.recipientId,
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,
@ -197,7 +202,7 @@ export const AddTemplateFieldsFormPartial = ({
nativeId: undefined, nativeId: undefined,
formId: nanoid(12), formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail, signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
signerId: selectedSigner?.id ?? lastActiveField.signerId, recipientId: selectedSigner?.id ?? lastActiveField.recipientId,
signerToken: selectedSigner?.token ?? lastActiveField.signerToken, signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
pageNumber, pageNumber,
}; };
@ -240,7 +245,7 @@ export const AddTemplateFieldsFormPartial = ({
formId: nanoid(12), formId: nanoid(12),
nativeId: undefined, nativeId: undefined,
signerEmail: selectedSigner?.email ?? copiedField.signerEmail, signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
signerId: selectedSigner?.id ?? copiedField.signerId, recipientId: selectedSigner?.id ?? copiedField.recipientId,
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,
@ -371,7 +376,7 @@ export const AddTemplateFieldsFormPartial = ({
pageWidth: fieldPageWidth, pageWidth: fieldPageWidth,
pageHeight: fieldPageHeight, pageHeight: fieldPageHeight,
signerEmail: selectedSigner.email, signerEmail: selectedSigner.email,
signerId: selectedSigner.id, recipientId: selectedSigner.id,
signerToken: selectedSigner.token ?? '', signerToken: selectedSigner.token ?? '',
fieldMeta: undefined, fieldMeta: undefined,
}; };
@ -597,14 +602,14 @@ export const AddTemplateFieldsFormPartial = ({
)} )}
{localFields.map((field, index) => { {localFields.map((field, index) => {
const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail); const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
return ( return (
<FieldItem <FieldItem
key={index} key={index}
recipientIndex={recipientIndex === -1 ? 0 : recipientIndex} recipientIndex={recipientIndex === -1 ? 0 : recipientIndex}
field={field} field={field}
disabled={selectedSigner?.email !== field.signerEmail} disabled={selectedSigner?.id !== field.recipientId}
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,6 +48,10 @@ 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[];
@ -56,7 +60,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<void>; onAutoSave: (_data: TAddTemplatePlacholderRecipientsFormSchema) => Promise<AutoSaveResponse>;
isDocumentPdfLoaded: boolean; isDocumentPdfLoaded: boolean;
}; };
@ -146,7 +150,44 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
const formData = form.getValues(); const formData = form.getValues();
scheduleSave(formData); scheduleSave(formData, (response) => {
// 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,7 +1,6 @@
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
@ -20,17 +19,7 @@ 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.