Compare commits

..

1 Commits

Author SHA1 Message Date
fbbb0289c3 fix: fields font-size 2025-10-01 15:10:01 +03:00
73 changed files with 786 additions and 6273 deletions

5
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

@ -157,6 +157,7 @@ export const DocumentSigningDateField = ({
'!text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
style={{ fontSize: `${parsedFieldMeta?.fontSize}px` }}
>
{localDateString}
</p>

View File

@ -132,7 +132,10 @@ export const DocumentSigningEmailField = ({
)}
{field.inserted && (
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
<DocumentSigningFieldsInserted
textAlign={parsedFieldMeta?.textAlign}
fontSize={parsedFieldMeta?.fontSize}
>
{field.customText}
</DocumentSigningFieldsInserted>
)}

View File

@ -27,14 +27,20 @@ type DocumentSigningFieldsInsertedProps = {
* Defaults to left.
*/
textAlign?: 'left' | 'center' | 'right';
/**
* The font size of the field in pixels.
*/
fontSize?: number;
};
export const DocumentSigningFieldsInserted = ({
children,
textAlign = 'left',
fontSize,
}: DocumentSigningFieldsInsertedProps) => {
return (
<div className="flex h-full w-full items-center overflow-hidden">
<div className="flex h-full w-full items-center overflow-visible">
<p
className={cn(
'text-foreground w-full whitespace-pre-wrap text-left text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
@ -43,6 +49,7 @@ export const DocumentSigningFieldsInserted = ({
'!text-right': textAlign === 'right',
},
)}
style={{ fontSize: `${fontSize}px` }}
>
{children}
</p>

View File

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

View File

@ -139,7 +139,10 @@ export const DocumentSigningInitialsField = ({
)}
{field.inserted && (
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
<DocumentSigningFieldsInserted
textAlign={parsedFieldMeta?.textAlign}
fontSize={parsedFieldMeta?.fontSize}
>
{field.customText}
</DocumentSigningFieldsInserted>
)}

View File

@ -178,7 +178,10 @@ export const DocumentSigningNameField = ({
)}
{field.inserted && (
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
<DocumentSigningFieldsInserted
textAlign={parsedFieldMeta?.textAlign}
fontSize={parsedFieldMeta?.fontSize}
>
{field.customText}
</DocumentSigningFieldsInserted>
)}

View File

@ -253,7 +253,10 @@ export const DocumentSigningNumberField = ({
)}
{field.inserted && (
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
<DocumentSigningFieldsInserted
textAlign={parsedFieldMeta?.textAlign}
fontSize={parsedFieldMeta?.fontSize}
>
{field.customText}
</DocumentSigningFieldsInserted>
)}

View File

@ -12,7 +12,7 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
@ -32,7 +32,6 @@ 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';
@ -47,7 +46,6 @@ import { DocumentSigningRejectDialog } from '~/components/general/document-signi
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
@ -72,12 +70,6 @@ export const DocumentSigningPageView = ({
}: DocumentSigningPageViewProps) => {
const { documentData, documentMeta } = document;
const { derivedRecipientAccessAuth, user: authUser } = useRequiredDocumentSigningAuthContext();
const hasAuthenticator = authUser?.twoFactorEnabled
? authUser.twoFactorEnabled && authUser.email === recipient.email
: false;
const navigate = useNavigate();
const analytics = useAnalytics();
@ -102,16 +94,14 @@ export const DocumentSigningPageView = ({
validateFieldsInserted(fieldsRequiringValidation);
};
const completeDocument = async (options: {
accessAuthOptions?: TRecipientAccessAuth;
nextSigner?: { email: string; name: string };
}) => {
const { accessAuthOptions, nextSigner } = options;
const completeDocument = async (
authOptions?: TRecipientActionAuth,
nextSigner?: { email: string; name: string },
) => {
const payload = {
token: recipient.token,
documentId: document.id,
accessAuthOptions,
authOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
};
@ -232,10 +222,7 @@ export const DocumentSigningPageView = ({
</span>
</div>
<div className="flex gap-2">
<DocumentSigningAttachmentsDialog document={document} />
<DocumentSigningRejectDialog document={document} token={recipient.token} />
</div>
<DocumentSigningRejectDialog document={document} token={recipient.token} />
</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">
@ -278,10 +265,10 @@ export const DocumentSigningPageView = ({
fields={fields}
fieldsValidated={fieldsValidated}
disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) =>
completeDocument({ nextSigner })
}
recipient={recipient}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role}
allowDictateNextSigner={
nextRecipient && documentMeta?.allowDictateNextSigner
}

View File

@ -250,7 +250,10 @@ export const DocumentSigningTextField = ({
)}
{field.inserted && (
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
<DocumentSigningFieldsInserted
textAlign={parsedFieldMeta?.textAlign}
fontSize={parsedFieldMeta?.fontSize}
>
{field.customText}
</DocumentSigningFieldsInserted>
)}

View File

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

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

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

View File

@ -12,7 +12,6 @@ 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';
@ -107,7 +106,7 @@ export default function DocumentEditPage() {
<Trans>Documents</Trans>
</Link>
<div className="mt-4 flex w-full flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div className="mt-4 flex w-full items-end justify-between">
<div className="flex-1">
<h1
className="block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
@ -141,12 +140,11 @@ export default function DocumentEditPage() {
</div>
</div>
<div className={document.useLegacyFieldInsertion ? 'flex items-center gap-2' : undefined}>
{document.useLegacyFieldInsertion && (
{document.useLegacyFieldInsertion && (
<div>
<LegacyFieldWarningPopover type="document" documentId={document.id} />
)}
<AttachmentForm documentId={document.id} />
</div>
</div>
)}
</div>
<DocumentEditForm

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -101,5 +101,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "1.12.10"
"version": "1.12.7"
}

6
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -87,7 +87,6 @@ export const getDocumentAndSenderByToken = async ({
token,
},
},
attachments: true,
team: {
select: {
name: true,

View File

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

View File

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

View File

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

View File

@ -290,7 +290,6 @@ export const createDocumentFromTemplate = async ({
fields: true,
},
},
attachments: true,
templateDocumentData: true,
templateMeta: true,
},
@ -399,15 +398,6 @@ 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,

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,12 +40,6 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated.
'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team.
'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([
@ -493,42 +487,6 @@ export const ZDocumentAuditLogEventDocumentRecipientRejectedSchema = z.object({
}),
});
/**
* Event: Document recipient requested a 2FA token.
*/
export const ZDocumentAuditLogEventDocumentRecipientRequested2FAEmailSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED),
data: z.object({
recipientEmail: z.string(),
recipientName: z.string(),
recipientId: z.number(),
}),
});
/**
* Event: Document recipient validated a 2FA token.
*/
export const ZDocumentAuditLogEventDocumentRecipientValidated2FAEmailSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED),
data: z.object({
recipientEmail: z.string(),
recipientName: z.string(),
recipientId: z.number(),
}),
});
/**
* Event: Document recipient failed to validate a 2FA token.
*/
export const ZDocumentAuditLogEventDocumentRecipientFailed2FAEmailSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED),
data: z.object({
recipientEmail: z.string(),
recipientName: z.string(),
recipientId: z.number(),
}),
});
/**
* Event: Document sent.
*/
@ -640,29 +598,6 @@ 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(),
@ -692,13 +627,9 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventDocumentViewedSchema,
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
ZDocumentAuditLogEventDocumentRecipientRejectedSchema,
ZDocumentAuditLogEventDocumentRecipientRequested2FAEmailSchema,
ZDocumentAuditLogEventDocumentRecipientValidated2FAEmailSchema,
ZDocumentAuditLogEventDocumentRecipientFailed2FAEmailSchema,
ZDocumentAuditLogEventDocumentSentSchema,
ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
ZDocumentAuditLogEventDocumentExternalIdUpdatedSchema,
ZDocumentAuditLogEventDocumentAttachmentsUpdatedSchema,
ZDocumentAuditLogEventFieldCreatedSchema,
ZDocumentAuditLogEventFieldRemovedSchema,
ZDocumentAuditLogEventFieldUpdatedSchema,

View File

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

View File

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

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

@ -339,32 +339,6 @@ 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
@ -423,15 +397,14 @@ model Document {
templateId Int?
source DocumentSource
auditLogs DocumentAuditLog[]
attachments Attachment[]
useLegacyFieldInsertion Boolean @default(false)
useLegacyFieldInsertion Boolean @default(false)
documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade)
template Template? @relation(fields: [templateId], references: [id], onDelete: SetNull)
folder Folder? @relation(fields: [folderId], references: [id], onDelete: Cascade)
folderId String?
auditLogs DocumentAuditLog[]
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
folderId String?
@@unique([documentDataId])
@@index([userId])
@ -927,11 +900,10 @@ model Template {
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
recipients Recipient[]
fields Field[]
directLink TemplateDirectLink?
documents Document[]
attachments Attachment[]
recipients Recipient[]
fields Field[]
directLink TemplateDirectLink?
documents Document[]
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
folderId String?

View File

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

View File

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

View File

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

@ -1,16 +0,0 @@
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,5 +1,4 @@
import { router } from '../trpc';
import { accessAuthRequest2FAEmailRoute } from './access-auth-request-2fa-email';
import { createDocumentRoute } from './create-document';
import { createDocumentTemporaryRoute } from './create-document-temporary';
import { deleteDocumentRoute } from './delete-document';
@ -8,7 +7,6 @@ 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';
@ -18,7 +16,6 @@ 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({
@ -41,10 +38,6 @@ export const documentRouter = router({
getDocumentByToken: getDocumentByTokenRoute,
findDocumentsInternal: findDocumentsInternalRoute,
accessAuth: router({
request2FAEmail: accessAuthRequest2FAEmailRoute,
}),
auditLog: {
find: findDocumentAuditLogsRoute,
download: downloadDocumentAuditLogsRoute,
@ -53,8 +46,4 @@ export const documentRouter = router({
find: findInboxRoute,
getCount: getInboxCountRoute,
}),
attachments: {
find: findDocumentAttachmentsRoute,
set: setDocumentAttachmentsRoute,
},
});

View File

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

View File

@ -1,26 +0,0 @@
import { z } from 'zod';
import { AttachmentType } from '@documenso/prisma/generated/types';
export const ZSetDocumentAttachmentsSchema = z.object({
documentId: z.number(),
attachments: z.array(
z.object({
id: z.string(),
label: z.string().min(1, 'Label is required'),
url: z.string().url('Invalid URL'),
type: z.nativeEnum(AttachmentType),
}),
),
});
export type TSetDocumentAttachmentsSchema = z.infer<typeof ZSetDocumentAttachmentsSchema>;
export const ZSetDocumentAttachmentsResponseSchema = z.array(
z.object({
id: z.string(),
label: z.string(),
url: z.string(),
type: z.nativeEnum(AttachmentType),
}),
);

View File

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

View File

@ -3,7 +3,6 @@ import { z } from 'zod';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import {
ZRecipientAccessAuthSchema,
ZRecipientAccessAuthTypesSchema,
ZRecipientActionAuthSchema,
ZRecipientActionAuthTypesSchema,
@ -165,7 +164,6 @@ export const ZCompleteDocumentWithTokenMutationSchema = z.object({
token: z.string(),
documentId: z.number(),
authOptions: ZRecipientActionAuthSchema.optional(),
accessAuthOptions: ZRecipientAccessAuthSchema.optional(),
nextSigner: z
.object({
email: z.string().email().max(254),

View File

@ -1,46 +0,0 @@
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZGetTemplateAttachmentsResponseSchema,
ZGetTemplateAttachmentsSchema,
} from './find-template-attachments.types';
export const findTemplateAttachmentsRoute = authenticatedProcedure
.input(ZGetTemplateAttachmentsSchema)
.output(ZGetTemplateAttachmentsResponseSchema)
.query(async ({ input, ctx }) => {
const { templateId } = input;
const attachments = await findTemplateAttachments({
templateId,
userId: ctx.user.id,
teamId: ctx.teamId,
});
return attachments;
});
export type FindTemplateAttachmentsOptions = {
templateId: number;
userId: number;
teamId: number;
};
export const findTemplateAttachments = async ({
templateId,
userId,
teamId,
}: FindTemplateAttachmentsOptions) => {
const attachments = await prisma.attachment.findMany({
where: {
template: {
id: templateId,
team: buildTeamWhereQuery({ teamId, userId }),
},
},
});
return attachments;
};

View File

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

View File

@ -28,7 +28,6 @@ import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-action
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../document-router/schema';
import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc';
import { findTemplateAttachmentsRoute } from './find-template-attachments';
import {
ZBulkSendTemplateMutationSchema,
ZCreateDocumentFromDirectTemplateRequestSchema,
@ -52,14 +51,8 @@ import {
ZUpdateTemplateRequestSchema,
ZUpdateTemplateResponseSchema,
} from './schema';
import { setTemplateAttachmentsRoute } from './set-template-attachments';
export const templateRouter = router({
attachments: {
find: findTemplateAttachmentsRoute,
set: setTemplateAttachmentsRoute,
},
/**
* @public
*/

View File

@ -1,95 +0,0 @@
import type { Attachment } from '@prisma/client';
import { AppError } from '@documenso/lib/errors/app-error';
import { AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZSetTemplateAttachmentsResponseSchema,
ZSetTemplateAttachmentsSchema,
} from './set-template-attachments.types';
export const setTemplateAttachmentsRoute = authenticatedProcedure
.input(ZSetTemplateAttachmentsSchema)
.output(ZSetTemplateAttachmentsResponseSchema)
.mutation(async ({ input, ctx }) => {
const { templateId, attachments } = input;
const updatedAttachments = await setTemplateAttachments({
templateId,
userId: ctx.user.id,
teamId: ctx.teamId,
attachments,
});
return updatedAttachments;
});
export type CreateAttachmentsOptions = {
templateId: number;
attachments: Pick<Attachment, 'id' | 'label' | 'url' | 'type'>[];
userId: number;
teamId: number;
};
export const setTemplateAttachments = async ({
templateId,
attachments,
userId,
teamId,
}: CreateAttachmentsOptions) => {
const template = await prisma.template.findUnique({
where: {
id: templateId,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
const existingAttachments = await prisma.attachment.findMany({
where: {
templateId,
},
});
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, templateId: template.id },
update: {
label: attachment.label,
url: attachment.url,
type: attachment.type,
templateId,
},
create: {
label: attachment.label,
url: attachment.url,
type: attachment.type,
templateId,
},
});
upsertedAttachments.push(updated);
}
return upsertedAttachments;
};

View File

@ -1,26 +0,0 @@
import { z } from 'zod';
import { AttachmentType } from '@documenso/prisma/generated/types';
export const ZSetTemplateAttachmentsSchema = z.object({
templateId: z.number(),
attachments: z.array(
z.object({
id: z.string(),
label: z.string().min(1, 'Label is required'),
url: z.string().url('Invalid URL'),
type: z.nativeEnum(AttachmentType),
}),
),
});
export type TSetTemplateAttachmentsSchema = z.infer<typeof ZSetTemplateAttachmentsSchema>;
export const ZSetTemplateAttachmentsResponseSchema = z.array(
z.object({
id: z.string(),
label: z.string(),
url: z.string(),
type: z.nativeEnum(AttachmentType),
}),
);

View File

@ -188,9 +188,11 @@ export const FieldContent = ({ field, documentMeta }: FieldIconProps) => {
}
const textAlign = fieldMeta && 'textAlign' in fieldMeta ? fieldMeta.textAlign : 'left';
const fontSize = fieldMeta && 'fontSize' in fieldMeta ? fieldMeta.fontSize : undefined;
const fontSizeStyle = fontSize ? { fontSize: `${fontSize}px` } : {};
return (
<div className="flex h-full w-full items-center overflow-hidden">
<div className="overflow-visibile flex h-full w-full items-center">
<p
className={cn(
'text-foreground w-full whitespace-pre-wrap text-left text-[clamp(0.07rem,25cqw,0.825rem)] duration-200',
@ -200,6 +202,7 @@ export const FieldContent = ({ field, documentMeta }: FieldIconProps) => {
'font-signature text-[clamp(0.07rem,25cqw,1.125rem)]': isSignatureField,
},
)}
style={fontSizeStyle}
>
{textToDisplay || labelToDisplay}
</p>

View File

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

View File

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