Compare commits

...

28 Commits

Author SHA1 Message Date
01ff304e6e Merge branch 'main' into feat/add-attachments-reworked 2025-10-10 14:39:21 +03:00
7b17156e56 v1.12.10 2025-10-09 15:32:35 +11:00
86e89e137e fix: bump search limit and path formatting (#2069) 2025-10-09 15:11:43 +11:00
26f65dbdd7 v1.12.9 2025-10-07 17:07:11 +11:00
a902bec96d fix: use select account prompt for sso oidc (#2065)
Use the `select_account` prompt for SSO OIDC to avoid constantly asking
for credentials to be entered with a client has an existing session with
the SSO provider.
2025-10-07 17:06:28 +11:00
399f91de73 feat: improve invite email (#2030)
Improve the email sent to invite a user to approve/sign/assist/view a
document.
The current links "Reject Document" / "Sign Document" confuse some users
as they think the document will be rejected/signed just by clicking on
these buttons.
This change makes it more clear that they will have a chance to view the
document before they can reject/sign.
2025-10-06 16:19:16 +11:00
995bc9c362 feat: support 2fa for document completion (#2063)
Adds support for 2FA when completing a document, also adds support for
using email for 2FA when no authenticator has been associated with the
account.
2025-10-06 16:17:54 +11:00
3467317271 chore: extract translations (#2056)
## Description

Extract translations to be translated
2025-10-02 13:20:30 +10:00
a5eaa8ad47 v1.12.8 2025-09-29 23:22:59 +10:00
577691214b fix: add viewed call for embedded signing (#2055)
Adds the missing `viewedDocument` method call for embedded signing.

I believe this had previously been added but was lost as the result of a
merge conflict being resolved.
2025-09-29 23:22:36 +10:00
c7d21c6587 fix: update personal organisation email settings (#2048) 2025-09-29 10:11:00 +03:00
2aa391f917 v1.12.7 2025-09-26 09:57:34 +10:00
681540b501 fix: add removed date formats (#2049)
Add date formats that were removed in a prior pull request causing
issues with certain API requests.
2025-09-26 09:56:46 +10:00
f3305ac306 feat: show branding logo on signing page (#2031)
If the team has the branding enabled & a logo uploaded, it'll show on
the document signing page view.
2025-09-25 22:41:17 +10:00
68b4305b6a feat: add max file size for uploaded documents (#2044) 2025-09-25 22:40:00 +10:00
3de1ea0a02 feat: resend dialog improvements (#2034)
The checkboxes were difficult to see and the "Send reminder" button
wasn't disabled when no recipients were selected. This PR disables the
sending button when there's no selected recipient and improves the
checkboxes visibility.
2025-09-25 22:23:07 +10:00
b8fc47b719 v1.12.6 2025-09-25 22:10:20 +10:00
cfceebd78f feat: change organisation owner in admin panel (#2047)
Allows changing the owner of an organisation within the admin panel,
useful for support requests to change ownership from a testing account
to the main admin account.

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

Update the tests to use the new date formats form this PR
https://github.com/documenso/documenso/pull/2038.
2025-09-25 16:55:31 +10:00
8590502338 fix: file upload error messages (#2041) 2025-09-24 16:06:41 +03:00
53f29daf50 fix: allow dates with and without time (#2038) 2025-09-24 14:46:04 +03:00
197d17ed7b v1.12.5 2025-09-23 21:00:48 +10:00
3c646d9475 feat: remove email requirement for recipients (#2040) 2025-09-23 17:13:52 +10:00
ed4dfc9b55 v1.12.4 2025-09-13 18:08:55 +10:00
32ce573de4 fix: incorrect certificate health logic (#2028) 2025-09-13 18:07:39 +10:00
90e40e4368 chore: remove duplicate route 2025-09-11 16:03:56 +03:00
2c89b0805a chore: merge main 2025-09-11 14:58:42 +03:00
f53fad8cd7 feat: attachments 2025-07-25 12:11:49 +03:00
130 changed files with 8310 additions and 1256 deletions

5
.gitignore vendored
View File

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

View File

@ -18,6 +18,11 @@ 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 } from 'react-hook-form';
import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session';
@ -85,6 +85,11 @@ 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 });
@ -151,7 +156,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
<FormControl>
<Checkbox
className="h-5 w-5 rounded-full"
className="h-5 w-5 rounded-full border border-neutral-400"
value={recipient.id}
checked={value.includes(recipient.id)}
onCheckedChange={(checked: boolean) =>
@ -182,7 +187,13 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
</Button>
</DialogClose>
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
<Button
className="flex-1"
loading={isSubmitting}
type="submit"
form={FORM_ID}
disabled={isSubmitting || selectedRecipients.length === 0}
>
<Trans>Send reminder</Trans>
</Button>
</div>

View File

@ -15,7 +15,6 @@ 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';
@ -46,50 +45,22 @@ 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(),
}),
),
})
// 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'],
});
}
});
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(),
}),
),
});
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
@ -278,14 +249,7 @@ export function TemplateUseDialog({
)}
<FormControl>
<Input
{...field}
placeholder={
isTemplateRecipientEmailPlaceholder(field.value)
? ''
: _(msg`Email`)
}
/>
<Input {...field} aria-label="Email" placeholder={_(msg`Email`)} />
</FormControl>
<FormMessage />
</FormItem>
@ -306,6 +270,7 @@ export function TemplateUseDialog({
<FormControl>
<Input
{...field}
aria-label="Name"
placeholder={recipients[index].name || _(msg`Name`)}
/>
</FormControl>

View File

@ -3,10 +3,11 @@ 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 } from '@prisma/client';
import { DocumentVisibility, OrganisationType } 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';
@ -86,8 +87,10 @@ 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';
@ -331,7 +334,7 @@ export const DocumentPreferencesForm = ({
)}
/>
{!isPersonalLayoutMode && (
{!isPersonalLayoutMode && !isPersonalOrganisation && (
<FormField
control={form.control}
name="includeSenderDetails"

View File

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

View File

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

@ -0,0 +1,63 @@
import { Trans } from '@lingui/react/macro';
import { LinkIcon } from 'lucide-react';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
export type DocumentSigningAttachmentsDialogProps = {
document: DocumentAndSender;
};
export const DocumentSigningAttachmentsDialog = ({
document,
}: DocumentSigningAttachmentsDialogProps) => {
const attachments = document.attachments ?? [];
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">
<Trans>Attachments</Trans>
</Button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Attachments</Trans>
</DialogTitle>
<DialogDescription>
<Trans>View all attachments for this document.</Trans>
</DialogDescription>
</DialogHeader>
<div className="mt-2 flex flex-col gap-2">
{attachments.length === 0 && (
<span className="text-muted-foreground text-sm">
<Trans>No attachments available.</Trans>
</span>
)}
{attachments.map((attachment, idx) => (
<a
key={attachment.id || idx}
href={attachment.url}
target="_blank"
rel="noopener noreferrer"
className="hover:bg-muted/50 flex items-center gap-2 rounded px-2 py-1"
>
<LinkIcon className="h-4 w-4" />
<span className="truncate">{attachment.label}</span>
</a>
))}
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -2,12 +2,17 @@ import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import type { Field } from '@prisma/client';
import type { Field, Recipient } 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 {
@ -27,15 +32,21 @@ 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 }) => void | Promise<void>;
role: RecipientRole;
onSignatureComplete: (
nextSigner?: { name: string; email: string },
accessAuthOptions?: TRecipientAccessAuth,
) => void | Promise<void>;
recipient: Pick<Recipient, 'name' | 'email' | 'role' | 'token'>;
disabled?: boolean;
allowDictateNextSigner?: boolean;
defaultNextSigner?: {
@ -47,6 +58,7 @@ 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>;
@ -57,7 +69,7 @@ export const DocumentSigningCompleteDialog = ({
fields,
fieldsValidated,
onSignatureComplete,
role,
recipient,
disabled = false,
allowDictateNextSigner = false,
defaultNextSigner,
@ -65,6 +77,11 @@ 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: {
@ -75,6 +92,11 @@ 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;
@ -93,16 +115,43 @@ export const DocumentSigningCompleteDialog = ({
const onFormSubmit = async (data: TNextSignerFormSchema) => {
try {
if (allowDictateNextSigner && data.name && data.email) {
await onSignatureComplete({ name: data.name, email: data.email });
} else {
await onSignatureComplete();
// Check if 2FA is required
if (completionRequires2FA && !data.accessAuthOptions) {
setShowTwoFactorForm(true);
return;
}
const nextSigner =
allowDictateNextSigner && data.name && data.email
? { name: data.name, email: data.email }
: undefined;
await onSignatureComplete(nextSigner, data.accessAuthOptions);
} catch (error) {
console.error('Error completing signature:', 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;
}
}
};
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 (
@ -116,7 +165,7 @@ export const DocumentSigningCompleteDialog = ({
loading={isSubmitting}
disabled={disabled}
>
{match({ isComplete, role })
{match({ isComplete, role: recipient.role })
.with({ isComplete: false }, () => <Trans>Next field</Trans>)
.with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>)
.with({ isComplete: true, role: RecipientRole.VIEWER }, () => (
@ -128,184 +177,194 @@ export const DocumentSigningCompleteDialog = ({
</DialogTrigger>
<DialogContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
<DialogTitle>
<div className="text-foreground text-xl font-semibold">
{match(role)
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
.exhaustive()}
</div>
</DialogTitle>
<div className="text-muted-foreground max-w-[50ch]">
{match(role)
.with(RecipientRole.VIEWER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</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>
{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>
<Button
type="button"
className="mt-2"
variant="outline"
size="sm"
onClick={() => setIsEditingNextSigner((prev) => !prev)}
>
<Trans>Update Recipient</Trans>
</Button>
</div>
)}
{isEditingNextSigner && (
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder="Enter the next signer's name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder="Enter the next signer's email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
)}
<DocumentSigningDisclosure className="mt-4" />
<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>
<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>)
{!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()}
</Button>
</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>
))}
</div>
</DialogFooter>
</fieldset>
</form>
</Form>
{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>
<Button
type="button"
className="mt-2"
variant="outline"
size="sm"
onClick={() => setIsEditingNextSigner((prev) => !prev)}
>
<Trans>Update Recipient</Trans>
</Button>
</div>
)}
{isEditingNextSigner && (
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder="Enter the next signer's name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder="Enter the next signer's email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
)}
<DocumentSigningDisclosure className="mt-4" />
<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>
<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>
)}
{showTwoFactorForm && (
<AccessAuth2FAForm
token={recipient.token}
error={twoFactorValidationError}
onSubmit={onTwoFactorFormSubmit}
/>
)}
</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 { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { TRecipientAccessAuth } 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: (
authOptions?: TRecipientActionAuth,
nextSigner?: { email: string; name: string },
) => Promise<void>;
completeDocument: (options: {
accessAuthOptions?: TRecipientAccessAuth;
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(undefined, nextSigner);
await completeDocument({ nextSigner });
} catch (err) {
toast({
title: 'Error',
@ -149,10 +149,10 @@ export const DocumentSigningForm = ({
documentTitle={document.title}
fields={fields}
fieldsValidated={localFieldsValidated}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role}
onSignatureComplete={async (nextSigner, accessAuthOptions) =>
completeDocument({ nextSigner, accessAuthOptions })
}
recipient={recipient}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
defaultNextSigner={
nextRecipient
@ -309,10 +309,13 @@ export const DocumentSigningForm = ({
fields={fields}
fieldsValidated={localFieldsValidated}
disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role}
onSignatureComplete={async (nextSigner, accessAuthOptions) =>
completeDocument({
accessAuthOptions,
nextSigner,
})
}
recipient={recipient}
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 { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
@ -32,6 +32,7 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { DocumentSigningAttachmentsDialog } from '~/components/general/document-signing/document-signing-attachments-dialog';
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
@ -46,6 +47,7 @@ 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';
@ -70,6 +72,12 @@ 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();
@ -94,14 +102,16 @@ export const DocumentSigningPageView = ({
validateFieldsInserted(fieldsRequiringValidation);
};
const completeDocument = async (
authOptions?: TRecipientActionAuth,
nextSigner?: { email: string; name: string },
) => {
const completeDocument = async (options: {
accessAuthOptions?: TRecipientAccessAuth;
nextSigner?: { email: string; name: string };
}) => {
const { accessAuthOptions, nextSigner } = options;
const payload = {
token: recipient.token,
documentId: document.id,
authOptions,
accessAuthOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
};
@ -160,6 +170,14 @@ 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}
@ -214,7 +232,10 @@ export const DocumentSigningPageView = ({
</span>
</div>
<DocumentSigningRejectDialog document={document} token={recipient.token} />
<div className="flex gap-2">
<DocumentSigningAttachmentsDialog document={document} />
<DocumentSigningRejectDialog document={document} token={recipient.token} />
</div>
</div>
<div className="relative mt-4 flex w-full flex-col gap-x-6 gap-y-8 sm:mt-8 md:flex-row lg:gap-x-8 lg:gap-y-0">
@ -257,10 +278,10 @@ export const DocumentSigningPageView = ({
fields={fields}
fieldsValidated={fieldsValidated}
disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role}
onSignatureComplete={async (nextSigner) =>
completeDocument({ nextSigner })
}
recipient={recipient}
allowDictateNextSigner={
nextRecipient && documentMeta?.allowDictateNextSigner
}

View File

@ -0,0 +1,192 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { Trash } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { nanoid } from '@documenso/lib/universal/id';
import { AttachmentType } from '@documenso/prisma/generated/types';
import { trpc } from '@documenso/trpc/react';
import type { TSetDocumentAttachmentsSchema } from '@documenso/trpc/server/document-router/set-document-attachments.types';
import { ZSetDocumentAttachmentsSchema } from '@documenso/trpc/server/document-router/set-document-attachments.types';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AttachmentFormProps = {
documentId: number;
};
export const AttachmentForm = ({ documentId }: AttachmentFormProps) => {
const { t } = useLingui();
const { toast } = useToast();
const { data: attachmentsData, refetch: refetchAttachments } =
trpc.document.attachments.find.useQuery({
documentId,
});
const { mutateAsync: setDocumentAttachments } = trpc.document.attachments.set.useMutation();
const defaultAttachments = [
{
id: nanoid(12),
label: '',
url: '',
type: AttachmentType.LINK,
},
];
const form = useForm<TSetDocumentAttachmentsSchema>({
resolver: zodResolver(ZSetDocumentAttachmentsSchema),
defaultValues: {
documentId,
attachments: attachmentsData ?? defaultAttachments,
},
});
const {
fields: attachments,
append: appendAttachment,
remove: removeAttachment,
} = useFieldArray({
control: form.control,
name: 'attachments',
});
const onAddAttachment = () => {
appendAttachment({
id: nanoid(12),
label: '',
url: '',
type: AttachmentType.LINK,
});
};
const onRemoveAttachment = (index: number) => {
removeAttachment(index);
};
useEffect(() => {
if (attachmentsData && attachmentsData.length > 0) {
form.setValue('attachments', attachmentsData);
}
}, [attachmentsData]);
const onSubmit = async (data: TSetDocumentAttachmentsSchema) => {
try {
await setDocumentAttachments({
documentId,
attachments: data.attachments,
});
toast({
title: t`Attachment(s) updated`,
description: t`The attachment(s) have been updated successfully`,
});
await refetchAttachments();
} catch (error) {
console.error(error);
toast({
title: t`Something went wrong`,
description: t`We encountered an unknown error while attempting to create the attachments.`,
variant: 'destructive',
duration: 5000,
});
}
};
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">
<Trans>Attachments</Trans>
</Button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Attachments</Trans>
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
{attachments.map((attachment, index) => (
<div key={attachment.id} className="flex items-end gap-2">
<FormField
control={form.control}
name={`attachments.${index}.label`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>
<Trans>Label</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder={t`Attachment label`} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`attachments.${index}.url`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>
<Trans>URL</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder="https://..." />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemoveAttachment(index)}
>
<Trash className="h-4 w-4" />
</Button>
</div>
))}
</fieldset>
<DialogFooter className="mt-4">
<Button type="button" variant="outline" onClick={onAddAttachment}>
<Trans>Add</Trans>
</Button>
<Button type="submit">
<Trans>Save</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

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

View File

@ -239,7 +239,27 @@ export const DocumentEditForm = ({
const onAddSignersFormAutoSave = async (data: TAddSignersFormSchema) => {
try {
await saveSignersData(data);
// For autosave, we need to return the recipients response for form state sync
const [, recipientsResponse] = await Promise.all([
updateDocument({
documentId: document.id,
meta: {
allowDictateNextSigner: data.allowDictateNextSigner,
signingOrder: data.signingOrder,
},
}),
setRecipients({
documentId: document.id,
recipients: data.signers.map((signer) => ({
...signer,
// Explicitly set to null to indicate we want to remove auth if required.
actionAuth: signer.actionAuth ?? [],
})),
}),
]);
return recipientsResponse;
} catch (err) {
console.error(err);
@ -248,6 +268,8 @@ 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()} key={folder.id}>
<Link to={formatPath()} data-folder-id={folder.id} data-folder-name={folder.name}>
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
<CardContent className="p-4">
<div className="flex min-w-0 items-center gap-3">

View File

@ -0,0 +1,192 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { Trash } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { nanoid } from '@documenso/lib/universal/id';
import { AttachmentType } from '@documenso/prisma/generated/types';
import { trpc } from '@documenso/trpc/react';
import type { TSetTemplateAttachmentsSchema } from '@documenso/trpc/server/template-router/set-template-attachments.types';
import { ZSetTemplateAttachmentsSchema } from '@documenso/trpc/server/template-router/set-template-attachments.types';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AttachmentFormProps = {
templateId: number;
};
export const AttachmentForm = ({ templateId }: AttachmentFormProps) => {
const { toast } = useToast();
const { t } = useLingui();
const { data: attachmentsData, refetch: refetchAttachments } =
trpc.template.attachments.find.useQuery({
templateId,
});
const { mutateAsync: setTemplateAttachments } = trpc.template.attachments.set.useMutation();
const defaultAttachments = [
{
id: nanoid(12),
label: '',
url: '',
type: AttachmentType.LINK,
},
];
const form = useForm<TSetTemplateAttachmentsSchema>({
resolver: zodResolver(ZSetTemplateAttachmentsSchema),
defaultValues: {
templateId,
attachments: attachmentsData ?? defaultAttachments,
},
});
const {
fields: attachments,
append: appendAttachment,
remove: removeAttachment,
} = useFieldArray({
control: form.control,
name: 'attachments',
});
const onAddAttachment = () => {
appendAttachment({
id: nanoid(12),
label: '',
url: '',
type: AttachmentType.LINK,
});
};
const onRemoveAttachment = (index: number) => {
removeAttachment(index);
};
useEffect(() => {
if (attachmentsData && attachmentsData.length > 0) {
form.setValue('attachments', attachmentsData);
}
}, [attachmentsData]);
const onSubmit = async (data: TSetTemplateAttachmentsSchema) => {
try {
await setTemplateAttachments({
templateId,
attachments: data.attachments,
});
toast({
title: t`Attachment(s) updated`,
description: t`The attachment(s) have been updated successfully`,
});
await refetchAttachments();
} catch (error) {
console.error(error);
toast({
title: t`Something went wrong`,
description: t`We encountered an unknown error while attempting to create the attachments.`,
variant: 'destructive',
duration: 5000,
});
}
};
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">
<Trans>Attachments</Trans>
</Button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Attachments</Trans>
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
{attachments.map((attachment, index) => (
<div key={attachment.id} className="flex items-end gap-2">
<FormField
control={form.control}
name={`attachments.${index}.label`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>
<Trans>Label</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder={t`Attachment label`} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`attachments.${index}.url`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>
<Trans>URL</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder={t`https://...`} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemoveAttachment(index)}
>
<Trash className="h-4 w-4" />
</Button>
</div>
))}
</fieldset>
<DialogFooter className="mt-4">
<Button type="button" variant="outline" onClick={onAddAttachment}>
<Trans>Add</Trans>
</Button>
<Button type="submit">
<Trans>Save</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

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

View File

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

View File

@ -46,7 +46,9 @@ export const TemplatesTableActionDropdown = ({
const isOwner = row.userId === user.id;
const isTeamTemplate = row.teamId === teamId;
const formatPath = `${templateRootPath}/${row.id}/edit`;
const formatPath = row.folderId
? `${templateRootPath}/f/${row.folderId}/${row.id}/edit`
: `${templateRootPath}/${row.id}/edit`;
return (
<DropdownMenu>

View File

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

@ -12,6 +12,7 @@ import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { logDocumentAccess } from '@documenso/lib/utils/logger';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { AttachmentForm } from '~/components/general/document/document-attachment-form';
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
import { DocumentStatus } from '~/components/general/document/document-status';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
@ -106,7 +107,7 @@ export default function DocumentEditPage() {
<Trans>Documents</Trans>
</Link>
<div className="mt-4 flex w-full items-end justify-between">
<div className="mt-4 flex w-full flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div className="flex-1">
<h1
className="block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
@ -140,11 +141,12 @@ export default function DocumentEditPage() {
</div>
</div>
{document.useLegacyFieldInsertion && (
<div>
<div className={document.useLegacyFieldInsertion ? 'flex items-center gap-2' : undefined}>
{document.useLegacyFieldInsertion && (
<LegacyFieldWarningPopover type="document" documentId={document.id} />
</div>
)}
)}
<AttachmentForm documentId={document.id} />
</div>
</div>
<DocumentEditForm

View File

@ -9,6 +9,7 @@ import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-direct-link-dialog-wrapper';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
import { AttachmentForm } from '~/components/general/template/template-attachment-form';
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
import { TemplateEditForm } from '~/components/general/template/template-edit-form';
import { TemplateType } from '~/components/general/template/template-type';
@ -89,6 +90,7 @@ export default function TemplateEditPage() {
<div className="mt-2 flex items-center gap-2 sm:mt-0 sm:self-end">
<TemplateDirectLinkDialogWrapper template={template} />
<AttachmentForm templateId={template.id} />
{template.useLegacyFieldInsertion && (
<div>

View File

@ -151,6 +151,7 @@ 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,10 +47,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
});
// Ensure typesafety when we add more options.
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
.with(DocumentAccessAuth.ACCOUNT, () => Boolean(session.user))
.with(undefined, () => true)
.exhaustive();
const isAccessAuthValid = derivedRecipientAccessAuth.every((auth) =>
match(auth)
.with(DocumentAccessAuth.ACCOUNT, () => Boolean(session.user))
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => 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,6 +19,7 @@ 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';
@ -98,16 +99,16 @@ export async function loader({ params, request }: Route.LoaderArgs) {
recipientAuth: recipient.authOptions,
});
const isDocumentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
documentAuthOptions: document.authOptions,
recipient,
userId: user?.id,
});
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(),
);
let recipientHasAccount: boolean | null = null;
if (!isDocumentAccessValid) {
if (!isAccessAuthValid) {
recipientHasAccount = await getUserByEmail({ email: recipient.email })
.then((user) => !!user)
.catch(() => false);

View File

@ -23,10 +23,12 @@ 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

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

View File

@ -1,10 +1,12 @@
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';
@ -23,6 +25,8 @@ 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 });
}
@ -71,10 +75,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
documentAuth: document.authOptions,
});
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
.with(undefined, () => true)
.exhaustive();
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(),
);
if (!isAccessAuthValid) {
throw data(
@ -102,6 +108,12 @@ 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.3"
"version": "1.12.10"
}

6
package-lock.json generated
View File

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

View File

@ -1,6 +1,6 @@
{
"private": true,
"version": "1.12.3",
"version": "1.12.10",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 a' }).click();
await page.getByRole('option', { name: 'DD/MM/YYYY' }).click();
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.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 hh:mm a');
expect(teamSettings.documentDateFormat).toEqual('dd/MM/yyyy');
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' }).click();
await page.getByRole('option', { name: 'MM/DD/YYYY', exact: true }).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 hh:mm a');
expect(updatedTeamSettings.documentDateFormat).toEqual('MM/dd/yyyy');
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 hh:mm a');
expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy');
});
test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {

View File

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

View File

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

View File

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

View File

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

View File

@ -23,12 +23,17 @@ 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 } = options;
const { c, clientOptions, redirectPath, prompt = 'login' } = options;
if (!clientOptions.clientId || !clientOptions.clientSecret) {
throw new AppError(AppErrorCode.NOT_SETUP);
@ -57,8 +62,8 @@ export const handleOAuthAuthorizeUrl = async (options: HandleOAuthAuthorizeUrlOp
scopes,
);
// Allow user to select account during login.
url.searchParams.append('prompt', 'login');
// Pass the prompt to the authorization endpoint.
url.searchParams.append('prompt', prompt);
setCookie(c, `${clientOptions.id}_oauth_state`, state, {
...sessionCookieOptions,

View File

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

View File

@ -0,0 +1,60 @@
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,5 +1,3 @@
import { useMemo } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { OrganisationType, RecipientRole } from '@prisma/client';
@ -38,12 +36,6 @@ 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} />
@ -99,22 +91,15 @@ export const TemplateDocumentInvite = ({
<Section className="mb-6 mt-8 text-center">
<Button
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"
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"
href={signDocumentLink}
>
{match(role)
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>View Document to sign</Trans>)
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>View Document to approve</Trans>)
.with(RecipientRole.CC, () => '')
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>View Document to assist</Trans>)
.exhaustive()}
</Button>
</Section>

View File

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

View File

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

View File

@ -17,6 +17,7 @@ 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 }> =
@ -32,6 +33,7 @@ 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

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

View File

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

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

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

@ -0,0 +1,37 @@
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,18 +2,25 @@ import * as fs from 'node:fs';
import { env } from '@documenso/lib/utils/env';
export type CertificateStatus = {
isAvailable: boolean;
};
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 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,7 +18,8 @@ import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import type { TRecipientActionAuth } from '../../types/document-auth';
import type { TRecipientAccessAuth, TRecipientActionAuth } from '../../types/document-auth';
import { DocumentAuth } from '../../types/document-auth';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
@ -26,6 +27,7 @@ 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 = {
@ -33,6 +35,7 @@ export type CompleteDocumentWithTokenOptions = {
documentId: number;
userId?: number;
authOptions?: TRecipientActionAuth;
accessAuthOptions?: TRecipientAccessAuth;
requestMetadata?: RequestMetadata;
nextSigner?: {
email: string;
@ -64,6 +67,8 @@ const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptio
export const completeDocumentWithToken = async ({
token,
documentId,
userId,
accessAuthOptions,
requestMetadata,
nextSigner,
}: CompleteDocumentWithTokenOptions) => {
@ -111,24 +116,57 @@ export const completeDocumentWithToken = async ({
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
}
// Document reauth for completing documents is currently not required.
// Check ACCESS AUTH 2FA validation during document completion
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
// const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
// documentAuth: document.authOptions,
// recipientAuth: recipient.authOptions,
// });
if (derivedRecipientAccessAuth.includes(DocumentAuth.TWO_FACTOR_AUTH)) {
if (!accessAuthOptions) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Access authentication required',
});
}
// const isValid = await isRecipientAuthorized({
// type: 'ACTION',
// document: document,
// recipient: recipient,
// userId,
// authOptions,
// });
const isValid = await isRecipientAuthorized({
type: 'ACCESS_2FA',
documentAuthOptions: document.authOptions,
recipient: recipient,
userId, // Can be undefined for non-account recipients
authOptions: accessAuthOptions,
});
// if (!isValid) {
// throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
// }
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,
},
}),
});
}
await prisma.$transaction(async (tx) => {
await tx.recipient.update({

View File

@ -87,10 +87,17 @@ export const getDocumentAndSenderByToken = async ({
token,
},
},
attachments: true,
team: {
select: {
name: true,
teamEmail: true,
teamGlobalSettings: {
select: {
brandingEnabled: true,
brandingLogo: true,
},
},
},
},
},

View File

@ -4,6 +4,7 @@ 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';
@ -14,9 +15,10 @@ import { getAuthenticatorOptions } from '../../utils/authenticator';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
type IsRecipientAuthorizedOptions = {
type: 'ACCESS' | 'ACTION';
// !: Probably find a better name than 'ACCESS_2FA' if requirements change.
type: 'ACCESS' | 'ACCESS_2FA' | 'ACTION';
documentAuthOptions: Document['authOptions'];
recipient: Pick<Recipient, 'authOptions' | 'email'>;
recipient: Pick<Recipient, 'authOptions' | 'email' | 'documentId'>;
/**
* The ID of the user who initiated the request.
@ -61,8 +63,11 @@ export const isRecipientAuthorized = async ({
recipientAuth: recipient.authOptions,
});
const authMethods: TDocumentAuth[] =
type === 'ACCESS' ? derivedRecipientAccessAuth : derivedRecipientActionAuth;
const authMethods: TDocumentAuth[] = match(type)
.with('ACCESS', () => derivedRecipientAccessAuth)
.with('ACCESS_2FA', () => derivedRecipientAccessAuth)
.with('ACTION', () => derivedRecipientActionAuth)
.exhaustive();
// Early true return when auth is not required.
if (
@ -72,6 +77,11 @@ 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 = {
@ -80,12 +90,16 @@ export const isRecipientAuthorized = async ({
}
// Authentication required does not match provided method.
if (!authOptions || !authMethods.includes(authOptions.type) || !userId) {
if (!authOptions || !authMethods.includes(authOptions.type)) {
return false;
}
return await match(authOptions)
.with({ type: DocumentAuth.ACCOUNT }, async () => {
if (!userId) {
return false;
}
const recipientUser = await getUserByEmail(recipient.email);
if (!recipientUser) {
@ -95,13 +109,40 @@ 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 }) => {
.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;
}
const user = await prisma.user.findFirst({
where: {
id: userId,
@ -115,6 +156,7 @@ export const isRecipientAuthorized = async ({
});
}
// For ACTION auth or authenticator method, use TOTP
return await verifyTwoFactorAuthenticationToken({
user,
totpCode: token,
@ -122,6 +164,10 @@ export const isRecipientAuthorized = async ({
});
})
.with({ type: DocumentAuth.PASSWORD }, async ({ password }) => {
if (!userId) {
return false;
}
return await verifyPassword({
userId,
password,

View File

@ -1,6 +1,5 @@
import { DocumentStatus } from '@prisma/client';
import type { Document, Recipient, User } from '@prisma/client';
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import {
@ -19,7 +18,7 @@ export type SearchDocumentsWithKeywordOptions = {
export const searchDocumentsWithKeyword = async ({
query,
userId,
limit = 5,
limit = 20,
}: SearchDocumentsWithKeywordOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
@ -122,6 +121,7 @@ export const searchDocumentsWithKeyword = async ({
},
},
},
distinct: ['id'],
orderBy: {
createdAt: 'desc',
},
@ -129,6 +129,7 @@ export const searchDocumentsWithKeyword = async ({
});
const isOwner = (document: Document, user: User) => document.userId === user.id;
const getSigningLink = (recipients: Recipient[], user: User) =>
`/sign/${recipients.find((r) => r.email === user.email)?.token}`;
@ -164,7 +165,7 @@ export const searchDocumentsWithKeyword = async ({
if (isOwner(document, user)) {
documentPath = `${formatDocumentsPath(document.team?.url)}/${document.id}`;
} else if (document.teamId && document.team) {
} else if (document.teamId && document.team.teamGroups.length > 0) {
documentPath = `${formatDocumentsPath(document.team.url)}/${document.id}`;
} else {
documentPath = getSigningLink(recipients, user);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -159,6 +159,7 @@ 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();
@ -205,6 +206,7 @@ export const createDocumentFromDirectTemplate = async ({
recipient: {
authOptions: directTemplateRecipient.authOptions,
email: directRecipientEmail,
documentId: template.id,
},
field: templateField,
userId: user?.id,

View File

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

View File

@ -53,7 +53,7 @@ import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type FinalRecipient = Pick<
Recipient,
'name' | 'email' | 'role' | 'authOptions' | 'signingOrder'
'name' | 'email' | 'role' | 'authOptions' | 'signingOrder' | 'token'
> & {
templateRecipientId: number;
fields: Field[];
@ -290,6 +290,7 @@ export const createDocumentFromTemplate = async ({
fields: true,
},
},
attachments: true,
templateDocumentData: true,
templateMeta: true,
},
@ -350,6 +351,7 @@ export const createDocumentFromTemplate = async ({
role: templateRecipient.role,
signingOrder: foundRecipient?.signingOrder ?? templateRecipient.signingOrder,
authOptions: templateRecipient.authOptions,
token: nanoid(),
};
});
@ -397,6 +399,15 @@ export const createDocumentFromTemplate = async ({
}),
visibility: template.visibility || settings.documentVisibility,
useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false,
attachments: {
create: template.attachments.map((attachment) => ({
type: attachment.type,
label: attachment.label,
url: attachment.url,
createdAt: attachment.createdAt,
updatedAt: attachment.updatedAt,
})),
},
documentMeta: {
create: extractDerivedDocumentMeta(settings, {
subject: override?.subject || template.templateMeta?.subject,
@ -441,7 +452,7 @@ export const createDocumentFromTemplate = async ({
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
signingOrder: recipient.signingOrder,
token: nanoid(),
token: recipient.token,
};
}),
},
@ -500,8 +511,8 @@ export const createDocumentFromTemplate = async ({
}
}
Object.values(finalRecipients).forEach(({ email, fields }) => {
const recipient = document.recipients.find((recipient) => recipient.email === email);
Object.values(finalRecipients).forEach(({ token, fields }) => {
const recipient = document.recipients.find((recipient) => recipient.token === token);
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,6 +40,12 @@ 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.
'DOCUMENT_ATTACHMENTS_UPDATED', // When the document attachments are updated.
// 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([
@ -487,6 +493,42 @@ 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.
*/
@ -598,6 +640,29 @@ export const ZDocumentAuditLogEventDocumentMovedToTeamSchema = z.object({
}),
});
/**
* Event: Document attachments updated.
*/
export const ZDocumentAuditLogEventDocumentAttachmentsUpdatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ATTACHMENTS_UPDATED),
data: z.object({
from: z.array(
z.object({
id: z.string(),
label: z.string(),
url: z.string(),
}),
),
to: z.array(
z.object({
id: z.string(),
label: z.string(),
url: z.string(),
}),
),
}),
});
export const ZDocumentAuditLogBaseSchema = z.object({
id: z.string(),
createdAt: z.date(),
@ -627,9 +692,13 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventDocumentViewedSchema,
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
ZDocumentAuditLogEventDocumentRecipientRejectedSchema,
ZDocumentAuditLogEventDocumentRecipientRequested2FAEmailSchema,
ZDocumentAuditLogEventDocumentRecipientValidated2FAEmailSchema,
ZDocumentAuditLogEventDocumentRecipientFailed2FAEmailSchema,
ZDocumentAuditLogEventDocumentSentSchema,
ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
ZDocumentAuditLogEventDocumentExternalIdUpdatedSchema,
ZDocumentAuditLogEventDocumentAttachmentsUpdatedSchema,
ZDocumentAuditLogEventFieldCreatedSchema,
ZDocumentAuditLogEventFieldRemovedSchema,
ZDocumentAuditLogEventFieldUpdatedSchema,

View File

@ -37,6 +37,7 @@ 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(),
});
/**
@ -55,9 +56,12 @@ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
*
* Must keep these two in sync.
*/
export const ZDocumentAccessAuthSchema = z.discriminatedUnion('type', [ZDocumentAuthAccountSchema]);
export const ZDocumentAccessAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
ZDocumentAuth2FASchema,
]);
export const ZDocumentAccessAuthTypesSchema = z
.enum([DocumentAuth.ACCOUNT])
.enum([DocumentAuth.ACCOUNT, DocumentAuth.TWO_FACTOR_AUTH])
.describe('The type of authentication required for the recipient to access the document.');
/**
@ -89,9 +93,10 @@ export const ZDocumentActionAuthTypesSchema = z
*/
export const ZRecipientAccessAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
ZDocumentAuth2FASchema,
]);
export const ZRecipientAccessAuthTypesSchema = z
.enum([DocumentAuth.ACCOUNT])
.enum([DocumentAuth.ACCOUNT, DocumentAuth.TWO_FACTOR_AUTH])
.describe('The type of authentication required for the recipient to access the document.');
/**

View File

@ -476,6 +476,36 @@ 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
@ -492,6 +522,10 @@ export const formatDocumentAuditLogAction = (
context: `Audit log format`,
}),
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ATTACHMENTS_UPDATED }, () => ({
anonymous: msg`Document attachments updated`,
identified: msg`${prefix} updated the document attachments`,
}))
.exhaustive();
return {

View File

@ -0,0 +1,28 @@
-- CreateEnum
CREATE TYPE "AttachmentType" AS ENUM ('FILE', 'VIDEO', 'AUDIO', 'IMAGE', 'LINK', 'OTHER');
-- DropForeignKey
ALTER TABLE "Document" DROP CONSTRAINT "Document_folderId_fkey";
-- CreateTable
CREATE TABLE "Attachment" (
"id" TEXT NOT NULL,
"type" "AttachmentType" NOT NULL DEFAULT 'LINK',
"label" TEXT NOT NULL,
"url" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"documentId" INTEGER,
"templateId" INTEGER,
CONSTRAINT "Attachment_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Attachment" ADD CONSTRAINT "Attachment_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Attachment" ADD CONSTRAINT "Attachment_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

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

View File

@ -339,6 +339,32 @@ enum DocumentVisibility {
ADMIN
}
// Only "LINK" is supported for now.
// All other attachment types are not yet supported.
enum AttachmentType {
FILE
VIDEO
AUDIO
IMAGE
LINK
OTHER
}
model Attachment {
id String @id @default(uuid())
type AttachmentType @default(LINK)
label String
url String
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
documentId Int?
document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
templateId Int?
template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
}
enum FolderType {
DOCUMENT
TEMPLATE
@ -397,14 +423,15 @@ model Document {
templateId Int?
source DocumentSource
useLegacyFieldInsertion Boolean @default(false)
auditLogs DocumentAuditLog[]
attachments Attachment[]
useLegacyFieldInsertion Boolean @default(false)
documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade)
template Template? @relation(fields: [templateId], references: [id], onDelete: SetNull)
auditLogs DocumentAuditLog[]
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
folderId String?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: Cascade)
folderId String?
@@unique([documentDataId])
@@index([userId])
@ -527,8 +554,6 @@ model Recipient {
fields Field[]
signatures Signature[]
@@unique([documentId, email])
@@unique([templateId, email])
@@index([documentId])
@@index([templateId])
@@index([token])
@ -902,10 +927,11 @@ model Template {
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
recipients Recipient[]
fields Field[]
directLink TemplateDirectLink?
documents Document[]
recipients Recipient[]
fields Field[]
directLink TemplateDirectLink?
documents Document[]
attachments Attachment[]
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
folderId String?

View File

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

View File

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

View File

@ -12,6 +12,7 @@ import { findDocumentsRoute } from './find-documents';
import { findSubscriptionClaimsRoute } from './find-subscription-claims';
import { 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';
@ -27,6 +28,9 @@ export const adminRouter = router({
create: createAdminOrganisationRoute,
update: updateAdminOrganisationRoute,
},
organisationMember: {
promoteToOwner: promoteMemberToOwnerRoute,
},
claims: {
find: findSubscriptionClaimsRoute,
create: createSubscriptionClaimRoute,

View File

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

@ -0,0 +1,17 @@
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,14 +78,7 @@ 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

@ -0,0 +1,47 @@
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZGetDocumentAttachmentsResponseSchema,
ZGetDocumentAttachmentsSchema,
} from './find-document-attachments.types';
export const findDocumentAttachmentsRoute = authenticatedProcedure
.input(ZGetDocumentAttachmentsSchema)
.output(ZGetDocumentAttachmentsResponseSchema)
.query(async ({ input, ctx }) => {
const { documentId } = input;
const { user } = ctx;
const attachments = await findDocumentAttachments({
documentId,
userId: user.id,
teamId: ctx.teamId,
});
return attachments;
});
export type FindDocumentAttachmentsOptions = {
documentId?: number;
userId: number;
teamId: number;
};
export const findDocumentAttachments = async ({
documentId,
userId,
teamId,
}: FindDocumentAttachmentsOptions) => {
const attachments = await prisma.attachment.findMany({
where: {
document: {
id: documentId,
team: buildTeamWhereQuery({ teamId, userId }),
},
},
});
return attachments;
};

View File

@ -0,0 +1,16 @@
import { z } from 'zod';
import { AttachmentType } from '@documenso/prisma/generated/types';
export const ZGetDocumentAttachmentsSchema = z.object({
documentId: z.number(),
});
export const ZGetDocumentAttachmentsResponseSchema = z.array(
z.object({
id: z.string(),
label: z.string(),
url: z.string(),
type: z.nativeEnum(AttachmentType),
}),
);

View File

@ -1,4 +1,5 @@
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';
@ -7,6 +8,7 @@ import { downloadDocumentRoute } from './download-document';
import { downloadDocumentAuditLogsRoute } from './download-document-audit-logs';
import { downloadDocumentCertificateRoute } from './download-document-certificate';
import { duplicateDocumentRoute } from './duplicate-document';
import { findDocumentAttachmentsRoute } from './find-document-attachments';
import { findDocumentAuditLogsRoute } from './find-document-audit-logs';
import { findDocumentsRoute } from './find-documents';
import { findDocumentsInternalRoute } from './find-documents-internal';
@ -16,6 +18,7 @@ import { getDocumentByTokenRoute } from './get-document-by-token';
import { getInboxCountRoute } from './get-inbox-count';
import { redistributeDocumentRoute } from './redistribute-document';
import { searchDocumentRoute } from './search-document';
import { setDocumentAttachmentsRoute } from './set-document-attachments';
import { updateDocumentRoute } from './update-document';
export const documentRouter = router({
@ -38,6 +41,10 @@ export const documentRouter = router({
getDocumentByToken: getDocumentByTokenRoute,
findDocumentsInternal: findDocumentsInternalRoute,
accessAuth: router({
request2FAEmail: accessAuthRequest2FAEmailRoute,
}),
auditLog: {
find: findDocumentAuditLogsRoute,
download: downloadDocumentAuditLogsRoute,
@ -46,4 +53,8 @@ export const documentRouter = router({
find: findInboxRoute,
getCount: getInboxCountRoute,
}),
attachments: {
find: findDocumentAttachmentsRoute,
set: setDocumentAttachmentsRoute,
},
});

View File

@ -0,0 +1,124 @@
import type { Attachment, User } from '@prisma/client';
import { AppError } from '@documenso/lib/errors/app-error';
import { AppErrorCode } from '@documenso/lib/errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZSetDocumentAttachmentsResponseSchema,
ZSetDocumentAttachmentsSchema,
} from './set-document-attachments.types';
export const setDocumentAttachmentsRoute = authenticatedProcedure
.input(ZSetDocumentAttachmentsSchema)
.output(ZSetDocumentAttachmentsResponseSchema)
.mutation(async ({ input, ctx }) => {
const { documentId, attachments } = input;
const updatedAttachments = await setDocumentAttachments({
documentId,
attachments,
user: ctx.user,
teamId: ctx.teamId,
requestMetadata: ctx.metadata.requestMetadata,
});
return updatedAttachments;
});
export type CreateAttachmentsOptions = {
documentId: number;
attachments: Pick<Attachment, 'id' | 'label' | 'url' | 'type'>[];
user: Pick<User, 'id' | 'email' | 'name'>;
teamId: number;
requestMetadata: RequestMetadata;
};
export const setDocumentAttachments = async ({
documentId,
attachments,
user,
teamId,
requestMetadata,
}: CreateAttachmentsOptions) => {
const document = await prisma.document.findUnique({
where: {
id: documentId,
team: buildTeamWhereQuery({ teamId, userId: user.id }),
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const existingAttachments = await prisma.attachment.findMany({
where: {
documentId,
},
});
const newIds = attachments.map((a) => a.id).filter(Boolean);
const toDelete = existingAttachments.filter((existing) => !newIds.includes(existing.id));
if (toDelete.length > 0) {
await prisma.attachment.deleteMany({
where: {
id: { in: toDelete.map((a) => a.id) },
},
});
}
const upsertedAttachments: Attachment[] = [];
for (const attachment of attachments) {
const updated = await prisma.attachment.upsert({
where: { id: attachment.id, documentId: document.id },
update: {
label: attachment.label,
url: attachment.url,
type: attachment.type,
},
create: {
label: attachment.label,
url: attachment.url,
type: attachment.type,
documentId,
},
});
upsertedAttachments.push(updated);
}
const isAttachmentsSame = upsertedAttachments.every((attachment) => {
const existingAttachment = existingAttachments.find((a) => a.id === attachment.id);
return (
existingAttachment?.label === attachment.label &&
existingAttachment?.url === attachment.url &&
existingAttachment?.type === attachment.type
);
});
if (!isAttachmentsSame) {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ATTACHMENTS_UPDATED,
documentId: document.id,
user,
data: {
from: existingAttachments,
to: upsertedAttachments,
},
requestMetadata,
}),
});
}
return upsertedAttachments;
};

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