mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
Compare commits
9 Commits
v1.12.8
...
0bbd9aa9a1
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bbd9aa9a1 | |||
| 5e8c3d5d92 | |||
| c97c2551db | |||
| 1863d990c8 | |||
| 38483bb88c | |||
| 9cbbdfb127 | |||
| fb6e2753df | |||
| a89c781b31 | |||
| 8b131e42c7 |
@ -18,11 +18,6 @@ The guide assumes you have a Documenso account. If you don't, you can create a f
|
|||||||
|
|
||||||
Navigate to the [Documenso dashboard](https://app.documenso.com/documents) and click on the "Add a document" button. Select the document you want to upload and wait for the upload to complete.
|
Navigate to the [Documenso dashboard](https://app.documenso.com/documents) and click on the "Add a document" button. Select the document you want to upload and wait for the upload to complete.
|
||||||
|
|
||||||
<Callout type="info">
|
|
||||||
The maximum file size for uploaded documents is 150MB in production. In staging, the limit is
|
|
||||||
50MB.
|
|
||||||
</Callout>
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
After the upload is complete, you will be redirected to the document's page. You can configure the document's settings and add recipients and fields here.
|
After the upload is complete, you will be redirected to the document's page. You can configure the document's settings and add recipients and fields here.
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { useLingui } from '@lingui/react';
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { type Recipient, SigningStatus } from '@prisma/client';
|
import { type Recipient, SigningStatus } from '@prisma/client';
|
||||||
import { History } from 'lucide-react';
|
import { History } from 'lucide-react';
|
||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
@ -85,11 +85,6 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
|||||||
formState: { isSubmitting },
|
formState: { isSubmitting },
|
||||||
} = form;
|
} = form;
|
||||||
|
|
||||||
const selectedRecipients = useWatch({
|
|
||||||
control: form.control,
|
|
||||||
name: 'recipients',
|
|
||||||
});
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
|
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await resendDocument({ documentId: document.id, recipients });
|
await resendDocument({ documentId: document.id, recipients });
|
||||||
@ -156,7 +151,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
|||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
className="h-5 w-5 rounded-full border border-neutral-400"
|
className="h-5 w-5 rounded-full"
|
||||||
value={recipient.id}
|
value={recipient.id}
|
||||||
checked={value.includes(recipient.id)}
|
checked={value.includes(recipient.id)}
|
||||||
onCheckedChange={(checked: boolean) =>
|
onCheckedChange={(checked: boolean) =>
|
||||||
@ -187,13 +182,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
|
|
||||||
<Button
|
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
|
||||||
className="flex-1"
|
|
||||||
loading={isSubmitting}
|
|
||||||
type="submit"
|
|
||||||
form={FORM_ID}
|
|
||||||
disabled={isSubmitting || selectedRecipients.length === 0}
|
|
||||||
>
|
|
||||||
<Trans>Send reminder</Trans>
|
<Trans>Send reminder</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
|||||||
import {
|
import {
|
||||||
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
|
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
|
||||||
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
|
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
|
||||||
|
isTemplateRecipientEmailPlaceholder,
|
||||||
} from '@documenso/lib/constants/template';
|
} from '@documenso/lib/constants/template';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
@ -45,22 +46,50 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
|
|||||||
import type { Toast } from '@documenso/ui/primitives/use-toast';
|
import type { Toast } from '@documenso/ui/primitives/use-toast';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
const ZAddRecipientsForNewDocumentSchema = z.object({
|
const ZAddRecipientsForNewDocumentSchema = z
|
||||||
distributeDocument: z.boolean(),
|
.object({
|
||||||
useCustomDocument: z.boolean().default(false),
|
distributeDocument: z.boolean(),
|
||||||
customDocumentData: z
|
useCustomDocument: z.boolean().default(false),
|
||||||
.any()
|
customDocumentData: z
|
||||||
.refine((data) => data instanceof File || data === undefined)
|
.any()
|
||||||
.optional(),
|
.refine((data) => data instanceof File || data === undefined)
|
||||||
recipients: z.array(
|
.optional(),
|
||||||
z.object({
|
recipients: z.array(
|
||||||
id: z.number(),
|
z.object({
|
||||||
email: z.string().email(),
|
id: z.number(),
|
||||||
name: z.string(),
|
email: z.string().email(),
|
||||||
signingOrder: z.number().optional(),
|
name: z.string(),
|
||||||
}),
|
signingOrder: z.number().optional(),
|
||||||
),
|
}),
|
||||||
});
|
),
|
||||||
|
})
|
||||||
|
// Display exactly which rows are duplicates.
|
||||||
|
.superRefine((items, ctx) => {
|
||||||
|
const uniqueEmails = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const [index, recipients] of items.recipients.entries()) {
|
||||||
|
const email = recipients.email.toLowerCase();
|
||||||
|
|
||||||
|
const firstFoundIndex = uniqueEmails.get(email);
|
||||||
|
|
||||||
|
if (firstFoundIndex === undefined) {
|
||||||
|
uniqueEmails.set(email, index);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Emails must be unique',
|
||||||
|
path: ['recipients', index, 'email'],
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Emails must be unique',
|
||||||
|
path: ['recipients', firstFoundIndex, 'email'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
||||||
|
|
||||||
@ -249,7 +278,14 @@ export function TemplateUseDialog({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} aria-label="Email" placeholder={_(msg`Email`)} />
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder={
|
||||||
|
isTemplateRecipientEmailPlaceholder(field.value)
|
||||||
|
? ''
|
||||||
|
: _(msg`Email`)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@ -270,7 +306,6 @@ export function TemplateUseDialog({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
aria-label="Name"
|
|
||||||
placeholder={recipients[index].name || _(msg`Name`)}
|
placeholder={recipients[index].name || _(msg`Name`)}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@ -3,11 +3,10 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { TeamGlobalSettings } from '@prisma/client';
|
import type { TeamGlobalSettings } from '@prisma/client';
|
||||||
import { DocumentVisibility, OrganisationType } from '@prisma/client';
|
import { DocumentVisibility } from '@prisma/client';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
|
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
|
||||||
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
|
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||||
@ -87,10 +86,8 @@ export const DocumentPreferencesForm = ({
|
|||||||
}: DocumentPreferencesFormProps) => {
|
}: DocumentPreferencesFormProps) => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { user, organisations } = useSession();
|
const { user, organisations } = useSession();
|
||||||
const currentOrganisation = useCurrentOrganisation();
|
|
||||||
|
|
||||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||||
const isPersonalOrganisation = currentOrganisation.type === OrganisationType.PERSONAL;
|
|
||||||
|
|
||||||
const placeholderEmail = user.email ?? 'user@example.com';
|
const placeholderEmail = user.email ?? 'user@example.com';
|
||||||
|
|
||||||
@ -334,7 +331,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!isPersonalLayoutMode && !isPersonalOrganisation && (
|
{!isPersonalLayoutMode && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="includeSenderDetails"
|
name="includeSenderDetails"
|
||||||
|
|||||||
@ -160,14 +160,6 @@ export const DocumentSigningPageView = ({
|
|||||||
return (
|
return (
|
||||||
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
|
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
|
||||||
<div className="mx-auto w-full max-w-screen-xl sm:px-6">
|
<div className="mx-auto w-full max-w-screen-xl sm:px-6">
|
||||||
{document.team.teamGlobalSettings.brandingEnabled &&
|
|
||||||
document.team.teamGlobalSettings.brandingLogo && (
|
|
||||||
<img
|
|
||||||
src={`/api/branding/logo/team/${document.teamId}`}
|
|
||||||
alt={`${document.team.name}'s Logo`}
|
|
||||||
className="mb-4 h-12 w-12 md:mb-2"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<h1
|
<h1
|
||||||
className="block max-w-[20rem] truncate text-2xl font-semibold sm:mt-4 md:max-w-[30rem] md:text-3xl"
|
className="block max-w-[20rem] truncate text-2xl font-semibold sm:mt-4 md:max-w-[30rem] md:text-3xl"
|
||||||
title={document.title}
|
title={document.title}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
|
import { useDropzone } from 'react-dropzone';
|
||||||
import { Link, useNavigate, useParams } from 'react-router';
|
import { Link, useNavigate, useParams } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -108,51 +108,15 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFileDropRejected = (fileRejections: FileRejection[]) => {
|
const onFileDropRejected = () => {
|
||||||
if (!fileRejections.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
|
|
||||||
const { file, errors } = fileRejections[0];
|
|
||||||
|
|
||||||
if (!errors.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorNodes = errors.map((error, index) => (
|
|
||||||
<span key={index} className="block">
|
|
||||||
{match(error.code)
|
|
||||||
.with(ErrorCode.FileTooLarge, () => (
|
|
||||||
<Trans>File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB</Trans>
|
|
||||||
))
|
|
||||||
.with(ErrorCode.FileInvalidType, () => <Trans>Only PDF files are allowed</Trans>)
|
|
||||||
.with(ErrorCode.FileTooSmall, () => <Trans>File is too small</Trans>)
|
|
||||||
.with(ErrorCode.TooManyFiles, () => (
|
|
||||||
<Trans>Only one file can be uploaded at a time</Trans>
|
|
||||||
))
|
|
||||||
.otherwise(() => (
|
|
||||||
<Trans>Unknown error</Trans>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
));
|
|
||||||
|
|
||||||
const description = (
|
|
||||||
<>
|
|
||||||
<span className="font-medium">
|
|
||||||
{file.name} <Trans>couldn't be uploaded:</Trans>
|
|
||||||
</span>
|
|
||||||
{errorNodes}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Upload failed`),
|
title: _(msg`Your document failed to upload.`),
|
||||||
description,
|
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
accept: {
|
accept: {
|
||||||
'application/pdf': ['.pdf'],
|
'application/pdf': ['.pdf'],
|
||||||
@ -165,8 +129,8 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
|||||||
void onFileDrop(acceptedFile);
|
void onFileDrop(acceptedFile);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDropRejected: (fileRejections) => {
|
onDropRejected: () => {
|
||||||
onFileDropRejected(fileRejections);
|
void onFileDropRejected();
|
||||||
},
|
},
|
||||||
noClick: true,
|
noClick: true,
|
||||||
noDragEventsBubbling: true,
|
noDragEventsBubbling: true,
|
||||||
|
|||||||
@ -239,27 +239,7 @@ export const DocumentEditForm = ({
|
|||||||
|
|
||||||
const onAddSignersFormAutoSave = async (data: TAddSignersFormSchema) => {
|
const onAddSignersFormAutoSave = async (data: TAddSignersFormSchema) => {
|
||||||
try {
|
try {
|
||||||
// For autosave, we need to return the recipients response for form state sync
|
await saveSignersData(data);
|
||||||
const [, recipientsResponse] = await Promise.all([
|
|
||||||
updateDocument({
|
|
||||||
documentId: document.id,
|
|
||||||
meta: {
|
|
||||||
allowDictateNextSigner: data.allowDictateNextSigner,
|
|
||||||
signingOrder: data.signingOrder,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
setRecipients({
|
|
||||||
documentId: document.id,
|
|
||||||
recipients: data.signers.map((signer) => ({
|
|
||||||
...signer,
|
|
||||||
// Explicitly set to null to indicate we want to remove auth if required.
|
|
||||||
actionAuth: signer.actionAuth ?? [],
|
|
||||||
})),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return recipientsResponse;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
@ -268,8 +248,6 @@ export const DocumentEditForm = ({
|
|||||||
description: _(msg`An error occurred while adding signers.`),
|
description: _(msg`An error occurred while adding signers.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|
||||||
throw err; // Re-throw so the autosave hook can handle the error
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -54,7 +54,7 @@ export const FolderCard = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={formatPath()} data-folder-id={folder.id} data-folder-name={folder.name}>
|
<Link to={formatPath()} key={folder.id}>
|
||||||
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
|
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
|||||||
@ -4,9 +4,8 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
|
import { useDropzone } from 'react-dropzone';
|
||||||
import { useNavigate, useParams } from 'react-router';
|
import { useNavigate, useParams } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||||
@ -68,47 +67,10 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFileDropRejected = (fileRejections: FileRejection[]) => {
|
const onFileDropRejected = () => {
|
||||||
if (!fileRejections.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
|
|
||||||
const { file, errors } = fileRejections[0];
|
|
||||||
|
|
||||||
if (!errors.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorNodes = errors.map((error, index) => (
|
|
||||||
<span key={index} className="block">
|
|
||||||
{match(error.code)
|
|
||||||
.with(ErrorCode.FileTooLarge, () => (
|
|
||||||
<Trans>File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB</Trans>
|
|
||||||
))
|
|
||||||
.with(ErrorCode.FileInvalidType, () => <Trans>Only PDF files are allowed</Trans>)
|
|
||||||
.with(ErrorCode.FileTooSmall, () => <Trans>File is too small</Trans>)
|
|
||||||
.with(ErrorCode.TooManyFiles, () => (
|
|
||||||
<Trans>Only one file can be uploaded at a time</Trans>
|
|
||||||
))
|
|
||||||
.otherwise(() => (
|
|
||||||
<Trans>Unknown error</Trans>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
));
|
|
||||||
|
|
||||||
const description = (
|
|
||||||
<>
|
|
||||||
<span className="font-medium">
|
|
||||||
{file.name} <Trans>couldn't be uploaded:</Trans>
|
|
||||||
</span>
|
|
||||||
{errorNodes}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Upload failed`),
|
title: _(msg`Your template failed to upload.`),
|
||||||
description,
|
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
@ -126,8 +88,8 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
|
|||||||
void onFileDrop(acceptedFile);
|
void onFileDrop(acceptedFile);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDropRejected: (fileRejections) => {
|
onDropRejected: () => {
|
||||||
onFileDropRejected(fileRejections);
|
void onFileDropRejected();
|
||||||
},
|
},
|
||||||
noClick: true,
|
noClick: true,
|
||||||
noDragEventsBubbling: true,
|
noDragEventsBubbling: true,
|
||||||
|
|||||||
@ -182,7 +182,7 @@ export const TemplateEditForm = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const saveTemplatePlaceholderData = async (data: TAddTemplatePlacholderRecipientsFormSchema) => {
|
const saveTemplatePlaceholderData = async (data: TAddTemplatePlacholderRecipientsFormSchema) => {
|
||||||
const [, recipients] = await Promise.all([
|
return Promise.all([
|
||||||
updateTemplateSettings({
|
updateTemplateSettings({
|
||||||
templateId: template.id,
|
templateId: template.id,
|
||||||
meta: {
|
meta: {
|
||||||
@ -196,8 +196,6 @@ export const TemplateEditForm = ({
|
|||||||
recipients: data.signers,
|
recipients: data.signers,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return recipients;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAddTemplatePlaceholderFormSubmit = async (
|
const onAddTemplatePlaceholderFormSubmit = async (
|
||||||
@ -220,7 +218,7 @@ export const TemplateEditForm = ({
|
|||||||
data: TAddTemplatePlacholderRecipientsFormSchema,
|
data: TAddTemplatePlacholderRecipientsFormSchema,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
return await saveTemplatePlaceholderData(data);
|
await saveTemplatePlaceholderData(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
@ -229,8 +227,6 @@ export const TemplateEditForm = ({
|
|||||||
description: _(msg`An error occurred while auto-saving the template placeholders.`),
|
description: _(msg`An error occurred while auto-saving the template placeholders.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -71,23 +71,6 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: promoteToOwner, isPending: isPromotingToOwner } =
|
|
||||||
trpc.admin.organisationMember.promoteToOwner.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: t`Success`,
|
|
||||||
description: t`Member promoted to owner successfully`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: t`Error`,
|
|
||||||
description: t`We couldn't promote the member to owner. Please try again.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const teamsColumns = useMemo(() => {
|
const teamsColumns = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -118,26 +101,6 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
|||||||
<Link to={`/admin/users/${row.original.user.id}`}>{row.original.user.email}</Link>
|
<Link to={`/admin/users/${row.original.user.id}`}>{row.original.user.email}</Link>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
header: t`Actions`,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex justify-end space-x-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={row.original.userId === organisation?.ownerUserId}
|
|
||||||
loading={isPromotingToOwner}
|
|
||||||
onClick={async () =>
|
|
||||||
promoteToOwner({
|
|
||||||
organisationId,
|
|
||||||
userId: row.original.userId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trans>Promote to owner</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['members'][number]>[];
|
] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['members'][number]>[];
|
||||||
}, [organisation]);
|
}, [organisation]);
|
||||||
|
|
||||||
|
|||||||
@ -23,12 +23,10 @@ export const loader = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const certStatus = getCertificateStatus();
|
const certStatus = getCertificateStatus();
|
||||||
|
|
||||||
if (certStatus.isAvailable) {
|
if (certStatus.isAvailable) {
|
||||||
checks.certificate = { status: 'ok' };
|
checks.certificate = { status: 'ok' };
|
||||||
} else {
|
} else {
|
||||||
checks.certificate = { status: 'warning' };
|
checks.certificate = { status: 'warning' };
|
||||||
|
|
||||||
if (overallStatus === 'ok') {
|
if (overallStatus === 'ok') {
|
||||||
overallStatus = 'warning';
|
overallStatus = 'warning';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,10 @@
|
|||||||
import { RecipientRole } from '@prisma/client';
|
import { RecipientRole } from '@prisma/client';
|
||||||
import { data } from 'react-router';
|
import { data } from 'react-router';
|
||||||
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
|
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
|
||||||
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||||
@ -25,8 +23,6 @@ import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
|||||||
import type { Route } from './+types/sign.$url';
|
import type { Route } from './+types/sign.$url';
|
||||||
|
|
||||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
const { requestMetadata } = getOptionalLoaderContext();
|
|
||||||
|
|
||||||
if (!params.url) {
|
if (!params.url) {
|
||||||
throw new Response('Not found', { status: 404 });
|
throw new Response('Not found', { status: 404 });
|
||||||
}
|
}
|
||||||
@ -106,12 +102,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await viewedDocument({
|
|
||||||
token,
|
|
||||||
requestMetadata,
|
|
||||||
recipientAccessAuth: derivedRecipientAccessAuth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const allRecipients =
|
const allRecipients =
|
||||||
recipient.role === RecipientRole.ASSISTANT
|
recipient.role === RecipientRole.ASSISTANT
|
||||||
? await getRecipientsForAssistant({
|
? await getRecipientsForAssistant({
|
||||||
|
|||||||
@ -101,5 +101,5 @@
|
|||||||
"vite-plugin-babel-macros": "^1.0.6",
|
"vite-plugin-babel-macros": "^1.0.6",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
},
|
},
|
||||||
"version": "1.12.8"
|
"version": "1.12.2-rc.6"
|
||||||
}
|
}
|
||||||
|
|||||||
6
package-lock.json
generated
6
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "1.12.8",
|
"version": "1.12.2-rc.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "1.12.8",
|
"version": "1.12.2-rc.6",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
@ -89,7 +89,7 @@
|
|||||||
},
|
},
|
||||||
"apps/remix": {
|
"apps/remix": {
|
||||||
"name": "@documenso/remix",
|
"name": "@documenso/remix",
|
||||||
"version": "1.12.8",
|
"version": "1.12.2-rc.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/api": "*",
|
"@documenso/api": "*",
|
||||||
"@documenso/assets": "*",
|
"@documenso/assets": "*",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.12.8",
|
"version": "1.12.2-rc.6",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"dev": "turbo run dev --filter=@documenso/remix",
|
"dev": "turbo run dev --filter=@documenso/remix",
|
||||||
|
|||||||
@ -310,11 +310,12 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
|||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(schema) => {
|
(schema) => {
|
||||||
|
const emails = schema.map((signer) => signer.email.toLowerCase());
|
||||||
const ids = schema.map((signer) => signer.id);
|
const ids = schema.map((signer) => signer.id);
|
||||||
|
|
||||||
return new Set(ids).size === ids.length;
|
return new Set(emails).size === emails.length && new Set(ids).size === ids.length;
|
||||||
},
|
},
|
||||||
{ message: 'Recipient IDs must be unique' },
|
{ message: 'Recipient IDs and emails must be unique' },
|
||||||
),
|
),
|
||||||
meta: z
|
meta: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@ -1,435 +0,0 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
|
||||||
|
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
|
||||||
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
|
||||||
|
|
||||||
import { apiSignin } from '../../fixtures/authentication';
|
|
||||||
|
|
||||||
test('[ADMIN]: promote member to owner', async ({ page }) => {
|
|
||||||
// Create an admin user who can access the admin panel
|
|
||||||
const { user: adminUser } = await seedUser({
|
|
||||||
isAdmin: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create an organisation owner
|
|
||||||
const { user: ownerUser, organisation } = await seedUser({
|
|
||||||
isPersonalOrganisation: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create organisation members with different roles
|
|
||||||
const memberEmail = `member-${nanoid()}@test.documenso.com`;
|
|
||||||
const managerEmail = `manager-${nanoid()}@test.documenso.com`;
|
|
||||||
const adminMemberEmail = `admin-member-${nanoid()}@test.documenso.com`;
|
|
||||||
|
|
||||||
const [memberUser, managerUser, adminMemberUser] = await seedOrganisationMembers({
|
|
||||||
members: [
|
|
||||||
{
|
|
||||||
email: memberEmail,
|
|
||||||
name: 'Test Member',
|
|
||||||
organisationRole: 'MEMBER',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
email: managerEmail,
|
|
||||||
name: 'Test Manager',
|
|
||||||
organisationRole: 'MANAGER',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
email: adminMemberEmail,
|
|
||||||
name: 'Test Admin Member',
|
|
||||||
organisationRole: 'ADMIN',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
organisationId: organisation.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sign in as admin and navigate to the organisation admin page
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: adminUser.email,
|
|
||||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify we're on the admin organisation page
|
|
||||||
await expect(page.getByText(`Manage organisation`)).toBeVisible();
|
|
||||||
|
|
||||||
await expect(page.getByLabel('Organisation Name')).toHaveValue(organisation.name);
|
|
||||||
|
|
||||||
// Check that the organisation members table shows the correct roles
|
|
||||||
const ownerRow = page.getByRole('row', { name: ownerUser.email });
|
|
||||||
|
|
||||||
await expect(ownerRow).toBeVisible();
|
|
||||||
await expect(ownerRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
|
||||||
|
|
||||||
await expect(page.getByRole('row', { name: memberUser.email })).toBeVisible();
|
|
||||||
await expect(page.getByRole('row', { name: adminMemberUser.email })).toBeVisible();
|
|
||||||
await expect(page.getByRole('row', { name: managerUser.email })).toBeVisible();
|
|
||||||
|
|
||||||
// Test promoting a MEMBER to owner
|
|
||||||
const memberRow = page.getByRole('row', { name: memberUser.email });
|
|
||||||
|
|
||||||
// Find and click the "Promote to owner" button for the member
|
|
||||||
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
|
|
||||||
await expect(promoteButton).toBeVisible();
|
|
||||||
await expect(promoteButton).not.toBeDisabled();
|
|
||||||
|
|
||||||
await promoteButton.click();
|
|
||||||
|
|
||||||
// Verify success toast appears
|
|
||||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible();
|
|
||||||
|
|
||||||
// Reload the page to see the changes
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Verify that the member is now the owner
|
|
||||||
const newOwnerRow = page.getByRole('row', { name: memberUser.email });
|
|
||||||
await expect(newOwnerRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
|
||||||
|
|
||||||
// Verify that the previous owner is no longer marked as owner
|
|
||||||
const previousOwnerRow = page.getByRole('row', { name: ownerUser.email });
|
|
||||||
await expect(previousOwnerRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
|
|
||||||
|
|
||||||
// Verify that the promote button is now disabled for the new owner
|
|
||||||
const newOwnerPromoteButton = newOwnerRow.getByRole('button', { name: 'Promote to owner' });
|
|
||||||
await expect(newOwnerPromoteButton).toBeDisabled();
|
|
||||||
|
|
||||||
// Test that we can't promote the current owner (button should be disabled)
|
|
||||||
await expect(newOwnerPromoteButton).toHaveAttribute('disabled');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('[ADMIN]: promote manager to owner', async ({ page }) => {
|
|
||||||
// Create an admin user who can access the admin panel
|
|
||||||
const { user: adminUser } = await seedUser({
|
|
||||||
isAdmin: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create an organisation with owner and manager
|
|
||||||
const { organisation } = await seedUser({
|
|
||||||
isPersonalOrganisation: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const managerEmail = `manager-${nanoid()}@test.documenso.com`;
|
|
||||||
|
|
||||||
const [managerUser] = await seedOrganisationMembers({
|
|
||||||
members: [
|
|
||||||
{
|
|
||||||
email: managerEmail,
|
|
||||||
name: 'Test Manager',
|
|
||||||
organisationRole: 'MANAGER',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
organisationId: organisation.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sign in as admin
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: adminUser.email,
|
|
||||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Promote the manager to owner
|
|
||||||
const managerRow = page.getByRole('row', { name: managerUser.email });
|
|
||||||
const promoteButton = managerRow.getByRole('button', { name: 'Promote to owner' });
|
|
||||||
|
|
||||||
await promoteButton.click();
|
|
||||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible();
|
|
||||||
|
|
||||||
// Reload and verify the change
|
|
||||||
await page.reload();
|
|
||||||
await expect(managerRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('[ADMIN]: promote admin member to owner', async ({ page }) => {
|
|
||||||
// Create an admin user who can access the admin panel
|
|
||||||
const { user: adminUser } = await seedUser({
|
|
||||||
isAdmin: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create an organisation with owner and admin member
|
|
||||||
const { organisation } = await seedUser({
|
|
||||||
isPersonalOrganisation: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const adminMemberEmail = `admin-member-${nanoid()}@test.documenso.com`;
|
|
||||||
|
|
||||||
const [adminMemberUser] = await seedOrganisationMembers({
|
|
||||||
members: [
|
|
||||||
{
|
|
||||||
email: adminMemberEmail,
|
|
||||||
name: 'Test Admin Member',
|
|
||||||
organisationRole: 'ADMIN',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
organisationId: organisation.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sign in as admin
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: adminUser.email,
|
|
||||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Promote the admin member to owner
|
|
||||||
const adminMemberRow = page.getByRole('row', { name: adminMemberUser.email });
|
|
||||||
const promoteButton = adminMemberRow.getByRole('button', { name: 'Promote to owner' });
|
|
||||||
|
|
||||||
await promoteButton.click();
|
|
||||||
|
|
||||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reload and verify the change
|
|
||||||
await page.reload();
|
|
||||||
await expect(adminMemberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('[ADMIN]: cannot promote non-existent user', async ({ page }) => {
|
|
||||||
// Create an admin user
|
|
||||||
const { user: adminUser } = await seedUser({
|
|
||||||
isAdmin: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create an organisation
|
|
||||||
const { organisation } = await seedUser({
|
|
||||||
isPersonalOrganisation: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sign in as admin
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: adminUser.email,
|
|
||||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Try to manually call the API with invalid data - this should be handled by the UI validation
|
|
||||||
// In a real scenario, the promote button wouldn't be available for non-existent users
|
|
||||||
// But we can test that the API properly handles invalid requests
|
|
||||||
|
|
||||||
// For now, just verify that non-existent users don't show up in the members table
|
|
||||||
await expect(page.getByRole('row', { name: 'Non Existent User' })).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => {
|
|
||||||
// Create an admin user
|
|
||||||
const { user: adminUser } = await seedUser({
|
|
||||||
isAdmin: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create organisation with a member
|
|
||||||
const { organisation } = await seedUser({
|
|
||||||
isPersonalOrganisation: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const memberEmail = `member-${nanoid()}@test.documenso.com`;
|
|
||||||
|
|
||||||
const [memberUser] = await seedOrganisationMembers({
|
|
||||||
members: [
|
|
||||||
{
|
|
||||||
email: memberEmail,
|
|
||||||
name: 'Test Member',
|
|
||||||
organisationRole: 'MEMBER',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
organisationId: organisation.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sign in as admin
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: adminUser.email,
|
|
||||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Before promotion - verify member has MEMBER role
|
|
||||||
let memberRow = page.getByRole('row', { name: memberUser.email });
|
|
||||||
await expect(memberRow).toBeVisible();
|
|
||||||
await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
|
|
||||||
|
|
||||||
// Promote member to owner
|
|
||||||
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
|
|
||||||
await promoteButton.click();
|
|
||||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reload page to see updated state
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// After promotion - verify member is now owner and has admin permissions
|
|
||||||
memberRow = page.getByRole('row', { name: memberUser.email });
|
|
||||||
await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
|
||||||
|
|
||||||
// Verify the promote button is now disabled for the new owner
|
|
||||||
const newOwnerPromoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
|
|
||||||
await expect(newOwnerPromoteButton).toBeDisabled();
|
|
||||||
|
|
||||||
// Sign in as the newly promoted user to verify they have owner permissions
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: memberUser.email,
|
|
||||||
redirectPath: `/o/${organisation.url}/settings/general`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify they can access organisation settings (owner permission)
|
|
||||||
await expect(page.getByText('Organisation Settings')).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('[ADMIN]: error handling for invalid organisation', async ({ page }) => {
|
|
||||||
// Create an admin user
|
|
||||||
const { user: adminUser } = await seedUser({
|
|
||||||
isAdmin: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sign in as admin and try to access non-existent organisation
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: adminUser.email,
|
|
||||||
redirectPath: `/admin/organisations/non-existent-org-id`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should show 404 error
|
|
||||||
await expect(page.getByRole('heading', { name: 'Organisation not found' })).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('[ADMIN]: multiple promotions in sequence', async ({ page }) => {
|
|
||||||
// Create an admin user
|
|
||||||
const { user: adminUser } = await seedUser({
|
|
||||||
isAdmin: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create organisation with multiple members
|
|
||||||
const { organisation } = await seedUser({
|
|
||||||
isPersonalOrganisation: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const member1Email = `member1-${nanoid()}@test.documenso.com`;
|
|
||||||
const member2Email = `member2-${nanoid()}@test.documenso.com`;
|
|
||||||
|
|
||||||
const [member1User, member2User] = await seedOrganisationMembers({
|
|
||||||
members: [
|
|
||||||
{
|
|
||||||
email: member1Email,
|
|
||||||
name: 'Test Member 1',
|
|
||||||
organisationRole: 'MEMBER',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
email: member2Email,
|
|
||||||
name: 'Test Member 2',
|
|
||||||
organisationRole: 'MANAGER',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
organisationId: organisation.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sign in as admin
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: adminUser.email,
|
|
||||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// First promotion: Member 1 becomes owner
|
|
||||||
let member1Row = page.getByRole('row', { name: member1User.email });
|
|
||||||
let promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
|
|
||||||
await promoteButton1.click();
|
|
||||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Verify Member 1 is now owner and button is disabled
|
|
||||||
member1Row = page.getByRole('row', { name: member1User.email });
|
|
||||||
await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
|
||||||
promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
|
|
||||||
await expect(promoteButton1).toBeDisabled();
|
|
||||||
|
|
||||||
// Second promotion: Member 2 becomes the new owner
|
|
||||||
const member2Row = page.getByRole('row', { name: member2User.email });
|
|
||||||
const promoteButton2 = member2Row.getByRole('button', { name: 'Promote to owner' });
|
|
||||||
await expect(promoteButton2).not.toBeDisabled();
|
|
||||||
await promoteButton2.click();
|
|
||||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Verify Member 2 is now owner and Member 1 is no longer owner
|
|
||||||
await expect(member2Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
|
||||||
await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
|
|
||||||
|
|
||||||
// Verify Member 1's promote button is now enabled again
|
|
||||||
const newPromoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
|
|
||||||
await expect(newPromoteButton1).not.toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('[ADMIN]: verify organisation access after ownership change', async ({ page }) => {
|
|
||||||
// Create admin user
|
|
||||||
const { user: adminUser } = await seedUser({
|
|
||||||
isAdmin: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create organisation with owner and member
|
|
||||||
const { user: originalOwner, organisation } = await seedUser({
|
|
||||||
isPersonalOrganisation: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const memberEmail = `member-${nanoid()}@test.documenso.com`;
|
|
||||||
|
|
||||||
const [memberUser] = await seedOrganisationMembers({
|
|
||||||
members: [
|
|
||||||
{
|
|
||||||
email: memberEmail,
|
|
||||||
name: 'Test Member',
|
|
||||||
organisationRole: 'MEMBER',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
organisationId: organisation.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sign in as admin and promote member to owner
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: adminUser.email,
|
|
||||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const memberRow = page.getByRole('row', { name: memberUser.email });
|
|
||||||
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
|
|
||||||
await promoteButton.click();
|
|
||||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test that the new owner can access organisation settings
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: memberUser.email,
|
|
||||||
redirectPath: `/o/${organisation.url}/settings/general`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should be able to access organisation settings
|
|
||||||
await expect(page.getByText('Organisation Settings')).toBeVisible();
|
|
||||||
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: 'Update organisation' })).toBeVisible();
|
|
||||||
|
|
||||||
// Should have delete permissions
|
|
||||||
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
|
|
||||||
|
|
||||||
// Test that the original owner no longer has owner-level access
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: originalOwner.email,
|
|
||||||
redirectPath: `/o/${organisation.url}/settings/general`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should still be able to access settings (as they should now be an admin)
|
|
||||||
await expect(page.getByText('Organisation Settings')).toBeVisible();
|
|
||||||
});
|
|
||||||
@ -33,7 +33,7 @@ const setupDocumentAndNavigateToFieldsStep = async (page: Page) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const triggerAutosave = async (page: Page) => {
|
const triggerAutosave = async (page: Page) => {
|
||||||
await page.locator('body').click({ position: { x: 0, y: 0 } });
|
await page.locator('#document-flow-form-container').click();
|
||||||
await page.locator('#document-flow-form-container').blur();
|
await page.locator('#document-flow-form-container').blur();
|
||||||
|
|
||||||
await page.waitForTimeout(5000);
|
await page.waitForTimeout(5000);
|
||||||
@ -70,7 +70,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
|
|
||||||
await triggerAutosave(page);
|
await triggerAutosave(page);
|
||||||
|
|
||||||
await page.getByRole('combobox').first().click();
|
await page.getByRole('combobox').click();
|
||||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
@ -127,7 +127,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
|
|
||||||
await triggerAutosave(page);
|
await triggerAutosave(page);
|
||||||
|
|
||||||
await page.getByRole('combobox').first().click();
|
await page.getByRole('combobox').click();
|
||||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
@ -140,7 +140,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
|
|
||||||
await triggerAutosave(page);
|
await triggerAutosave(page);
|
||||||
|
|
||||||
await page.getByRole('combobox').first().click();
|
await page.getByRole('combobox').click();
|
||||||
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
|
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
|
||||||
|
|
||||||
await page.getByText('Text').nth(1).click();
|
await page.getByText('Text').nth(1).click();
|
||||||
@ -191,7 +191,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
|
|
||||||
await triggerAutosave(page);
|
await triggerAutosave(page);
|
||||||
|
|
||||||
await page.getByRole('combobox').first().click();
|
await page.getByRole('combobox').click();
|
||||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
@ -204,7 +204,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
|
|
||||||
await triggerAutosave(page);
|
await triggerAutosave(page);
|
||||||
|
|
||||||
await page.getByRole('combobox').first().click();
|
await page.getByRole('combobox').click();
|
||||||
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
|
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
|
||||||
|
|
||||||
await page.getByText('Signature').nth(1).click();
|
await page.getByText('Signature').nth(1).click();
|
||||||
|
|||||||
@ -24,7 +24,7 @@ const setupDocument = async (page: Page) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const triggerAutosave = async (page: Page) => {
|
const triggerAutosave = async (page: Page) => {
|
||||||
await page.locator('body').click({ position: { x: 0, y: 0 } });
|
await page.locator('#document-flow-form-container').click();
|
||||||
await page.locator('#document-flow-form-container').blur();
|
await page.locator('#document-flow-form-container').blur();
|
||||||
|
|
||||||
await page.waitForTimeout(5000);
|
await page.waitForTimeout(5000);
|
||||||
|
|||||||
@ -26,7 +26,7 @@ const setupDocumentAndNavigateToSignersStep = async (page: Page) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const triggerAutosave = async (page: Page) => {
|
const triggerAutosave = async (page: Page) => {
|
||||||
await page.locator('body').click({ position: { x: 0, y: 0 } });
|
await page.locator('#document-flow-form-container').click();
|
||||||
await page.locator('#document-flow-form-container').blur();
|
await page.locator('#document-flow-form-container').blur();
|
||||||
|
|
||||||
await page.waitForTimeout(5000);
|
await page.waitForTimeout(5000);
|
||||||
@ -92,7 +92,7 @@ test.describe('AutoSave Signers Step', () => {
|
|||||||
|
|
||||||
await triggerAutosave(page);
|
await triggerAutosave(page);
|
||||||
|
|
||||||
await page.getByRole('combobox').first().click();
|
await page.getByRole('combobox').click();
|
||||||
await page.getByRole('option', { name: 'Receives copy' }).click();
|
await page.getByRole('option', { name: 'Receives copy' }).click();
|
||||||
|
|
||||||
await triggerAutosave(page);
|
await triggerAutosave(page);
|
||||||
@ -160,20 +160,9 @@ test.describe('AutoSave Signers Step', () => {
|
|||||||
expect(retrievedDocumentData.documentMeta?.signingOrder).toBe('SEQUENTIAL');
|
expect(retrievedDocumentData.documentMeta?.signingOrder).toBe('SEQUENTIAL');
|
||||||
expect(retrievedDocumentData.documentMeta?.allowDictateNextSigner).toBe(true);
|
expect(retrievedDocumentData.documentMeta?.allowDictateNextSigner).toBe(true);
|
||||||
expect(retrievedRecipients.length).toBe(3);
|
expect(retrievedRecipients.length).toBe(3);
|
||||||
|
expect(retrievedRecipients[0].signingOrder).toBe(2);
|
||||||
const firstRecipient = retrievedRecipients.find(
|
expect(retrievedRecipients[1].signingOrder).toBe(3);
|
||||||
(r) => r.email === 'recipient1@documenso.com',
|
expect(retrievedRecipients[2].signingOrder).toBe(1);
|
||||||
);
|
|
||||||
const secondRecipient = retrievedRecipients.find(
|
|
||||||
(r) => r.email === 'recipient2@documenso.com',
|
|
||||||
);
|
|
||||||
const thirdRecipient = retrievedRecipients.find(
|
|
||||||
(r) => r.email === 'recipient3@documenso.com',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(firstRecipient?.signingOrder).toBe(2);
|
|
||||||
expect(secondRecipient?.signingOrder).toBe(3);
|
|
||||||
expect(thirdRecipient?.signingOrder).toBe(1);
|
|
||||||
}).toPass();
|
}).toPass();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -42,7 +42,7 @@ export const setupDocumentAndNavigateToSubjectStep = async (page: Page) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const triggerAutosave = async (page: Page) => {
|
export const triggerAutosave = async (page: Page) => {
|
||||||
await page.locator('body').click({ position: { x: 0, y: 0 } });
|
await page.locator('#document-flow-form-container').click();
|
||||||
await page.locator('#document-flow-form-container').blur();
|
await page.locator('#document-flow-form-container').blur();
|
||||||
|
|
||||||
await page.waitForTimeout(5000);
|
await page.waitForTimeout(5000);
|
||||||
|
|||||||
@ -1,56 +0,0 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
|
||||||
|
|
||||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
|
||||||
|
|
||||||
import { apiSignin } from '../fixtures/authentication';
|
|
||||||
|
|
||||||
test('[DOCUMENT_FLOW]: Simple duplicate recipients test', async ({ page }) => {
|
|
||||||
const { user, team } = await seedUser();
|
|
||||||
const document = await seedBlankDocument(user, team.id);
|
|
||||||
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: user.email,
|
|
||||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 1: Settings - Continue with defaults
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
|
||||||
|
|
||||||
// Step 2: Add duplicate recipients
|
|
||||||
await page.getByPlaceholder('Email').fill('duplicate@example.com');
|
|
||||||
await page.getByPlaceholder('Name').fill('Duplicate 1');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
|
||||||
await page.getByLabel('Email').nth(1).fill('duplicate@example.com');
|
|
||||||
await page.getByLabel('Name').nth(1).fill('Duplicate 2');
|
|
||||||
|
|
||||||
// Continue to fields
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
|
||||||
|
|
||||||
// Step 3: Add fields
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
|
||||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
|
||||||
|
|
||||||
await page.getByRole('combobox').first().click();
|
|
||||||
|
|
||||||
// Switch to second duplicate and add field
|
|
||||||
await page.getByText('Duplicate 2 (duplicate@example.com)').first().click();
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
|
||||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
|
||||||
|
|
||||||
// Continue to send
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
|
||||||
|
|
||||||
// Send document
|
|
||||||
await page.waitForTimeout(2500);
|
|
||||||
await page.getByRole('button', { name: 'Send' }).click();
|
|
||||||
|
|
||||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
|
||||||
|
|
||||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
|
||||||
});
|
|
||||||
@ -1,355 +0,0 @@
|
|||||||
import { type Page, expect, test } from '@playwright/test';
|
|
||||||
import type { Document, Team } from '@prisma/client';
|
|
||||||
|
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
|
||||||
|
|
||||||
import { apiSignin } from '../fixtures/authentication';
|
|
||||||
import { signSignaturePad } from '../fixtures/signature';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test helper to complete the document creation flow with duplicate recipients
|
|
||||||
*/
|
|
||||||
const completeDocumentFlowWithDuplicateRecipients = async (options: {
|
|
||||||
page: Page;
|
|
||||||
team: Team;
|
|
||||||
document: Document;
|
|
||||||
}) => {
|
|
||||||
const { page, team, document } = options;
|
|
||||||
|
|
||||||
// Step 1: Settings - Continue with defaults
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
|
||||||
|
|
||||||
// Step 2: Add duplicate recipients
|
|
||||||
await page.getByPlaceholder('Email').fill('duplicate@example.com');
|
|
||||||
await page.getByPlaceholder('Name').fill('Duplicate Recipient 1');
|
|
||||||
|
|
||||||
// Add second signer with same email
|
|
||||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
|
||||||
await page.getByLabel('Email').nth(1).fill('duplicate@example.com');
|
|
||||||
await page.getByLabel('Name').nth(1).fill('Duplicate Recipient 2');
|
|
||||||
|
|
||||||
// Add third signer with different email for comparison
|
|
||||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
|
||||||
await page.getByLabel('Email').nth(2).fill('unique@example.com');
|
|
||||||
await page.getByLabel('Name').nth(2).fill('Unique Recipient');
|
|
||||||
|
|
||||||
// Continue to fields
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
|
||||||
|
|
||||||
// Step 3: Add fields for each recipient
|
|
||||||
// Add signature field for first duplicate recipient
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
|
||||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
|
||||||
|
|
||||||
await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click();
|
|
||||||
|
|
||||||
// Switch to second duplicate recipient and add their field
|
|
||||||
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
|
||||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
|
||||||
|
|
||||||
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
|
|
||||||
|
|
||||||
// Switch to unique recipient and add their field
|
|
||||||
await page.getByText('Unique Recipient (unique@example.com)').click();
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
|
||||||
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
|
|
||||||
|
|
||||||
// Continue to subject
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
|
||||||
|
|
||||||
// Step 4: Complete with subject and send
|
|
||||||
await page.waitForTimeout(2500);
|
|
||||||
await page.getByRole('button', { name: 'Send' }).click();
|
|
||||||
|
|
||||||
// Wait for send confirmation
|
|
||||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
|
||||||
|
|
||||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
|
||||||
};
|
|
||||||
|
|
||||||
test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
|
||||||
test('should allow creating document with duplicate recipient emails', async ({ page }) => {
|
|
||||||
const { user, team } = await seedUser();
|
|
||||||
|
|
||||||
const document = await seedBlankDocument(user, team.id);
|
|
||||||
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: user.email,
|
|
||||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Complete the flow
|
|
||||||
await completeDocumentFlowWithDuplicateRecipients({
|
|
||||||
page,
|
|
||||||
team,
|
|
||||||
document,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify document was created successfully
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/t/${team.url}/documents`));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should allow adding duplicate recipient after saving document initially', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const { user, team } = await seedUser();
|
|
||||||
const document = await seedBlankDocument(user, team.id);
|
|
||||||
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: user.email,
|
|
||||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 1: Settings - Continue with defaults
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
|
||||||
|
|
||||||
// Step 2: Add initial recipient
|
|
||||||
await page.getByPlaceholder('Email').fill('test@example.com');
|
|
||||||
await page.getByPlaceholder('Name').fill('Test Recipient');
|
|
||||||
|
|
||||||
// Continue to fields and add a field
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
|
||||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
|
||||||
|
|
||||||
// Save the document by going to subject
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
|
||||||
|
|
||||||
// Navigate back to signers to add duplicate
|
|
||||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
|
||||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
|
||||||
|
|
||||||
// Add duplicate recipient
|
|
||||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
|
||||||
await page.getByLabel('Email').nth(1).fill('test@example.com');
|
|
||||||
await page.getByLabel('Name').nth(1).fill('Test Recipient Duplicate');
|
|
||||||
|
|
||||||
// Continue and add field for duplicate
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
|
||||||
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
// Switch to duplicate recipient and add field
|
|
||||||
await page.getByRole('combobox').first().click();
|
|
||||||
await page.getByText('Test Recipient Duplicate (test@example.com)').first().click();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
|
||||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
|
||||||
|
|
||||||
// Complete the flow
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
|
||||||
await page.waitForTimeout(2500);
|
|
||||||
await page.getByRole('button', { name: 'Send' }).click();
|
|
||||||
|
|
||||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
|
||||||
|
|
||||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should isolate fields per recipient token even with duplicate emails', async ({
|
|
||||||
page,
|
|
||||||
context,
|
|
||||||
}) => {
|
|
||||||
const { user, team } = await seedUser();
|
|
||||||
|
|
||||||
const document = await seedBlankDocument(user, team.id);
|
|
||||||
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: user.email,
|
|
||||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Complete the document flow
|
|
||||||
await completeDocumentFlowWithDuplicateRecipients({
|
|
||||||
page,
|
|
||||||
team,
|
|
||||||
document,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Navigate to documents list and get the document
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/t/${team.url}/documents`));
|
|
||||||
|
|
||||||
const recipients = await prisma.recipient.findMany({
|
|
||||||
where: {
|
|
||||||
documentId: document.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(recipients).toHaveLength(3);
|
|
||||||
|
|
||||||
const tokens = recipients.map((r) => r.token);
|
|
||||||
|
|
||||||
expect(new Set(tokens).size).toBe(3); // All tokens should be unique
|
|
||||||
|
|
||||||
// Test each signing experience in separate browser contexts
|
|
||||||
for (const recipient of recipients) {
|
|
||||||
// Navigate to signing URL
|
|
||||||
await page.goto(`/sign/${recipient.token}`, {
|
|
||||||
waitUntil: 'networkidle',
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
|
|
||||||
|
|
||||||
// Verify only one signature field is visible for this recipient
|
|
||||||
expect(
|
|
||||||
await page.locator(`[data-field-type="SIGNATURE"]:not([data-readonly="true"])`).all(),
|
|
||||||
).toHaveLength(1);
|
|
||||||
|
|
||||||
// Verify recipient name is correct
|
|
||||||
await expect(page.getByLabel('Full Name')).toHaveValue(recipient.name);
|
|
||||||
|
|
||||||
// Sign the document
|
|
||||||
await signSignaturePad(page);
|
|
||||||
|
|
||||||
await page
|
|
||||||
.locator('[data-field-type="SIGNATURE"]:not([data-readonly="true"])')
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Complete' }).click();
|
|
||||||
await page.getByRole('button', { name: 'Sign' }).click();
|
|
||||||
|
|
||||||
// Verify completion
|
|
||||||
await page.waitForURL(`/sign/${recipient?.token}/complete`);
|
|
||||||
await expect(page.getByText('Document Signed')).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle duplicate recipient workflow with different field types', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const { user, team } = await seedUser();
|
|
||||||
const document = await seedBlankDocument(user, team.id);
|
|
||||||
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: user.email,
|
|
||||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 1: Settings
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
|
|
||||||
// Step 2: Add duplicate recipients with different roles
|
|
||||||
await page.getByPlaceholder('Email').fill('signer@example.com');
|
|
||||||
await page.getByPlaceholder('Name').fill('Signer Role');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
|
||||||
await page.getByLabel('Email').nth(1).fill('signer@example.com');
|
|
||||||
await page.getByLabel('Name').nth(1).fill('Approver Role');
|
|
||||||
|
|
||||||
// Change second recipient role if role selector is available
|
|
||||||
const roleDropdown = page.getByLabel('Role').nth(1);
|
|
||||||
|
|
||||||
if (await roleDropdown.isVisible()) {
|
|
||||||
await roleDropdown.click();
|
|
||||||
await page.getByText('Approver').click();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Add different field types for each duplicate
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
|
|
||||||
// Add signature for first recipient
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
|
||||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
|
||||||
|
|
||||||
// Add name field for second recipient
|
|
||||||
await page.getByRole('combobox').first().click();
|
|
||||||
|
|
||||||
await page.getByText('Approver Role (signer@example.com)').first().click();
|
|
||||||
await page.getByRole('button', { name: 'Name' }).click();
|
|
||||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
|
||||||
|
|
||||||
// Add date field for second recipient
|
|
||||||
await page.getByRole('button', { name: 'Date' }).click();
|
|
||||||
await page.locator('canvas').click({ position: { x: 200, y: 150 } });
|
|
||||||
|
|
||||||
// Complete the document
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Send' }).click();
|
|
||||||
|
|
||||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
|
||||||
|
|
||||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should preserve field assignments when editing document with duplicates', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const { user, team } = await seedUser();
|
|
||||||
const document = await seedBlankDocument(user, team.id);
|
|
||||||
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: user.email,
|
|
||||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create document with duplicates and fields
|
|
||||||
await completeDocumentFlowWithDuplicateRecipients({
|
|
||||||
page,
|
|
||||||
team,
|
|
||||||
document,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Navigate back to edit the document
|
|
||||||
await page.goto(`/t/${team.url}/documents/${document.id}/edit`);
|
|
||||||
|
|
||||||
// Go to fields step
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click(); // Settings
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click(); // Signers
|
|
||||||
|
|
||||||
// Verify fields are assigned to correct recipients
|
|
||||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
|
||||||
|
|
||||||
// Click on first duplicate recipient
|
|
||||||
await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click();
|
|
||||||
|
|
||||||
// Verify their field is visible and can be selected
|
|
||||||
const firstRecipientFields = await page
|
|
||||||
.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`)
|
|
||||||
.all();
|
|
||||||
expect(firstRecipientFields.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// Switch to second duplicate recipient
|
|
||||||
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
|
|
||||||
|
|
||||||
// Verify they have their own field
|
|
||||||
const secondRecipientFields = await page
|
|
||||||
.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`)
|
|
||||||
.all();
|
|
||||||
expect(secondRecipientFields.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// Add another field to the second duplicate
|
|
||||||
await page.getByRole('button', { name: 'Name' }).click();
|
|
||||||
await page.locator('canvas').click({ position: { x: 250, y: 150 } });
|
|
||||||
|
|
||||||
// Save changes
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
|
||||||
await page.waitForTimeout(2500);
|
|
||||||
await page.getByRole('button', { name: 'Send' }).click();
|
|
||||||
|
|
||||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
|
||||||
|
|
||||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -573,7 +573,6 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
|
|||||||
y: 100 * i,
|
y: 100 * i,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByText(`User ${i} (user${i}@example.com)`).click();
|
await page.getByText(`User ${i} (user${i}@example.com)`).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -277,13 +277,13 @@ test('[TEAMS]: document folder and its contents can be deleted', async ({ page }
|
|||||||
|
|
||||||
await page.goto(`/t/${team.url}/documents`);
|
await page.goto(`/t/${team.url}/documents`);
|
||||||
|
|
||||||
await expect(page.locator(`[data-folder-id="${folder.id}"]`)).not.toBeVisible();
|
await expect(page.locator('div').filter({ hasText: folder.name })).not.toBeVisible();
|
||||||
await expect(page.getByText(proposal.title)).not.toBeVisible();
|
await expect(page.getByText(proposal.title)).not.toBeVisible();
|
||||||
|
|
||||||
await page.goto(`/t/${team.url}/documents/f/${folder.id}`);
|
await page.goto(`/t/${team.url}/documents/f/${folder.id}`);
|
||||||
|
|
||||||
await expect(page.getByText(report.title)).not.toBeVisible();
|
await expect(page.getByText(report.title)).not.toBeVisible();
|
||||||
await expect(page.locator(`[data-folder-id="${reportsFolder.id}"]`)).not.toBeVisible();
|
await expect(page.locator('div').filter({ hasText: reportsFolder.name })).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[TEAMS]: create folder button is visible on templates page', async ({ page }) => {
|
test('[TEAMS]: create folder button is visible on templates page', async ({ page }) => {
|
||||||
@ -318,7 +318,9 @@ test('[TEAMS]: can create a template folder', async ({ page }) => {
|
|||||||
await expect(page.getByText('Team template folder')).toBeVisible();
|
await expect(page.getByText('Team template folder')).toBeVisible();
|
||||||
|
|
||||||
await page.goto(`/t/${team.url}/templates`);
|
await page.goto(`/t/${team.url}/templates`);
|
||||||
await expect(page.locator(`[data-folder-name="Team template folder"]`)).toBeVisible();
|
await expect(
|
||||||
|
page.locator('div').filter({ hasText: 'Team template folder' }).nth(3),
|
||||||
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[TEAMS]: can create a template subfolder inside a template folder', async ({ page }) => {
|
test('[TEAMS]: can create a template subfolder inside a template folder', async ({ page }) => {
|
||||||
@ -372,8 +374,11 @@ test('[TEAMS]: can create a template inside a template folder', async ({ page })
|
|||||||
|
|
||||||
await page.getByRole('button', { name: 'New Template' }).click();
|
await page.getByRole('button', { name: 'New Template' }).click();
|
||||||
|
|
||||||
await page.getByText('Upload Template Document').click();
|
await page
|
||||||
|
.locator('div')
|
||||||
|
.filter({ hasText: /^Upload Template DocumentDrag & drop your PDF here\.$/ })
|
||||||
|
.nth(2)
|
||||||
|
.click();
|
||||||
await page.locator('input[type="file"]').nth(0).waitFor({ state: 'attached' });
|
await page.locator('input[type="file"]').nth(0).waitFor({ state: 'attached' });
|
||||||
|
|
||||||
await page
|
await page
|
||||||
@ -532,7 +537,7 @@ test('[TEAMS]: template folder can be moved to another template folder', async (
|
|||||||
await expect(page.getByText('Team Contract Templates')).toBeVisible();
|
await expect(page.getByText('Team Contract Templates')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[TEAMS]: template folder can be deleted', async ({ page }) => {
|
test('[TEAMS]: template folder and its contents can be deleted', async ({ page }) => {
|
||||||
const { team, teamOwner } = await seedTeamDocuments();
|
const { team, teamOwner } = await seedTeamDocuments();
|
||||||
|
|
||||||
const folder = await seedBlankFolder(teamOwner, team.id, {
|
const folder = await seedBlankFolder(teamOwner, team.id, {
|
||||||
@ -580,16 +585,13 @@ test('[TEAMS]: template folder can be deleted', async ({ page }) => {
|
|||||||
|
|
||||||
await page.goto(`/t/${team.url}/templates`);
|
await page.goto(`/t/${team.url}/templates`);
|
||||||
|
|
||||||
await page.waitForTimeout(1000);
|
await expect(page.locator('div').filter({ hasText: folder.name })).not.toBeVisible();
|
||||||
|
await expect(page.getByText(template.title)).not.toBeVisible();
|
||||||
// !: This is no longer the case, when deleting a folder its contents will be moved to the root folder.
|
|
||||||
// await expect(page.locator(`[data-folder-id="${folder.id}"]`)).not.toBeVisible();
|
|
||||||
// await expect(page.getByText(template.title)).not.toBeVisible();
|
|
||||||
|
|
||||||
await page.goto(`/t/${team.url}/templates/f/${folder.id}`);
|
await page.goto(`/t/${team.url}/templates/f/${folder.id}`);
|
||||||
|
|
||||||
await expect(page.getByText(reportTemplate.title)).not.toBeVisible();
|
await expect(page.getByText(reportTemplate.title)).not.toBeVisible();
|
||||||
await expect(page.locator(`[data-folder-id="${subfolder.id}"]`)).not.toBeVisible();
|
await expect(page.locator('div').filter({ hasText: subfolder.name })).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[TEAMS]: can navigate between template folders', async ({ page }) => {
|
test('[TEAMS]: can navigate between template folders', async ({ page }) => {
|
||||||
@ -841,15 +843,10 @@ test('[TEAMS]: documents inherit folder visibility', async ({ page }) => {
|
|||||||
|
|
||||||
await page.getByText('Admin Only Folder').click();
|
await page.getByText('Admin Only Folder').click();
|
||||||
|
|
||||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/f/.+`));
|
const fileInput = page.locator('input[type="file"]').nth(1);
|
||||||
|
await fileInput.waitFor({ state: 'attached' });
|
||||||
|
|
||||||
// Upload document.
|
await fileInput.setInputFiles(
|
||||||
const [fileChooser] = await Promise.all([
|
|
||||||
page.waitForEvent('filechooser'),
|
|
||||||
page.getByRole('button', { name: 'Upload Document' }).click(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await fileChooser.setFiles(
|
|
||||||
path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'),
|
path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -30,8 +30,8 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
|||||||
await page.getByRole('option', { name: 'Australia/Perth' }).click();
|
await page.getByRole('option', { name: 'Australia/Perth' }).click();
|
||||||
|
|
||||||
// Set default date
|
// Set default date
|
||||||
await page.getByRole('combobox').filter({ hasText: 'yyyy-MM-dd hh:mm AM/PM' }).click();
|
await page.getByRole('combobox').filter({ hasText: 'yyyy-MM-dd hh:mm a' }).click();
|
||||||
await page.getByRole('option', { name: 'DD/MM/YYYY', exact: true }).click();
|
await page.getByRole('option', { name: 'DD/MM/YYYY' }).click();
|
||||||
|
|
||||||
await page.getByTestId('signature-types-trigger').click();
|
await page.getByTestId('signature-types-trigger').click();
|
||||||
await page.getByRole('option', { name: 'Draw' }).click();
|
await page.getByRole('option', { name: 'Draw' }).click();
|
||||||
@ -51,7 +51,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
|||||||
expect(teamSettings.documentVisibility).toEqual(DocumentVisibility.MANAGER_AND_ABOVE);
|
expect(teamSettings.documentVisibility).toEqual(DocumentVisibility.MANAGER_AND_ABOVE);
|
||||||
expect(teamSettings.documentLanguage).toEqual('de');
|
expect(teamSettings.documentLanguage).toEqual('de');
|
||||||
expect(teamSettings.documentTimezone).toEqual('Australia/Perth');
|
expect(teamSettings.documentTimezone).toEqual('Australia/Perth');
|
||||||
expect(teamSettings.documentDateFormat).toEqual('dd/MM/yyyy');
|
expect(teamSettings.documentDateFormat).toEqual('dd/MM/yyyy hh:mm a');
|
||||||
expect(teamSettings.includeSenderDetails).toEqual(false);
|
expect(teamSettings.includeSenderDetails).toEqual(false);
|
||||||
expect(teamSettings.includeSigningCertificate).toEqual(false);
|
expect(teamSettings.includeSigningCertificate).toEqual(false);
|
||||||
expect(teamSettings.typedSignatureEnabled).toEqual(true);
|
expect(teamSettings.typedSignatureEnabled).toEqual(true);
|
||||||
@ -72,7 +72,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
|||||||
|
|
||||||
// Override team date format settings
|
// Override team date format settings
|
||||||
await page.getByTestId('document-date-format-trigger').click();
|
await page.getByTestId('document-date-format-trigger').click();
|
||||||
await page.getByRole('option', { name: 'MM/DD/YYYY', exact: true }).click();
|
await page.getByRole('option', { name: 'MM/DD/YYYY' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||||
@ -85,7 +85,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
|||||||
expect(updatedTeamSettings.documentVisibility).toEqual(DocumentVisibility.EVERYONE);
|
expect(updatedTeamSettings.documentVisibility).toEqual(DocumentVisibility.EVERYONE);
|
||||||
expect(updatedTeamSettings.documentLanguage).toEqual('pl');
|
expect(updatedTeamSettings.documentLanguage).toEqual('pl');
|
||||||
expect(updatedTeamSettings.documentTimezone).toEqual('Europe/London');
|
expect(updatedTeamSettings.documentTimezone).toEqual('Europe/London');
|
||||||
expect(updatedTeamSettings.documentDateFormat).toEqual('MM/dd/yyyy');
|
expect(updatedTeamSettings.documentDateFormat).toEqual('MM/dd/yyyy hh:mm a');
|
||||||
expect(updatedTeamSettings.includeSenderDetails).toEqual(false);
|
expect(updatedTeamSettings.includeSenderDetails).toEqual(false);
|
||||||
expect(updatedTeamSettings.includeSigningCertificate).toEqual(false);
|
expect(updatedTeamSettings.includeSigningCertificate).toEqual(false);
|
||||||
expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true);
|
expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true);
|
||||||
@ -108,7 +108,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
|||||||
expect(documentMeta.drawSignatureEnabled).toEqual(false);
|
expect(documentMeta.drawSignatureEnabled).toEqual(false);
|
||||||
expect(documentMeta.language).toEqual('pl');
|
expect(documentMeta.language).toEqual('pl');
|
||||||
expect(documentMeta.timezone).toEqual('Europe/London');
|
expect(documentMeta.timezone).toEqual('Europe/London');
|
||||||
expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy');
|
expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy hh:mm a');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
|
test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
|
||||||
|
|||||||
@ -1,283 +0,0 @@
|
|||||||
import { type Page, expect, test } from '@playwright/test';
|
|
||||||
import type { Team, Template } from '@prisma/client';
|
|
||||||
|
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
|
||||||
|
|
||||||
import { apiSignin } from '../fixtures/authentication';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test helper to complete template creation with duplicate recipients
|
|
||||||
*/
|
|
||||||
const completeTemplateFlowWithDuplicateRecipients = async (options: {
|
|
||||||
page: Page;
|
|
||||||
team: Team;
|
|
||||||
template: Template;
|
|
||||||
}) => {
|
|
||||||
const { page, team, template } = options;
|
|
||||||
// Step 1: Settings - Continue with defaults
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
|
|
||||||
|
|
||||||
// Step 2: Add duplicate recipients with real emails for testing
|
|
||||||
await page.getByPlaceholder('Email').fill('duplicate@example.com');
|
|
||||||
await page.getByPlaceholder('Name').fill('First Instance');
|
|
||||||
|
|
||||||
// Add second signer with same email
|
|
||||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
|
||||||
await page.getByPlaceholder('Email').nth(1).fill('duplicate@example.com');
|
|
||||||
await page.getByPlaceholder('Name').nth(1).fill('Second Instance');
|
|
||||||
|
|
||||||
// Add third signer with different email
|
|
||||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
|
||||||
await page.getByPlaceholder('Email').nth(2).fill('unique@example.com');
|
|
||||||
await page.getByPlaceholder('Name').nth(2).fill('Different Recipient');
|
|
||||||
|
|
||||||
// Continue to fields
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
|
||||||
|
|
||||||
// Step 3: Add fields for each recipient instance
|
|
||||||
// Add signature field for first instance
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
|
||||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
|
||||||
|
|
||||||
// Switch to second instance and add their field
|
|
||||||
await page.getByRole('combobox').first().click();
|
|
||||||
await page.getByText('Second Instance').first().click();
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
|
||||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
|
||||||
|
|
||||||
// Switch to different recipient and add their field
|
|
||||||
await page.getByRole('combobox').first().click();
|
|
||||||
await page.getByText('Different Recipient').first().click();
|
|
||||||
await page.getByRole('button', { name: 'Name' }).click();
|
|
||||||
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
|
|
||||||
|
|
||||||
// Save template
|
|
||||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
|
||||||
|
|
||||||
// Wait for creation confirmation
|
|
||||||
await page.waitForURL(`/t/${team.url}/templates`);
|
|
||||||
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
|
|
||||||
};
|
|
||||||
|
|
||||||
test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
|
|
||||||
test('should allow creating template with duplicate recipient emails', async ({ page }) => {
|
|
||||||
const { user, team } = await seedUser();
|
|
||||||
|
|
||||||
const template = await seedBlankTemplate(user, team.id);
|
|
||||||
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: user.email,
|
|
||||||
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Complete the template flow
|
|
||||||
await completeTemplateFlowWithDuplicateRecipients({ page, team, template });
|
|
||||||
|
|
||||||
// Verify template was created successfully
|
|
||||||
await expect(page).toHaveURL(`/t/${team.url}/templates`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should create document from template with duplicate recipients using same email', async ({
|
|
||||||
page,
|
|
||||||
context,
|
|
||||||
}) => {
|
|
||||||
const { user, team } = await seedUser();
|
|
||||||
|
|
||||||
const template = await seedBlankTemplate(user, team.id);
|
|
||||||
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: user.email,
|
|
||||||
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Complete template creation
|
|
||||||
await completeTemplateFlowWithDuplicateRecipients({ page, team, template });
|
|
||||||
|
|
||||||
// Navigate to template and create document
|
|
||||||
await page.goto(`/t/${team.url}/templates`);
|
|
||||||
|
|
||||||
await page
|
|
||||||
.getByRole('row', { name: template.title })
|
|
||||||
.getByRole('button', { name: 'Use Template' })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// Fill recipient information with same email for both instances
|
|
||||||
await expect(page.getByRole('heading', { name: 'Create document' })).toBeVisible();
|
|
||||||
|
|
||||||
// Set same email for both recipient instances
|
|
||||||
const emailInputs = await page.locator('[aria-label="Email"]').all();
|
|
||||||
const nameInputs = await page.locator('[aria-label="Name"]').all();
|
|
||||||
|
|
||||||
// First instance
|
|
||||||
await emailInputs[0].fill('same@example.com');
|
|
||||||
await nameInputs[0].fill('John Doe - Role 1');
|
|
||||||
|
|
||||||
// Second instance (same email)
|
|
||||||
await emailInputs[1].fill('same@example.com');
|
|
||||||
await nameInputs[1].fill('John Doe - Role 2');
|
|
||||||
|
|
||||||
// Different recipient
|
|
||||||
await emailInputs[2].fill('different@example.com');
|
|
||||||
await nameInputs[2].fill('Jane Smith');
|
|
||||||
|
|
||||||
await page.getByLabel('Send document').click();
|
|
||||||
|
|
||||||
// Create document
|
|
||||||
await page.getByRole('button', { name: 'Create and send' }).click();
|
|
||||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
|
||||||
|
|
||||||
// Get the document ID from URL for database queries
|
|
||||||
const url = page.url();
|
|
||||||
const documentIdMatch = url.match(/\/documents\/(\d+)/);
|
|
||||||
|
|
||||||
const documentId = documentIdMatch ? parseInt(documentIdMatch[1]) : null;
|
|
||||||
|
|
||||||
expect(documentId).not.toBeNull();
|
|
||||||
|
|
||||||
// Get recipients directly from database
|
|
||||||
const recipients = await prisma.recipient.findMany({
|
|
||||||
where: {
|
|
||||||
documentId: documentId!,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(recipients).toHaveLength(3);
|
|
||||||
|
|
||||||
// Verify all tokens are unique
|
|
||||||
const tokens = recipients.map((r) => r.token);
|
|
||||||
expect(new Set(tokens).size).toBe(3);
|
|
||||||
|
|
||||||
// Test signing experience for duplicate email recipients
|
|
||||||
const duplicateRecipients = recipients.filter((r) => r.email === 'same@example.com');
|
|
||||||
expect(duplicateRecipients).toHaveLength(2);
|
|
||||||
|
|
||||||
for (const recipient of duplicateRecipients) {
|
|
||||||
// Navigate to signing URL
|
|
||||||
await page.goto(`/sign/${recipient.token}`, {
|
|
||||||
waitUntil: 'networkidle',
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
|
|
||||||
|
|
||||||
// Verify correct recipient name is shown
|
|
||||||
await expect(page.getByLabel('Full Name')).toHaveValue(recipient.name);
|
|
||||||
|
|
||||||
// Verify only one signature field is visible for this recipient
|
|
||||||
expect(
|
|
||||||
await page.locator(`[data-field-type="SIGNATURE"]:not([data-readonly="true"])`).all(),
|
|
||||||
).toHaveLength(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle template with different types of duplicate emails', async ({ page }) => {
|
|
||||||
const { user, team } = await seedUser();
|
|
||||||
|
|
||||||
const template = await seedBlankTemplate(user, team.id);
|
|
||||||
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: user.email,
|
|
||||||
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 1: Settings
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
|
|
||||||
// Step 2: Add multiple recipients with duplicate emails
|
|
||||||
await page.getByPlaceholder('Email').fill('duplicate@example.com');
|
|
||||||
await page.getByPlaceholder('Name').fill('Duplicate Recipient 1');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
|
||||||
await page.getByPlaceholder('Email').nth(1).fill('duplicate@example.com');
|
|
||||||
await page.getByPlaceholder('Name').nth(1).fill('Duplicate Recipient 2');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
|
||||||
await page.getByPlaceholder('Email').nth(2).fill('different@example.com');
|
|
||||||
await page.getByPlaceholder('Name').nth(2).fill('Different Recipient');
|
|
||||||
|
|
||||||
// Continue and add fields
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
|
|
||||||
// Add fields for each recipient
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
|
||||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
|
||||||
|
|
||||||
await page.getByRole('combobox').first().click();
|
|
||||||
await page.getByText('Duplicate Recipient 2').first().click();
|
|
||||||
await page.getByRole('button', { name: 'Date' }).click();
|
|
||||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
|
||||||
|
|
||||||
await page.getByRole('combobox').first().click();
|
|
||||||
await page.getByText('Different Recipient').first().click();
|
|
||||||
await page.getByRole('button', { name: 'Name' }).click();
|
|
||||||
await page.locator('canvas').click({ position: { x: 100, y: 200 } });
|
|
||||||
|
|
||||||
// Save template
|
|
||||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
|
||||||
|
|
||||||
await page.waitForURL(`/t/${team.url}/templates`);
|
|
||||||
|
|
||||||
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should validate field assignments per recipient in template editing', async ({ page }) => {
|
|
||||||
const { user, team } = await seedUser();
|
|
||||||
|
|
||||||
const template = await seedBlankTemplate(user, team.id);
|
|
||||||
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: user.email,
|
|
||||||
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create template with duplicates
|
|
||||||
await completeTemplateFlowWithDuplicateRecipients({ page, team, template });
|
|
||||||
|
|
||||||
// Navigate back to edit the template
|
|
||||||
await page.goto(`/t/${team.url}/templates/${template.id}/edit`);
|
|
||||||
|
|
||||||
// Go to fields step
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click(); // Settings
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click(); // Signers
|
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
|
||||||
|
|
||||||
// Verify fields are correctly assigned to each recipient instance
|
|
||||||
await page.getByRole('combobox').first().click();
|
|
||||||
await page.getByRole('option', { name: 'First Instance' }).first().click();
|
|
||||||
let visibleFields = await page.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`).all();
|
|
||||||
expect(visibleFields.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
await page.getByRole('combobox').first().click();
|
|
||||||
await page.getByRole('option', { name: 'Second Instance' }).first().click();
|
|
||||||
visibleFields = await page.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`).all();
|
|
||||||
expect(visibleFields.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
await page.getByRole('combobox').first().click();
|
|
||||||
await page.getByRole('option', { name: 'Different Recipient' }).first().click();
|
|
||||||
const nameFields = await page.locator(`[data-field-type="NAME"]:not(:disabled)`).all();
|
|
||||||
expect(nameFields.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// Add additional field to verify proper assignment
|
|
||||||
await page.getByRole('combobox').first().click();
|
|
||||||
await page.getByRole('option', { name: 'First Instance' }).first().click();
|
|
||||||
await page.getByRole('button', { name: 'Name' }).click();
|
|
||||||
await page.locator('canvas').click({ position: { x: 100, y: 300 } });
|
|
||||||
|
|
||||||
await page.waitForTimeout(2500);
|
|
||||||
|
|
||||||
// Save changes
|
|
||||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
|
||||||
|
|
||||||
await page.waitForURL(`/t/${team.url}/templates`);
|
|
||||||
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -33,7 +33,7 @@ const setupTemplateAndNavigateToFieldsStep = async (page: Page) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const triggerAutosave = async (page: Page) => {
|
const triggerAutosave = async (page: Page) => {
|
||||||
await page.locator('body').click({ position: { x: 0, y: 0 } });
|
await page.locator('#document-flow-form-container').click();
|
||||||
await page.locator('#document-flow-form-container').blur();
|
await page.locator('#document-flow-form-container').blur();
|
||||||
|
|
||||||
await page.waitForTimeout(5000);
|
await page.waitForTimeout(5000);
|
||||||
@ -70,7 +70,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
|
|
||||||
await triggerAutosave(page);
|
await triggerAutosave(page);
|
||||||
|
|
||||||
await page.getByRole('combobox').first().click();
|
await page.getByRole('combobox').click();
|
||||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
@ -129,7 +129,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
|
|
||||||
await triggerAutosave(page);
|
await triggerAutosave(page);
|
||||||
|
|
||||||
await page.getByRole('combobox').first().click();
|
await page.getByRole('combobox').click();
|
||||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
@ -142,7 +142,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
|
|
||||||
await triggerAutosave(page);
|
await triggerAutosave(page);
|
||||||
|
|
||||||
await page.getByRole('combobox').first().click();
|
await page.getByRole('combobox').click();
|
||||||
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
|
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
|
||||||
|
|
||||||
await page.getByText('Text').nth(1).click();
|
await page.getByText('Text').nth(1).click();
|
||||||
@ -195,7 +195,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
|
|
||||||
await triggerAutosave(page);
|
await triggerAutosave(page);
|
||||||
|
|
||||||
await page.getByRole('combobox').first().click();
|
await page.getByRole('combobox').click();
|
||||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
@ -208,7 +208,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
|
|
||||||
await triggerAutosave(page);
|
await triggerAutosave(page);
|
||||||
|
|
||||||
await page.getByRole('combobox').first().click();
|
await page.getByRole('combobox').click();
|
||||||
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
|
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
|
||||||
|
|
||||||
await page.getByText('Signature').nth(1).click();
|
await page.getByText('Signature').nth(1).click();
|
||||||
|
|||||||
@ -23,7 +23,7 @@ const setupTemplate = async (page: Page) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const triggerAutosave = async (page: Page) => {
|
const triggerAutosave = async (page: Page) => {
|
||||||
await page.locator('body').click({ position: { x: 0, y: 0 } });
|
await page.locator('#document-flow-form-container').click();
|
||||||
await page.locator('#document-flow-form-container').blur();
|
await page.locator('#document-flow-form-container').blur();
|
||||||
|
|
||||||
await page.waitForTimeout(5000);
|
await page.waitForTimeout(5000);
|
||||||
|
|||||||
@ -26,7 +26,7 @@ const setupTemplateAndNavigateToSignersStep = async (page: Page) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const triggerAutosave = async (page: Page) => {
|
const triggerAutosave = async (page: Page) => {
|
||||||
await page.locator('body').click({ position: { x: 0, y: 0 } });
|
await page.locator('#document-flow-form-container').click();
|
||||||
await page.locator('#document-flow-form-container').blur();
|
await page.locator('#document-flow-form-container').blur();
|
||||||
|
|
||||||
await page.waitForTimeout(5000);
|
await page.waitForTimeout(5000);
|
||||||
|
|||||||
@ -47,8 +47,8 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
|
|||||||
|
|
||||||
// Set advanced options.
|
// Set advanced options.
|
||||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||||
await page.locator('button').filter({ hasText: 'YYYY-MM-DD hh:mm AM/PM' }).click();
|
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
|
||||||
await page.getByLabel('DD/MM/YYYY HH:mm', { exact: true }).click();
|
await page.getByLabel('DD/MM/YYYY').click();
|
||||||
|
|
||||||
await page.locator('.time-zone-field').click();
|
await page.locator('.time-zone-field').click();
|
||||||
await page.getByRole('option', { name: 'Etc/UTC' }).click();
|
await page.getByRole('option', { name: 'Etc/UTC' }).click();
|
||||||
@ -96,7 +96,7 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
|
|||||||
expect(document.title).toEqual('TEMPLATE_TITLE');
|
expect(document.title).toEqual('TEMPLATE_TITLE');
|
||||||
expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT');
|
expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT');
|
||||||
|
|
||||||
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy HH:mm');
|
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
|
||||||
expect(document.documentMeta?.message).toEqual('MESSAGE');
|
expect(document.documentMeta?.message).toEqual('MESSAGE');
|
||||||
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
|
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
|
||||||
expect(document.documentMeta?.subject).toEqual('SUBJECT');
|
expect(document.documentMeta?.subject).toEqual('SUBJECT');
|
||||||
@ -150,8 +150,8 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
|
|||||||
|
|
||||||
// Set advanced options.
|
// Set advanced options.
|
||||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||||
await page.locator('button').filter({ hasText: 'YYYY-MM-DD hh:mm AM/PM' }).click();
|
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
|
||||||
await page.getByLabel('DD/MM/YYYY HH:mm', { exact: true }).click();
|
await page.getByLabel('DD/MM/YYYY').click();
|
||||||
|
|
||||||
await page.locator('.time-zone-field').click();
|
await page.locator('.time-zone-field').click();
|
||||||
await page.getByRole('option', { name: 'Etc/UTC' }).click();
|
await page.getByRole('option', { name: 'Etc/UTC' }).click();
|
||||||
@ -200,7 +200,7 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
|
|||||||
|
|
||||||
expect(document.title).toEqual('TEMPLATE_TITLE');
|
expect(document.title).toEqual('TEMPLATE_TITLE');
|
||||||
expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT');
|
expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT');
|
||||||
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy HH:mm');
|
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
|
||||||
expect(document.documentMeta?.message).toEqual('MESSAGE');
|
expect(document.documentMeta?.message).toEqual('MESSAGE');
|
||||||
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
|
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
|
||||||
expect(document.documentMeta?.subject).toEqual('SUBJECT');
|
expect(document.documentMeta?.subject).toEqual('SUBJECT');
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export default defineConfig({
|
|||||||
testDir: './e2e',
|
testDir: './e2e',
|
||||||
/* Run tests in files in parallel */
|
/* Run tests in files in parallel */
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 2,
|
workers: 4,
|
||||||
maxFailures: process.env.CI ? 1 : undefined,
|
maxFailures: process.env.CI ? 1 : undefined,
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
@ -33,7 +33,7 @@ export default defineConfig({
|
|||||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
trace: 'on',
|
trace: 'on',
|
||||||
|
|
||||||
video: 'on-first-retry',
|
video: 'retain-on-failure',
|
||||||
|
|
||||||
/* Add explicit timeouts for actions */
|
/* Add explicit timeouts for actions */
|
||||||
actionTimeout: 15_000,
|
actionTimeout: 15_000,
|
||||||
@ -48,7 +48,7 @@ export default defineConfig({
|
|||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
use: {
|
use: {
|
||||||
...devices['Desktop Chrome'],
|
...devices['Desktop Chrome'],
|
||||||
viewport: { width: 1920, height: 1200 },
|
viewport: { width: 1920, height: 1080 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -1,56 +1,23 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
type SaveRequest<T, R> = {
|
export const useAutoSave = <T>(onSave: (data: T) => Promise<void>) => {
|
||||||
data: T;
|
|
||||||
onResponse?: (response: R) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useAutoSave = <T, R = void>(
|
|
||||||
onSave: (data: T) => Promise<R>,
|
|
||||||
options: { delay?: number } = {},
|
|
||||||
) => {
|
|
||||||
const { delay = 2000 } = options;
|
|
||||||
|
|
||||||
const saveTimeoutRef = useRef<NodeJS.Timeout>();
|
const saveTimeoutRef = useRef<NodeJS.Timeout>();
|
||||||
const saveQueueRef = useRef<SaveRequest<T, R>[]>([]);
|
|
||||||
const isProcessingRef = useRef(false);
|
|
||||||
|
|
||||||
const processQueue = async () => {
|
const saveFormData = async (data: T) => {
|
||||||
if (isProcessingRef.current || saveQueueRef.current.length === 0) {
|
try {
|
||||||
return;
|
await onSave(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auto-save failed:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
isProcessingRef.current = true;
|
|
||||||
|
|
||||||
while (saveQueueRef.current.length > 0) {
|
|
||||||
const request = saveQueueRef.current.shift()!;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await onSave(request.data);
|
|
||||||
request.onResponse?.(response);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Auto-save failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isProcessingRef.current = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveFormData = async (data: T, onResponse?: (response: R) => void) => {
|
const scheduleSave = useCallback((data: T) => {
|
||||||
saveQueueRef.current.push({ data, onResponse });
|
if (saveTimeoutRef.current) {
|
||||||
await processQueue();
|
clearTimeout(saveTimeoutRef.current);
|
||||||
};
|
}
|
||||||
|
|
||||||
const scheduleSave = useCallback(
|
saveTimeoutRef.current = setTimeout(() => void saveFormData(data), 2000);
|
||||||
(data: T, onResponse?: (response: R) => void) => {
|
}, []);
|
||||||
if (saveTimeoutRef.current) {
|
|
||||||
clearTimeout(saveTimeoutRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveTimeoutRef.current = setTimeout(() => void saveFormData(data, onResponse), delay);
|
|
||||||
},
|
|
||||||
[delay],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@ -7,25 +7,14 @@ export const DEFAULT_DOCUMENT_DATE_FORMAT = 'yyyy-MM-dd hh:mm a';
|
|||||||
export const VALID_DATE_FORMAT_VALUES = [
|
export const VALID_DATE_FORMAT_VALUES = [
|
||||||
DEFAULT_DOCUMENT_DATE_FORMAT,
|
DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||||
'yyyy-MM-dd',
|
'yyyy-MM-dd',
|
||||||
'dd/MM/yyyy',
|
|
||||||
'MM/dd/yyyy',
|
|
||||||
'yy-MM-dd',
|
|
||||||
'MMMM dd, yyyy',
|
|
||||||
'EEEE, MMMM dd, yyyy',
|
|
||||||
'dd/MM/yyyy hh:mm a',
|
'dd/MM/yyyy hh:mm a',
|
||||||
'dd/MM/yyyy HH:mm',
|
|
||||||
'MM/dd/yyyy hh:mm a',
|
'MM/dd/yyyy hh:mm a',
|
||||||
'MM/dd/yyyy HH:mm',
|
|
||||||
'dd.MM.yyyy',
|
|
||||||
'dd.MM.yyyy HH:mm',
|
'dd.MM.yyyy HH:mm',
|
||||||
'yyyy-MM-dd HH:mm',
|
'yyyy-MM-dd HH:mm',
|
||||||
'yy-MM-dd hh:mm a',
|
'yy-MM-dd hh:mm a',
|
||||||
'yy-MM-dd HH:mm',
|
|
||||||
'yyyy-MM-dd HH:mm:ss',
|
'yyyy-MM-dd HH:mm:ss',
|
||||||
'MMMM dd, yyyy hh:mm a',
|
'MMMM dd, yyyy hh:mm a',
|
||||||
'MMMM dd, yyyy HH:mm',
|
|
||||||
'EEEE, MMMM dd, yyyy hh:mm a',
|
'EEEE, MMMM dd, yyyy hh:mm a',
|
||||||
'EEEE, MMMM dd, yyyy HH:mm',
|
|
||||||
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
|
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@ -33,80 +22,10 @@ export type ValidDateFormat = (typeof VALID_DATE_FORMAT_VALUES)[number];
|
|||||||
|
|
||||||
export const DATE_FORMATS = [
|
export const DATE_FORMATS = [
|
||||||
{
|
{
|
||||||
key: 'yyyy-MM-dd_HH:mm_12H',
|
key: 'yyyy-MM-dd_hh:mm_a',
|
||||||
label: 'YYYY-MM-DD hh:mm AM/PM',
|
label: 'YYYY-MM-DD HH:mm a',
|
||||||
value: DEFAULT_DOCUMENT_DATE_FORMAT,
|
value: DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'yyyy-MM-dd_HH:mm',
|
|
||||||
label: 'YYYY-MM-DD HH:mm',
|
|
||||||
value: 'yyyy-MM-dd HH:mm',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'DDMMYYYY_TIME',
|
|
||||||
label: 'DD/MM/YYYY HH:mm',
|
|
||||||
value: 'dd/MM/yyyy HH:mm',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'DDMMYYYY_TIME_12H',
|
|
||||||
label: 'DD/MM/YYYY HH:mm AM/PM',
|
|
||||||
value: 'dd/MM/yyyy hh:mm a',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'MMDDYYYY_TIME',
|
|
||||||
label: 'MM/DD/YYYY HH:mm',
|
|
||||||
value: 'MM/dd/yyyy HH:mm',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'MMDDYYYY_TIME_12H',
|
|
||||||
label: 'MM/DD/YYYY HH:mm AM/PM',
|
|
||||||
value: 'MM/dd/yyyy hh:mm a',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'DDMMYYYYHHMM',
|
|
||||||
label: 'DD.MM.YYYY HH:mm',
|
|
||||||
value: 'dd.MM.yyyy HH:mm',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'YYMMDD_TIME',
|
|
||||||
label: 'YY-MM-DD HH:mm',
|
|
||||||
value: 'yy-MM-dd HH:mm',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'YYMMDD_TIME_12H',
|
|
||||||
label: 'YY-MM-DD HH:mm AM/PM',
|
|
||||||
value: 'yy-MM-dd hh:mm a',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'YYYY_MM_DD_HH_MM_SS',
|
|
||||||
label: 'YYYY-MM-DD HH:mm:ss',
|
|
||||||
value: 'yyyy-MM-dd HH:mm:ss',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'MonthDateYear_TIME',
|
|
||||||
label: 'Month Date, Year HH:mm',
|
|
||||||
value: 'MMMM dd, yyyy HH:mm',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'MonthDateYear_TIME_12H',
|
|
||||||
label: 'Month Date, Year HH:mm AM/PM',
|
|
||||||
value: 'MMMM dd, yyyy hh:mm a',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'DayMonthYear_TIME',
|
|
||||||
label: 'Day, Month Year HH:mm',
|
|
||||||
value: 'EEEE, MMMM dd, yyyy HH:mm',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'DayMonthYear_TIME_12H',
|
|
||||||
label: 'Day, Month Year HH:mm AM/PM',
|
|
||||||
value: 'EEEE, MMMM dd, yyyy hh:mm a',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'ISO8601',
|
|
||||||
label: 'ISO 8601',
|
|
||||||
value: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'YYYYMMDD',
|
key: 'YYYYMMDD',
|
||||||
label: 'YYYY-MM-DD',
|
label: 'YYYY-MM-DD',
|
||||||
@ -115,32 +34,47 @@ export const DATE_FORMATS = [
|
|||||||
{
|
{
|
||||||
key: 'DDMMYYYY',
|
key: 'DDMMYYYY',
|
||||||
label: 'DD/MM/YYYY',
|
label: 'DD/MM/YYYY',
|
||||||
value: 'dd/MM/yyyy',
|
value: 'dd/MM/yyyy hh:mm a',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'MMDDYYYY',
|
key: 'MMDDYYYY',
|
||||||
label: 'MM/DD/YYYY',
|
label: 'MM/DD/YYYY',
|
||||||
value: 'MM/dd/yyyy',
|
value: 'MM/dd/yyyy hh:mm a',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'DDMMYYYY_DOT',
|
key: 'DDMMYYYYHHMM',
|
||||||
label: 'DD.MM.YYYY',
|
label: 'DD.MM.YYYY HH:mm',
|
||||||
value: 'dd.MM.yyyy',
|
value: 'dd.MM.yyyy HH:mm',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'YYYYMMDDHHmm',
|
||||||
|
label: 'YYYY-MM-DD HH:mm',
|
||||||
|
value: 'yyyy-MM-dd HH:mm',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'YYMMDD',
|
key: 'YYMMDD',
|
||||||
label: 'YY-MM-DD',
|
label: 'YY-MM-DD',
|
||||||
value: 'yy-MM-dd',
|
value: 'yy-MM-dd hh:mm a',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'YYYYMMDDhhmmss',
|
||||||
|
label: 'YYYY-MM-DD HH:mm:ss',
|
||||||
|
value: 'yyyy-MM-dd HH:mm:ss',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'MonthDateYear',
|
key: 'MonthDateYear',
|
||||||
label: 'Month Date, Year',
|
label: 'Month Date, Year',
|
||||||
value: 'MMMM dd, yyyy',
|
value: 'MMMM dd, yyyy hh:mm a',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'DayMonthYear',
|
key: 'DayMonthYear',
|
||||||
label: 'Day, Month Year',
|
label: 'Day, Month Year',
|
||||||
value: 'EEEE, MMMM dd, yyyy',
|
value: 'EEEE, MMMM dd, yyyy hh:mm a',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ISO8601',
|
||||||
|
label: 'ISO 8601',
|
||||||
|
value: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
|
||||||
},
|
},
|
||||||
] satisfies {
|
] satisfies {
|
||||||
key: string;
|
key: string;
|
||||||
|
|||||||
@ -2,25 +2,18 @@ import * as fs from 'node:fs';
|
|||||||
|
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
|
|
||||||
export const getCertificateStatus = () => {
|
export type CertificateStatus = {
|
||||||
if (env('NEXT_PRIVATE_SIGNING_TRANSPORT') !== 'local') {
|
isAvailable: boolean;
|
||||||
return { isAvailable: true };
|
};
|
||||||
}
|
|
||||||
|
|
||||||
if (env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS')) {
|
|
||||||
return { isAvailable: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export const getCertificateStatus = (): CertificateStatus => {
|
||||||
const defaultPath =
|
const defaultPath =
|
||||||
env('NODE_ENV') === 'production' ? '/opt/documenso/cert.p12' : './example/cert.p12';
|
env('NODE_ENV') === 'production' ? '/opt/documenso/cert.p12' : './example/cert.p12';
|
||||||
|
|
||||||
const filePath = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH') || defaultPath;
|
const filePath = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH') || defaultPath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs.accessSync(filePath, fs.constants.F_OK | fs.constants.R_OK);
|
fs.accessSync(filePath, fs.constants.F_OK | fs.constants.R_OK);
|
||||||
|
|
||||||
const stats = fs.statSync(filePath);
|
const stats = fs.statSync(filePath);
|
||||||
|
|
||||||
return { isAvailable: stats.size > 0 };
|
return { isAvailable: stats.size > 0 };
|
||||||
} catch {
|
} catch {
|
||||||
return { isAvailable: false };
|
return { isAvailable: false };
|
||||||
|
|||||||
@ -91,12 +91,6 @@ export const getDocumentAndSenderByToken = async ({
|
|||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
teamEmail: true,
|
teamEmail: true,
|
||||||
teamGlobalSettings: {
|
|
||||||
select: {
|
|
||||||
brandingEnabled: true,
|
|
||||||
brandingLogo: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -0,0 +1,84 @@
|
|||||||
|
import { deletedAccountServiceAccount } from '@documenso/lib/server-only/user/service-accounts/deleted-account';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
type HandleDocumentOwnershipOnDeletionOptions = {
|
||||||
|
documentIds: number[];
|
||||||
|
organisationOwnerId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleDocumentOwnershipOnDeletion = async ({
|
||||||
|
documentIds,
|
||||||
|
organisationOwnerId,
|
||||||
|
}: HandleDocumentOwnershipOnDeletionOptions) => {
|
||||||
|
if (documentIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceAccount = await deletedAccountServiceAccount();
|
||||||
|
const serviceAccountTeam = serviceAccount.ownedOrganisations[0].teams[0];
|
||||||
|
|
||||||
|
await prisma.document.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
in: documentIds,
|
||||||
|
},
|
||||||
|
status: DocumentStatus.DRAFT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const organisationOwner = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
id: organisationOwnerId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
ownedOrganisations: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
teams: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (organisationOwner && organisationOwner.ownedOrganisations.length > 0) {
|
||||||
|
const ownerPersonalTeam = organisationOwner.ownedOrganisations[0].teams[0];
|
||||||
|
|
||||||
|
await prisma.document.updateMany({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
in: documentIds,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
in: [DocumentStatus.PENDING, DocumentStatus.REJECTED, DocumentStatus.COMPLETED],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
userId: organisationOwner.id,
|
||||||
|
teamId: ownerPersonalTeam.id,
|
||||||
|
deletedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.document.updateMany({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
in: documentIds,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
in: [DocumentStatus.PENDING, DocumentStatus.REJECTED, DocumentStatus.COMPLETED],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
userId: serviceAccount.id,
|
||||||
|
teamId: serviceAccountTeam.id,
|
||||||
|
deletedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -84,7 +84,9 @@ export const setFieldsForDocument = async ({
|
|||||||
const linkedFields = fields.map((field) => {
|
const linkedFields = fields.map((field) => {
|
||||||
const existing = existingFields.find((existingField) => existingField.id === field.id);
|
const existing = existingFields.find((existingField) => existingField.id === field.id);
|
||||||
|
|
||||||
const recipient = document.recipients.find((recipient) => recipient.id === field.recipientId);
|
const recipient = document.recipients.find(
|
||||||
|
(recipient) => recipient.email.toLowerCase() === field.signerEmail.toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
// Each field MUST have a recipient associated with it.
|
// Each field MUST have a recipient associated with it.
|
||||||
if (!recipient) {
|
if (!recipient) {
|
||||||
@ -224,8 +226,10 @@ export const setFieldsForDocument = async ({
|
|||||||
},
|
},
|
||||||
recipient: {
|
recipient: {
|
||||||
connect: {
|
connect: {
|
||||||
id: field.recipientId,
|
documentId_email: {
|
||||||
documentId,
|
documentId,
|
||||||
|
email: fieldSignerEmail,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -326,7 +330,6 @@ type FieldData = {
|
|||||||
id?: number | null;
|
id?: number | null;
|
||||||
type: FieldType;
|
type: FieldType;
|
||||||
signerEmail: string;
|
signerEmail: string;
|
||||||
recipientId: number;
|
|
||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
pageX: number;
|
pageX: number;
|
||||||
pageY: number;
|
pageY: number;
|
||||||
|
|||||||
@ -26,7 +26,6 @@ export type SetFieldsForTemplateOptions = {
|
|||||||
id?: number | null;
|
id?: number | null;
|
||||||
type: FieldType;
|
type: FieldType;
|
||||||
signerEmail: string;
|
signerEmail: string;
|
||||||
recipientId: number;
|
|
||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
pageX: number;
|
pageX: number;
|
||||||
pageY: number;
|
pageY: number;
|
||||||
@ -170,8 +169,10 @@ export const setFieldsForTemplate = async ({
|
|||||||
},
|
},
|
||||||
recipient: {
|
recipient: {
|
||||||
connect: {
|
connect: {
|
||||||
id: field.recipientId,
|
templateId_email: {
|
||||||
templateId,
|
templateId,
|
||||||
|
email: field.signerEmail.toLowerCase(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -85,6 +85,20 @@ export const createDocumentRecipients = async ({
|
|||||||
email: recipient.email.toLowerCase(),
|
email: recipient.email.toLowerCase(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const duplicateRecipients = normalizedRecipients.filter((newRecipient) => {
|
||||||
|
const existingRecipient = document.recipients.find(
|
||||||
|
(existingRecipient) => existingRecipient.email === newRecipient.email,
|
||||||
|
);
|
||||||
|
|
||||||
|
return existingRecipient !== undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (duplicateRecipients.length > 0) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: `Duplicate recipient(s) found for ${duplicateRecipients.map((recipient) => recipient.email).join(', ')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const createdRecipients = await prisma.$transaction(async (tx) => {
|
const createdRecipients = await prisma.$transaction(async (tx) => {
|
||||||
return await Promise.all(
|
return await Promise.all(
|
||||||
normalizedRecipients.map(async (recipient) => {
|
normalizedRecipients.map(async (recipient) => {
|
||||||
|
|||||||
@ -71,6 +71,20 @@ export const createTemplateRecipients = async ({
|
|||||||
email: recipient.email.toLowerCase(),
|
email: recipient.email.toLowerCase(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const duplicateRecipients = normalizedRecipients.filter((newRecipient) => {
|
||||||
|
const existingRecipient = template.recipients.find(
|
||||||
|
(existingRecipient) => existingRecipient.email === newRecipient.email,
|
||||||
|
);
|
||||||
|
|
||||||
|
return existingRecipient !== undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (duplicateRecipients.length > 0) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: `Duplicate recipient(s) found for ${duplicateRecipients.map((recipient) => recipient.email).join(', ')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const createdRecipients = await prisma.$transaction(async (tx) => {
|
const createdRecipients = await prisma.$transaction(async (tx) => {
|
||||||
return await Promise.all(
|
return await Promise.all(
|
||||||
normalizedRecipients.map(async (recipient) => {
|
normalizedRecipients.map(async (recipient) => {
|
||||||
|
|||||||
@ -122,12 +122,16 @@ export const setDocumentRecipients = async ({
|
|||||||
|
|
||||||
const removedRecipients = existingRecipients.filter(
|
const removedRecipients = existingRecipients.filter(
|
||||||
(existingRecipient) =>
|
(existingRecipient) =>
|
||||||
!normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
|
!normalizedRecipients.find(
|
||||||
|
(recipient) =>
|
||||||
|
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const linkedRecipients = normalizedRecipients.map((recipient) => {
|
const linkedRecipients = normalizedRecipients.map((recipient) => {
|
||||||
const existing = existingRecipients.find(
|
const existing = existingRecipients.find(
|
||||||
(existingRecipient) => existingRecipient.id === recipient.id,
|
(existingRecipient) =>
|
||||||
|
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
|
||||||
);
|
);
|
||||||
|
|
||||||
const canPersistedRecipientBeModified =
|
const canPersistedRecipientBeModified =
|
||||||
|
|||||||
@ -94,7 +94,10 @@ export const setTemplateRecipients = async ({
|
|||||||
|
|
||||||
const removedRecipients = existingRecipients.filter(
|
const removedRecipients = existingRecipients.filter(
|
||||||
(existingRecipient) =>
|
(existingRecipient) =>
|
||||||
!normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
|
!normalizedRecipients.find(
|
||||||
|
(recipient) =>
|
||||||
|
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (template.directLink !== null) {
|
if (template.directLink !== null) {
|
||||||
@ -121,7 +124,8 @@ export const setTemplateRecipients = async ({
|
|||||||
|
|
||||||
const linkedRecipients = normalizedRecipients.map((recipient) => {
|
const linkedRecipients = normalizedRecipients.map((recipient) => {
|
||||||
const existing = existingRecipients.find(
|
const existing = existingRecipients.find(
|
||||||
(existingRecipient) => existingRecipient.id === recipient.id,
|
(existingRecipient) =>
|
||||||
|
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -91,6 +91,17 @@ export const updateDocumentRecipients = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const duplicateRecipientWithSameEmail = document.recipients.find(
|
||||||
|
(existingRecipient) =>
|
||||||
|
existingRecipient.email === recipient.email && existingRecipient.id !== recipient.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (duplicateRecipientWithSameEmail) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: `Duplicate recipient with the same email found: ${duplicateRecipientWithSameEmail.email}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!canRecipientBeModified(originalRecipient, document.fields)) {
|
if (!canRecipientBeModified(originalRecipient, document.fields)) {
|
||||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
message: 'Cannot modify a recipient who has already interacted with the document',
|
message: 'Cannot modify a recipient who has already interacted with the document',
|
||||||
|
|||||||
@ -80,6 +80,17 @@ export const updateTemplateRecipients = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const duplicateRecipientWithSameEmail = template.recipients.find(
|
||||||
|
(existingRecipient) =>
|
||||||
|
existingRecipient.email === recipient.email && existingRecipient.id !== recipient.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (duplicateRecipientWithSameEmail) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: `Duplicate recipient with the same email found: ${duplicateRecipientWithSameEmail.email}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
originalRecipient,
|
originalRecipient,
|
||||||
recipientUpdateData: recipient,
|
recipientUpdateData: recipient,
|
||||||
|
|||||||
@ -19,8 +19,6 @@ export type CreateDocumentFromTemplateLegacyOptions = {
|
|||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// !TODO: Make this work
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy server function for /api/v1
|
* Legacy server function for /api/v1
|
||||||
*/
|
*/
|
||||||
@ -60,15 +58,6 @@ export const createDocumentFromTemplateLegacy = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const recipientsToCreate = template.recipients.map((recipient) => ({
|
|
||||||
id: recipient.id,
|
|
||||||
email: recipient.email,
|
|
||||||
name: recipient.name,
|
|
||||||
role: recipient.role,
|
|
||||||
signingOrder: recipient.signingOrder,
|
|
||||||
token: nanoid(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const document = await prisma.document.create({
|
const document = await prisma.document.create({
|
||||||
data: {
|
data: {
|
||||||
qrToken: prefixedId('qr'),
|
qrToken: prefixedId('qr'),
|
||||||
@ -81,12 +70,12 @@ export const createDocumentFromTemplateLegacy = async ({
|
|||||||
documentDataId: documentData.id,
|
documentDataId: documentData.id,
|
||||||
useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false,
|
useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false,
|
||||||
recipients: {
|
recipients: {
|
||||||
create: recipientsToCreate.map((recipient) => ({
|
create: template.recipients.map((recipient) => ({
|
||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
name: recipient.name,
|
name: recipient.name,
|
||||||
role: recipient.role,
|
role: recipient.role,
|
||||||
signingOrder: recipient.signingOrder,
|
signingOrder: recipient.signingOrder,
|
||||||
token: recipient.token,
|
token: nanoid(),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
documentMeta: {
|
documentMeta: {
|
||||||
@ -106,11 +95,9 @@ export const createDocumentFromTemplateLegacy = async ({
|
|||||||
|
|
||||||
await prisma.field.createMany({
|
await prisma.field.createMany({
|
||||||
data: template.fields.map((field) => {
|
data: template.fields.map((field) => {
|
||||||
const recipient = recipientsToCreate.find((recipient) => recipient.id === field.recipientId);
|
const recipient = template.recipients.find((recipient) => recipient.id === field.recipientId);
|
||||||
|
|
||||||
const documentRecipient = document.recipients.find(
|
const documentRecipient = document.recipients.find((doc) => doc.email === recipient?.email);
|
||||||
(documentRecipient) => documentRecipient.token === recipient?.token,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!documentRecipient) {
|
if (!documentRecipient) {
|
||||||
throw new Error('Recipient not found.');
|
throw new Error('Recipient not found.');
|
||||||
@ -131,34 +118,30 @@ export const createDocumentFromTemplateLegacy = async ({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Replicate the old logic, get by index and create if we exceed the number of existing recipients.
|
|
||||||
if (recipients && recipients.length > 0) {
|
if (recipients && recipients.length > 0) {
|
||||||
await Promise.all(
|
document.recipients = await Promise.all(
|
||||||
recipients.map(async (recipient, index) => {
|
recipients.map(async (recipient, index) => {
|
||||||
const existingRecipient = document.recipients.at(index);
|
const existingRecipient = document.recipients.at(index);
|
||||||
|
|
||||||
if (existingRecipient) {
|
return await prisma.recipient.upsert({
|
||||||
return await prisma.recipient.update({
|
where: {
|
||||||
where: {
|
documentId_email: {
|
||||||
id: existingRecipient.id,
|
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
|
email: existingRecipient?.email ?? recipient.email,
|
||||||
},
|
},
|
||||||
data: {
|
},
|
||||||
name: recipient.name,
|
update: {
|
||||||
email: recipient.email,
|
|
||||||
role: recipient.role,
|
|
||||||
signingOrder: recipient.signingOrder,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return await prisma.recipient.create({
|
|
||||||
data: {
|
|
||||||
documentId: document.id,
|
|
||||||
name: recipient.name,
|
name: recipient.name,
|
||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
role: recipient.role,
|
role: recipient.role,
|
||||||
signingOrder: recipient.signingOrder,
|
signingOrder: recipient.signingOrder,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
documentId: document.id,
|
||||||
|
email: recipient.email,
|
||||||
|
name: recipient.name,
|
||||||
|
role: recipient.role,
|
||||||
|
signingOrder: recipient.signingOrder,
|
||||||
token: nanoid(),
|
token: nanoid(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -166,18 +149,5 @@ export const createDocumentFromTemplateLegacy = async ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gross but we need to do the additional fetch since we mutate above.
|
return document;
|
||||||
const updatedRecipients = await prisma.recipient.findMany({
|
|
||||||
where: {
|
|
||||||
documentId: document.id,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
id: 'asc',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...document,
|
|
||||||
recipients: updatedRecipients,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -53,7 +53,7 @@ import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
|||||||
|
|
||||||
type FinalRecipient = Pick<
|
type FinalRecipient = Pick<
|
||||||
Recipient,
|
Recipient,
|
||||||
'name' | 'email' | 'role' | 'authOptions' | 'signingOrder' | 'token'
|
'name' | 'email' | 'role' | 'authOptions' | 'signingOrder'
|
||||||
> & {
|
> & {
|
||||||
templateRecipientId: number;
|
templateRecipientId: number;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
@ -350,7 +350,6 @@ export const createDocumentFromTemplate = async ({
|
|||||||
role: templateRecipient.role,
|
role: templateRecipient.role,
|
||||||
signingOrder: foundRecipient?.signingOrder ?? templateRecipient.signingOrder,
|
signingOrder: foundRecipient?.signingOrder ?? templateRecipient.signingOrder,
|
||||||
authOptions: templateRecipient.authOptions,
|
authOptions: templateRecipient.authOptions,
|
||||||
token: nanoid(),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -442,7 +441,7 @@ export const createDocumentFromTemplate = async ({
|
|||||||
? SigningStatus.SIGNED
|
? SigningStatus.SIGNED
|
||||||
: SigningStatus.NOT_SIGNED,
|
: SigningStatus.NOT_SIGNED,
|
||||||
signingOrder: recipient.signingOrder,
|
signingOrder: recipient.signingOrder,
|
||||||
token: recipient.token,
|
token: nanoid(),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@ -501,8 +500,8 @@ export const createDocumentFromTemplate = async ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.values(finalRecipients).forEach(({ token, fields }) => {
|
Object.values(finalRecipients).forEach(({ email, fields }) => {
|
||||||
const recipient = document.recipients.find((recipient) => recipient.token === token);
|
const recipient = document.recipients.find((recipient) => recipient.email === email);
|
||||||
|
|
||||||
if (!recipient) {
|
if (!recipient) {
|
||||||
throw new Error('Recipient not found.');
|
throw new Error('Recipient not found.');
|
||||||
|
|||||||
@ -5,6 +5,20 @@ export const deletedAccountServiceAccount = async () => {
|
|||||||
where: {
|
where: {
|
||||||
email: 'deleted-account@documenso.com',
|
email: 'deleted-account@documenso.com',
|
||||||
},
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
ownedOrganisations: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
teams: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!serviceAccount) {
|
if (!serviceAccount) {
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
-- DropIndex
|
|
||||||
DROP INDEX "Recipient_documentId_email_key";
|
|
||||||
|
|
||||||
-- DropIndex
|
|
||||||
DROP INDEX "Recipient_templateId_email_key";
|
|
||||||
@ -527,6 +527,8 @@ model Recipient {
|
|||||||
fields Field[]
|
fields Field[]
|
||||||
signatures Signature[]
|
signatures Signature[]
|
||||||
|
|
||||||
|
@@unique([documentId, email])
|
||||||
|
@@unique([templateId, email])
|
||||||
@@index([documentId])
|
@@index([documentId])
|
||||||
@@index([templateId])
|
@@index([templateId])
|
||||||
@@index([token])
|
@@index([token])
|
||||||
|
|||||||
@ -1,124 +0,0 @@
|
|||||||
import { OrganisationGroupType, OrganisationMemberRole } from '@prisma/client';
|
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { generateDatabaseId } from '@documenso/lib/universal/id';
|
|
||||||
import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
|
|
||||||
import { adminProcedure } from '../trpc';
|
|
||||||
import {
|
|
||||||
ZPromoteMemberToOwnerRequestSchema,
|
|
||||||
ZPromoteMemberToOwnerResponseSchema,
|
|
||||||
} from './promote-member-to-owner.types';
|
|
||||||
|
|
||||||
export const promoteMemberToOwnerRoute = adminProcedure
|
|
||||||
.input(ZPromoteMemberToOwnerRequestSchema)
|
|
||||||
.output(ZPromoteMemberToOwnerResponseSchema)
|
|
||||||
.mutation(async ({ input, ctx }) => {
|
|
||||||
const { organisationId, userId } = input;
|
|
||||||
|
|
||||||
ctx.logger.info({
|
|
||||||
input: {
|
|
||||||
organisationId,
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// First, verify the organisation exists and get member details with groups
|
|
||||||
const organisation = await prisma.organisation.findUnique({
|
|
||||||
where: {
|
|
||||||
id: organisationId,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
groups: {
|
|
||||||
where: {
|
|
||||||
type: OrganisationGroupType.INTERNAL_ORGANISATION,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
members: {
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
organisationGroupMembers: {
|
|
||||||
include: {
|
|
||||||
group: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!organisation) {
|
|
||||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
||||||
message: 'Organisation not found',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the user is a member of the organisation
|
|
||||||
const [member] = organisation.members;
|
|
||||||
|
|
||||||
if (!member) {
|
|
||||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
||||||
message: 'User is not a member of this organisation',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the user is not already the owner
|
|
||||||
if (organisation.ownerUserId === userId) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
||||||
message: 'User is already the owner of this organisation',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current organisation role
|
|
||||||
const currentOrganisationRole = getHighestOrganisationRoleInGroup(
|
|
||||||
member.organisationGroupMembers.flatMap((member) => member.group),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Find the current and target organisation groups
|
|
||||||
const currentMemberGroup = organisation.groups.find(
|
|
||||||
(group) => group.organisationRole === currentOrganisationRole,
|
|
||||||
);
|
|
||||||
|
|
||||||
const adminGroup = organisation.groups.find(
|
|
||||||
(group) => group.organisationRole === OrganisationMemberRole.ADMIN,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!currentMemberGroup) {
|
|
||||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
|
||||||
message: 'Current member group not found',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!adminGroup) {
|
|
||||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
|
||||||
message: 'Admin group not found',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the organisation owner and member role in a transaction
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
// Update the organisation to set the new owner
|
|
||||||
await tx.organisation.update({
|
|
||||||
where: {
|
|
||||||
id: organisationId,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
ownerUserId: userId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Only update role if the user is not already an admin then add them to the admin group
|
|
||||||
if (currentOrganisationRole !== OrganisationMemberRole.ADMIN) {
|
|
||||||
await tx.organisationGroupMember.create({
|
|
||||||
data: {
|
|
||||||
id: generateDatabaseId('group_member'),
|
|
||||||
organisationMemberId: member.id,
|
|
||||||
groupId: adminGroup.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
export const ZPromoteMemberToOwnerRequestSchema = z.object({
|
|
||||||
organisationId: z.string().min(1),
|
|
||||||
userId: z.number().min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ZPromoteMemberToOwnerResponseSchema = z.void();
|
|
||||||
|
|
||||||
export type TPromoteMemberToOwnerRequest = z.infer<typeof ZPromoteMemberToOwnerRequestSchema>;
|
|
||||||
export type TPromoteMemberToOwnerResponse = z.infer<typeof ZPromoteMemberToOwnerResponseSchema>;
|
|
||||||
@ -12,7 +12,6 @@ import { findDocumentsRoute } from './find-documents';
|
|||||||
import { findSubscriptionClaimsRoute } from './find-subscription-claims';
|
import { findSubscriptionClaimsRoute } from './find-subscription-claims';
|
||||||
import { getAdminOrganisationRoute } from './get-admin-organisation';
|
import { getAdminOrganisationRoute } from './get-admin-organisation';
|
||||||
import { getUserRoute } from './get-user';
|
import { getUserRoute } from './get-user';
|
||||||
import { promoteMemberToOwnerRoute } from './promote-member-to-owner';
|
|
||||||
import { resealDocumentRoute } from './reseal-document';
|
import { resealDocumentRoute } from './reseal-document';
|
||||||
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
|
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
|
||||||
import { updateAdminOrganisationRoute } from './update-admin-organisation';
|
import { updateAdminOrganisationRoute } from './update-admin-organisation';
|
||||||
@ -28,9 +27,6 @@ export const adminRouter = router({
|
|||||||
create: createAdminOrganisationRoute,
|
create: createAdminOrganisationRoute,
|
||||||
update: updateAdminOrganisationRoute,
|
update: updateAdminOrganisationRoute,
|
||||||
},
|
},
|
||||||
organisationMember: {
|
|
||||||
promoteToOwner: promoteMemberToOwnerRoute,
|
|
||||||
},
|
|
||||||
claims: {
|
claims: {
|
||||||
find: findSubscriptionClaimsRoute,
|
find: findSubscriptionClaimsRoute,
|
||||||
create: createSubscriptionClaimRoute,
|
create: createSubscriptionClaimRoute,
|
||||||
|
|||||||
@ -78,7 +78,14 @@ export const ZCreateDocumentTemporaryRequestSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.refine(
|
||||||
|
(recipients) => {
|
||||||
|
const emails = recipients.map((recipient) => recipient.email);
|
||||||
|
|
||||||
|
return new Set(emails).size === emails.length;
|
||||||
|
},
|
||||||
|
{ message: 'Recipients must have unique emails' },
|
||||||
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
meta: z
|
meta: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@ -47,7 +47,14 @@ export const ZCreateEmbeddingDocumentRequestSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.refine(
|
||||||
|
(recipients) => {
|
||||||
|
const emails = recipients.map((recipient) => recipient.email);
|
||||||
|
|
||||||
|
return new Set(emails).size === emails.length;
|
||||||
|
},
|
||||||
|
{ message: 'Recipients must have unique emails' },
|
||||||
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
meta: z
|
meta: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@ -30,27 +30,36 @@ export const ZUpdateEmbeddingDocumentRequestSchema = z.object({
|
|||||||
documentId: z.number(),
|
documentId: z.number(),
|
||||||
title: ZDocumentTitleSchema,
|
title: ZDocumentTitleSchema,
|
||||||
externalId: ZDocumentExternalIdSchema.optional(),
|
externalId: ZDocumentExternalIdSchema.optional(),
|
||||||
recipients: z.array(
|
recipients: z
|
||||||
z.object({
|
.array(
|
||||||
id: z.number().optional(),
|
z.object({
|
||||||
email: z.string().toLowerCase().email().min(1),
|
id: z.number().optional(),
|
||||||
name: z.string(),
|
email: z.string().toLowerCase().email().min(1),
|
||||||
role: z.nativeEnum(RecipientRole),
|
name: z.string(),
|
||||||
signingOrder: z.number().optional(),
|
role: z.nativeEnum(RecipientRole),
|
||||||
fields: ZFieldAndMetaSchema.and(
|
signingOrder: z.number().optional(),
|
||||||
z.object({
|
fields: ZFieldAndMetaSchema.and(
|
||||||
id: z.number().optional(),
|
z.object({
|
||||||
pageNumber: ZFieldPageNumberSchema,
|
id: z.number().optional(),
|
||||||
pageX: ZFieldPageXSchema,
|
pageNumber: ZFieldPageNumberSchema,
|
||||||
pageY: ZFieldPageYSchema,
|
pageX: ZFieldPageXSchema,
|
||||||
width: ZFieldWidthSchema,
|
pageY: ZFieldPageYSchema,
|
||||||
height: ZFieldHeightSchema,
|
width: ZFieldWidthSchema,
|
||||||
}),
|
height: ZFieldHeightSchema,
|
||||||
)
|
}),
|
||||||
.array()
|
)
|
||||||
.optional(),
|
.array()
|
||||||
}),
|
.optional(),
|
||||||
),
|
}),
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(recipients) => {
|
||||||
|
const emails = recipients.map((recipient) => recipient.email);
|
||||||
|
|
||||||
|
return new Set(emails).size === emails.length;
|
||||||
|
},
|
||||||
|
{ message: 'Recipients must have unique emails' },
|
||||||
|
),
|
||||||
meta: z
|
meta: z
|
||||||
.object({
|
.object({
|
||||||
subject: ZDocumentMetaSubjectSchema.optional(),
|
subject: ZDocumentMetaSubjectSchema.optional(),
|
||||||
|
|||||||
@ -274,7 +274,6 @@ export const fieldRouter = router({
|
|||||||
fields: fields.map((field) => ({
|
fields: fields.map((field) => ({
|
||||||
id: field.nativeId,
|
id: field.nativeId,
|
||||||
signerEmail: field.signerEmail,
|
signerEmail: field.signerEmail,
|
||||||
recipientId: field.recipientId,
|
|
||||||
type: field.type,
|
type: field.type,
|
||||||
pageNumber: field.pageNumber,
|
pageNumber: field.pageNumber,
|
||||||
pageX: field.pageX,
|
pageX: field.pageX,
|
||||||
@ -514,7 +513,6 @@ export const fieldRouter = router({
|
|||||||
fields: fields.map((field) => ({
|
fields: fields.map((field) => ({
|
||||||
id: field.nativeId,
|
id: field.nativeId,
|
||||||
signerEmail: field.signerEmail,
|
signerEmail: field.signerEmail,
|
||||||
recipientId: field.recipientId,
|
|
||||||
type: field.type,
|
type: field.type,
|
||||||
pageNumber: field.pageNumber,
|
pageNumber: field.pageNumber,
|
||||||
pageX: field.pageX,
|
pageX: field.pageX,
|
||||||
|
|||||||
@ -114,7 +114,6 @@ export const ZSetDocumentFieldsRequestSchema = z.object({
|
|||||||
nativeId: z.number().optional(),
|
nativeId: z.number().optional(),
|
||||||
type: z.nativeEnum(FieldType),
|
type: z.nativeEnum(FieldType),
|
||||||
signerEmail: z.string().min(1),
|
signerEmail: z.string().min(1),
|
||||||
recipientId: z.number().min(1),
|
|
||||||
pageNumber: z.number().min(1),
|
pageNumber: z.number().min(1),
|
||||||
pageX: z.number().min(0),
|
pageX: z.number().min(0),
|
||||||
pageY: z.number().min(0),
|
pageY: z.number().min(0),
|
||||||
@ -137,7 +136,6 @@ export const ZSetFieldsForTemplateRequestSchema = z.object({
|
|||||||
nativeId: z.number().optional(),
|
nativeId: z.number().optional(),
|
||||||
type: z.nativeEnum(FieldType),
|
type: z.nativeEnum(FieldType),
|
||||||
signerEmail: z.string().min(1),
|
signerEmail: z.string().min(1),
|
||||||
recipientId: z.number().min(1),
|
|
||||||
pageNumber: z.number().min(1),
|
pageNumber: z.number().min(1),
|
||||||
pageX: z.number().min(0),
|
pageX: z.number().min(0),
|
||||||
pageY: z.number().min(0),
|
pageY: z.number().min(0),
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
ORGANISATION_USER_ACCOUNT_TYPE,
|
ORGANISATION_USER_ACCOUNT_TYPE,
|
||||||
} from '@documenso/lib/constants/organisations';
|
} from '@documenso/lib/constants/organisations';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { handleDocumentOwnershipOnDeletion } from '@documenso/lib/server-only/document/handle-document-ownership-on-deletion';
|
||||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
@ -32,6 +33,24 @@ export const deleteOrganisationRoute = authenticatedProcedure
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_ORGANISATION'],
|
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_ORGANISATION'],
|
||||||
}),
|
}),
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
owner: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
teams: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
documents: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!organisation) {
|
if (!organisation) {
|
||||||
@ -40,6 +59,15 @@ export const deleteOrganisationRoute = authenticatedProcedure
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const documentIds = organisation.teams.flatMap((team) => team.documents.map((doc) => doc.id));
|
||||||
|
|
||||||
|
if (documentIds && documentIds.length > 0) {
|
||||||
|
await handleDocumentOwnershipOnDeletion({
|
||||||
|
documentIds,
|
||||||
|
organisationOwnerId: organisation.owner.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
await tx.account.deleteMany({
|
await tx.account.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import { OrganisationType } from '@prisma/client';
|
|
||||||
|
|
||||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||||
@ -106,19 +104,6 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPersonalOrganisation = organisation.type === OrganisationType.PERSONAL;
|
|
||||||
const currentIncludeSenderDetails =
|
|
||||||
organisation.organisationGlobalSettings.includeSenderDetails;
|
|
||||||
|
|
||||||
const isChangingIncludeSenderDetails =
|
|
||||||
includeSenderDetails !== undefined && includeSenderDetails !== currentIncludeSenderDetails;
|
|
||||||
|
|
||||||
if (isPersonalOrganisation && isChangingIncludeSenderDetails) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: 'Personal organisations cannot update the sender details',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.organisation.update({
|
await prisma.organisation.update({
|
||||||
where: {
|
where: {
|
||||||
id: organisationId,
|
id: organisationId,
|
||||||
|
|||||||
@ -50,7 +50,16 @@ export const ZCreateDocumentRecipientResponseSchema = ZRecipientLiteSchema;
|
|||||||
|
|
||||||
export const ZCreateDocumentRecipientsRequestSchema = z.object({
|
export const ZCreateDocumentRecipientsRequestSchema = z.object({
|
||||||
documentId: z.number(),
|
documentId: z.number(),
|
||||||
recipients: z.array(ZCreateRecipientSchema),
|
recipients: z.array(ZCreateRecipientSchema).refine(
|
||||||
|
(recipients) => {
|
||||||
|
const emails = recipients.map((recipient) => recipient.email.toLowerCase());
|
||||||
|
|
||||||
|
return new Set(emails).size === emails.length;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'Recipients must have unique emails',
|
||||||
|
},
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZCreateDocumentRecipientsResponseSchema = z.object({
|
export const ZCreateDocumentRecipientsResponseSchema = z.object({
|
||||||
@ -66,7 +75,18 @@ export const ZUpdateDocumentRecipientResponseSchema = ZRecipientSchema;
|
|||||||
|
|
||||||
export const ZUpdateDocumentRecipientsRequestSchema = z.object({
|
export const ZUpdateDocumentRecipientsRequestSchema = z.object({
|
||||||
documentId: z.number(),
|
documentId: z.number(),
|
||||||
recipients: z.array(ZUpdateRecipientSchema),
|
recipients: z.array(ZUpdateRecipientSchema).refine(
|
||||||
|
(recipients) => {
|
||||||
|
const emails = recipients
|
||||||
|
.filter((recipient) => recipient.email !== undefined)
|
||||||
|
.map((recipient) => recipient.email?.toLowerCase());
|
||||||
|
|
||||||
|
return new Set(emails).size === emails.length;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'Recipients must have unique emails',
|
||||||
|
},
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZUpdateDocumentRecipientsResponseSchema = z.object({
|
export const ZUpdateDocumentRecipientsResponseSchema = z.object({
|
||||||
@ -77,19 +97,29 @@ export const ZDeleteDocumentRecipientRequestSchema = z.object({
|
|||||||
recipientId: z.number(),
|
recipientId: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZSetDocumentRecipientsRequestSchema = z.object({
|
export const ZSetDocumentRecipientsRequestSchema = z
|
||||||
documentId: z.number(),
|
.object({
|
||||||
recipients: z.array(
|
documentId: z.number(),
|
||||||
z.object({
|
recipients: z.array(
|
||||||
nativeId: z.number().optional(),
|
z.object({
|
||||||
email: z.string().toLowerCase().email().min(1).max(254),
|
nativeId: z.number().optional(),
|
||||||
name: z.string().max(255),
|
email: z.string().toLowerCase().email().min(1).max(254),
|
||||||
role: z.nativeEnum(RecipientRole),
|
name: z.string().max(255),
|
||||||
signingOrder: z.number().optional(),
|
role: z.nativeEnum(RecipientRole),
|
||||||
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
signingOrder: z.number().optional(),
|
||||||
}),
|
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
||||||
),
|
}),
|
||||||
});
|
),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(schema) => {
|
||||||
|
const emails = schema.recipients.map((recipient) => recipient.email.toLowerCase());
|
||||||
|
|
||||||
|
return new Set(emails).size === emails.length;
|
||||||
|
},
|
||||||
|
// Dirty hack to handle errors when .root is populated for an array type
|
||||||
|
{ message: 'Recipients must have unique emails', path: ['recipients__root'] },
|
||||||
|
);
|
||||||
|
|
||||||
export const ZSetDocumentRecipientsResponseSchema = z.object({
|
export const ZSetDocumentRecipientsResponseSchema = z.object({
|
||||||
recipients: ZRecipientLiteSchema.array(),
|
recipients: ZRecipientLiteSchema.array(),
|
||||||
@ -104,7 +134,16 @@ export const ZCreateTemplateRecipientResponseSchema = ZRecipientLiteSchema;
|
|||||||
|
|
||||||
export const ZCreateTemplateRecipientsRequestSchema = z.object({
|
export const ZCreateTemplateRecipientsRequestSchema = z.object({
|
||||||
templateId: z.number(),
|
templateId: z.number(),
|
||||||
recipients: z.array(ZCreateRecipientSchema),
|
recipients: z.array(ZCreateRecipientSchema).refine(
|
||||||
|
(recipients) => {
|
||||||
|
const emails = recipients.map((recipient) => recipient.email);
|
||||||
|
|
||||||
|
return new Set(emails).size === emails.length;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'Recipients must have unique emails',
|
||||||
|
},
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZCreateTemplateRecipientsResponseSchema = z.object({
|
export const ZCreateTemplateRecipientsResponseSchema = z.object({
|
||||||
@ -120,7 +159,18 @@ export const ZUpdateTemplateRecipientResponseSchema = ZRecipientSchema;
|
|||||||
|
|
||||||
export const ZUpdateTemplateRecipientsRequestSchema = z.object({
|
export const ZUpdateTemplateRecipientsRequestSchema = z.object({
|
||||||
templateId: z.number(),
|
templateId: z.number(),
|
||||||
recipients: z.array(ZUpdateRecipientSchema),
|
recipients: z.array(ZUpdateRecipientSchema).refine(
|
||||||
|
(recipients) => {
|
||||||
|
const emails = recipients
|
||||||
|
.filter((recipient) => recipient.email !== undefined)
|
||||||
|
.map((recipient) => recipient.email);
|
||||||
|
|
||||||
|
return new Set(emails).size === emails.length;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'Recipients must have unique emails',
|
||||||
|
},
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZUpdateTemplateRecipientsResponseSchema = z.object({
|
export const ZUpdateTemplateRecipientsResponseSchema = z.object({
|
||||||
@ -131,30 +181,43 @@ export const ZDeleteTemplateRecipientRequestSchema = z.object({
|
|||||||
recipientId: z.number(),
|
recipientId: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZSetTemplateRecipientsRequestSchema = z.object({
|
export const ZSetTemplateRecipientsRequestSchema = z
|
||||||
templateId: z.number(),
|
.object({
|
||||||
recipients: z.array(
|
templateId: z.number(),
|
||||||
z.object({
|
recipients: z.array(
|
||||||
nativeId: z.number().optional(),
|
z.object({
|
||||||
email: z
|
nativeId: z.number().optional(),
|
||||||
.string()
|
email: z
|
||||||
.toLowerCase()
|
.string()
|
||||||
.refine(
|
.toLowerCase()
|
||||||
(email) => {
|
.refine(
|
||||||
return (
|
(email) => {
|
||||||
isTemplateRecipientEmailPlaceholder(email) ||
|
return (
|
||||||
z.string().email().safeParse(email).success
|
isTemplateRecipientEmailPlaceholder(email) ||
|
||||||
);
|
z.string().email().safeParse(email).success
|
||||||
},
|
);
|
||||||
{ message: 'Please enter a valid email address' },
|
},
|
||||||
),
|
{ message: 'Please enter a valid email address' },
|
||||||
name: z.string(),
|
),
|
||||||
role: z.nativeEnum(RecipientRole),
|
name: z.string(),
|
||||||
signingOrder: z.number().optional(),
|
role: z.nativeEnum(RecipientRole),
|
||||||
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
signingOrder: z.number().optional(),
|
||||||
}),
|
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
||||||
),
|
}),
|
||||||
});
|
),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(schema) => {
|
||||||
|
// Filter out placeholder emails and only check uniqueness for actual emails
|
||||||
|
const nonPlaceholderEmails = schema.recipients
|
||||||
|
.map((recipient) => recipient.email)
|
||||||
|
.filter((email) => !isTemplateRecipientEmailPlaceholder(email));
|
||||||
|
|
||||||
|
return new Set(nonPlaceholderEmails).size === nonPlaceholderEmails.length;
|
||||||
|
},
|
||||||
|
// Dirty hack to handle errors when .root is populated for an array type
|
||||||
|
{ message: 'Recipients must have unique emails', path: ['recipients__root'] },
|
||||||
|
);
|
||||||
|
|
||||||
export const ZSetTemplateRecipientsResponseSchema = z.object({
|
export const ZSetTemplateRecipientsResponseSchema = z.object({
|
||||||
recipients: ZRecipientLiteSchema.array(),
|
recipients: ZRecipientLiteSchema.array(),
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
|
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||||
|
import { handleDocumentOwnershipOnDeletion } from '@documenso/lib/server-only/document/handle-document-ownership-on-deletion';
|
||||||
import { deleteTeam } from '@documenso/lib/server-only/team/delete-team';
|
import { deleteTeam } from '@documenso/lib/server-only/team/delete-team';
|
||||||
|
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { authenticatedProcedure } from '../trpc';
|
import { authenticatedProcedure } from '../trpc';
|
||||||
import { ZDeleteTeamRequestSchema, ZDeleteTeamResponseSchema } from './delete-team.types';
|
import { ZDeleteTeamRequestSchema, ZDeleteTeamResponseSchema } from './delete-team.types';
|
||||||
@ -11,12 +15,53 @@ export const deleteTeamRoute = authenticatedProcedure
|
|||||||
const { teamId } = input;
|
const { teamId } = input;
|
||||||
const { user } = ctx;
|
const { user } = ctx;
|
||||||
|
|
||||||
|
const team = await prisma.team.findUnique({
|
||||||
|
where: {
|
||||||
|
id: teamId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const organisation = await prisma.organisation.findFirst({
|
||||||
|
where: buildOrganisationWhereQuery({
|
||||||
|
organisationId: team?.organisationId,
|
||||||
|
userId: user.id,
|
||||||
|
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_ORGANISATION'],
|
||||||
|
}),
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
owner: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
teams: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
documents: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
ctx.logger.info({
|
ctx.logger.info({
|
||||||
input: {
|
input: {
|
||||||
teamId,
|
teamId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const documentIds = organisation?.teams.flatMap((team) => team.documents.map((doc) => doc.id));
|
||||||
|
|
||||||
|
if (documentIds && documentIds.length > 0 && organisation) {
|
||||||
|
await handleDocumentOwnershipOnDeletion({
|
||||||
|
documentIds,
|
||||||
|
organisationOwnerId: organisation.owner.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await deleteTeam({
|
await deleteTeam({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId,
|
teamId,
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { OrganisationType } from '@prisma/client';
|
|
||||||
|
|
||||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
|
||||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
|
||||||
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
|
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
@ -100,35 +97,6 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const organisation = await prisma.organisation.findFirst({
|
|
||||||
where: buildOrganisationWhereQuery({
|
|
||||||
organisationId: team.organisationId,
|
|
||||||
userId: user.id,
|
|
||||||
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
|
||||||
}),
|
|
||||||
select: {
|
|
||||||
type: true,
|
|
||||||
organisationGlobalSettings: {
|
|
||||||
select: {
|
|
||||||
includeSenderDetails: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const isPersonalOrganisation = organisation?.type === OrganisationType.PERSONAL;
|
|
||||||
const currentIncludeSenderDetails =
|
|
||||||
organisation?.organisationGlobalSettings.includeSenderDetails;
|
|
||||||
|
|
||||||
const isChangingIncludeSenderDetails =
|
|
||||||
includeSenderDetails !== undefined && includeSenderDetails !== currentIncludeSenderDetails;
|
|
||||||
|
|
||||||
if (isPersonalOrganisation && isChangingIncludeSenderDetails) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: 'Personal teams cannot update the sender details',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.team.update({
|
await prisma.team.update({
|
||||||
where: {
|
where: {
|
||||||
id: teamId,
|
id: teamId,
|
||||||
|
|||||||
@ -101,7 +101,12 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
|
|||||||
name: z.string().max(255).optional(),
|
name: z.string().max(255).optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.describe('The information of the recipients to create the document with.'),
|
.describe('The information of the recipients to create the document with.')
|
||||||
|
.refine((recipients) => {
|
||||||
|
const emails = recipients.map((signer) => signer.email);
|
||||||
|
|
||||||
|
return new Set(emails).size === emails.length;
|
||||||
|
}, 'Recipients must have unique emails'),
|
||||||
distributeDocument: z
|
distributeDocument: z
|
||||||
.boolean()
|
.boolean()
|
||||||
.describe('Whether to create the document as pending and distribute it to recipients.')
|
.describe('Whether to create the document as pending and distribute it to recipients.')
|
||||||
|
|||||||
@ -105,7 +105,6 @@ export const DocumentReadOnlyFields = ({
|
|||||||
<FieldRootContainer
|
<FieldRootContainer
|
||||||
field={field}
|
field={field}
|
||||||
key={field.id}
|
key={field.id}
|
||||||
readonly={true}
|
|
||||||
color={
|
color={
|
||||||
showRecipientColors
|
showRecipientColors
|
||||||
? getRecipientColorStyles(
|
? getRecipientColorStyles(
|
||||||
|
|||||||
@ -70,16 +70,9 @@ export type FieldRootContainerProps = {
|
|||||||
color?: RecipientColorStyles;
|
color?: RecipientColorStyles;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
readonly?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function FieldRootContainer({
|
export function FieldRootContainer({ field, children, color, className }: FieldRootContainerProps) {
|
||||||
field,
|
|
||||||
children,
|
|
||||||
color,
|
|
||||||
className,
|
|
||||||
readonly,
|
|
||||||
}: FieldRootContainerProps) {
|
|
||||||
const [isValidating, setIsValidating] = useState(false);
|
const [isValidating, setIsValidating] = useState(false);
|
||||||
const ref = React.useRef<HTMLDivElement>(null);
|
const ref = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@ -110,7 +103,6 @@ export function FieldRootContainer({
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
data-field-type={field.type}
|
data-field-type={field.type}
|
||||||
data-inserted={field.inserted ? 'true' : 'false'}
|
data-inserted={field.inserted ? 'true' : 'false'}
|
||||||
data-readonly={readonly ? 'true' : 'false'}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'field--FieldRootContainer field-card-container dark-mode-disabled group relative z-20 flex h-full w-full items-center rounded-[2px] bg-white/90 ring-2 ring-gray-200 transition-all',
|
'field--FieldRootContainer field-card-container dark-mode-disabled group relative z-20 flex h-full w-full items-center rounded-[2px] bg-white/90 ring-2 ring-gray-200 transition-all',
|
||||||
color?.base,
|
color?.base,
|
||||||
|
|||||||
@ -39,9 +39,7 @@ export interface BadgeProps
|
|||||||
VariantProps<typeof badgeVariants> {}
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
function Badge({ className, variant, size, ...props }: BadgeProps) {
|
function Badge({ className, variant, size, ...props }: BadgeProps) {
|
||||||
return (
|
return <div className={cn(badgeVariants({ variant, size }), className)} {...props} />;
|
||||||
<div role="status" className={cn(badgeVariants({ variant, size }), className)} {...props} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Badge, badgeVariants };
|
export { Badge, badgeVariants };
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
@ -47,7 +46,7 @@ import { Form } from '../form/form';
|
|||||||
import { RecipientSelector } from '../recipient-selector';
|
import { RecipientSelector } from '../recipient-selector';
|
||||||
import { useStep } from '../stepper';
|
import { useStep } from '../stepper';
|
||||||
import { useToast } from '../use-toast';
|
import { useToast } from '../use-toast';
|
||||||
import { type TAddFieldsFormSchema, ZAddFieldsFormSchema } from './add-fields.types';
|
import type { TAddFieldsFormSchema } from './add-fields.types';
|
||||||
import {
|
import {
|
||||||
DocumentFlowFormContainerActions,
|
DocumentFlowFormContainerActions,
|
||||||
DocumentFlowFormContainerContent,
|
DocumentFlowFormContainerContent,
|
||||||
@ -76,7 +75,6 @@ export type FieldFormType = {
|
|||||||
pageWidth: number;
|
pageWidth: number;
|
||||||
pageHeight: number;
|
pageHeight: number;
|
||||||
signerEmail: string;
|
signerEmail: string;
|
||||||
recipientId: number;
|
|
||||||
fieldMeta?: FieldMeta;
|
fieldMeta?: FieldMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -129,11 +127,9 @@ export const AddFieldsFormPartial = ({
|
|||||||
pageHeight: Number(field.height),
|
pageHeight: Number(field.height),
|
||||||
signerEmail:
|
signerEmail:
|
||||||
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
||||||
recipientId: field.recipientId,
|
|
||||||
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
|
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZAddFieldsFormSchema),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt));
|
useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt));
|
||||||
@ -327,7 +323,6 @@ export const AddFieldsFormPartial = ({
|
|||||||
|
|
||||||
const field = {
|
const field = {
|
||||||
formId: nanoid(12),
|
formId: nanoid(12),
|
||||||
nativeId: undefined,
|
|
||||||
type: selectedField,
|
type: selectedField,
|
||||||
pageNumber,
|
pageNumber,
|
||||||
pageX,
|
pageX,
|
||||||
@ -335,7 +330,6 @@ export const AddFieldsFormPartial = ({
|
|||||||
pageWidth: fieldPageWidth,
|
pageWidth: fieldPageWidth,
|
||||||
pageHeight: fieldPageHeight,
|
pageHeight: fieldPageHeight,
|
||||||
signerEmail: selectedSigner.email,
|
signerEmail: selectedSigner.email,
|
||||||
recipientId: selectedSigner.id,
|
|
||||||
fieldMeta: undefined,
|
fieldMeta: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -420,7 +414,6 @@ export const AddFieldsFormPartial = ({
|
|||||||
nativeId: undefined,
|
nativeId: undefined,
|
||||||
formId: nanoid(12),
|
formId: nanoid(12),
|
||||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||||
recipientId: selectedSigner?.id ?? lastActiveField.recipientId,
|
|
||||||
pageX: lastActiveField.pageX + 3,
|
pageX: lastActiveField.pageX + 3,
|
||||||
pageY: lastActiveField.pageY + 3,
|
pageY: lastActiveField.pageY + 3,
|
||||||
};
|
};
|
||||||
@ -445,7 +438,6 @@ export const AddFieldsFormPartial = ({
|
|||||||
nativeId: undefined,
|
nativeId: undefined,
|
||||||
formId: nanoid(12),
|
formId: nanoid(12),
|
||||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||||
recipientId: selectedSigner?.id ?? lastActiveField.recipientId,
|
|
||||||
pageNumber,
|
pageNumber,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -478,7 +470,6 @@ export const AddFieldsFormPartial = ({
|
|||||||
nativeId: undefined,
|
nativeId: undefined,
|
||||||
formId: nanoid(12),
|
formId: nanoid(12),
|
||||||
signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
|
signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
|
||||||
recipientId: selectedSigner?.id ?? copiedField.recipientId,
|
|
||||||
pageX: copiedField.pageX + 3,
|
pageX: copiedField.pageX + 3,
|
||||||
pageY: copiedField.pageY + 3,
|
pageY: copiedField.pageY + 3,
|
||||||
});
|
});
|
||||||
@ -672,7 +663,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
|
|
||||||
{isDocumentPdfLoaded &&
|
{isDocumentPdfLoaded &&
|
||||||
localFields.map((field, index) => {
|
localFields.map((field, index) => {
|
||||||
const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
|
const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail);
|
||||||
const hasFieldError =
|
const hasFieldError =
|
||||||
emptyCheckboxFields.find((f) => f.formId === field.formId) ||
|
emptyCheckboxFields.find((f) => f.formId === field.formId) ||
|
||||||
emptyRadioFields.find((f) => f.formId === field.formId) ||
|
emptyRadioFields.find((f) => f.formId === field.formId) ||
|
||||||
|
|||||||
@ -10,7 +10,6 @@ export const ZAddFieldsFormSchema = z.object({
|
|||||||
nativeId: z.number().optional(),
|
nativeId: z.number().optional(),
|
||||||
type: z.nativeEnum(FieldType),
|
type: z.nativeEnum(FieldType),
|
||||||
signerEmail: z.string().min(1),
|
signerEmail: z.string().min(1),
|
||||||
recipientId: z.number().min(1),
|
|
||||||
pageNumber: z.number().min(1),
|
pageNumber: z.number().min(1),
|
||||||
pageX: z.number().min(0),
|
pageX: z.number().min(0),
|
||||||
pageY: z.number().min(0),
|
pageY: z.number().min(0),
|
||||||
|
|||||||
@ -53,10 +53,6 @@ import {
|
|||||||
import { SigningOrderConfirmation } from './signing-order-confirmation';
|
import { SigningOrderConfirmation } from './signing-order-confirmation';
|
||||||
import type { DocumentFlowStep } from './types';
|
import type { DocumentFlowStep } from './types';
|
||||||
|
|
||||||
type AutoSaveResponse = {
|
|
||||||
recipients: Recipient[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AddSignersFormProps = {
|
export type AddSignersFormProps = {
|
||||||
documentFlow: DocumentFlowStep;
|
documentFlow: DocumentFlowStep;
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
@ -64,7 +60,7 @@ export type AddSignersFormProps = {
|
|||||||
signingOrder?: DocumentSigningOrder | null;
|
signingOrder?: DocumentSigningOrder | null;
|
||||||
allowDictateNextSigner?: boolean;
|
allowDictateNextSigner?: boolean;
|
||||||
onSubmit: (_data: TAddSignersFormSchema) => void;
|
onSubmit: (_data: TAddSignersFormSchema) => void;
|
||||||
onAutoSave: (_data: TAddSignersFormSchema) => Promise<AutoSaveResponse>;
|
onAutoSave: (_data: TAddSignersFormSchema) => Promise<void>;
|
||||||
isDocumentPdfLoaded: boolean;
|
isDocumentPdfLoaded: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -212,44 +208,7 @@ export const AddSignersFormPartial = ({
|
|||||||
|
|
||||||
const formData = form.getValues();
|
const formData = form.getValues();
|
||||||
|
|
||||||
scheduleSave(formData, (response) => {
|
scheduleSave(formData);
|
||||||
// Sync the response recipients back to form state to prevent duplicates
|
|
||||||
if (response?.recipients) {
|
|
||||||
const currentSigners = form.getValues('signers');
|
|
||||||
const updatedSigners = currentSigners.map((signer) => {
|
|
||||||
// Find the matching recipient from the response using nativeId
|
|
||||||
const matchingRecipient = response.recipients.find(
|
|
||||||
(recipient) => recipient.id === signer.nativeId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (matchingRecipient) {
|
|
||||||
// Update the signer with the server-returned data, especially the ID
|
|
||||||
return {
|
|
||||||
...signer,
|
|
||||||
nativeId: matchingRecipient.id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// For new signers without nativeId, match by email and update with server ID
|
|
||||||
if (!signer.nativeId) {
|
|
||||||
const newRecipient = response.recipients.find(
|
|
||||||
(recipient) => recipient.email === signer.email,
|
|
||||||
);
|
|
||||||
if (newRecipient) {
|
|
||||||
return {
|
|
||||||
...signer,
|
|
||||||
nativeId: newRecipient.id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return signer;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update the form state with the synced data
|
|
||||||
form.setValue('signers', updatedSigners, { shouldValidate: false });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email);
|
const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email);
|
||||||
@ -755,6 +714,7 @@ export const AddSignersFormPartial = ({
|
|||||||
handleRecipientAutoCompleteSelect(index, suggestion)
|
handleRecipientAutoCompleteSelect(index, suggestion)
|
||||||
}
|
}
|
||||||
onSearchQueryChange={(query) => {
|
onSearchQueryChange={(query) => {
|
||||||
|
console.log('onSearchQueryChange', query);
|
||||||
field.onChange(query);
|
field.onChange(query);
|
||||||
setRecipientSearchQuery(query);
|
setRecipientSearchQuery(query);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -4,23 +4,33 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
||||||
|
|
||||||
export const ZAddSignersFormSchema = z.object({
|
export const ZAddSignersFormSchema = z
|
||||||
signers: z.array(
|
.object({
|
||||||
z.object({
|
signers: z.array(
|
||||||
formId: z.string().min(1),
|
z.object({
|
||||||
nativeId: z.number().optional(),
|
formId: z.string().min(1),
|
||||||
email: z
|
nativeId: z.number().optional(),
|
||||||
.string()
|
email: z
|
||||||
.email({ message: msg`Invalid email`.id })
|
.string()
|
||||||
.min(1),
|
.email({ message: msg`Invalid email`.id })
|
||||||
name: z.string(),
|
.min(1),
|
||||||
role: z.nativeEnum(RecipientRole),
|
name: z.string(),
|
||||||
signingOrder: z.number().optional(),
|
role: z.nativeEnum(RecipientRole),
|
||||||
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
signingOrder: z.number().optional(),
|
||||||
}),
|
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
||||||
),
|
}),
|
||||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
),
|
||||||
allowDictateNextSigner: z.boolean().default(false),
|
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||||
});
|
allowDictateNextSigner: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(schema) => {
|
||||||
|
const emails = schema.signers.map((signer) => signer.email.toLowerCase());
|
||||||
|
|
||||||
|
return new Set(emails).size === emails.length;
|
||||||
|
},
|
||||||
|
// Dirty hack to handle errors when .root is populated for an array type
|
||||||
|
{ message: msg`Signers must have unique emails`.id, path: ['signers__root'] },
|
||||||
|
);
|
||||||
|
|
||||||
export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>;
|
export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>;
|
||||||
|
|||||||
@ -299,8 +299,6 @@ export const FieldItem = ({
|
|||||||
}}
|
}}
|
||||||
ref={$el}
|
ref={$el}
|
||||||
data-field-id={field.nativeId}
|
data-field-id={field.nativeId}
|
||||||
data-field-type={field.type}
|
|
||||||
data-recipient-id={field.recipientId}
|
|
||||||
>
|
>
|
||||||
<FieldContent field={field} />
|
<FieldContent field={field} />
|
||||||
|
|
||||||
|
|||||||
@ -8,14 +8,19 @@ import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
|||||||
export const ZDocumentFlowFormSchema = z.object({
|
export const ZDocumentFlowFormSchema = z.object({
|
||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
|
|
||||||
signers: z.array(
|
signers: z
|
||||||
z.object({
|
.array(
|
||||||
formId: z.string().min(1),
|
z.object({
|
||||||
nativeId: z.number().optional(),
|
formId: z.string().min(1),
|
||||||
email: z.string().min(1).email(),
|
nativeId: z.number().optional(),
|
||||||
name: z.string(),
|
email: z.string().min(1).email(),
|
||||||
}),
|
name: z.string(),
|
||||||
),
|
}),
|
||||||
|
)
|
||||||
|
.refine((signers) => {
|
||||||
|
const emails = signers.map((signer) => signer.email);
|
||||||
|
return new Set(emails).size === emails.length;
|
||||||
|
}, 'Signers must have unique emails'),
|
||||||
|
|
||||||
fields: z.array(
|
fields: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
@ -23,7 +28,6 @@ export const ZDocumentFlowFormSchema = z.object({
|
|||||||
nativeId: z.number().optional(),
|
nativeId: z.number().optional(),
|
||||||
type: z.nativeEnum(FieldType),
|
type: z.nativeEnum(FieldType),
|
||||||
signerEmail: z.string().min(1).optional(),
|
signerEmail: z.string().min(1).optional(),
|
||||||
recipientId: z.number().min(1),
|
|
||||||
pageNumber: z.number().min(1),
|
pageNumber: z.number().min(1),
|
||||||
pageX: z.number().min(0),
|
pageX: z.number().min(0),
|
||||||
pageY: z.number().min(0),
|
pageY: z.number().min(0),
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import type { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { KeyboardIcon, UploadCloudIcon } from 'lucide-react';
|
import { KeyboardIcon, UploadCloudIcon } from 'lucide-react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
|
|
||||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
@ -62,10 +61,7 @@ import type { FieldFormType } from '../document-flow/add-fields';
|
|||||||
import { FieldAdvancedSettings } from '../document-flow/field-item-advanced-settings';
|
import { FieldAdvancedSettings } from '../document-flow/field-item-advanced-settings';
|
||||||
import { Form } from '../form/form';
|
import { Form } from '../form/form';
|
||||||
import { useStep } from '../stepper';
|
import { useStep } from '../stepper';
|
||||||
import {
|
import type { TAddTemplateFieldsFormSchema } from './add-template-fields.types';
|
||||||
type TAddTemplateFieldsFormSchema,
|
|
||||||
ZAddTemplateFieldsFormSchema,
|
|
||||||
} from './add-template-fields.types';
|
|
||||||
|
|
||||||
const MIN_HEIGHT_PX = 12;
|
const MIN_HEIGHT_PX = 12;
|
||||||
const MIN_WIDTH_PX = 36;
|
const MIN_WIDTH_PX = 36;
|
||||||
@ -116,7 +112,7 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
pageY: Number(field.positionY),
|
pageY: Number(field.positionY),
|
||||||
pageWidth: Number(field.width),
|
pageWidth: Number(field.width),
|
||||||
pageHeight: Number(field.height),
|
pageHeight: Number(field.height),
|
||||||
recipientId: field.recipientId ?? -1,
|
signerId: field.recipientId ?? -1,
|
||||||
signerEmail:
|
signerEmail:
|
||||||
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
||||||
signerToken:
|
signerToken:
|
||||||
@ -124,7 +120,6 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
|
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZAddTemplateFieldsFormSchema),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const onFormSubmit = form.handleSubmit(onSubmit);
|
const onFormSubmit = form.handleSubmit(onSubmit);
|
||||||
@ -175,7 +170,7 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
nativeId: undefined,
|
nativeId: undefined,
|
||||||
formId: nanoid(12),
|
formId: nanoid(12),
|
||||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||||
recipientId: selectedSigner?.id ?? lastActiveField.recipientId,
|
signerId: selectedSigner?.id ?? lastActiveField.signerId,
|
||||||
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
|
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
|
||||||
pageX: lastActiveField.pageX + 3,
|
pageX: lastActiveField.pageX + 3,
|
||||||
pageY: lastActiveField.pageY + 3,
|
pageY: lastActiveField.pageY + 3,
|
||||||
@ -202,7 +197,7 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
nativeId: undefined,
|
nativeId: undefined,
|
||||||
formId: nanoid(12),
|
formId: nanoid(12),
|
||||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||||
recipientId: selectedSigner?.id ?? lastActiveField.recipientId,
|
signerId: selectedSigner?.id ?? lastActiveField.signerId,
|
||||||
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
|
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
|
||||||
pageNumber,
|
pageNumber,
|
||||||
};
|
};
|
||||||
@ -245,7 +240,7 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
formId: nanoid(12),
|
formId: nanoid(12),
|
||||||
nativeId: undefined,
|
nativeId: undefined,
|
||||||
signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
|
signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
|
||||||
recipientId: selectedSigner?.id ?? copiedField.recipientId,
|
signerId: selectedSigner?.id ?? copiedField.signerId,
|
||||||
signerToken: selectedSigner?.token ?? copiedField.signerToken,
|
signerToken: selectedSigner?.token ?? copiedField.signerToken,
|
||||||
pageX: copiedField.pageX + 3,
|
pageX: copiedField.pageX + 3,
|
||||||
pageY: copiedField.pageY + 3,
|
pageY: copiedField.pageY + 3,
|
||||||
@ -376,7 +371,7 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
pageWidth: fieldPageWidth,
|
pageWidth: fieldPageWidth,
|
||||||
pageHeight: fieldPageHeight,
|
pageHeight: fieldPageHeight,
|
||||||
signerEmail: selectedSigner.email,
|
signerEmail: selectedSigner.email,
|
||||||
recipientId: selectedSigner.id,
|
signerId: selectedSigner.id,
|
||||||
signerToken: selectedSigner.token ?? '',
|
signerToken: selectedSigner.token ?? '',
|
||||||
fieldMeta: undefined,
|
fieldMeta: undefined,
|
||||||
};
|
};
|
||||||
@ -602,14 +597,14 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{localFields.map((field, index) => {
|
{localFields.map((field, index) => {
|
||||||
const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
|
const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FieldItem
|
<FieldItem
|
||||||
key={index}
|
key={index}
|
||||||
recipientIndex={recipientIndex === -1 ? 0 : recipientIndex}
|
recipientIndex={recipientIndex === -1 ? 0 : recipientIndex}
|
||||||
field={field}
|
field={field}
|
||||||
disabled={selectedSigner?.id !== field.recipientId}
|
disabled={selectedSigner?.email !== field.signerEmail}
|
||||||
minHeight={MIN_HEIGHT_PX}
|
minHeight={MIN_HEIGHT_PX}
|
||||||
minWidth={MIN_WIDTH_PX}
|
minWidth={MIN_WIDTH_PX}
|
||||||
defaultHeight={DEFAULT_HEIGHT_PX}
|
defaultHeight={DEFAULT_HEIGHT_PX}
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -48,10 +48,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
|
|||||||
import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
|
import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
|
||||||
import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
|
import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
|
||||||
|
|
||||||
type AutoSaveResponse = {
|
|
||||||
recipients: Recipient[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AddTemplatePlaceholderRecipientsFormProps = {
|
export type AddTemplatePlaceholderRecipientsFormProps = {
|
||||||
documentFlow: DocumentFlowStep;
|
documentFlow: DocumentFlowStep;
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
@ -60,7 +56,7 @@ export type AddTemplatePlaceholderRecipientsFormProps = {
|
|||||||
allowDictateNextSigner?: boolean;
|
allowDictateNextSigner?: boolean;
|
||||||
templateDirectLink?: TemplateDirectLink | null;
|
templateDirectLink?: TemplateDirectLink | null;
|
||||||
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
|
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
|
||||||
onAutoSave: (_data: TAddTemplatePlacholderRecipientsFormSchema) => Promise<AutoSaveResponse>;
|
onAutoSave: (_data: TAddTemplatePlacholderRecipientsFormSchema) => Promise<void>;
|
||||||
isDocumentPdfLoaded: boolean;
|
isDocumentPdfLoaded: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -150,44 +146,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
|
|
||||||
const formData = form.getValues();
|
const formData = form.getValues();
|
||||||
|
|
||||||
scheduleSave(formData, (response) => {
|
scheduleSave(formData);
|
||||||
// Sync the response recipients back to form state to prevent duplicates
|
|
||||||
if (response?.recipients) {
|
|
||||||
const currentSigners = form.getValues('signers');
|
|
||||||
const updatedSigners = currentSigners.map((signer) => {
|
|
||||||
// Find the matching recipient from the response using nativeId
|
|
||||||
const matchingRecipient = response.recipients.find(
|
|
||||||
(recipient) => recipient.id === signer.nativeId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (matchingRecipient) {
|
|
||||||
// Update the signer with the server-returned data, especially the ID
|
|
||||||
return {
|
|
||||||
...signer,
|
|
||||||
nativeId: matchingRecipient.id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// For new signers without nativeId, match by email and update with server ID
|
|
||||||
if (!signer.nativeId) {
|
|
||||||
const newRecipient = response.recipients.find(
|
|
||||||
(recipient) => recipient.email === signer.email,
|
|
||||||
);
|
|
||||||
if (newRecipient) {
|
|
||||||
return {
|
|
||||||
...signer,
|
|
||||||
nativeId: newRecipient.id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return signer;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update the form state with the synced data
|
|
||||||
form.setValue('signers', updatedSigners, { shouldValidate: false });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX } from '@documenso/lib/constants/template';
|
||||||
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
||||||
|
|
||||||
export const ZAddTemplatePlacholderRecipientsFormSchema = z
|
export const ZAddTemplatePlacholderRecipientsFormSchema = z
|
||||||
@ -19,7 +20,17 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z
|
|||||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||||
allowDictateNextSigner: z.boolean().default(false),
|
allowDictateNextSigner: z.boolean().default(false),
|
||||||
})
|
})
|
||||||
|
.refine(
|
||||||
|
(schema) => {
|
||||||
|
const nonPlaceholderEmails = schema.signers
|
||||||
|
.map((signer) => signer.email.toLowerCase())
|
||||||
|
.filter((email) => !TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX.test(email));
|
||||||
|
|
||||||
|
return new Set(nonPlaceholderEmails).size === nonPlaceholderEmails.length;
|
||||||
|
},
|
||||||
|
// Dirty hack to handle errors when .root is populated for an array type
|
||||||
|
{ message: 'Signers must have unique emails', path: ['signers__root'] },
|
||||||
|
)
|
||||||
.refine(
|
.refine(
|
||||||
/*
|
/*
|
||||||
Since placeholder emails are empty, we need to check that the names are unique.
|
Since placeholder emails are empty, we need to check that the names are unique.
|
||||||
|
|||||||
@ -216,7 +216,12 @@ export const AddTemplateSettingsFormPartial = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input className="bg-background" {...field} maxLength={255} onBlur={handleAutoSave} />
|
<Input
|
||||||
|
className="bg-background"
|
||||||
|
{...field}
|
||||||
|
maxLength={255}
|
||||||
|
onBlur={handleAutoSave}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@ -624,7 +629,12 @@ export const AddTemplateSettingsFormPartial = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input className="bg-background" {...field} maxLength={255} onBlur={handleAutoSave} />
|
<Input
|
||||||
|
className="bg-background"
|
||||||
|
{...field}
|
||||||
|
maxLength={255}
|
||||||
|
onBlur={handleAutoSave}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -715,7 +725,12 @@ export const AddTemplateSettingsFormPartial = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input className="bg-background" {...field} maxLength={255} onBlur={handleAutoSave} />
|
<Input
|
||||||
|
className="bg-background"
|
||||||
|
{...field}
|
||||||
|
maxLength={255}
|
||||||
|
onBlur={handleAutoSave}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
Reference in New Issue
Block a user