Compare commits

..

6 Commits

115 changed files with 1410 additions and 7358 deletions

5
.gitignore vendored
View File

@ -56,7 +56,4 @@ logs.json
# claude
.claude
CLAUDE.md
# agents
.specs
CLAUDE.md

View File

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

View File

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

View File

@ -15,6 +15,7 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import {
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
isTemplateRecipientEmailPlaceholder,
} from '@documenso/lib/constants/template';
import { AppError } from '@documenso/lib/errors/app-error';
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 { useToast } from '@documenso/ui/primitives/use-toast';
const ZAddRecipientsForNewDocumentSchema = z.object({
distributeDocument: z.boolean(),
useCustomDocument: z.boolean().default(false),
customDocumentData: z
.any()
.refine((data) => data instanceof File || data === undefined)
.optional(),
recipients: z.array(
z.object({
id: z.number(),
email: z.string().email(),
name: z.string(),
signingOrder: z.number().optional(),
}),
),
});
const ZAddRecipientsForNewDocumentSchema = z
.object({
distributeDocument: z.boolean(),
useCustomDocument: z.boolean().default(false),
customDocumentData: z
.any()
.refine((data) => data instanceof File || data === undefined)
.optional(),
recipients: z.array(
z.object({
id: z.number(),
email: z.string().email(),
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>;
@ -249,7 +278,14 @@ export function TemplateUseDialog({
)}
<FormControl>
<Input {...field} aria-label="Email" placeholder={_(msg`Email`)} />
<Input
{...field}
placeholder={
isTemplateRecipientEmailPlaceholder(field.value)
? ''
: _(msg`Email`)
}
/>
</FormControl>
<FormMessage />
</FormItem>
@ -270,7 +306,6 @@ export function TemplateUseDialog({
<FormControl>
<Input
{...field}
aria-label="Name"
placeholder={recipients[index].name || _(msg`Name`)}
/>
</FormControl>

View File

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

View File

@ -417,11 +417,11 @@ export const DirectTemplateSigningForm = ({
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting}
onSignatureComplete={async () => handleSubmit()}
onSignatureComplete={handleSubmit}
documentTitle={template.title}
fields={localFields}
fieldsValidated={fieldsValidated}
recipient={directRecipient}
role={directRecipient.role}
/>
</div>
</DocumentFlowFormContainerFooter>

View File

@ -1,312 +0,0 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { ArrowLeftIcon, KeyIcon, MailIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Alert } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Form, FormField, FormItem } from '@documenso/ui/primitives/form/form';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
type FormStep = 'method-selection' | 'code-input';
type TwoFactorMethod = 'email' | 'authenticator';
const ZAccessAuth2FAFormSchema = z.object({
token: z.string().length(6, { message: 'Token must be 6 characters long' }),
});
type TAccessAuth2FAFormSchema = z.infer<typeof ZAccessAuth2FAFormSchema>;
export type AccessAuth2FAFormProps = {
onSubmit: (accessAuthOptions: TRecipientAccessAuth) => void;
token: string;
error?: string | null;
};
export const AccessAuth2FAForm = ({ onSubmit, token, error }: AccessAuth2FAFormProps) => {
const [step, setStep] = useState<FormStep>('method-selection');
const [selectedMethod, setSelectedMethod] = useState<TwoFactorMethod | null>(null);
const [expiresAt, setExpiresAt] = useState<Date | null>(null);
const [millisecondsRemaining, setMillisecondsRemaining] = useState<number | null>(null);
const { _ } = useLingui();
const { toast } = useToast();
const { user } = useRequiredDocumentSigningAuthContext();
const { mutateAsync: request2FAEmail, isPending: isRequesting2FAEmail } =
trpc.document.accessAuth.request2FAEmail.useMutation();
const form = useForm({
resolver: zodResolver(ZAccessAuth2FAFormSchema),
defaultValues: {
token: '',
},
});
const hasAuthenticatorEnabled = user?.twoFactorEnabled === true;
const onMethodSelect = async (method: TwoFactorMethod) => {
setSelectedMethod(method);
if (method === 'email') {
try {
const result = await request2FAEmail({
token: token,
});
setExpiresAt(result.expiresAt);
setMillisecondsRemaining(result.expiresAt.valueOf() - Date.now());
setStep('code-input');
} catch (error) {
toast({
title: _(msg`An error occurred`),
description: _(
msg`We encountered an unknown error while attempting to request the two-factor authentication code. Please try again later.`,
),
variant: 'destructive',
});
return;
}
}
setStep('code-input');
};
const onFormSubmit = (data: TAccessAuth2FAFormSchema) => {
if (!selectedMethod) {
return;
}
// Prepare the auth options for the completion attempt
const accessAuthOptions: TRecipientAccessAuth = {
type: 'TWO_FACTOR_AUTH',
token: data.token, // Just the user's code - backend will validate using method type
method: selectedMethod,
};
onSubmit(accessAuthOptions);
};
const onGoBack = () => {
setStep('method-selection');
setSelectedMethod(null);
setExpiresAt(null);
setMillisecondsRemaining(null);
};
const onResendEmail = async () => {
if (selectedMethod !== 'email') {
return;
}
try {
const result = await request2FAEmail({
token: token,
});
setExpiresAt(result.expiresAt);
setMillisecondsRemaining(result.expiresAt.valueOf() - Date.now());
} catch (error) {
toast({
title: _(msg`An error occurred`),
description: _(
msg`We encountered an unknown error while attempting to request the two-factor authentication code. Please try again later.`,
),
variant: 'destructive',
});
}
};
useEffect(() => {
const interval = setInterval(() => {
if (expiresAt) {
setMillisecondsRemaining(expiresAt.valueOf() - Date.now());
}
}, 1000);
return () => clearInterval(interval);
}, [expiresAt]);
return (
<div className="py-4">
{step === 'method-selection' && (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold">
<Trans>Choose verification method</Trans>
</h3>
<p className="text-muted-foreground text-sm">
<Trans>Please select how you'd like to receive your verification code.</Trans>
</p>
</div>
{error && (
<Alert variant="destructive" padding="tight" className="text-sm">
{error}
</Alert>
)}
<div className="space-y-3">
<Button
type="button"
variant="outline"
className="flex h-auto w-full justify-start gap-3 p-4"
onClick={async () => onMethodSelect('email')}
disabled={isRequesting2FAEmail}
>
<MailIcon className="h-5 w-5" />
<div className="text-left">
<div className="font-medium">
<Trans>Email verification</Trans>
</div>
<div className="text-muted-foreground text-sm">
<Trans>We'll send a 6-digit code to your email</Trans>
</div>
</div>
</Button>
{hasAuthenticatorEnabled && (
<Button
type="button"
variant="outline"
className="flex h-auto w-full justify-start gap-3 p-4"
onClick={async () => onMethodSelect('authenticator')}
disabled={isRequesting2FAEmail}
>
<KeyIcon className="h-5 w-5" />
<div className="text-left">
<div className="font-medium">
<Trans>Authenticator app</Trans>
</div>
<div className="text-muted-foreground text-sm">
<Trans>Use your authenticator app to generate a code</Trans>
</div>
</div>
</Button>
)}
</div>
</div>
)}
{step === 'code-input' && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onGoBack}>
<ArrowLeftIcon className="h-4 w-4" />
</Button>
<h3 className="text-lg font-semibold">
<Trans>Enter verification code</Trans>
</h3>
</div>
<div className="text-muted-foreground text-sm">
{selectedMethod === 'email' ? (
<Trans>
We've sent a 6-digit verification code to your email. Please enter it below to
complete the document.
</Trans>
) : (
<Trans>
Please open your authenticator app and enter the 6-digit code for this document.
</Trans>
)}
</div>
<Form {...form}>
<form
id="access-auth-2fa-form"
className="space-y-4"
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset disabled={isRequesting2FAEmail || form.formState.isSubmitting}>
<FormField
control={form.control}
name="token"
render={({ field }) => (
<FormItem className="flex-1 items-center justify-center">
<PinInput
{...field}
maxLength={6}
autoFocus
inputMode="numeric"
pattern="^\d+$"
aria-label="2FA code"
containerClassName="h-12 justify-center"
>
<PinInputGroup>
<PinInputSlot className="h-12 w-12 text-lg" index={0} />
<PinInputSlot className="h-12 w-12 text-lg" index={1} />
<PinInputSlot className="h-12 w-12 text-lg" index={2} />
<PinInputSlot className="h-12 w-12 text-lg" index={3} />
<PinInputSlot className="h-12 w-12 text-lg" index={4} />
<PinInputSlot className="h-12 w-12 text-lg" index={5} />
</PinInputGroup>
</PinInput>
{expiresAt && millisecondsRemaining !== null && (
<div
className={cn('text-muted-foreground mt-2 text-center text-sm', {
'text-destructive': millisecondsRemaining <= 0,
})}
>
<Trans>
Expires in{' '}
{DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat(
'mm:ss',
)}
</Trans>
</div>
)}
</FormItem>
)}
/>
<div className="mt-4 space-y-2">
<Button
type="submit"
form="access-auth-2fa-form"
className="w-full"
disabled={!form.formState.isValid}
loading={isRequesting2FAEmail || form.formState.isSubmitting}
>
<Trans>Verify & Complete</Trans>
</Button>
{selectedMethod === 'email' && (
<Button
type="button"
variant="ghost"
size="sm"
className="w-full"
onClick={onResendEmail}
loading={isRequesting2FAEmail}
>
<Trans>Resend code</Trans>
</Button>
)}
</div>
</fieldset>
</form>
</Form>
</div>
)}
</div>
);
};

View File

@ -2,17 +2,12 @@ import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client';
import type { Field } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import {
type TRecipientAccessAuth,
ZDocumentAccessAuthSchema,
} from '@documenso/lib/types/document-auth';
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -32,21 +27,15 @@ import {
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form';
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
export type DocumentSigningCompleteDialogProps = {
isSubmitting: boolean;
documentTitle: string;
fields: Field[];
fieldsValidated: () => void | Promise<void>;
onSignatureComplete: (
nextSigner?: { name: string; email: string },
accessAuthOptions?: TRecipientAccessAuth,
) => void | Promise<void>;
recipient: Pick<Recipient, 'name' | 'email' | 'role' | 'token'>;
onSignatureComplete: (nextSigner?: { name: string; email: string }) => void | Promise<void>;
role: RecipientRole;
disabled?: boolean;
allowDictateNextSigner?: boolean;
defaultNextSigner?: {
@ -58,7 +47,6 @@ export type DocumentSigningCompleteDialogProps = {
const ZNextSignerFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
accessAuthOptions: ZDocumentAccessAuthSchema.optional(),
});
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
@ -69,7 +57,7 @@ export const DocumentSigningCompleteDialog = ({
fields,
fieldsValidated,
onSignatureComplete,
recipient,
role,
disabled = false,
allowDictateNextSigner = false,
defaultNextSigner,
@ -77,11 +65,6 @@ export const DocumentSigningCompleteDialog = ({
const [showDialog, setShowDialog] = useState(false);
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
const [showTwoFactorForm, setShowTwoFactorForm] = useState(false);
const [twoFactorValidationError, setTwoFactorValidationError] = useState<string | null>(null);
const { derivedRecipientAccessAuth, user } = useRequiredDocumentSigningAuthContext();
const form = useForm<TNextSignerFormSchema>({
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
defaultValues: {
@ -92,11 +75,6 @@ export const DocumentSigningCompleteDialog = ({
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
const completionRequires2FA = useMemo(
() => derivedRecipientAccessAuth.includes('TWO_FACTOR_AUTH'),
[derivedRecipientAccessAuth],
);
const handleOpenChange = (open: boolean) => {
if (form.formState.isSubmitting || !isComplete) {
return;
@ -115,43 +93,16 @@ export const DocumentSigningCompleteDialog = ({
const onFormSubmit = async (data: TNextSignerFormSchema) => {
try {
// Check if 2FA is required
if (completionRequires2FA && !data.accessAuthOptions) {
setShowTwoFactorForm(true);
return;
if (allowDictateNextSigner && data.name && data.email) {
await onSignatureComplete({ name: data.name, email: data.email });
} else {
await onSignatureComplete();
}
const nextSigner =
allowDictateNextSigner && data.name && data.email
? { name: data.name, email: data.email }
: undefined;
await onSignatureComplete(nextSigner, data.accessAuthOptions);
} catch (error) {
const err = AppError.parseError(error);
if (AppErrorCode.TWO_FACTOR_AUTH_FAILED === err.code) {
// This was a 2FA validation failure - show the 2FA dialog again with error
form.setValue('accessAuthOptions', undefined);
setTwoFactorValidationError('Invalid verification code. Please try again.');
setShowTwoFactorForm(true);
return;
}
console.error('Error completing signature:', error);
}
};
const onTwoFactorFormSubmit = (validatedAuthOptions: TRecipientAccessAuth) => {
form.setValue('accessAuthOptions', validatedAuthOptions);
setShowTwoFactorForm(false);
setTwoFactorValidationError(null);
// Now trigger the form submission with auth options
void form.handleSubmit(onFormSubmit)();
};
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
return (
@ -165,7 +116,7 @@ export const DocumentSigningCompleteDialog = ({
loading={isSubmitting}
disabled={disabled}
>
{match({ isComplete, role: recipient.role })
{match({ isComplete, role })
.with({ isComplete: false }, () => <Trans>Next field</Trans>)
.with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>)
.with({ isComplete: true, role: RecipientRole.VIEWER }, () => (
@ -177,194 +128,184 @@ export const DocumentSigningCompleteDialog = ({
</DialogTrigger>
<DialogContent>
{!showTwoFactorForm && (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
<DialogTitle>
<div className="text-foreground text-xl font-semibold">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
.exhaustive()}
</div>
</DialogTitle>
<div className="text-muted-foreground max-w-[50ch]">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))
.with(RecipientRole.SIGNER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete signing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))
.with(RecipientRole.APPROVER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete approving{' '}
<span className="inline-block max-w-[11rem] truncate align-baseline">
"{documentTitle}"
</span>
.
</span>
<br /> Are you sure?
</Trans>
</span>
))
.otherwise(() => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))}
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
<DialogTitle>
<div className="text-foreground text-xl font-semibold">
{match(role)
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
.exhaustive()}
</div>
</DialogTitle>
{allowDictateNextSigner && (
<div className="mt-4 flex flex-col gap-4">
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
</p>
<div className="text-muted-foreground max-w-[50ch]">
{match(role)
.with(RecipientRole.VIEWER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))
.with(RecipientRole.SIGNER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete signing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))
.with(RecipientRole.APPROVER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete approving{' '}
<span className="inline-block max-w-[11rem] truncate align-baseline">
"{documentTitle}"
</span>
.
</span>
<br /> Are you sure?
</Trans>
</span>
))
.otherwise(() => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))}
</div>
<Button
type="button"
className="mt-2"
variant="outline"
size="sm"
onClick={() => setIsEditingNextSigner((prev) => !prev)}
>
<Trans>Update Recipient</Trans>
</Button>
</div>
)}
{allowDictateNextSigner && (
<div className="mt-4 flex flex-col gap-4">
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
</p>
{isEditingNextSigner && (
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder="Enter the next signer's name"
/>
</FormControl>
<Button
type="button"
className="mt-2"
variant="outline"
size="sm"
onClick={() => setIsEditingNextSigner((prev) => !prev)}
>
<Trans>Update Recipient</Trans>
</Button>
</div>
)}
<FormMessage />
</FormItem>
)}
/>
{isEditingNextSigner && (
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder="Enter the next signer's name"
/>
</FormControl>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder="Enter the next signer's email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
)}
<FormMessage />
</FormItem>
)}
/>
<DocumentSigningDisclosure className="mt-4" />
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder="Enter the next signer's email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
)}
<DialogFooter className="mt-4">
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="flex-1"
variant="secondary"
onClick={() => setShowDialog(false)}
disabled={form.formState.isSubmitting}
>
<Trans>Cancel</Trans>
</Button>
<DocumentSigningDisclosure className="mt-4" />
<Button
type="submit"
className="flex-1"
disabled={!isComplete || !isNextSignerValid}
loading={form.formState.isSubmitting}
>
{match(recipient.role)
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
.exhaustive()}
</Button>
</div>
</DialogFooter>
</fieldset>
</form>
</Form>
)}
<DialogFooter className="mt-4">
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="flex-1"
variant="secondary"
onClick={() => setShowDialog(false)}
disabled={form.formState.isSubmitting}
>
<Trans>Cancel</Trans>
</Button>
{showTwoFactorForm && (
<AccessAuth2FAForm
token={recipient.token}
error={twoFactorValidationError}
onSubmit={onTwoFactorFormSubmit}
/>
)}
<Button
type="submit"
className="flex-1"
disabled={!isComplete || !isNextSignerValid}
loading={form.formState.isSubmitting}
>
{match(role)
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
.exhaustive()}
</Button>
</div>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);

View File

@ -8,7 +8,7 @@ import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
@ -34,10 +34,10 @@ export type DocumentSigningFormProps = {
isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[];
setSelectedSignerId?: (id: number | null) => void;
completeDocument: (options: {
accessAuthOptions?: TRecipientAccessAuth;
nextSigner?: { email: string; name: string };
}) => Promise<void>;
completeDocument: (
authOptions?: TRecipientActionAuth,
nextSigner?: { email: string; name: string },
) => Promise<void>;
isSubmitting: boolean;
fieldsValidated: () => void;
nextRecipient?: RecipientWithFields;
@ -105,7 +105,7 @@ export const DocumentSigningForm = ({
setIsAssistantSubmitting(true);
try {
await completeDocument({ nextSigner });
await completeDocument(undefined, nextSigner);
} catch (err) {
toast({
title: 'Error',
@ -149,10 +149,10 @@ export const DocumentSigningForm = ({
documentTitle={document.title}
fields={fields}
fieldsValidated={localFieldsValidated}
onSignatureComplete={async (nextSigner, accessAuthOptions) =>
completeDocument({ nextSigner, accessAuthOptions })
}
recipient={recipient}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
defaultNextSigner={
nextRecipient
@ -309,13 +309,10 @@ export const DocumentSigningForm = ({
fields={fields}
fieldsValidated={localFieldsValidated}
disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner, accessAuthOptions) =>
completeDocument({
accessAuthOptions,
nextSigner,
})
}
recipient={recipient}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role}
allowDictateNextSigner={
nextRecipient && document.documentMeta?.allowDictateNextSigner
}

View File

@ -12,7 +12,7 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
@ -46,7 +46,6 @@ import { DocumentSigningRejectDialog } from '~/components/general/document-signi
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
@ -71,12 +70,6 @@ export const DocumentSigningPageView = ({
}: DocumentSigningPageViewProps) => {
const { documentData, documentMeta } = document;
const { derivedRecipientAccessAuth, user: authUser } = useRequiredDocumentSigningAuthContext();
const hasAuthenticator = authUser?.twoFactorEnabled
? authUser.twoFactorEnabled && authUser.email === recipient.email
: false;
const navigate = useNavigate();
const analytics = useAnalytics();
@ -101,16 +94,14 @@ export const DocumentSigningPageView = ({
validateFieldsInserted(fieldsRequiringValidation);
};
const completeDocument = async (options: {
accessAuthOptions?: TRecipientAccessAuth;
nextSigner?: { email: string; name: string };
}) => {
const { accessAuthOptions, nextSigner } = options;
const completeDocument = async (
authOptions?: TRecipientActionAuth,
nextSigner?: { email: string; name: string },
) => {
const payload = {
token: recipient.token,
documentId: document.id,
accessAuthOptions,
authOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
};
@ -169,14 +160,6 @@ export const DocumentSigningPageView = ({
return (
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
<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
className="block max-w-[20rem] truncate text-2xl font-semibold sm:mt-4 md:max-w-[30rem] md:text-3xl"
title={document.title}
@ -274,10 +257,10 @@ export const DocumentSigningPageView = ({
fields={fields}
fieldsValidated={fieldsValidated}
disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) =>
completeDocument({ nextSigner })
}
recipient={recipient}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role}
allowDictateNextSigner={
nextRecipient && documentMeta?.allowDictateNextSigner
}

View File

@ -1,13 +1,16 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DownloadIcon } from 'lucide-react';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export type DocumentAuditLogDownloadButtonProps = {
className?: string;
documentId: number;
@ -19,44 +22,38 @@ export const DocumentAuditLogDownloadButton = ({
}: DocumentAuditLogDownloadButtonProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const { mutateAsync: downloadAuditLogs, isPending } =
trpc.document.auditLog.download.useMutation();
const [isPending, setIsPending] = useState(false);
const team = useCurrentTeam();
const onDownloadAuditLogsClick = async () => {
setIsPending(true);
try {
const { url } = await downloadAuditLogs({ documentId });
const response = await fetch(`/api/t/${team.url}/download/audit-logs/${documentId}`);
const iframe = Object.assign(document.createElement('iframe'), {
src: url,
});
if (!response.ok) {
throw new Error('Failed to download certificate');
}
Object.assign(iframe.style, {
position: 'fixed',
top: '0',
left: '0',
width: '0',
height: '0',
});
const contentDisposition = response.headers.get('Content-Disposition');
const filename =
contentDisposition?.split('filename="')[1]?.split('"')[0] ||
`document_${documentId}_audit_logs.pdf`;
const onLoaded = () => {
if (iframe.contentDocument?.readyState === 'complete') {
iframe.contentWindow?.print();
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
iframe.contentWindow?.addEventListener('afterprint', () => {
document.body.removeChild(iframe);
});
}
};
link.href = url;
link.download = filename;
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
document.body.appendChild(link);
link.click();
document.body.appendChild(iframe);
onLoaded();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error(error);
console.error('Audit logs download error:', error);
toast({
title: _(msg`Something went wrong`),
@ -65,6 +62,8 @@ export const DocumentAuditLogDownloadButton = ({
),
variant: 'destructive',
});
} finally {
setIsPending(false);
}
};

View File

@ -1,3 +1,5 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
@ -5,11 +7,12 @@ import type { DocumentStatus } from '@prisma/client';
import { DownloadIcon } from 'lucide-react';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export type DocumentCertificateDownloadButtonProps = {
className?: string;
documentId: number;
@ -23,44 +26,38 @@ export const DocumentCertificateDownloadButton = ({
}: DocumentCertificateDownloadButtonProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const { mutateAsync: downloadCertificate, isPending } =
trpc.document.downloadCertificate.useMutation();
const [isPending, setIsPending] = useState(false);
const team = useCurrentTeam();
const onDownloadCertificatesClick = async () => {
setIsPending(true);
try {
const { url } = await downloadCertificate({ documentId });
const response = await fetch(`/api/t/${team.url}/download/certificate/${documentId}`);
const iframe = Object.assign(document.createElement('iframe'), {
src: url,
});
if (!response.ok) {
throw new Error('Failed to download certificate');
}
Object.assign(iframe.style, {
position: 'fixed',
top: '0',
left: '0',
width: '0',
height: '0',
});
const contentDisposition = response.headers.get('Content-Disposition');
const filename =
contentDisposition?.split('filename="')[1]?.split('"')[0] ||
`document_${documentId}_certificate.pdf`;
const onLoaded = () => {
if (iframe.contentDocument?.readyState === 'complete') {
iframe.contentWindow?.print();
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
iframe.contentWindow?.addEventListener('afterprint', () => {
document.body.removeChild(iframe);
});
}
};
link.href = url;
link.download = filename;
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
document.body.appendChild(link);
link.click();
document.body.appendChild(iframe);
onLoaded();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error(error);
console.error('Certificate download error:', error);
toast({
title: _(msg`Something went wrong`),
@ -69,6 +66,8 @@ export const DocumentCertificateDownloadButton = ({
),
variant: 'destructive',
});
} finally {
setIsPending(false);
}
};

View File

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

View File

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

View File

@ -54,7 +54,7 @@ export const FolderCard = ({
};
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">
<CardContent className="p-4">
<div className="flex min-w-0 items-center gap-3">

View File

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

View File

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

View File

@ -71,23 +71,6 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
},
});
const { mutateAsync: promoteToOwner, isPending: isPromotingToOwner } =
trpc.admin.organisationMember.promoteToOwner.useMutation({
onSuccess: () => {
toast({
title: t`Success`,
description: t`Member promoted to owner successfully`,
});
},
onError: () => {
toast({
title: t`Error`,
description: t`We couldn't promote the member to owner. Please try again.`,
variant: 'destructive',
});
},
});
const teamsColumns = useMemo(() => {
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>
),
},
{
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]>[];
}, [organisation]);

View File

@ -151,7 +151,6 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
authLevel = match(accessAuthMethod)
.with('ACCOUNT', () => _(msg`Account Authentication`))
.with('TWO_FACTOR_AUTH', () => _(msg`Two-Factor Authentication`))
.with(undefined, () => _(msg`Email`))
.exhaustive();
}

View File

@ -47,12 +47,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
});
// Ensure typesafety when we add more options.
const isAccessAuthValid = derivedRecipientAccessAuth.every((auth) =>
match(auth)
.with(DocumentAccessAuth.ACCOUNT, () => Boolean(session.user))
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true)
.exhaustive(),
);
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
.with(DocumentAccessAuth.ACCOUNT, () => Boolean(session.user))
.with(undefined, () => true)
.exhaustive();
if (!isAccessAuthValid) {
return superLoaderJson({

View File

@ -3,12 +3,12 @@ import { DocumentSigningOrder, DocumentStatus, RecipientRole, SigningStatus } fr
import { Clock8 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
import { match } from 'ts-pattern';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
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';
@ -19,7 +19,6 @@ import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
@ -99,16 +98,16 @@ export async function loader({ params, request }: Route.LoaderArgs) {
recipientAuth: recipient.authOptions,
});
const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) =>
match(accesssAuth)
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true) // Allow without account requirement
.exhaustive(),
);
const isDocumentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
documentAuthOptions: document.authOptions,
recipient,
userId: user?.id,
});
let recipientHasAccount: boolean | null = null;
if (!isAccessAuthValid) {
if (!isDocumentAccessValid) {
recipientHasAccount = await getUserByEmail({ email: recipient.email })
.then((user) => !!user)
.catch(() => false);

View File

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

View File

@ -0,0 +1,61 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getAuditLogsPdf } from '@documenso/lib/server-only/htmltopdf/get-audit-logs-pdf';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import type { Route } from './+types/t.$teamUrl.download.audit-logs.$documentId';
export async function loader({ request, params }: Route.LoaderArgs) {
const documentId = Number(params.documentId);
const teamUrl = params.teamUrl;
if (!documentId || !teamUrl) {
return Response.json({ error: 'Invalid document ID or team URL' }, { status: 400 });
}
try {
const { user } = await getSession(request);
const team = await getTeamByUrl({ userId: user.id, teamUrl });
if (!team) {
return Response.json({ error: 'Team not found or access denied' }, { status: 404 });
}
const document = await getDocumentById({
documentId,
userId: user.id,
teamId: team.id,
}).catch(() => null);
if (!document || (team.id && document.teamId !== team.id)) {
return Response.json({ error: 'Document not found or access denied' }, { status: 404 });
}
const pdfBuffer = await getAuditLogsPdf({
documentId: document.id,
language: document.documentMeta?.language,
});
const filename = `${document.title.replace(/\.pdf$/, '')}_audit_logs.pdf`;
return new Response(pdfBuffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': pdfBuffer.length.toString(),
'Cache-Control': 'no-cache, no-store, must-revalidate',
Expires: '0',
},
});
} catch (error) {
if (error instanceof AppError) {
const statusCode = error.code === AppErrorCode.UNAUTHORIZED ? 401 : 400;
return Response.json({ error: error.message }, { status: statusCode });
}
return Response.json({ error: 'Failed to generate audit logs PDF' }, { status: 500 });
}
}

View File

@ -0,0 +1,69 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getCertificatePdf } from '@documenso/lib/server-only/htmltopdf/get-certificate-pdf';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import type { Route } from './+types/t.$teamUrl.download.certificate.$documentId';
export async function loader({ request, params }: Route.LoaderArgs) {
const documentId = Number(params.documentId);
const teamUrl = params.teamUrl;
if (!documentId || !teamUrl) {
return Response.json({ error: 'Invalid document ID or team URL' }, { status: 400 });
}
try {
const { user } = await getSession(request);
const team = await getTeamByUrl({ userId: user.id, teamUrl }).catch(() => null);
if (!team) {
return Response.json({ error: 'Team not found or access denied' }, { status: 404 });
}
const document = await getDocumentById({
documentId,
userId: user.id,
teamId: team.id,
}).catch(() => null);
if (!document || document.teamId !== team.id) {
return Response.json({ error: 'Document not found or access denied' }, { status: 404 });
}
if (!isDocumentCompleted(document.status)) {
return Response.json(
{ error: 'Document must be completed to download the certificate' },
{ status: 400 },
);
}
const pdfBuffer = await getCertificatePdf({
documentId: document.id,
language: document.documentMeta?.language,
});
const filename = `${document.title.replace(/\.pdf$/, '')}_certificate.pdf`;
return new Response(pdfBuffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': pdfBuffer.length.toString(),
'Cache-Control': 'no-cache, no-store, must-revalidate',
Expires: '0',
},
});
} catch (error) {
if (error instanceof AppError) {
const statusCode = error.code === AppErrorCode.UNAUTHORIZED ? 401 : 400;
return Response.json({ error: error.message }, { status: statusCode });
}
return Response.json({ error: 'Failed to generate certificate PDF' }, { status: 500 });
}
}

View File

@ -58,12 +58,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
documentAuth: template.authOptions,
});
const isAccessAuthValid = derivedRecipientAccessAuth.every((auth) =>
match(auth)
.with(DocumentAccessAuth.ACCOUNT, () => !!user)
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => false) // Not supported for direct links
.exhaustive(),
);
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
.with(DocumentAccessAuth.ACCOUNT, () => !!user)
.with(undefined, () => true)
.exhaustive();
if (!isAccessAuthValid) {
throw data(

View File

@ -1,12 +1,10 @@
import { RecipientRole } from '@prisma/client';
import { data } from 'react-router';
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
import { match } from 'ts-pattern';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
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 { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
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';
export async function loader({ params, request }: Route.LoaderArgs) {
const { requestMetadata } = getOptionalLoaderContext();
if (!params.url) {
throw new Response('Not found', { status: 404 });
}
@ -75,12 +71,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
documentAuth: document.authOptions,
});
const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) =>
match(accesssAuth)
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true) // Allow without account requirement
.exhaustive(),
);
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
.with(undefined, () => true)
.exhaustive();
if (!isAccessAuthValid) {
throw data(
@ -108,12 +102,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
);
}
await viewedDocument({
token,
requestMetadata,
recipientAccessAuth: derivedRecipientAccessAuth,
});
const allRecipients =
recipient.role === RecipientRole.ASSISTANT
? await getRecipientsForAssistant({

View File

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

6
package-lock.json generated
View File

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

View File

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

View File

@ -310,11 +310,12 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
)
.refine(
(schema) => {
const emails = schema.map((signer) => signer.email.toLowerCase());
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
.object({

View File

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

View File

@ -33,7 +33,7 @@ const setupDocumentAndNavigateToFieldsStep = async (page: Page) => {
};
const triggerAutosave = async (page: Page) => {
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.waitForTimeout(5000);
@ -70,7 +70,7 @@ test.describe('AutoSave Fields Step', () => {
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('button', { name: 'Signature' }).click();
@ -127,7 +127,7 @@ test.describe('AutoSave Fields Step', () => {
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('button', { name: 'Signature' }).click();
@ -140,7 +140,7 @@ test.describe('AutoSave Fields Step', () => {
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.getByText('Text').nth(1).click();
@ -191,7 +191,7 @@ test.describe('AutoSave Fields Step', () => {
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('button', { name: 'Signature' }).click();
@ -204,7 +204,7 @@ test.describe('AutoSave Fields Step', () => {
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.getByText('Signature').nth(1).click();

View File

@ -24,7 +24,7 @@ const setupDocument = 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.waitForTimeout(5000);

View File

@ -26,7 +26,7 @@ const setupDocumentAndNavigateToSignersStep = 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.waitForTimeout(5000);
@ -92,7 +92,7 @@ test.describe('AutoSave Signers Step', () => {
await triggerAutosave(page);
await page.getByRole('combobox').first().click();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Receives copy' }).click();
await triggerAutosave(page);
@ -160,20 +160,9 @@ test.describe('AutoSave Signers Step', () => {
expect(retrievedDocumentData.documentMeta?.signingOrder).toBe('SEQUENTIAL');
expect(retrievedDocumentData.documentMeta?.allowDictateNextSigner).toBe(true);
expect(retrievedRecipients.length).toBe(3);
const firstRecipient = retrievedRecipients.find(
(r) => r.email === 'recipient1@documenso.com',
);
const secondRecipient = retrievedRecipients.find(
(r) => r.email === 'recipient2@documenso.com',
);
const thirdRecipient = retrievedRecipients.find(
(r) => r.email === 'recipient3@documenso.com',
);
expect(firstRecipient?.signingOrder).toBe(2);
expect(secondRecipient?.signingOrder).toBe(3);
expect(thirdRecipient?.signingOrder).toBe(1);
expect(retrievedRecipients[0].signingOrder).toBe(2);
expect(retrievedRecipients[1].signingOrder).toBe(3);
expect(retrievedRecipients[2].signingOrder).toBe(1);
}).toPass();
});
});

View File

@ -42,7 +42,7 @@ export const setupDocumentAndNavigateToSubjectStep = 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.waitForTimeout(5000);

View File

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

View File

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

View File

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

View File

@ -277,13 +277,13 @@ test('[TEAMS]: document folder and its contents can be deleted', async ({ page }
await page.goto(`/t/${team.url}/documents`);
await 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 page.goto(`/t/${team.url}/documents/f/${folder.id}`);
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 }) => {
@ -318,7 +318,9 @@ test('[TEAMS]: can create a template folder', async ({ page }) => {
await expect(page.getByText('Team template folder')).toBeVisible();
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 }) => {
@ -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.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
@ -532,7 +537,7 @@ test('[TEAMS]: template folder can be moved to another template folder', async (
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 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.waitForTimeout(1000);
// !: 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 expect(page.locator('div').filter({ hasText: folder.name })).not.toBeVisible();
await expect(page.getByText(template.title)).not.toBeVisible();
await page.goto(`/t/${team.url}/templates/f/${folder.id}`);
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 }) => {
@ -841,15 +843,10 @@ test('[TEAMS]: documents inherit folder visibility', async ({ page }) => {
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.
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.getByRole('button', { name: 'Upload Document' }).click(),
]);
await fileChooser.setFiles(
await fileInput.setInputFiles(
path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'),
);

View File

@ -30,8 +30,8 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
await page.getByRole('option', { name: 'Australia/Perth' }).click();
// Set default date
await page.getByRole('combobox').filter({ hasText: 'yyyy-MM-dd hh:mm AM/PM' }).click();
await page.getByRole('option', { name: 'DD/MM/YYYY', exact: true }).click();
await page.getByRole('combobox').filter({ hasText: 'yyyy-MM-dd hh:mm a' }).click();
await page.getByRole('option', { name: 'DD/MM/YYYY' }).click();
await page.getByTestId('signature-types-trigger').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.documentLanguage).toEqual('de');
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.includeSigningCertificate).toEqual(false);
expect(teamSettings.typedSignatureEnabled).toEqual(true);
@ -72,7 +72,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
// Override team date format settings
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 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.documentLanguage).toEqual('pl');
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.includeSigningCertificate).toEqual(false);
expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true);
@ -108,7 +108,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
expect(documentMeta.drawSignatureEnabled).toEqual(false);
expect(documentMeta.language).toEqual('pl');
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 }) => {

View File

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

View File

@ -33,7 +33,7 @@ const setupTemplateAndNavigateToFieldsStep = async (page: Page) => {
};
const triggerAutosave = async (page: Page) => {
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.waitForTimeout(5000);
@ -70,7 +70,7 @@ test.describe('AutoSave Fields Step', () => {
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('button', { name: 'Signature' }).click();
@ -129,7 +129,7 @@ test.describe('AutoSave Fields Step', () => {
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('button', { name: 'Signature' }).click();
@ -142,7 +142,7 @@ test.describe('AutoSave Fields Step', () => {
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.getByText('Text').nth(1).click();
@ -195,7 +195,7 @@ test.describe('AutoSave Fields Step', () => {
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('button', { name: 'Signature' }).click();
@ -208,7 +208,7 @@ test.describe('AutoSave Fields Step', () => {
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.getByText('Signature').nth(1).click();

View File

@ -23,7 +23,7 @@ const setupTemplate = 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.waitForTimeout(5000);

View File

@ -26,7 +26,7 @@ const setupTemplateAndNavigateToSignersStep = 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.waitForTimeout(5000);

View File

@ -47,8 +47,8 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
// Set advanced options.
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.locator('button').filter({ hasText: 'YYYY-MM-DD hh:mm AM/PM' }).click();
await page.getByLabel('DD/MM/YYYY HH:mm', { exact: true }).click();
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
await page.getByLabel('DD/MM/YYYY').click();
await page.locator('.time-zone-field').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(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?.redirectUrl).toEqual('https://documenso.com');
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.
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.locator('button').filter({ hasText: 'YYYY-MM-DD hh:mm AM/PM' }).click();
await page.getByLabel('DD/MM/YYYY HH:mm', { exact: true }).click();
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
await page.getByLabel('DD/MM/YYYY').click();
await page.locator('.time-zone-field').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(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?.redirectUrl).toEqual('https://documenso.com');
expect(document.documentMeta?.subject).toEqual('SUBJECT');

View File

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

View File

@ -23,17 +23,12 @@ type HandleOAuthAuthorizeUrlOptions = {
* Optional redirect path to redirect the user somewhere on the app after authorization.
*/
redirectPath?: string;
/**
* Optional prompt to pass to the authorization endpoint.
*/
prompt?: 'login' | 'consent' | 'select_account';
};
const oauthCookieMaxAge = 60 * 10; // 10 minutes.
export const handleOAuthAuthorizeUrl = async (options: HandleOAuthAuthorizeUrlOptions) => {
const { c, clientOptions, redirectPath, prompt = 'login' } = options;
const { c, clientOptions, redirectPath } = options;
if (!clientOptions.clientId || !clientOptions.clientSecret) {
throw new AppError(AppErrorCode.NOT_SETUP);
@ -62,8 +57,8 @@ export const handleOAuthAuthorizeUrl = async (options: HandleOAuthAuthorizeUrlOp
scopes,
);
// Pass the prompt to the authorization endpoint.
url.searchParams.append('prompt', prompt);
// Allow user to select account during login.
url.searchParams.append('prompt', 'login');
setCookie(c, `${clientOptions.id}_oauth_state`, state, {
...sessionCookieOptions,

View File

@ -50,6 +50,5 @@ export const oauthRoute = new Hono<HonoAuthContext>()
return await handleOAuthAuthorizeUrl({
c,
clientOptions,
prompt: 'select_account',
});
});

View File

@ -1,60 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { Heading, Img, Section, Text } from '../components';
export type TemplateAccessAuth2FAProps = {
documentTitle: string;
code: string;
userEmail: string;
userName: string;
expiresInMinutes: number;
assetBaseUrl?: string;
};
export const TemplateAccessAuth2FA = ({
documentTitle,
code,
userName,
expiresInMinutes,
assetBaseUrl = 'http://localhost:3002',
}: TemplateAccessAuth2FAProps) => {
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<div>
<Img src={getAssetUrl('/static/document.png')} alt="Document" className="mx-auto h-12 w-12" />
<Section className="mt-8">
<Heading className="text-center text-lg font-semibold text-slate-900">
<Trans>Verification Code Required</Trans>
</Heading>
<Text className="mt-2 text-center text-slate-700">
<Trans>
Hi {userName}, you need to enter a verification code to complete the document "
{documentTitle}".
</Trans>
</Text>
<Section className="mt-6 rounded-lg bg-slate-50 p-6 text-center">
<Text className="mb-2 text-sm font-medium text-slate-600">
<Trans>Your verification code:</Trans>
</Text>
<Text className="text-2xl font-bold tracking-wider text-slate-900">{code}</Text>
</Section>
<Text className="mt-4 text-center text-sm text-slate-600">
<Trans>This code will expire in {expiresInMinutes} minutes.</Trans>
</Text>
<Text className="mt-4 text-center text-sm text-slate-500">
<Trans>
If you didn't request this verification code, you can safely ignore this email.
</Trans>
</Text>
</Section>
</div>
);
};

View File

@ -1,3 +1,5 @@
import { useMemo } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { OrganisationType, RecipientRole } from '@prisma/client';
@ -36,6 +38,12 @@ export const TemplateDocumentInvite = ({
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
const rejectDocumentLink = useMemo(() => {
const url = new URL(signDocumentLink);
url.searchParams.set('reject', 'true');
return url.toString();
}, [signDocumentLink]);
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
@ -91,15 +99,22 @@ export const TemplateDocumentInvite = ({
<Section className="mb-6 mt-8 text-center">
<Button
className="bg-documenso-500 text-sbase inline-flex items-center justify-center rounded-lg px-6 py-3 text-center font-medium text-black no-underline"
className="mr-4 inline-flex items-center justify-center rounded-lg bg-red-500 px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={rejectDocumentLink}
>
<Trans>Reject Document</Trans>
</Button>
<Button
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={signDocumentLink}
>
{match(role)
.with(RecipientRole.SIGNER, () => <Trans>View Document to sign</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>View Document to approve</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
.with(RecipientRole.CC, () => '')
.with(RecipientRole.ASSISTANT, () => <Trans>View Document to assist</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
.exhaustive()}
</Button>
</Section>

View File

@ -1,77 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Body, Container, Head, Html, Img, Preview, Section } from '../components';
import { useBranding } from '../providers/branding';
import { TemplateAccessAuth2FA } from '../template-components/template-access-auth-2fa';
import { TemplateFooter } from '../template-components/template-footer';
export type AccessAuth2FAEmailTemplateProps = {
documentTitle: string;
code: string;
userEmail: string;
userName: string;
expiresInMinutes: number;
assetBaseUrl?: string;
};
export const AccessAuth2FAEmailTemplate = ({
documentTitle,
code,
userEmail,
userName,
expiresInMinutes,
assetBaseUrl = 'http://localhost:3002',
}: AccessAuth2FAEmailTemplateProps) => {
const { _ } = useLingui();
const branding = useBranding();
const previewText = msg`Your verification code is ${code}`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Section>
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
<Section>
{branding.brandingEnabled && branding.brandingLogo ? (
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
) : (
<Img
src={getAssetUrl('/static/logo.png')}
alt="Documenso Logo"
className="mb-4 h-6"
/>
)}
<TemplateAccessAuth2FA
documentTitle={documentTitle}
code={code}
userEmail={userEmail}
userName={userName}
expiresInMinutes={expiresInMinutes}
assetBaseUrl={assetBaseUrl}
/>
</Section>
</Container>
<div className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Html>
);
};
export default AccessAuth2FAEmailTemplate;

View File

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

View File

@ -7,25 +7,14 @@ export const DEFAULT_DOCUMENT_DATE_FORMAT = 'yyyy-MM-dd hh:mm a';
export const VALID_DATE_FORMAT_VALUES = [
DEFAULT_DOCUMENT_DATE_FORMAT,
'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',
'MM/dd/yyyy hh:mm a',
'MM/dd/yyyy HH:mm',
'dd.MM.yyyy',
'dd.MM.yyyy HH:mm',
'yyyy-MM-dd HH:mm',
'yy-MM-dd hh:mm a',
'yy-MM-dd HH:mm',
'yyyy-MM-dd HH:mm:ss',
'MMMM dd, yyyy hh:mm a',
'MMMM dd, yyyy HH:mm',
'EEEE, MMMM dd, yyyy hh:mm a',
'EEEE, MMMM dd, yyyy HH:mm',
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
] as const;
@ -33,80 +22,10 @@ export type ValidDateFormat = (typeof VALID_DATE_FORMAT_VALUES)[number];
export const DATE_FORMATS = [
{
key: 'yyyy-MM-dd_HH:mm_12H',
label: 'YYYY-MM-DD hh:mm AM/PM',
key: 'yyyy-MM-dd_hh:mm_a',
label: 'YYYY-MM-DD HH:mm a',
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',
label: 'YYYY-MM-DD',
@ -115,32 +34,47 @@ export const DATE_FORMATS = [
{
key: 'DDMMYYYY',
label: 'DD/MM/YYYY',
value: 'dd/MM/yyyy',
value: 'dd/MM/yyyy hh:mm a',
},
{
key: 'MMDDYYYY',
label: 'MM/DD/YYYY',
value: 'MM/dd/yyyy',
value: 'MM/dd/yyyy hh:mm a',
},
{
key: 'DDMMYYYY_DOT',
label: 'DD.MM.YYYY',
value: 'dd.MM.yyyy',
key: 'DDMMYYYYHHMM',
label: 'DD.MM.YYYY HH:mm',
value: 'dd.MM.yyyy HH:mm',
},
{
key: 'YYYYMMDDHHmm',
label: 'YYYY-MM-DD HH:mm',
value: 'yyyy-MM-dd HH:mm',
},
{
key: 'YYMMDD',
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',
label: 'Month Date, Year',
value: 'MMMM dd, yyyy',
value: 'MMMM dd, yyyy hh:mm a',
},
{
key: 'DayMonthYear',
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 {
key: string;

View File

@ -17,7 +17,6 @@ export enum AppErrorCode {
'RETRY_EXCEPTION' = 'RETRY_EXCEPTION',
'SCHEMA_FAILED' = 'SCHEMA_FAILED',
'TOO_MANY_REQUESTS' = 'TOO_MANY_REQUESTS',
'TWO_FACTOR_AUTH_FAILED' = 'TWO_FACTOR_AUTH_FAILED',
}
export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string; status: number }> =
@ -33,7 +32,6 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.RETRY_EXCEPTION]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.SCHEMA_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.TOO_MANY_REQUESTS]: { code: 'TOO_MANY_REQUESTS', status: 429 },
[AppErrorCode.TWO_FACTOR_AUTH_FAILED]: { code: 'UNAUTHORIZED', status: 401 },
};
export const ZAppErrorJsonSchema = z.object({

View File

@ -1 +0,0 @@
export const TWO_FACTOR_EMAIL_EXPIRATION_MINUTES = 5;

View File

@ -1,38 +0,0 @@
import { hmac } from '@noble/hashes/hmac';
import { sha256 } from '@noble/hashes/sha256';
import { createTOTPKeyURI } from 'oslo/otp';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../../constants/crypto';
const ISSUER = 'Documenso Email 2FA';
export type GenerateTwoFactorCredentialsFromEmailOptions = {
documentId: number;
email: string;
};
/**
* Generate an encrypted token containing a 6-digit 2FA code for email verification.
*
* @param options - The options for generating the token
* @returns Object containing the token and the 6-digit code
*/
export const generateTwoFactorCredentialsFromEmail = ({
documentId,
email,
}: GenerateTwoFactorCredentialsFromEmailOptions) => {
if (!DOCUMENSO_ENCRYPTION_KEY) {
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
}
const identity = `email-2fa|v1|email:${email}|id:${documentId}`;
const secret = hmac(sha256, DOCUMENSO_ENCRYPTION_KEY, identity);
const uri = createTOTPKeyURI(ISSUER, email, secret);
return {
uri,
secret,
};
};

View File

@ -1,23 +0,0 @@
import { generateHOTP } from 'oslo/otp';
import { generateTwoFactorCredentialsFromEmail } from './generate-2fa-credentials-from-email';
export type GenerateTwoFactorTokenFromEmailOptions = {
documentId: number;
email: string;
period?: number;
};
export const generateTwoFactorTokenFromEmail = async ({
email,
documentId,
period = 30_000,
}: GenerateTwoFactorTokenFromEmailOptions) => {
const { secret } = generateTwoFactorCredentialsFromEmail({ email, documentId });
const counter = Math.floor(Date.now() / period);
const token = await generateHOTP(secret, counter);
return token;
};

View File

@ -1,124 +0,0 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { mailer } from '@documenso/email/mailer';
import { AccessAuth2FAEmailTemplate } from '@documenso/email/templates/access-auth-2fa';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { AppError, AppErrorCode } from '../../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { getEmailContext } from '../../email/get-email-context';
import { TWO_FACTOR_EMAIL_EXPIRATION_MINUTES } from './constants';
import { generateTwoFactorTokenFromEmail } from './generate-2fa-token-from-email';
export type Send2FATokenEmailOptions = {
token: string;
documentId: number;
};
export const send2FATokenEmail = async ({ token, documentId }: Send2FATokenEmailOptions) => {
const document = await prisma.document.findFirst({
where: {
id: documentId,
recipients: {
some: {
token,
},
},
},
include: {
recipients: {
where: {
token,
},
},
documentMeta: true,
team: {
select: {
teamEmail: true,
name: true,
},
},
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const [recipient] = document.recipients;
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
const twoFactorTokenToken = await generateTwoFactorTokenFromEmail({
documentId,
email: recipient.email,
});
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta,
});
const i18n = await getI18nInstance(emailLanguage);
const subject = i18n._(msg`Your two-factor authentication code`);
const template = createElement(AccessAuth2FAEmailTemplate, {
documentTitle: document.title,
userName: recipient.name,
userEmail: recipient.email,
code: twoFactorTokenToken,
expiresInMinutes: TWO_FACTOR_EMAIL_EXPIRATION_MINUTES,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
await prisma.$transaction(
async (tx) => {
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: senderEmail,
replyTo: replyToEmail,
subject,
html,
text,
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED,
documentId: document.id,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
},
}),
});
},
{ timeout: 30_000 },
);
};

View File

@ -1,37 +0,0 @@
import { generateHOTP } from 'oslo/otp';
import { generateTwoFactorCredentialsFromEmail } from './generate-2fa-credentials-from-email';
export type ValidateTwoFactorTokenFromEmailOptions = {
documentId: number;
email: string;
code: string;
period?: number;
window?: number;
};
export const validateTwoFactorTokenFromEmail = async ({
documentId,
email,
code,
period = 30_000,
window = 1,
}: ValidateTwoFactorTokenFromEmailOptions) => {
const { secret } = generateTwoFactorCredentialsFromEmail({ email, documentId });
let now = Date.now();
for (let i = 0; i < window; i++) {
const counter = Math.floor(now / period);
const hotp = await generateHOTP(secret, counter);
if (code === hotp) {
return true;
}
now -= period;
}
return false;
};

View File

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

View File

@ -18,8 +18,7 @@ import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import type { TRecipientAccessAuth, TRecipientActionAuth } from '../../types/document-auth';
import { DocumentAuth } from '../../types/document-auth';
import type { TRecipientActionAuth } from '../../types/document-auth';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
@ -27,7 +26,6 @@ import {
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { isRecipientAuthorized } from './is-recipient-authorized';
import { sendPendingEmail } from './send-pending-email';
export type CompleteDocumentWithTokenOptions = {
@ -35,7 +33,6 @@ export type CompleteDocumentWithTokenOptions = {
documentId: number;
userId?: number;
authOptions?: TRecipientActionAuth;
accessAuthOptions?: TRecipientAccessAuth;
requestMetadata?: RequestMetadata;
nextSigner?: {
email: string;
@ -67,8 +64,6 @@ const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptio
export const completeDocumentWithToken = async ({
token,
documentId,
userId,
accessAuthOptions,
requestMetadata,
nextSigner,
}: CompleteDocumentWithTokenOptions) => {
@ -116,57 +111,24 @@ export const completeDocumentWithToken = async ({
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
}
// Check ACCESS AUTH 2FA validation during document completion
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
// Document reauth for completing documents is currently not required.
if (derivedRecipientAccessAuth.includes(DocumentAuth.TWO_FACTOR_AUTH)) {
if (!accessAuthOptions) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Access authentication required',
});
}
// const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
// documentAuth: document.authOptions,
// recipientAuth: recipient.authOptions,
// });
const isValid = await isRecipientAuthorized({
type: 'ACCESS_2FA',
documentAuthOptions: document.authOptions,
recipient: recipient,
userId, // Can be undefined for non-account recipients
authOptions: accessAuthOptions,
});
// const isValid = await isRecipientAuthorized({
// type: 'ACTION',
// document: document,
// recipient: recipient,
// userId,
// authOptions,
// });
if (!isValid) {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED,
documentId: document.id,
data: {
recipientId: recipient.id,
recipientName: recipient.name,
recipientEmail: recipient.email,
},
}),
});
throw new AppError(AppErrorCode.TWO_FACTOR_AUTH_FAILED, {
message: 'Invalid 2FA authentication',
});
}
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED,
documentId: document.id,
data: {
recipientId: recipient.id,
recipientName: recipient.name,
recipientEmail: recipient.email,
},
}),
});
}
// if (!isValid) {
// throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
// }
await prisma.$transaction(async (tx) => {
await tx.recipient.update({

View File

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

View File

@ -4,7 +4,6 @@ import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { validateTwoFactorTokenFromEmail } from '../2fa/email/validate-2fa-token-from-email';
import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token';
import { verifyPassword } from '../2fa/verify-password';
import { AppError, AppErrorCode } from '../../errors/app-error';
@ -15,10 +14,9 @@ import { getAuthenticatorOptions } from '../../utils/authenticator';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
type IsRecipientAuthorizedOptions = {
// !: Probably find a better name than 'ACCESS_2FA' if requirements change.
type: 'ACCESS' | 'ACCESS_2FA' | 'ACTION';
type: 'ACCESS' | 'ACTION';
documentAuthOptions: Document['authOptions'];
recipient: Pick<Recipient, 'authOptions' | 'email' | 'documentId'>;
recipient: Pick<Recipient, 'authOptions' | 'email'>;
/**
* The ID of the user who initiated the request.
@ -63,11 +61,8 @@ export const isRecipientAuthorized = async ({
recipientAuth: recipient.authOptions,
});
const authMethods: TDocumentAuth[] = match(type)
.with('ACCESS', () => derivedRecipientAccessAuth)
.with('ACCESS_2FA', () => derivedRecipientAccessAuth)
.with('ACTION', () => derivedRecipientActionAuth)
.exhaustive();
const authMethods: TDocumentAuth[] =
type === 'ACCESS' ? derivedRecipientAccessAuth : derivedRecipientActionAuth;
// Early true return when auth is not required.
if (
@ -77,11 +72,6 @@ export const isRecipientAuthorized = async ({
return true;
}
// Early true return for ACCESS auth if all methods are 2FA since validation happens in ACCESS_2FA.
if (type === 'ACCESS' && authMethods.every((method) => method === DocumentAuth.TWO_FACTOR_AUTH)) {
return true;
}
// Create auth options when none are passed for account.
if (!authOptions && authMethods.some((method) => method === DocumentAuth.ACCOUNT)) {
authOptions = {
@ -90,16 +80,12 @@ export const isRecipientAuthorized = async ({
}
// Authentication required does not match provided method.
if (!authOptions || !authMethods.includes(authOptions.type)) {
if (!authOptions || !authMethods.includes(authOptions.type) || !userId) {
return false;
}
return await match(authOptions)
.with({ type: DocumentAuth.ACCOUNT }, async () => {
if (!userId) {
return false;
}
const recipientUser = await getUserByEmail(recipient.email);
if (!recipientUser) {
@ -109,40 +95,13 @@ export const isRecipientAuthorized = async ({
return recipientUser.id === userId;
})
.with({ type: DocumentAuth.PASSKEY }, async ({ authenticationResponse, tokenReference }) => {
if (!userId) {
return false;
}
return await isPasskeyAuthValid({
userId,
authenticationResponse,
tokenReference,
});
})
.with({ type: DocumentAuth.TWO_FACTOR_AUTH }, async ({ token, method }) => {
if (type === 'ACCESS') {
return true;
}
if (type === 'ACCESS_2FA' && method === 'email') {
if (!recipient.documentId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document ID is required for email 2FA verification',
});
}
return await validateTwoFactorTokenFromEmail({
documentId: recipient.documentId,
email: recipient.email,
code: token,
window: 10, // 5 minutes worth of tokens
});
}
if (!userId) {
return false;
}
.with({ type: DocumentAuth.TWO_FACTOR_AUTH }, async ({ token }) => {
const user = await prisma.user.findFirst({
where: {
id: userId,
@ -156,7 +115,6 @@ export const isRecipientAuthorized = async ({
});
}
// For ACTION auth or authenticator method, use TOTP
return await verifyTwoFactorAuthenticationToken({
user,
totpCode: token,
@ -164,10 +122,6 @@ export const isRecipientAuthorized = async ({
});
})
.with({ type: DocumentAuth.PASSWORD }, async ({ password }) => {
if (!userId) {
return false;
}
return await verifyPassword({
userId,
password,

View File

@ -7,7 +7,7 @@ import { isRecipientAuthorized } from './is-recipient-authorized';
export type ValidateFieldAuthOptions = {
documentAuthOptions: Document['authOptions'];
recipient: Pick<Recipient, 'authOptions' | 'email' | 'documentId'>;
recipient: Pick<Recipient, 'authOptions' | 'email'>;
field: Field;
userId?: number;
authOptions?: TRecipientActionAuth;

View File

@ -84,7 +84,9 @@ export const setFieldsForDocument = async ({
const linkedFields = fields.map((field) => {
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.
if (!recipient) {
@ -224,8 +226,10 @@ export const setFieldsForDocument = async ({
},
recipient: {
connect: {
id: field.recipientId,
documentId,
documentId_email: {
documentId,
email: fieldSignerEmail,
},
},
},
},
@ -326,7 +330,6 @@ type FieldData = {
id?: number | null;
type: FieldType;
signerEmail: string;
recipientId: number;
pageNumber: number;
pageX: number;
pageY: number;

View File

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

View File

@ -85,6 +85,20 @@ export const createDocumentRecipients = async ({
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) => {
return await Promise.all(
normalizedRecipients.map(async (recipient) => {

View File

@ -71,6 +71,20 @@ export const createTemplateRecipients = async ({
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) => {
return await Promise.all(
normalizedRecipients.map(async (recipient) => {

View File

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

View File

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

View File

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

View File

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

View File

@ -159,7 +159,6 @@ export const createDocumentFromDirectTemplate = async ({
// Ensure typesafety when we add more options.
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
.with(DocumentAccessAuth.ACCOUNT, () => user && user?.email === directRecipientEmail)
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => false) // Not supported for direct templates
.with(undefined, () => true)
.exhaustive();
@ -206,7 +205,6 @@ export const createDocumentFromDirectTemplate = async ({
recipient: {
authOptions: directTemplateRecipient.authOptions,
email: directRecipientEmail,
documentId: template.id,
},
field: templateField,
userId: user?.id,

View File

@ -19,8 +19,6 @@ export type CreateDocumentFromTemplateLegacyOptions = {
}[];
};
// !TODO: Make this work
/**
* 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({
data: {
qrToken: prefixedId('qr'),
@ -81,12 +70,12 @@ export const createDocumentFromTemplateLegacy = async ({
documentDataId: documentData.id,
useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false,
recipients: {
create: recipientsToCreate.map((recipient) => ({
create: template.recipients.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: recipient.token,
token: nanoid(),
})),
},
documentMeta: {
@ -106,11 +95,9 @@ export const createDocumentFromTemplateLegacy = async ({
await prisma.field.createMany({
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(
(documentRecipient) => documentRecipient.token === recipient?.token,
);
const documentRecipient = document.recipients.find((doc) => doc.email === recipient?.email);
if (!documentRecipient) {
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) {
await Promise.all(
document.recipients = await Promise.all(
recipients.map(async (recipient, index) => {
const existingRecipient = document.recipients.at(index);
if (existingRecipient) {
return await prisma.recipient.update({
where: {
id: existingRecipient.id,
return await prisma.recipient.upsert({
where: {
documentId_email: {
documentId: document.id,
email: existingRecipient?.email ?? recipient.email,
},
data: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
},
});
}
return await prisma.recipient.create({
data: {
documentId: document.id,
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
},
create: {
documentId: document.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
},
});
@ -166,18 +149,5 @@ export const createDocumentFromTemplateLegacy = async ({
);
}
// Gross but we need to do the additional fetch since we mutate above.
const updatedRecipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
},
orderBy: {
id: 'asc',
},
});
return {
...document,
recipients: updatedRecipients,
};
return document;
};

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -40,11 +40,6 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated.
'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team.
// ACCESS AUTH 2FA events.
'DOCUMENT_ACCESS_AUTH_2FA_REQUESTED', // When ACCESS AUTH 2FA is requested.
'DOCUMENT_ACCESS_AUTH_2FA_VALIDATED', // When ACCESS AUTH 2FA is successfully validated.
'DOCUMENT_ACCESS_AUTH_2FA_FAILED', // When ACCESS AUTH 2FA validation fails.
]);
export const ZDocumentAuditLogEmailTypeSchema = z.enum([
@ -492,42 +487,6 @@ export const ZDocumentAuditLogEventDocumentRecipientRejectedSchema = z.object({
}),
});
/**
* Event: Document recipient requested a 2FA token.
*/
export const ZDocumentAuditLogEventDocumentRecipientRequested2FAEmailSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED),
data: z.object({
recipientEmail: z.string(),
recipientName: z.string(),
recipientId: z.number(),
}),
});
/**
* Event: Document recipient validated a 2FA token.
*/
export const ZDocumentAuditLogEventDocumentRecipientValidated2FAEmailSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED),
data: z.object({
recipientEmail: z.string(),
recipientName: z.string(),
recipientId: z.number(),
}),
});
/**
* Event: Document recipient failed to validate a 2FA token.
*/
export const ZDocumentAuditLogEventDocumentRecipientFailed2FAEmailSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED),
data: z.object({
recipientEmail: z.string(),
recipientName: z.string(),
recipientId: z.number(),
}),
});
/**
* Event: Document sent.
*/
@ -668,9 +627,6 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventDocumentViewedSchema,
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
ZDocumentAuditLogEventDocumentRecipientRejectedSchema,
ZDocumentAuditLogEventDocumentRecipientRequested2FAEmailSchema,
ZDocumentAuditLogEventDocumentRecipientValidated2FAEmailSchema,
ZDocumentAuditLogEventDocumentRecipientFailed2FAEmailSchema,
ZDocumentAuditLogEventDocumentSentSchema,
ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
ZDocumentAuditLogEventDocumentExternalIdUpdatedSchema,

View File

@ -37,7 +37,6 @@ const ZDocumentAuthPasswordSchema = z.object({
const ZDocumentAuth2FASchema = z.object({
type: z.literal(DocumentAuth.TWO_FACTOR_AUTH),
token: z.string().min(4).max(10),
method: z.enum(['email', 'authenticator']).default('authenticator').optional(),
});
/**
@ -56,12 +55,9 @@ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
*
* Must keep these two in sync.
*/
export const ZDocumentAccessAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
ZDocumentAuth2FASchema,
]);
export const ZDocumentAccessAuthSchema = z.discriminatedUnion('type', [ZDocumentAuthAccountSchema]);
export const ZDocumentAccessAuthTypesSchema = z
.enum([DocumentAuth.ACCOUNT, DocumentAuth.TWO_FACTOR_AUTH])
.enum([DocumentAuth.ACCOUNT])
.describe('The type of authentication required for the recipient to access the document.');
/**
@ -93,10 +89,9 @@ export const ZDocumentActionAuthTypesSchema = z
*/
export const ZRecipientAccessAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
ZDocumentAuth2FASchema,
]);
export const ZRecipientAccessAuthTypesSchema = z
.enum([DocumentAuth.ACCOUNT, DocumentAuth.TWO_FACTOR_AUTH])
.enum([DocumentAuth.ACCOUNT])
.describe('The type of authentication required for the recipient to access the document.');
/**

View File

@ -476,36 +476,6 @@ export const formatDocumentAuditLogAction = (
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED }, ({ data }) => {
const userName = prefix || _(msg`Recipient`);
const result = msg`${userName} requested a 2FA token for the document`;
return {
anonymous: result,
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED }, ({ data }) => {
const userName = prefix || _(msg`Recipient`);
const result = msg`${userName} validated a 2FA token for the document`;
return {
anonymous: result,
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED }, ({ data }) => {
const userName = prefix || _(msg`Recipient`);
const result = msg`${userName} failed to validate a 2FA token for the document`;
return {
anonymous: result,
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({
anonymous: data.isResending ? msg`Email resent` : msg`Email sent`,
identified: data.isResending

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,94 +0,0 @@
import { TRPCError } from '@trpc/server';
import { DateTime } from 'luxon';
import { TWO_FACTOR_EMAIL_EXPIRATION_MINUTES } from '@documenso/lib/server-only/2fa/email/constants';
import { send2FATokenEmail } from '@documenso/lib/server-only/2fa/email/send-2fa-token-email';
import { DocumentAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { procedure } from '../trpc';
import {
ZAccessAuthRequest2FAEmailRequestSchema,
ZAccessAuthRequest2FAEmailResponseSchema,
} from './access-auth-request-2fa-email.types';
export const accessAuthRequest2FAEmailRoute = procedure
.input(ZAccessAuthRequest2FAEmailRequestSchema)
.output(ZAccessAuthRequest2FAEmailResponseSchema)
.mutation(async ({ input, ctx }) => {
try {
const { token } = input;
const user = ctx.user;
// Get document and recipient by token
const document = await prisma.document.findFirst({
where: {
recipients: {
some: {
token,
},
},
},
include: {
recipients: {
where: {
token,
},
},
},
});
if (!document) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Document not found',
});
}
const [recipient] = document.recipients;
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
if (!derivedRecipientAccessAuth.includes(DocumentAuth.TWO_FACTOR_AUTH)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '2FA is not required for this document',
});
}
// if (user && recipient.email !== user.email) {
// throw new TRPCError({
// code: 'UNAUTHORIZED',
// message: 'User does not match recipient',
// });
// }
const expiresAt = DateTime.now().plus({ minutes: TWO_FACTOR_EMAIL_EXPIRATION_MINUTES });
await send2FATokenEmail({
token,
documentId: document.id,
});
return {
success: true,
expiresAt: expiresAt.toJSDate(),
};
} catch (error) {
console.error('Error sending access auth 2FA email:', error);
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to send 2FA email',
});
}
});

View File

@ -1,17 +0,0 @@
import { z } from 'zod';
export const ZAccessAuthRequest2FAEmailRequestSchema = z.object({
token: z.string().min(1),
});
export const ZAccessAuthRequest2FAEmailResponseSchema = z.object({
success: z.boolean(),
expiresAt: z.date(),
});
export type TAccessAuthRequest2FAEmailRequest = z.infer<
typeof ZAccessAuthRequest2FAEmailRequestSchema
>;
export type TAccessAuthRequest2FAEmailResponse = z.infer<
typeof ZAccessAuthRequest2FAEmailResponseSchema
>;

View File

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

View File

@ -1,5 +1,4 @@
import { router } from '../trpc';
import { accessAuthRequest2FAEmailRoute } from './access-auth-request-2fa-email';
import { createDocumentRoute } from './create-document';
import { createDocumentTemporaryRoute } from './create-document-temporary';
import { deleteDocumentRoute } from './delete-document';
@ -39,10 +38,6 @@ export const documentRouter = router({
getDocumentByToken: getDocumentByTokenRoute,
findDocumentsInternal: findDocumentsInternalRoute,
accessAuth: router({
request2FAEmail: accessAuthRequest2FAEmailRoute,
}),
auditLog: {
find: findDocumentAuditLogsRoute,
download: downloadDocumentAuditLogsRoute,

View File

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

View File

@ -30,27 +30,36 @@ export const ZUpdateEmbeddingDocumentRequestSchema = z.object({
documentId: z.number(),
title: ZDocumentTitleSchema,
externalId: ZDocumentExternalIdSchema.optional(),
recipients: z.array(
z.object({
id: z.number().optional(),
email: z.string().toLowerCase().email().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
fields: ZFieldAndMetaSchema.and(
z.object({
id: z.number().optional(),
pageNumber: ZFieldPageNumberSchema,
pageX: ZFieldPageXSchema,
pageY: ZFieldPageYSchema,
width: ZFieldWidthSchema,
height: ZFieldHeightSchema,
}),
)
.array()
.optional(),
}),
),
recipients: z
.array(
z.object({
id: z.number().optional(),
email: z.string().toLowerCase().email().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
fields: ZFieldAndMetaSchema.and(
z.object({
id: z.number().optional(),
pageNumber: ZFieldPageNumberSchema,
pageX: ZFieldPageXSchema,
pageY: ZFieldPageYSchema,
width: ZFieldWidthSchema,
height: ZFieldHeightSchema,
}),
)
.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
.object({
subject: ZDocumentMetaSubjectSchema.optional(),

View File

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

View File

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

View File

@ -1,5 +1,3 @@
import { OrganisationType } from '@prisma/client';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
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({
where: {
id: organisationId,

View File

@ -525,7 +525,7 @@ export const recipientRouter = router({
completeDocumentWithToken: procedure
.input(ZCompleteDocumentWithTokenMutationSchema)
.mutation(async ({ input, ctx }) => {
const { token, documentId, authOptions, accessAuthOptions, nextSigner } = input;
const { token, documentId, authOptions, nextSigner } = input;
ctx.logger.info({
input: {
@ -537,7 +537,6 @@ export const recipientRouter = router({
token,
documentId,
authOptions,
accessAuthOptions,
nextSigner,
userId: ctx.user?.id,
requestMetadata: ctx.metadata.requestMetadata,

View File

@ -3,7 +3,6 @@ import { z } from 'zod';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import {
ZRecipientAccessAuthSchema,
ZRecipientAccessAuthTypesSchema,
ZRecipientActionAuthSchema,
ZRecipientActionAuthTypesSchema,
@ -51,7 +50,16 @@ export const ZCreateDocumentRecipientResponseSchema = ZRecipientLiteSchema;
export const ZCreateDocumentRecipientsRequestSchema = z.object({
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({
@ -67,7 +75,18 @@ export const ZUpdateDocumentRecipientResponseSchema = ZRecipientSchema;
export const ZUpdateDocumentRecipientsRequestSchema = z.object({
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({
@ -78,19 +97,29 @@ export const ZDeleteDocumentRecipientRequestSchema = z.object({
recipientId: z.number(),
});
export const ZSetDocumentRecipientsRequestSchema = z.object({
documentId: z.number(),
recipients: z.array(
z.object({
nativeId: z.number().optional(),
email: z.string().toLowerCase().email().min(1).max(254),
name: z.string().max(255),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}),
),
});
export const ZSetDocumentRecipientsRequestSchema = z
.object({
documentId: z.number(),
recipients: z.array(
z.object({
nativeId: z.number().optional(),
email: z.string().toLowerCase().email().min(1).max(254),
name: z.string().max(255),
role: z.nativeEnum(RecipientRole),
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({
recipients: ZRecipientLiteSchema.array(),
@ -105,7 +134,16 @@ export const ZCreateTemplateRecipientResponseSchema = ZRecipientLiteSchema;
export const ZCreateTemplateRecipientsRequestSchema = z.object({
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({
@ -121,7 +159,18 @@ export const ZUpdateTemplateRecipientResponseSchema = ZRecipientSchema;
export const ZUpdateTemplateRecipientsRequestSchema = z.object({
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({
@ -132,30 +181,43 @@ export const ZDeleteTemplateRecipientRequestSchema = z.object({
recipientId: z.number(),
});
export const ZSetTemplateRecipientsRequestSchema = z.object({
templateId: z.number(),
recipients: z.array(
z.object({
nativeId: z.number().optional(),
email: z
.string()
.toLowerCase()
.refine(
(email) => {
return (
isTemplateRecipientEmailPlaceholder(email) ||
z.string().email().safeParse(email).success
);
},
{ message: 'Please enter a valid email address' },
),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}),
),
});
export const ZSetTemplateRecipientsRequestSchema = z
.object({
templateId: z.number(),
recipients: z.array(
z.object({
nativeId: z.number().optional(),
email: z
.string()
.toLowerCase()
.refine(
(email) => {
return (
isTemplateRecipientEmailPlaceholder(email) ||
z.string().email().safeParse(email).success
);
},
{ message: 'Please enter a valid email address' },
),
name: z.string(),
role: z.nativeEnum(RecipientRole),
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({
recipients: ZRecipientLiteSchema.array(),
@ -165,7 +227,6 @@ export const ZCompleteDocumentWithTokenMutationSchema = z.object({
token: z.string(),
documentId: z.number(),
authOptions: ZRecipientActionAuthSchema.optional(),
accessAuthOptions: ZRecipientAccessAuthSchema.optional(),
nextSigner: z
.object({
email: z.string().email().max(254),

Some files were not shown because too many files have changed in this diff Show More