fix: merge conflicts

This commit is contained in:
Ephraim Atta-Duncan
2025-08-14 22:13:19 +00:00
88 changed files with 4791 additions and 1066 deletions

View File

@ -20,8 +20,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
token: ${{ secrets.GH_PAT }}
- uses: ./.github/actions/node-install - uses: ./.github/actions/node-install

View File

@ -5,15 +5,14 @@ import { Callout, Steps } from 'nextra/components';
Email Domains allow you to send emails to recipients from your own domain instead of the default Documenso email address. Email Domains allow you to send emails to recipients from your own domain instead of the default Documenso email address.
<Callout type="info"> <Callout type="info">
**Platform and Enterprise Only**: Email Domains is only available to Platform and Enterprise **Enterprise Only**: Email Domains is only available to Enterprise customers and custom plans
customers.
</Callout> </Callout>
## Creating Email Domains ## Creating Email Domains
Before setting up email domains, ensure you have: Before setting up email domains, ensure you have:
- A Platform or Enterprise subscription - An Enterprise subscription
- Access to your domain's DNS settings - Access to your domain's DNS settings
- Access to your Documenso organisation as an admin or manager - Access to your Documenso organisation as an admin or manager

View File

@ -4,7 +4,9 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client'; import { TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog'; import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Link } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { z } from 'zod'; import { z } from 'zod';
@ -39,6 +41,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@documenso/ui/primitives/select'; } from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
@ -140,8 +143,28 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
{match(step) {match(step)
.with('SELECT', () => ( .with('SELECT', () => (
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle className="flex flex-row items-center">
<Trans>Add members</Trans> <Trans>Add members</Trans>
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-xs">
<Trans>
To be able to add members to a team, you must first add them to the
organisation. For more information, please see the{' '}
<Link
to="https://docs.documenso.com/users/organisations/members"
target="_blank"
rel="noreferrer"
className="text-documenso-700 hover:text-documenso-600 hover:underline"
>
documentation
</Link>
.
</Trans>
</TooltipContent>
</Tooltip>
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>

View File

@ -15,6 +15,7 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { import {
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX, TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX, TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
isTemplateRecipientEmailPlaceholder,
} from '@documenso/lib/constants/template'; } from '@documenso/lib/constants/template';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
@ -279,7 +280,11 @@ export function TemplateUseDialog({
<FormControl> <FormControl>
<Input <Input
{...field} {...field}
placeholder={recipients[index].email || _(msg`Email`)} placeholder={
isTemplateRecipientEmailPlaceholder(field.value)
? ''
: _(msg`Email`)
}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@ -484,6 +489,7 @@ export function TemplateUseDialog({
<input <input
type="file" type="file"
data-testid="template-use-dialog-file-input"
className="absolute h-full w-full opacity-0" className="absolute h-full w-full opacity-0"
accept=".pdf,application/pdf" accept=".pdf,application/pdf"
onChange={(e) => { onChange={(e) => {

View File

@ -55,6 +55,7 @@ export type TDocumentPreferencesFormSchema = {
documentDateFormat: TDocumentMetaDateFormat | null; documentDateFormat: TDocumentMetaDateFormat | null;
includeSenderDetails: boolean | null; includeSenderDetails: boolean | null;
includeSigningCertificate: boolean | null; includeSigningCertificate: boolean | null;
includeAuditLog: boolean | null;
signatureTypes: DocumentSignatureType[]; signatureTypes: DocumentSignatureType[];
}; };
@ -66,6 +67,7 @@ type SettingsSubset = Pick<
| 'documentDateFormat' | 'documentDateFormat'
| 'includeSenderDetails' | 'includeSenderDetails'
| 'includeSigningCertificate' | 'includeSigningCertificate'
| 'includeAuditLog'
| 'typedSignatureEnabled' | 'typedSignatureEnabled'
| 'uploadSignatureEnabled' | 'uploadSignatureEnabled'
| 'drawSignatureEnabled' | 'drawSignatureEnabled'
@ -96,6 +98,7 @@ export const DocumentPreferencesForm = ({
documentDateFormat: ZDocumentMetaTimezoneSchema.nullable(), documentDateFormat: ZDocumentMetaTimezoneSchema.nullable(),
includeSenderDetails: z.boolean().nullable(), includeSenderDetails: z.boolean().nullable(),
includeSigningCertificate: z.boolean().nullable(), includeSigningCertificate: z.boolean().nullable(),
includeAuditLog: z.boolean().nullable(),
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, { signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
message: msg`At least one signature type must be enabled`.id, message: msg`At least one signature type must be enabled`.id,
}), }),
@ -112,6 +115,7 @@ export const DocumentPreferencesForm = ({
documentDateFormat: settings.documentDateFormat as TDocumentMetaDateFormat | null, documentDateFormat: settings.documentDateFormat as TDocumentMetaDateFormat | null,
includeSenderDetails: settings.includeSenderDetails, includeSenderDetails: settings.includeSenderDetails,
includeSigningCertificate: settings.includeSigningCertificate, includeSigningCertificate: settings.includeSigningCertificate,
includeAuditLog: settings.includeAuditLog,
signatureTypes: extractTeamSignatureSettings({ ...settings }), signatureTypes: extractTeamSignatureSettings({ ...settings }),
}, },
resolver: zodResolver(ZDocumentPreferencesFormSchema), resolver: zodResolver(ZDocumentPreferencesFormSchema),
@ -452,6 +456,56 @@ export const DocumentPreferencesForm = ({
)} )}
/> />
<FormField
control={form.control}
name="includeAuditLog"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Include the Audit Logs in the Document</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Controls whether the audit logs will be included in the document when it is
downloaded. The audit logs can still be downloaded from the logs page
separately.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<div className="flex flex-row justify-end space-x-4"> <div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}> <Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans> <Trans>Update</Trans>

View File

@ -16,7 +16,6 @@ import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/uti
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
@ -186,15 +185,7 @@ export const DocumentSigningForm = ({
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]); }, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
return ( return (
<div <div className="flex h-full flex-col">
className={cn(
'dark:bg-background border-border bg-widget sticky flex h-full flex-col rounded-xl border px-4 py-6',
{
'top-20 max-h-[min(68rem,calc(100vh-6rem))]': user,
'top-4 max-h-[min(68rem,calc(100vh-2rem))]': !user,
},
)}
>
{validateUninsertedFields && uninsertedFields[0] && ( {validateUninsertedFields && uninsertedFields[0] && (
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning"> <FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
<Trans>Click to insert field</Trans> <Trans>Click to insert field</Trans>
@ -203,21 +194,8 @@ export const DocumentSigningForm = ({
<div className="custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2"> <div className="custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2">
<div className="flex flex-1 flex-col"> <div className="flex flex-1 flex-col">
<h3 className="text-foreground text-2xl font-semibold">
{recipient.role === RecipientRole.VIEWER && <Trans>View Document</Trans>}
{recipient.role === RecipientRole.SIGNER && <Trans>Sign Document</Trans>}
{recipient.role === RecipientRole.APPROVER && <Trans>Approve Document</Trans>}
{recipient.role === RecipientRole.ASSISTANT && <Trans>Assist Document</Trans>}
</h3>
{recipient.role === RecipientRole.VIEWER ? ( {recipient.role === RecipientRole.VIEWER ? (
<> <>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>Please mark as viewed to complete</Trans>
</p>
<hr className="border-border mb-8 mt-4" />
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2"> <div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<div className="flex flex-1 flex-col gap-y-4" /> <div className="flex flex-1 flex-col gap-y-4" />
<div className="flex flex-col gap-4 md:flex-row"> <div className="flex flex-col gap-4 md:flex-row">
@ -259,15 +237,6 @@ export const DocumentSigningForm = ({
) : recipient.role === RecipientRole.ASSISTANT ? ( ) : recipient.role === RecipientRole.ASSISTANT ? (
<> <>
<form onSubmit={assistantForm.handleSubmit(onAssistantFormSubmit)}> <form onSubmit={assistantForm.handleSubmit(onAssistantFormSubmit)}>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>
Complete the fields for the following signers. Once reviewed, they will inform
you if any modifications are needed.
</Trans>
</p>
<hr className="border-border my-4" />
<fieldset className="dark:bg-background border-border rounded-2xl border bg-white p-3"> <fieldset className="dark:bg-background border-border rounded-2xl border bg-white p-3">
<Controller <Controller
name="selectedSignerId" name="selectedSignerId"
@ -354,93 +323,81 @@ export const DocumentSigningForm = ({
</> </>
) : ( ) : (
<> <>
<div> <fieldset
<p className="text-muted-foreground mt-2 text-sm"> disabled={isSubmitting}
{recipient.role === RecipientRole.APPROVER && !hasSignatureField ? ( className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2"
<Trans>Please review the document before approving.</Trans> >
) : ( <div className="flex flex-1 flex-col gap-y-4">
<Trans>Please review the document before signing.</Trans> <div>
)} <Label htmlFor="full-name">
</p> <Trans>Full Name</Trans>
</Label>
<hr className="border-border mb-8 mt-4" /> <Input
type="text"
id="full-name"
className="bg-background mt-2"
value={fullName}
onChange={(e) => setFullName(e.target.value.trimStart())}
/>
</div>
<fieldset {hasSignatureField && (
disabled={isSubmitting}
className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2"
>
<div className="flex flex-1 flex-col gap-y-4">
<div> <div>
<Label htmlFor="full-name"> <Label htmlFor="Signature">
<Trans>Full Name</Trans> <Trans>Signature</Trans>
</Label> </Label>
<Input <SignaturePadDialog
type="text" className="mt-2"
id="full-name" disabled={isSubmitting}
className="bg-background mt-2" value={signature ?? ''}
value={fullName} onChange={(v) => setSignature(v ?? '')}
onChange={(e) => setFullName(e.target.value.trimStart())} typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
/> />
</div> </div>
)}
{hasSignatureField && (
<div>
<Label htmlFor="Signature">
<Trans>Signature</Trans>
</Label>
<SignaturePadDialog
className="mt-2"
disabled={isSubmitting}
value={signature ?? ''}
onChange={(v) => setSignature(v ?? '')}
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
/>
</div>
)}
</div>
</fieldset>
<div className="mt-6 flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={async () => navigate(-1)}
>
<Trans>Cancel</Trans>
</Button>
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting || isAssistantSubmitting}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) => {
await executeActionAuthProcedure({
actionTarget: 'DOCUMENT',
onReauthFormSubmit: async (authOptions) => {
await completeDocument(authOptions, nextSigner);
},
});
}}
role={recipient.role}
allowDictateNextSigner={
nextRecipient && document.documentMeta?.allowDictateNextSigner
}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
/>
</div> </div>
</fieldset>
<div className="mt-6 flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={async () => navigate(-1)}
>
<Trans>Cancel</Trans>
</Button>
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting || isAssistantSubmitting}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) => {
await executeActionAuthProcedure({
actionTarget: 'DOCUMENT',
onReauthFormSubmit: async (authOptions) => {
await completeDocument(authOptions, nextSigner);
},
});
}}
role={recipient.role}
allowDictateNextSigner={
nextRecipient && document.documentMeta?.allowDictateNextSigner
}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
/>
</div> </div>
</> </>
)} )}

View File

@ -3,6 +3,7 @@ import { useState } from 'react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { Field } from '@prisma/client'; import type { Field } from '@prisma/client';
import { FieldType, RecipientRole } from '@prisma/client'; import { FieldType, RecipientRole } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
@ -20,6 +21,7 @@ import type { CompletedField } from '@documenso/lib/types/fields';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields'; import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
@ -64,6 +66,7 @@ export const DocumentSigningPageView = ({
const { documentData, documentMeta } = document; const { documentData, documentMeta } = document;
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id); const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
const [isExpanded, setIsExpanded] = useState(false);
let senderName = document.user.name ?? ''; let senderName = document.user.name ?? '';
let senderEmail = `(${document.user.email})`; let senderEmail = `(${document.user.email})`;
@ -79,15 +82,15 @@ export const DocumentSigningPageView = ({
return ( return (
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}> <DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
<div className="mx-auto w-full max-w-screen-xl"> <div className="mx-auto w-full max-w-screen-xl sm:px-6">
<h1 <h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl" className="block max-w-[20rem] truncate text-2xl font-semibold sm:mt-4 md:max-w-[30rem] md:text-3xl"
title={document.title} title={document.title}
> >
{document.title} {document.title}
</h1> </h1>
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-6"> <div className="mt-1.5 flex flex-wrap items-center justify-between gap-y-2 sm:mt-2.5 sm:gap-y-0">
<div className="max-w-[50ch]"> <div className="max-w-[50ch]">
<span className="text-muted-foreground truncate" title={senderName}> <span className="text-muted-foreground truncate" title={senderName}>
{senderName} {senderEmail} {senderName} {senderEmail}
@ -137,27 +140,80 @@ export const DocumentSigningPageView = ({
<DocumentSigningRejectDialog document={document} token={recipient.token} /> <DocumentSigningRejectDialog document={document} token={recipient.token} />
</div> </div>
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0"> <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">
<Card <div className="flex-1">
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8" <Card className="rounded-xl before:rounded-xl" gradient>
gradient <CardContent className="p-2">
> <PDFViewer key={documentData.id} documentData={documentData} document={document} />
<CardContent className="p-2"> </CardContent>
<PDFViewer key={documentData.id} documentData={documentData} document={document} /> </Card>
</CardContent> </div>
</Card>
<div className="col-span-12 lg:col-span-5 xl:col-span-4"> <div
<DocumentSigningForm key={isExpanded ? 'expanded' : 'collapsed'}
document={document} className="group/document-widget fixed bottom-6 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-4 md:sticky md:bottom-[unset] md:top-4 md:z-auto md:w-[350px] md:px-0"
recipient={recipient} data-expanded={isExpanded || undefined}
fields={fields} >
redirectUrl={documentMeta?.redirectUrl} <div className="border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
isRecipientsTurn={isRecipientsTurn} <div className="flex items-center justify-between gap-x-2">
allRecipients={allRecipients} <h3 className="text-foreground text-xl font-semibold md:text-2xl">
setSelectedSignerId={setSelectedSignerId} {match(recipient.role)
isEnterprise={isEnterprise} .with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
/> .with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
.otherwise(() => null)}
</h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
{isExpanded ? (
<LucideChevronDown
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(false)}
/>
) : (
<LucideChevronUp
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(true)}
/>
)}
</Button>
</div>
<div className="hidden group-data-[expanded]/document-widget:block md:block">
<p className="text-muted-foreground mt-2 text-sm">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => (
<Trans>Please mark as viewed to complete.</Trans>
))
.with(RecipientRole.SIGNER, () => (
<Trans>Please review the document before signing.</Trans>
))
.with(RecipientRole.APPROVER, () => (
<Trans>Please review the document before approving.</Trans>
))
.with(RecipientRole.ASSISTANT, () => (
<Trans>Complete the fields for the following signers.</Trans>
))
.otherwise(() => null)}
</p>
<hr className="border-border mb-8 mt-4" />
</div>
<div className="-mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
<DocumentSigningForm
document={document}
recipient={recipient}
fields={fields}
redirectUrl={documentMeta?.redirectUrl}
isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients}
setSelectedSignerId={setSelectedSignerId}
isEnterprise={isEnterprise}
/>
</div>
</div>
</div> </div>
</div> </div>

View File

@ -227,19 +227,8 @@ export const DocumentSigningTextField = ({
const parsedField = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : undefined; const parsedField = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : undefined;
const labelDisplay = const labelDisplay = parsedField?.label;
parsedField?.label && parsedField.label.length < 20 const textDisplay = parsedField?.text;
? parsedField.label
: parsedField?.label
? parsedField?.label.substring(0, 20) + '...'
: undefined;
const textDisplay =
parsedField?.text && parsedField.text.length < 20
? parsedField.text
: parsedField?.text
? parsedField?.text.substring(0, 20) + '...'
: undefined;
const fieldDisplayName = labelDisplay ? labelDisplay : textDisplay; const fieldDisplayName = labelDisplay ? labelDisplay : textDisplay;
const charactersRemaining = (parsedFieldMeta?.characterLimit ?? 0) - (localText.length ?? 0); const charactersRemaining = (parsedFieldMeta?.characterLimit ?? 0) - (localText.length ?? 0);

View File

@ -289,7 +289,7 @@ export const DocumentEditForm = ({
message, message,
distributionMethod, distributionMethod,
emailId, emailId,
emailReplyTo, emailReplyTo: emailReplyTo || null,
emailSettings: emailSettings, emailSettings: emailSettings,
}, },
}); });

View File

@ -164,7 +164,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link to={`${documentsPath}/${document.id}/logs`}> <Link to={`${documentsPath}/${document.id}/logs`}>
<ScrollTextIcon className="mr-2 h-4 w-4" /> <ScrollTextIcon className="mr-2 h-4 w-4" />
<Trans>Audit Log</Trans> <Trans>Audit Logs</Trans>
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>

View File

@ -0,0 +1,129 @@
import { type ReactNode, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { useNavigate, useParams } from 'react-router';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export interface TemplateDropZoneWrapperProps {
children: ReactNode;
className?: string;
}
export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZoneWrapperProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { folderId } = useParams();
const team = useCurrentTeam();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
const onFileDrop = async (file: File) => {
try {
setIsLoading(true);
const documentData = await putPdfFile(file);
const { id } = await createTemplate({
title: file.name,
templateDocumentDataId: documentData.id,
folderId: folderId ?? undefined,
});
toast({
title: _(msg`Template uploaded`),
description: _(
msg`Your template has been uploaded successfully. You will be redirected to the template page.`,
),
duration: 5000,
});
await navigate(`${formatTemplatesPath(team.url)}/${id}/edit`);
} catch {
toast({
title: _(msg`Something went wrong`),
description: _(msg`Please try again later.`),
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
const onFileDropRejected = () => {
toast({
title: _(msg`Your template failed to upload.`),
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
duration: 5000,
variant: 'destructive',
});
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
},
//disabled: isUploadDisabled,
multiple: false,
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
onDrop: ([acceptedFile]) => {
if (acceptedFile) {
void onFileDrop(acceptedFile);
}
},
onDropRejected: () => {
void onFileDropRejected();
},
noClick: true,
noDragEventsBubbling: true,
});
return (
<div {...getRootProps()} className={cn('relative min-h-screen', className)}>
<input {...getInputProps()} />
{children}
{isDragActive && (
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
<h2 className="text-foreground text-2xl font-semibold">
<Trans>Upload Template</Trans>
</h2>
<p className="text-muted-foreground text-md mt-4">
<Trans>Drag and drop your PDF file here</Trans>
</p>
</div>
</div>
)}
{isLoading && (
<div className="bg-muted/30 absolute inset-0 z-50 backdrop-blur-[2px]">
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
<Loader className="text-primary h-12 w-12 animate-spin" />
<p className="text-foreground mt-8 font-medium">
<Trans>Uploading template...</Trans>
</p>
</div>
</div>
)}
</div>
);
};

View File

@ -143,6 +143,7 @@ export const TemplateEditForm = ({
}, },
meta: { meta: {
...data.meta, ...data.meta,
emailReplyTo: data.meta.emailReplyTo || null,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),

View File

@ -6,6 +6,8 @@ import { PenIcon, PlusIcon } from 'lucide-react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { AvatarWithText } from '@documenso/ui/primitives/avatar'; import { AvatarWithText } from '@documenso/ui/primitives/avatar';
export type TemplatePageViewRecipientsProps = { export type TemplatePageViewRecipientsProps = {
@ -53,8 +55,18 @@ export const TemplatePageViewRecipients = ({
{recipients.map((recipient) => ( {recipients.map((recipient) => (
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm"> <li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<AvatarWithText <AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()} avatarFallback={
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>} isTemplateRecipientEmailPlaceholder(recipient.email)
? extractInitials(recipient.name)
: recipient.email.slice(0, 1).toUpperCase()
}
primaryText={
isTemplateRecipientEmailPlaceholder(recipient.email) ? (
<p className="text-muted-foreground text-sm">{recipient.name}</p>
) : (
<p className="text-muted-foreground text-sm">{recipient.email}</p>
)
}
secondaryText={ secondaryText={
<p className="text-muted-foreground/70 text-xs"> <p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)} {_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}

View File

@ -1,20 +1,18 @@
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
import type { DateTimeFormatOptions } from 'luxon'; import type { DateTimeFormatOptions } from 'luxon';
import { DateTime } from 'luxon';
import { P, match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js'; import { UAParser } from 'ua-parser-js';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import type { TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs';
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
import { import {
Table, DOCUMENT_AUDIT_LOG_TYPE,
TableBody, type TDocumentAuditLog,
TableCell, } from '@documenso/lib/types/document-audit-logs';
TableHead, import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
TableHeader, import { cn } from '@documenso/ui/lib/utils';
TableRow, import { Card, CardContent } from '@documenso/ui/primitives/card';
} from '@documenso/ui/primitives/table';
export type AuditLogDataTableProps = { export type AuditLogDataTableProps = {
logs: TDocumentAuditLog[]; logs: TDocumentAuditLog[];
@ -25,71 +23,129 @@ const dateFormat: DateTimeFormatOptions = {
hourCycle: 'h12', hourCycle: 'h12',
}; };
/**
* Get the color indicator for the audit log type
*/
const getAuditLogIndicatorColor = (type: string) =>
match(type)
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => 'bg-green-500')
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, () => 'bg-red-500')
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, () => 'bg-orange-500')
.with(
P.union(
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
),
() => 'bg-blue-500',
)
.otherwise(() => 'bg-muted');
/** /**
* DO NOT USE TRANS. YOU MUST USE _ FOR THIS FILE AND ALL CHILDREN COMPONENTS. * DO NOT USE TRANS. YOU MUST USE _ FOR THIS FILE AND ALL CHILDREN COMPONENTS.
*/ */
const formatUserAgent = (userAgent: string | null | undefined, userAgentInfo: UAParser.IResult) => {
if (!userAgent) {
return msg`N/A`;
}
const browser = userAgentInfo.browser.name;
const version = userAgentInfo.browser.version;
const os = userAgentInfo.os.name;
// If we can parse meaningful browser info, format it nicely
if (browser && os) {
const browserInfo = version ? `${browser} ${version}` : browser;
return msg`${browserInfo} on ${os}`;
}
return msg`${userAgent}`;
};
export const InternalAuditLogTable = ({ logs }: AuditLogDataTableProps) => { export const InternalAuditLogTable = ({ logs }: AuditLogDataTableProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const parser = new UAParser(); const parser = new UAParser();
const uppercaseFistLetter = (text: string) => {
return text.charAt(0).toUpperCase() + text.slice(1);
};
return ( return (
<Table overflowHidden> <div className="space-y-4">
<TableHeader> {logs.map((log, index) => {
<TableRow> parser.setUA(log.userAgent || '');
<TableHead>{_(msg`Time`)}</TableHead> const formattedAction = formatDocumentAuditLogAction(_, log);
<TableHead>{_(msg`User`)}</TableHead> const userAgentInfo = parser.getResult();
<TableHead>{_(msg`Action`)}</TableHead>
<TableHead>{_(msg`IP Address`)}</TableHead>
<TableHead>{_(msg`Browser`)}</TableHead>
</TableRow>
</TableHeader>
<TableBody className="print:text-xs"> return (
{logs.map((log, i) => ( <Card
<TableRow className="break-inside-avoid" key={i}> key={index}
<TableCell> // Add top margin for the first card to ensure it's not cut off from the 2nd page onwards
{DateTime.fromJSDate(log.createdAt) className={`border shadow-sm ${index > 0 ? 'print:mt-8' : ''}`}
.setLocale(APP_I18N_OPTIONS.defaultLocale) style={{
.toLocaleString(dateFormat)} pageBreakInside: 'avoid',
</TableCell> breakInside: 'avoid',
}}
>
<CardContent className="p-4">
{/* Header Section with indicator, event type, and timestamp */}
<div className="mb-3 flex items-start justify-between">
<div className="flex items-baseline gap-3">
<div
className={cn(`h-2 w-2 rounded-full`, getAuditLogIndicatorColor(log.type))}
/>
<TableCell> <div>
{log.name || log.email ? ( <div className="text-muted-foreground text-sm font-medium uppercase tracking-wide print:text-[8pt]">
<div> {log.type.replace(/_/g, ' ')}
{log.name && ( </div>
<p className="break-all" title={log.name}>
{log.name}
</p>
)}
{log.email && ( <div className="text-foreground text-sm font-medium print:text-[8pt]">
<p className="text-muted-foreground break-all" title={log.email}> {formattedAction.description}
{log.email} </div>
</p> </div>
)}
</div> </div>
) : (
<p>N/A</p>
)}
</TableCell>
<TableCell> <div className="text-muted-foreground text-sm print:text-[8pt]">
{uppercaseFistLetter(formatDocumentAuditLogAction(_, log).description)} {DateTime.fromJSDate(log.createdAt)
</TableCell> .setLocale(APP_I18N_OPTIONS.defaultLocale)
.toLocaleString(dateFormat)}
</div>
</div>
<TableCell>{log.ipAddress}</TableCell> <hr className="my-4" />
<TableCell> {/* Details Section - Two column layout */}
{log.userAgent ? parser.setUA(log.userAgent).getBrowser().name : 'N/A'} <div className="grid grid-cols-2 gap-x-8 gap-y-2 text-xs print:text-[6pt]">
</TableCell> <div>
</TableRow> <div className="text-muted-foreground/70 font-medium uppercase tracking-wide">
))} {_(msg`User`)}
</TableBody> </div>
</Table>
<div className="text-foreground mt-1 font-mono">{log.email || 'N/A'}</div>
</div>
<div className="text-right">
<div className="text-muted-foreground/70 font-medium uppercase tracking-wide">
{_(msg`IP Address`)}
</div>
<div className="text-foreground mt-1 font-mono">{log.ipAddress || 'N/A'}</div>
</div>
<div className="col-span-2">
<div className="text-muted-foreground/70 font-medium uppercase tracking-wide">
{_(msg`User Agent`)}
</div>
<div className="text-foreground mt-1">
{_(formatUserAgent(log.userAgent, userAgentInfo))}
</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
); );
}; };

View File

@ -46,6 +46,7 @@ export default function OrganisationSettingsDocumentPage() {
documentDateFormat, documentDateFormat,
includeSenderDetails, includeSenderDetails,
includeSigningCertificate, includeSigningCertificate,
includeAuditLog,
signatureTypes, signatureTypes,
} = data; } = data;
@ -54,7 +55,8 @@ export default function OrganisationSettingsDocumentPage() {
documentLanguage === null || documentLanguage === null ||
documentDateFormat === null || documentDateFormat === null ||
includeSenderDetails === null || includeSenderDetails === null ||
includeSigningCertificate === null includeSigningCertificate === null ||
includeAuditLog === null
) { ) {
throw new Error('Should not be possible.'); throw new Error('Should not be possible.');
} }
@ -68,6 +70,7 @@ export default function OrganisationSettingsDocumentPage() {
documentDateFormat, documentDateFormat,
includeSenderDetails, includeSenderDetails,
includeSigningCertificate, includeSigningCertificate,
includeAuditLog,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),

View File

@ -171,7 +171,7 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
<OrganisationEmailDomainRecordsDialog <OrganisationEmailDomainRecordsDialog
records={records} records={records}
trigger={ trigger={
<Button variant="secondary"> <Button variant="outline">
<Trans>View DNS Records</Trans> <Trans>View DNS Records</Trans>
</Button> </Button>
} }

View File

@ -10,6 +10,7 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id'; import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility'; import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { logDocumentAccess } from '@documenso/lib/utils/logger';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields'; import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { Badge } from '@documenso/ui/primitives/badge'; import { Badge } from '@documenso/ui/primitives/badge';
@ -83,6 +84,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw redirect(documentRootPath); throw redirect(documentRootPath);
} }
logDocumentAccess({
request,
documentId,
userId: user.id,
});
return superLoaderJson({ return superLoaderJson({
document, document,
documentRootPath, documentRootPath,

View File

@ -9,6 +9,7 @@ import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility'; import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { logDocumentAccess } from '@documenso/lib/utils/logger';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentEditForm } from '~/components/general/document/document-edit-form'; import { DocumentEditForm } from '~/components/general/document/document-edit-form';
@ -78,6 +79,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw redirect(`${documentRootPath}/${documentId}`); throw redirect(`${documentRootPath}/${documentId}`);
} }
logDocumentAccess({
request,
documentId,
userId: user.id,
});
return superLoaderJson({ return superLoaderJson({
document: { document: {
...document, ...document,

View File

@ -11,6 +11,7 @@ import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { logDocumentAccess } from '@documenso/lib/utils/logger';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { Card } from '@documenso/ui/primitives/card'; import { Card } from '@documenso/ui/primitives/card';
@ -59,6 +60,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
teamId: team?.id, teamId: team?.id,
}); });
logDocumentAccess({
request,
documentId,
userId: user.id,
});
return { return {
document, document,
documentRootPath, documentRootPath,
@ -170,7 +177,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
<ul className="text-muted-foreground list-inside list-disc"> <ul className="text-muted-foreground list-inside list-disc">
{recipients.map((recipient) => ( {recipients.map((recipient) => (
<li key={`recipient-${recipient.id}`}> <li key={`recipient-${recipient.id}`}>
<span className="-ml-2">{formatRecipientText(recipient)}</span> <span>{formatRecipientText(recipient)}</span>
</li> </li>
))} ))}
</ul> </ul>

View File

@ -38,6 +38,7 @@ export default function TeamsSettingsPage() {
documentDateFormat, documentDateFormat,
includeSenderDetails, includeSenderDetails,
includeSigningCertificate, includeSigningCertificate,
includeAuditLog,
signatureTypes, signatureTypes,
} = data; } = data;
@ -50,6 +51,7 @@ export default function TeamsSettingsPage() {
documentDateFormat, documentDateFormat,
includeSenderDetails, includeSenderDetails,
includeSigningCertificate, includeSigningCertificate,
includeAuditLog,
...(signatureTypes.length === 0 ...(signatureTypes.length === 0
? { ? {
typedSignatureEnabled: null, typedSignatureEnabled: null,

View File

@ -9,6 +9,7 @@ import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { FolderGrid } from '~/components/general/folder/folder-grid'; import { FolderGrid } from '~/components/general/folder/folder-grid';
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
import { TemplatesTable } from '~/components/tables/templates-table'; import { TemplatesTable } from '~/components/tables/templates-table';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta'; import { appMetaTags } from '~/utils/meta';
@ -36,51 +37,54 @@ export default function TemplatesPage() {
}); });
return ( return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8"> <TemplateDropZoneWrapper>
<FolderGrid type={FolderType.TEMPLATE} parentId={folderId ?? null} /> <div className="mx-auto max-w-screen-xl px-4 md:px-8">
<FolderGrid type={FolderType.TEMPLATE} parentId={folderId ?? null} />
<div className="mt-8">
<div className="flex flex-row items-center">
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">
{team.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
<h1 className="truncate text-2xl font-semibold md:text-3xl">
<Trans>Templates</Trans>
</h1>
</div>
<div className="mt-8"> <div className="mt-8">
{data && data.count === 0 ? ( <div className="flex flex-row items-center">
<div className="text-muted-foreground/60 flex h-96 flex-col items-center justify-center gap-y-4"> <Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
<Bird className="h-12 w-12" strokeWidth={1.5} /> {team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">
{team.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
<div className="text-center"> <h1 className="truncate text-2xl font-semibold md:text-3xl">
<h3 className="text-lg font-semibold"> <Trans>Templates</Trans>
<Trans>We're all empty</Trans> </h1>
</h3> </div>
<p className="mt-2 max-w-[50ch]"> <div className="mt-8">
<Trans> {data && data.count === 0 ? (
You have not yet created any templates. To create a template please upload one. <div className="text-muted-foreground/60 flex h-96 flex-col items-center justify-center gap-y-4">
</Trans> <Bird className="h-12 w-12" strokeWidth={1.5} />
</p>
<div className="text-center">
<h3 className="text-lg font-semibold">
<Trans>We're all empty</Trans>
</h3>
<p className="mt-2 max-w-[50ch]">
<Trans>
You have not yet created any templates. To create a template please upload
one.
</Trans>
</p>
</div>
</div> </div>
</div> ) : (
) : ( <TemplatesTable
<TemplatesTable data={data}
data={data} isLoading={isLoading}
isLoading={isLoading} isLoadingError={isLoadingError}
isLoadingError={isLoadingError} documentRootPath={documentRootPath}
documentRootPath={documentRootPath} templateRootPath={templateRootPath}
templateRootPath={templateRootPath} />
/> )}
)} </div>
</div> </div>
</div> </div>
</div> </TemplateDropZoneWrapper>
); );
} }

View File

@ -0,0 +1,5 @@
@media print {
html {
font-size: 10pt;
}
}

View File

@ -12,10 +12,17 @@ import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-
import { getTranslations } from '@documenso/lib/utils/i18n'; import { getTranslations } from '@documenso/lib/utils/i18n';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import appStylesheet from '~/app.css?url';
import { BrandingLogo } from '~/components/general/branding-logo'; import { BrandingLogo } from '~/components/general/branding-logo';
import { InternalAuditLogTable } from '~/components/tables/internal-audit-log-table'; import { InternalAuditLogTable } from '~/components/tables/internal-audit-log-table';
import type { Route } from './+types/audit-log'; import type { Route } from './+types/audit-log';
import auditLogStylesheet from './audit-log.print.css?url';
export const links: Route.LinksFunction = () => [
{ rel: 'stylesheet', href: appStylesheet },
{ rel: 'stylesheet', href: auditLogStylesheet },
];
export async function loader({ request }: Route.LoaderArgs) { export async function loader({ request }: Route.LoaderArgs) {
const d = new URL(request.url).searchParams.get('d'); const d = new URL(request.url).searchParams.get('d');
@ -76,8 +83,8 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
return ( return (
<div className="print-provider pointer-events-none mx-auto max-w-screen-md"> <div className="print-provider pointer-events-none mx-auto max-w-screen-md">
<div className="flex items-center"> <div className="mb-6 border-b pb-4">
<h1 className="my-8 text-2xl font-bold">{_(msg`Version History`)}</h1> <h1 className="text-xl font-semibold">{_(msg`Audit Log`)}</h1>
</div> </div>
<Card> <Card>
@ -157,11 +164,9 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
</CardContent> </CardContent>
</Card> </Card>
<Card className="mt-8"> <div className="mt-8">
<CardContent className="p-0"> <InternalAuditLogTable logs={auditLogs} />
<InternalAuditLogTable logs={auditLogs} /> </div>
</CardContent>
</Card>
<div className="my-8 flex-row-reverse"> <div className="my-8 flex-row-reverse">
<div className="flex items-end justify-end gap-x-4"> <div className="flex items-end justify-end gap-x-4">

View File

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

6
package-lock.json generated
View File

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

View File

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

View File

@ -1178,13 +1178,12 @@ test.describe('Unauthorized Access - Document API V2', () => {
const { user: firstRecipientUser } = await seedUser(); const { user: firstRecipientUser } = await seedUser();
const { user: secondRecipientUser } = await seedUser(); const { user: secondRecipientUser } = await seedUser();
await prisma.template.update({ const updatedTemplate = await prisma.template.update({
where: { id: template.id }, where: { id: template.id },
data: { data: {
recipients: { recipients: {
create: [ create: [
{ {
id: firstRecipientUser.id,
name: firstRecipientUser.name || '', name: firstRecipientUser.name || '',
email: firstRecipientUser.email, email: firstRecipientUser.email,
token: nanoid(12), token: nanoid(12),
@ -1193,7 +1192,6 @@ test.describe('Unauthorized Access - Document API V2', () => {
signingStatus: SigningStatus.NOT_SIGNED, signingStatus: SigningStatus.NOT_SIGNED,
}, },
{ {
id: secondRecipientUser.id,
name: secondRecipientUser.name || '', name: secondRecipientUser.name || '',
email: secondRecipientUser.email, email: secondRecipientUser.email,
token: nanoid(12), token: nanoid(12),
@ -1204,21 +1202,35 @@ test.describe('Unauthorized Access - Document API V2', () => {
], ],
}, },
}, },
include: {
recipients: true,
},
}); });
const recipientAId = updatedTemplate.recipients.find(
(recipient) => recipient.email === firstRecipientUser.email,
)?.id;
const recipientBId = updatedTemplate.recipients.find(
(recipient) => recipient.email === secondRecipientUser.email,
)?.id;
if (!recipientAId || !recipientBId) {
throw new Error('Recipient IDs not found');
}
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, { const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: { Authorization: `Bearer ${tokenB}` }, headers: { Authorization: `Bearer ${tokenB}` },
data: { data: {
templateId: template.id, templateId: template.id,
recipients: [ recipients: [
{ {
id: firstRecipientUser.id, id: recipientAId,
name: firstRecipientUser.name, name: firstRecipientUser.name,
email: firstRecipientUser.email, email: firstRecipientUser.email,
role: RecipientRole.SIGNER, role: RecipientRole.SIGNER,
}, },
{ {
id: secondRecipientUser.id, id: recipientBId,
name: secondRecipientUser.name, name: secondRecipientUser.name,
email: secondRecipientUser.email, email: secondRecipientUser.email,
role: RecipientRole.SIGNER, role: RecipientRole.SIGNER,

View File

@ -379,10 +379,11 @@ test('[TEAMS]: can create a template inside a template folder', async ({ page })
.filter({ hasText: /^Upload Template DocumentDrag & drop your PDF here\.$/ }) .filter({ hasText: /^Upload Template DocumentDrag & drop your PDF here\.$/ })
.nth(2) .nth(2)
.click(); .click();
await page.locator('input[type="file"]').waitFor({ state: 'attached' }); await page.locator('input[type="file"]').nth(0).waitFor({ state: 'attached' });
await page await page
.locator('input[type="file"]') .locator('input[type="file"]')
.nth(0)
.setInputFiles(path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf')); .setInputFiles(path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'));
await page.waitForTimeout(3000); await page.waitForTimeout(3000);

View File

@ -268,7 +268,7 @@ test('[TEMPLATE]: should create a document from a template with custom document'
// Upload document. // Upload document.
const [fileChooser] = await Promise.all([ const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'), page.waitForEvent('filechooser'),
page.locator('input[type=file]').evaluate((e) => { page.getByTestId('template-use-dialog-file-input').evaluate((e) => {
if (e instanceof HTMLInputElement) { if (e instanceof HTMLInputElement) {
e.click(); e.click();
} }
@ -361,7 +361,7 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
// Upload document. // Upload document.
const [fileChooser] = await Promise.all([ const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'), page.waitForEvent('filechooser'),
page.locator('input[type=file]').evaluate((e) => { page.getByTestId('template-use-dialog-file-input').evaluate((e) => {
if (e instanceof HTMLInputElement) { if (e instanceof HTMLInputElement) {
e.click(); e.click();
} }

View File

@ -13,4 +13,4 @@ export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED
export const API_V2_BETA_URL = '/api/v2-beta'; export const API_V2_BETA_URL = '/api/v2-beta';
export const SUPPORT_EMAIL = 'support@documenso.com'; export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@documenso.com';

View File

@ -49,15 +49,24 @@ type DocumentSignatureTypeData = {
export const DOCUMENT_SIGNATURE_TYPES = { export const DOCUMENT_SIGNATURE_TYPES = {
[DocumentSignatureType.DRAW]: { [DocumentSignatureType.DRAW]: {
label: msg`Draw`, label: msg({
message: `Draw`,
context: `Draw signatute type`,
}),
value: DocumentSignatureType.DRAW, value: DocumentSignatureType.DRAW,
}, },
[DocumentSignatureType.TYPE]: { [DocumentSignatureType.TYPE]: {
label: msg`Type`, label: msg({
message: `Type`,
context: `Type signatute type`,
}),
value: DocumentSignatureType.TYPE, value: DocumentSignatureType.TYPE,
}, },
[DocumentSignatureType.UPLOAD]: { [DocumentSignatureType.UPLOAD]: {
label: msg`Upload`, label: msg({
message: `Upload`,
context: `Upload signatute type`,
}),
value: DocumentSignatureType.UPLOAD, value: DocumentSignatureType.UPLOAD,
}, },
} satisfies Record<DocumentSignatureType, DocumentSignatureTypeData>; } satisfies Record<DocumentSignatureType, DocumentSignatureTypeData>;

View File

@ -4,39 +4,114 @@ import { RecipientRole } from '@prisma/client';
export const RECIPIENT_ROLES_DESCRIPTION = { export const RECIPIENT_ROLES_DESCRIPTION = {
[RecipientRole.APPROVER]: { [RecipientRole.APPROVER]: {
actionVerb: msg`Approve`, actionVerb: msg({
actioned: msg`Approved`, message: `Approve`,
progressiveVerb: msg`Approving`, context: `Recipient role action verb`,
roleName: msg`Approver`, }),
roleNamePlural: msg`Approvers`, actioned: msg({
message: `Approved`,
context: `Recipient role actioned`,
}),
progressiveVerb: msg({
message: `Approving`,
context: `Recipient role progressive verb`,
}),
roleName: msg({
message: `Approver`,
context: `Recipient role name`,
}),
roleNamePlural: msg({
message: `Approvers`,
context: `Recipient role plural name`,
}),
}, },
[RecipientRole.CC]: { [RecipientRole.CC]: {
actionVerb: msg`CC`, actionVerb: msg({
actioned: msg`CC'd`, message: `CC`,
progressiveVerb: msg`CC`, context: `Recipient role action verb`,
roleName: msg`Cc`, }),
roleNamePlural: msg`Ccers`, actioned: msg({
message: `CC'd`,
context: `Recipient role actioned`,
}),
progressiveVerb: msg({
message: `CC`,
context: `Recipient role progressive verb`,
}),
roleName: msg({
message: `Cc`,
context: `Recipient role name`,
}),
roleNamePlural: msg({
message: `Ccers`,
context: `Recipient role plural name`,
}),
}, },
[RecipientRole.SIGNER]: { [RecipientRole.SIGNER]: {
actionVerb: msg`Sign`, actionVerb: msg({
actioned: msg`Signed`, message: `Sign`,
progressiveVerb: msg`Signing`, context: `Recipient role action verb`,
roleName: msg`Signer`, }),
roleNamePlural: msg`Signers`, actioned: msg({
message: `Signed`,
context: `Recipient role actioned`,
}),
progressiveVerb: msg({
message: `Signing`,
context: `Recipient role progressive verb`,
}),
roleName: msg({
message: `Signer`,
context: `Recipient role name`,
}),
roleNamePlural: msg({
message: `Signers`,
context: `Recipient role plural name`,
}),
}, },
[RecipientRole.VIEWER]: { [RecipientRole.VIEWER]: {
actionVerb: msg`View`, actionVerb: msg({
actioned: msg`Viewed`, message: `View`,
progressiveVerb: msg`Viewing`, context: `Recipient role action verb`,
roleName: msg`Viewer`, }),
roleNamePlural: msg`Viewers`, actioned: msg({
message: `Viewed`,
context: `Recipient role actioned`,
}),
progressiveVerb: msg({
message: `Viewing`,
context: `Recipient role progressive verb`,
}),
roleName: msg({
message: `Viewer`,
context: `Recipient role name`,
}),
roleNamePlural: msg({
message: `Viewers`,
context: `Recipient role plural name`,
}),
}, },
[RecipientRole.ASSISTANT]: { [RecipientRole.ASSISTANT]: {
actionVerb: msg`Assist`, actionVerb: msg({
actioned: msg`Assisted`, message: `Assist`,
progressiveVerb: msg`Assisting`, context: `Recipient role action verb`,
roleName: msg`Assistant`, }),
roleNamePlural: msg`Assistants`, actioned: msg({
message: `Assisted`,
context: `Recipient role actioned`,
}),
progressiveVerb: msg({
message: `Assisting`,
context: `Recipient role progressive verb`,
}),
roleName: msg({
message: `Assistant`,
context: `Recipient role name`,
}),
roleNamePlural: msg({
message: `Assistants`,
context: `Recipient role plural name`,
}),
}, },
} satisfies Record<keyof typeof RecipientRole, unknown>; } satisfies Record<keyof typeof RecipientRole, unknown>;

View File

@ -3,6 +3,10 @@ import { msg } from '@lingui/core/macro';
export const TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX = /recipient\.\d+@documenso\.com/i; export const TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX = /recipient\.\d+@documenso\.com/i;
export const TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX = /Recipient \d+/i; export const TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX = /Recipient \d+/i;
export const isTemplateRecipientEmailPlaceholder = (email: string) => {
return TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX.test(email);
};
export const DIRECT_TEMPLATE_DOCUMENTATION = [ export const DIRECT_TEMPLATE_DOCUMENTATION = [
{ {
title: msg`Enable Direct Link Signing`, title: msg`Enable Direct Link Signing`,

View File

@ -48,7 +48,7 @@ export const run = async ({
type: 'team', type: 'team',
teamId: document.teamId, teamId: document.teamId,
}, },
meta: document.documentMeta || null, meta: document.documentMeta,
}); });
const { documentMeta, user: documentOwner } = document; const { documentMeta, user: documentOwner } = document;

View File

@ -76,7 +76,7 @@ export const run = async ({
type: 'team', type: 'team',
teamId: document.teamId, teamId: document.teamId,
}, },
meta: document.documentMeta || null, meta: document.documentMeta,
}); });
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';

View File

@ -68,7 +68,7 @@ export const run = async ({
type: 'team', type: 'team',
teamId: document.teamId, teamId: document.teamId,
}, },
meta: document.documentMeta || null, meta: document.documentMeta,
}); });
const i18n = await getI18nInstance(emailLanguage); const i18n = await getI18nInstance(emailLanguage);

View File

@ -86,7 +86,7 @@ export const run = async ({
type: 'team', type: 'team',
teamId: document.teamId, teamId: document.teamId,
}, },
meta: document.documentMeta || null, meta: document.documentMeta,
}); });
const customEmail = document?.documentMeta; const customEmail = document?.documentMeta;

View File

@ -9,6 +9,7 @@ import { signPdf } from '@documenso/signing';
import { AppError, AppErrorCode } from '../../../errors/app-error'; import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email'; import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client'; import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf'; import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf'; import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations'; import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
@ -145,7 +146,24 @@ export const run = async ({
? await getCertificatePdf({ ? await getCertificatePdf({
documentId, documentId,
language: document.documentMeta?.language, language: document.documentMeta?.language,
}).catch(() => null) }).catch((e) => {
console.log('Failed to get certificate PDF');
console.error(e);
return null;
})
: null;
const auditLogData = settings.includeAuditLog
? await getAuditLogsPdf({
documentId,
language: document.documentMeta?.language,
}).catch((e) => {
console.log('Failed to get audit logs PDF');
console.error(e);
return null;
})
: null; : null;
const newDataId = await io.runTask('decorate-and-sign-pdf', async () => { const newDataId = await io.runTask('decorate-and-sign-pdf', async () => {
@ -174,6 +192,16 @@ export const run = async ({
}); });
} }
if (auditLogData) {
const auditLogDoc = await PDFDocument.load(auditLogData);
const auditLogPages = await pdfDoc.copyPages(auditLogDoc, auditLogDoc.getPageIndices());
auditLogPages.forEach((page) => {
pdfDoc.addPage(page);
});
}
for (const field of fields) { for (const field of fields) {
if (field.inserted) { if (field.inserted) {
document.useLegacyFieldInsertion document.useLegacyFieldInsertion

View File

@ -212,7 +212,7 @@ export const createDocumentV2 = async ({
}), }),
); );
// Todo: Is it necessary to create a full audit log with all fields and recipients audit logs? // Todo: Is it necessary to create a full audit logs with all fields and recipients audit logs?
await tx.documentAuditLog.create({ await tx.documentAuditLog.create({
data: createDocumentAuditLogData({ data: createDocumentAuditLogData({

View File

@ -156,7 +156,7 @@ const handleDocumentOwnerDelete = async ({
type: 'team', type: 'team',
teamId: document.teamId, teamId: document.teamId,
}, },
meta: document.documentMeta || null, meta: document.documentMeta,
}); });
// Soft delete completed documents. // Soft delete completed documents.

View File

@ -102,7 +102,7 @@ export const resendDocument = async ({
type: 'team', type: 'team',
teamId: document.teamId, teamId: document.teamId,
}, },
meta: document.documentMeta || null, meta: document.documentMeta,
}); });
await Promise.all( await Promise.all(

View File

@ -17,6 +17,7 @@ import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server'; import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server'; import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers'; import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers';
import { getAuditLogsPdf } from '../htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf'; import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf'; import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations'; import { flattenAnnotations } from '../pdf/flatten-annotations';
@ -125,6 +126,18 @@ export const sealDocument = async ({
}) })
: null; : null;
const auditLogData = settings.includeAuditLog
? await getAuditLogsPdf({
documentId,
language: document.documentMeta?.language,
}).catch((e) => {
console.log('Failed to get audit logs PDF');
console.error(e);
return null;
})
: null;
const doc = await PDFDocument.load(pdfData); const doc = await PDFDocument.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature // Normalize and flatten layers that could cause issues with the signature
@ -147,6 +160,16 @@ export const sealDocument = async ({
}); });
} }
if (auditLogData) {
const auditLog = await PDFDocument.load(auditLogData);
const auditLogPages = await doc.copyPages(auditLog, auditLog.getPageIndices());
auditLogPages.forEach((page) => {
doc.addPage(page);
});
}
for (const field of fields) { for (const field of fields) {
document.useLegacyFieldInsertion document.useLegacyFieldInsertion
? await legacy_insertFieldInPDF(doc, field) ? await legacy_insertFieldInPDF(doc, field)

View File

@ -59,7 +59,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
type: 'team', type: 'team',
teamId: document.teamId, teamId: document.teamId,
}, },
meta: document.documentMeta || null, meta: document.documentMeta,
}); });
const { user: owner } = document; const { user: owner } = document;

View File

@ -49,7 +49,7 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
type: 'team', type: 'team',
teamId: document.teamId, teamId: document.teamId,
}, },
meta: document.documentMeta || null, meta: document.documentMeta,
}); });
const { email, name } = document.user; const { email, name } = document.user;

View File

@ -51,7 +51,7 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
type: 'team', type: 'team',
teamId: document.teamId, teamId: document.teamId,
}, },
meta: document.documentMeta || null, meta: document.documentMeta,
}); });
const isDocumentPendingEmailEnabled = extractDerivedDocumentEmailSettings( const isDocumentPendingEmailEnabled = extractDerivedDocumentEmailSettings(

View File

@ -46,7 +46,7 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
type: 'team', type: 'team',
teamId: document.teamId, teamId: document.teamId,
}, },
meta: document.documentMeta || null, meta: document.documentMeta,
}); });
const { status, user } = document; const { status, user } = document;

View File

@ -59,7 +59,7 @@ type RecipientGetEmailContextOptions = BaseGetEmailContextOptions & {
* Force meta options as a typesafe way to ensure developers don't forget to * Force meta options as a typesafe way to ensure developers don't forget to
* pass it in if it is available. * pass it in if it is available.
*/ */
meta: EmailMetaOption | null; meta: EmailMetaOption | null | undefined;
}; };
type GetEmailContextOptions = InternalGetEmailContextOptions | RecipientGetEmailContextOptions; type GetEmailContextOptions = InternalGetEmailContextOptions | RecipientGetEmailContextOptions;
@ -104,7 +104,7 @@ export const getEmailContext = async (
} }
const replyToEmail = meta?.emailReplyTo || emailContext.settings.emailReplyTo || undefined; const replyToEmail = meta?.emailReplyTo || emailContext.settings.emailReplyTo || undefined;
const senderEmailId = meta?.emailId || emailContext.settings.emailId; const senderEmailId = meta?.emailId === null ? null : emailContext.settings.emailId;
const foundSenderEmail = emailContext.allowedEmails.find((email) => email.id === senderEmailId); const foundSenderEmail = emailContext.allowedEmails.find((email) => email.id === senderEmailId);

View File

@ -0,0 +1,83 @@
import { DateTime } from 'luxon';
import type { Browser } from 'playwright';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { type SupportedLanguageCodes, isValidLanguageCode } from '../../constants/i18n';
import { env } from '../../utils/env';
import { encryptSecondaryData } from '../crypto/encrypt';
export type GetAuditLogsPdfOptions = {
documentId: number;
// eslint-disable-next-line @typescript-eslint/ban-types
language?: SupportedLanguageCodes | (string & {});
};
export const getAuditLogsPdf = async ({ documentId, language }: GetAuditLogsPdfOptions) => {
const { chromium } = await import('playwright');
const encryptedId = encryptSecondaryData({
data: documentId.toString(),
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
});
let browser: Browser;
const browserlessUrl = env('NEXT_PRIVATE_BROWSERLESS_URL');
if (browserlessUrl) {
// !: Use CDP rather than the default `connect` method to avoid coupling to the playwright version.
// !: Previously we would have to keep the playwright version in sync with the browserless version to avoid errors.
browser = await chromium.connectOverCDP(browserlessUrl);
} else {
browser = await chromium.launch();
}
if (!browser) {
throw new Error(
'Failed to establish a browser, please ensure you have either a Browserless.io url or chromium browser installed',
);
}
const browserContext = await browser.newContext();
const page = await browserContext.newPage();
const lang = isValidLanguageCode(language) ? language : 'en';
await page.context().addCookies([
{
name: 'language',
value: lang,
url: NEXT_PUBLIC_WEBAPP_URL(),
},
]);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encryptedId}`, {
waitUntil: 'networkidle',
timeout: 10_000,
});
// !: This is a workaround to ensure the page is loaded correctly.
// !: It's not clear why but suddenly browserless cdp connections would
// !: cause the page to render blank until a reload is performed.
await page.reload({
waitUntil: 'networkidle',
timeout: 10_000,
});
await page.waitForSelector('h1', {
state: 'visible',
timeout: 10_000,
});
const result = await page.pdf({
format: 'A4',
printBackground: true,
});
await browserContext.close();
void browser.close();
return result;
};

View File

@ -46,7 +46,7 @@ export const getCertificatePdf = async ({ documentId, language }: GetCertificate
await page.context().addCookies([ await page.context().addCookies([
{ {
name: 'language', name: 'lang',
value: lang, value: lang,
url: NEXT_PUBLIC_WEBAPP_URL(), url: NEXT_PUBLIC_WEBAPP_URL(),
}, },
@ -57,8 +57,22 @@ export const getCertificatePdf = async ({ documentId, language }: GetCertificate
timeout: 10_000, timeout: 10_000,
}); });
// !: This is a workaround to ensure the page is loaded correctly.
// !: It's not clear why but suddenly browserless cdp connections would
// !: cause the page to render blank until a reload is performed.
await page.reload({
waitUntil: 'networkidle',
timeout: 10_000,
});
await page.waitForSelector('h1', {
state: 'visible',
timeout: 10_000,
});
const result = await page.pdf({ const result = await page.pdf({
format: 'A4', format: 'A4',
printBackground: true,
}); });
await browserContext.close(); await browserContext.close();

View File

@ -2,8 +2,10 @@ import type { Prisma } from '@prisma/client';
import { OrganisationType } from '@prisma/client'; import { OrganisationType } from '@prisma/client';
import { OrganisationMemberRole } from '@prisma/client'; import { OrganisationMemberRole } from '@prisma/client';
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { IS_BILLING_ENABLED } from '../../constants/app';
import { ORGANISATION_INTERNAL_GROUPS } from '../../constants/organisations'; import { ORGANISATION_INTERNAL_GROUPS } from '../../constants/organisations';
import { AppErrorCode } from '../../errors/app-error'; import { AppErrorCode } from '../../errors/app-error';
import { AppError } from '../../errors/app-error'; import { AppError } from '../../errors/app-error';
@ -30,6 +32,33 @@ export const createOrganisation = async ({
customerId, customerId,
claim, claim,
}: CreateOrganisationOptions) => { }: CreateOrganisationOptions) => {
let customerIdToUse = customerId;
if (!customerId && IS_BILLING_ENABLED()) {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
customerIdToUse = await createCustomer({
name: user.name || user.email,
email: user.email,
})
.then((customer) => customer.id)
.catch((err) => {
console.error(err);
return undefined;
});
}
return await prisma.$transaction(async (tx) => { return await prisma.$transaction(async (tx) => {
const organisationSetting = await tx.organisationGlobalSettings.create({ const organisationSetting = await tx.organisationGlobalSettings.create({
data: { data: {
@ -64,7 +93,7 @@ export const createOrganisation = async ({
id: generateDatabaseId('org_group'), id: generateDatabaseId('org_group'),
})), })),
}, },
customerId, customerId: customerIdToUse,
}, },
include: { include: {
groups: true, groups: true,

View File

@ -130,7 +130,7 @@ export const deleteDocumentRecipient = async ({
type: 'team', type: 'team',
teamId: document.teamId, teamId: document.teamId,
}, },
meta: document.documentMeta || null, meta: document.documentMeta,
}); });
const [html, text] = await Promise.all([ const [html, text] = await Promise.all([

View File

@ -95,7 +95,7 @@ export const setDocumentRecipients = async ({
type: 'team', type: 'team',
teamId, teamId,
}, },
meta: document.documentMeta || null, meta: document.documentMeta,
}); });
const recipientsHaveActionAuth = recipients.some( const recipientsHaveActionAuth = recipients.some(

File diff suppressed because it is too large Load Diff

View File

@ -167,6 +167,10 @@ msgstr "{0} Recipient(s)"
msgid "{0} Teams" msgid "{0} Teams"
msgstr "{0} Teams" msgstr "{0} Teams"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
msgid "{browserInfo} on {os}"
msgstr "{browserInfo} on {os}"
#: apps/remix/app/components/general/document-signing/document-signing-text-field.tsx #: apps/remix/app/components/general/document-signing/document-signing-text-field.tsx
msgid "{charactersRemaining, plural, one {1 character remaining} other {{charactersRemaining} characters remaining}}" msgid "{charactersRemaining, plural, one {1 character remaining} other {{charactersRemaining} characters remaining}}"
msgstr "{charactersRemaining, plural, one {1 character remaining} other {{charactersRemaining} characters remaining}}" msgstr "{charactersRemaining, plural, one {1 character remaining} other {{charactersRemaining} characters remaining}}"
@ -368,6 +372,10 @@ msgstr "{teamName} has invited you to {0}<0/>\"{documentName}\""
msgid "{teamName} has invited you to {action} {documentName}" msgid "{teamName} has invited you to {action} {documentName}"
msgstr "{teamName} has invited you to {action} {documentName}" msgstr "{teamName} has invited you to {action} {documentName}"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
msgid "{userAgent}"
msgstr "{userAgent}"
#: packages/lib/utils/document-audit-logs.ts #: packages/lib/utils/document-audit-logs.ts
msgid "{userName} approved the document" msgid "{userName} approved the document"
msgstr "{userName} approved the document" msgstr "{userName} approved the document"
@ -745,7 +753,6 @@ msgstr "Acknowledgment"
#: apps/remix/app/components/tables/settings-security-activity-table.tsx #: apps/remix/app/components/tables/settings-security-activity-table.tsx
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx #: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx #: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx #: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
@ -1486,10 +1493,14 @@ msgstr "At least one signature type must be enabled"
msgid "Attempts sealing the document again, useful for after a code change has occurred to resolve an erroneous document." msgid "Attempts sealing the document again, useful for after a code change has occurred to resolve an erroneous document."
msgstr "Attempts sealing the document again, useful for after a code change has occurred to resolve an erroneous document." msgstr "Attempts sealing the document again, useful for after a code change has occurred to resolve an erroneous document."
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx #: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
msgid "Audit Log" msgid "Audit Log"
msgstr "Audit Log" msgstr "Audit Log"
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
msgid "Audit Logs"
msgstr "Audit Logs"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx #: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
msgid "Authentication Level" msgid "Authentication Level"
msgstr "Authentication Level" msgstr "Authentication Level"
@ -1591,7 +1602,6 @@ msgid "Branding preferences updated"
msgstr "Branding preferences updated" msgstr "Branding preferences updated"
#: apps/remix/app/components/tables/settings-security-activity-table.tsx #: apps/remix/app/components/tables/settings-security-activity-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
msgid "Browser" msgid "Browser"
msgstr "Browser" msgstr "Browser"
@ -2109,6 +2119,10 @@ msgstr "Controls the formatting of the message that will be sent when inviting a
msgid "Controls the language for the document, including the language to be used for email notifications, and the final certificate that is generated and attached to the document." msgid "Controls the language for the document, including the language to be used for email notifications, and the final certificate that is generated and attached to the document."
msgstr "Controls the language for the document, including the language to be used for email notifications, and the final certificate that is generated and attached to the document." msgstr "Controls the language for the document, including the language to be used for email notifications, and the final certificate that is generated and attached to the document."
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Controls whether the audit logs will be included in the document when it is downloaded. The audit logs can still be downloaded from the logs page separately."
msgstr "Controls whether the audit logs will be included in the document when it is downloaded. The audit logs can still be downloaded from the logs page separately."
#: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Controls whether the signing certificate will be included in the document when it is downloaded. The signing certificate can still be downloaded from the logs page separately." msgid "Controls whether the signing certificate will be included in the document when it is downloaded. The signing certificate can still be downloaded from the logs page separately."
msgstr "Controls whether the signing certificate will be included in the document when it is downloaded. The signing certificate can still be downloaded from the logs page separately." msgstr "Controls whether the signing certificate will be included in the document when it is downloaded. The signing certificate can still be downloaded from the logs page separately."
@ -3140,6 +3154,7 @@ msgstr "Drag & drop your PDF here."
msgid "Drag and drop or click to upload" msgid "Drag and drop or click to upload"
msgstr "Drag and drop or click to upload" msgstr "Drag and drop or click to upload"
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
#: apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx #: apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx
msgid "Drag and drop your PDF file here" msgid "Drag and drop your PDF file here"
msgstr "Drag and drop your PDF file here" msgstr "Drag and drop your PDF file here"
@ -3668,6 +3683,7 @@ msgstr "Fields"
msgid "Fields updated" msgid "Fields updated"
msgstr "Fields updated" msgstr "Fields updated"
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
#: apps/remix/app/components/general/document/document-upload.tsx #: apps/remix/app/components/general/document/document-upload.tsx
#: apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx #: apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx #: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
@ -4042,6 +4058,10 @@ msgstr "Inbox"
msgid "Inbox documents" msgid "Inbox documents"
msgstr "Inbox documents" msgstr "Inbox documents"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Include the Audit Logs in the Document"
msgstr "Include the Audit Logs in the Document"
#: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Include the Signing Certificate in the Document" msgid "Include the Signing Certificate in the Document"
msgstr "Include the Signing Certificate in the Document" msgstr "Include the Signing Certificate in the Document"
@ -4064,6 +4084,7 @@ msgstr "Inherit authentication method"
#: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/branding-preferences-form.tsx #: apps/remix/app/components/forms/branding-preferences-form.tsx
msgid "Inherit from organisation" msgid "Inherit from organisation"
msgstr "Inherit from organisation" msgstr "Inherit from organisation"
@ -4599,6 +4620,10 @@ msgstr "Multiple access methods can be selected."
msgid "My Folder" msgid "My Folder"
msgstr "My Folder" msgstr "My Folder"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
msgid "N/A"
msgstr "N/A"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx
#: apps/remix/app/components/tables/settings-security-passkey-table.tsx #: apps/remix/app/components/tables/settings-security-passkey-table.tsx
@ -4679,6 +4704,7 @@ msgstr "Next"
msgid "Next field" msgid "Next field"
msgstr "Next field" msgstr "Next field"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/branding-preferences-form.tsx #: apps/remix/app/components/forms/branding-preferences-form.tsx
@ -5338,6 +5364,7 @@ msgstr "Please try a different domain."
msgid "Please try again and make sure you enter the correct email address." msgid "Please try again and make sure you enter the correct email address."
msgstr "Please try again and make sure you enter the correct email address." msgstr "Please try again and make sure you enter the correct email address."
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
#: apps/remix/app/components/dialogs/template-create-dialog.tsx #: apps/remix/app/components/dialogs/template-create-dialog.tsx
msgid "Please try again later." msgid "Please try again later."
msgstr "Please try again later." msgstr "Please try again later."
@ -6390,6 +6417,7 @@ msgstr "Some signers have not been assigned a signature field. Please assign at
#: apps/remix/app/components/general/share-document-download-button.tsx #: apps/remix/app/components/general/share-document-download-button.tsx
#: apps/remix/app/components/general/billing-plans.tsx #: apps/remix/app/components/general/billing-plans.tsx
#: apps/remix/app/components/general/billing-plans.tsx #: apps/remix/app/components/general/billing-plans.tsx
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
#: apps/remix/app/components/general/teams/team-email-usage.tsx #: apps/remix/app/components/general/teams/team-email-usage.tsx
#: apps/remix/app/components/general/teams/team-email-dropdown.tsx #: apps/remix/app/components/general/teams/team-email-dropdown.tsx
#: apps/remix/app/components/general/organisations/organisation-invitations.tsx #: apps/remix/app/components/general/organisations/organisation-invitations.tsx
@ -6827,6 +6855,10 @@ msgstr "Template title"
msgid "Template updated successfully" msgid "Template updated successfully"
msgstr "Template updated successfully" msgstr "Template updated successfully"
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
msgid "Template uploaded"
msgstr "Template uploaded"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
@ -7445,7 +7477,6 @@ msgstr "This will remove all emails associated with this email domain"
msgid "This will sign you out of all other devices. You will need to sign in again on those devices to continue using your account." msgid "This will sign you out of all other devices. You will need to sign in again on those devices to continue using your account."
msgstr "This will sign you out of all other devices. You will need to sign in again on those devices to continue using your account." msgstr "This will sign you out of all other devices. You will need to sign in again on those devices to continue using your account."
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx #: apps/remix/app/components/tables/document-logs-table.tsx
msgid "Time" msgid "Time"
msgstr "Time" msgstr "Time"
@ -7479,6 +7510,10 @@ msgstr "Title cannot be empty"
msgid "To accept this invitation you must create an account." msgid "To accept this invitation you must create an account."
msgstr "To accept this invitation you must create an account." msgstr "To accept this invitation you must create an account."
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
msgid "To be able to add members to a team, you must first add them to the organisation. For more information, please see the <0>documentation</0>."
msgstr "To be able to add members to a team, you must first add them to the organisation. For more information, please see the <0>documentation</0>."
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
msgid "To change the email you must remove and add a new email address." msgid "To change the email you must remove and add a new email address."
msgstr "To change the email you must remove and add a new email address." msgstr "To change the email you must remove and add a new email address."
@ -7928,6 +7963,10 @@ msgstr "Upload Document"
msgid "Upload Signature" msgid "Upload Signature"
msgstr "Upload Signature" msgstr "Upload Signature"
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
msgid "Upload Template"
msgstr "Upload Template"
#: packages/ui/primitives/document-upload.tsx #: packages/ui/primitives/document-upload.tsx
#: packages/ui/primitives/document-dropzone.tsx #: packages/ui/primitives/document-dropzone.tsx
msgid "Upload Template Document" msgid "Upload Template Document"
@ -7958,6 +7997,10 @@ msgstr "Uploaded file not an allowed file type"
msgid "Uploading document..." msgid "Uploading document..."
msgstr "Uploading document..." msgstr "Uploading document..."
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
msgid "Uploading template..."
msgstr "Uploading template..."
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
msgid "Use" msgid "Use"
msgstr "Use" msgstr "Use"
@ -7985,6 +8028,10 @@ msgstr "Use your passkey for authentication"
msgid "User" msgid "User"
msgstr "User" msgstr "User"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
msgid "User Agent"
msgstr "User Agent"
#: apps/remix/app/components/forms/password.tsx #: apps/remix/app/components/forms/password.tsx
msgid "User has no password." msgid "User has no password."
msgstr "User has no password." msgstr "User has no password."
@ -8062,10 +8109,6 @@ msgstr "Verify your email to upload documents."
msgid "Verify your team email address" msgid "Verify your team email address"
msgstr "Verify your team email address" msgstr "Verify your team email address"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
msgid "Version History"
msgstr "Version History"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Vertical" msgid "Vertical"
msgstr "Vertical" msgstr "Vertical"
@ -8616,6 +8659,7 @@ msgstr "Write a description to display on your public profile"
msgid "Yearly" msgid "Yearly"
msgstr "Yearly" msgstr "Yearly"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/branding-preferences-form.tsx #: apps/remix/app/components/forms/branding-preferences-form.tsx
@ -9224,6 +9268,10 @@ msgstr "Your team has been successfully deleted."
msgid "Your team has been successfully updated." msgid "Your team has been successfully updated."
msgstr "Your team has been successfully updated." msgstr "Your team has been successfully updated."
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
msgid "Your template failed to upload."
msgstr "Your template failed to upload."
#: apps/remix/app/routes/embed+/v1+/authoring_.completed.create.tsx #: apps/remix/app/routes/embed+/v1+/authoring_.completed.create.tsx
msgid "Your template has been created successfully" msgid "Your template has been created successfully"
msgstr "Your template has been created successfully" msgstr "Your template has been created successfully"
@ -9236,6 +9284,10 @@ msgstr "Your template has been duplicated successfully."
msgid "Your template has been successfully deleted." msgid "Your template has been successfully deleted."
msgstr "Your template has been successfully deleted." msgstr "Your template has been successfully deleted."
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
msgid "Your template has been uploaded successfully. You will be redirected to the template page."
msgstr "Your template has been uploaded successfully. You will be redirected to the template page."
#: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx #: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx
msgid "Your template will be duplicated." msgid "Your template will be duplicated."
msgstr "Your template will be duplicated." msgstr "Your template will be duplicated."

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

@ -134,7 +134,7 @@ export const internalClaims: InternalClaims = {
unlimitedDocuments: true, unlimitedDocuments: true,
allowCustomBranding: true, allowCustomBranding: true,
hidePoweredBy: true, hidePoweredBy: true,
emailDomains: true, emailDomains: false,
embedAuthoring: false, embedAuthoring: false,
embedAuthoringWhiteLabel: true, embedAuthoringWhiteLabel: true,
embedSigning: false, embedSigning: false,

View File

@ -305,87 +305,150 @@ export const formatDocumentAuditLogAction = (
const description = match(auditLog) const description = match(auditLog)
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED }, () => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED }, () => ({
anonymous: msg`A field was added`, anonymous: msg({
message: `A field was added`,
context: `Audit log format`,
}),
identified: msg`${prefix} added a field`, identified: msg`${prefix} added a field`,
})) }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED }, () => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED }, () => ({
anonymous: msg`A field was removed`, anonymous: msg({
message: `A field was removed`,
context: `Audit log format`,
}),
identified: msg`${prefix} removed a field`, identified: msg`${prefix} removed a field`,
})) }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED }, () => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED }, () => ({
anonymous: msg`A field was updated`, anonymous: msg({
message: `A field was updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated a field`, identified: msg`${prefix} updated a field`,
})) }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED }, () => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED }, () => ({
anonymous: msg`A recipient was added`, anonymous: msg({
message: `A recipient was added`,
context: `Audit log format`,
}),
identified: msg`${prefix} added a recipient`, identified: msg`${prefix} added a recipient`,
})) }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED }, () => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED }, () => ({
anonymous: msg`A recipient was removed`, anonymous: msg({
message: `A recipient was removed`,
context: `Audit log format`,
}),
identified: msg`${prefix} removed a recipient`, identified: msg`${prefix} removed a recipient`,
})) }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, () => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, () => ({
anonymous: msg`A recipient was updated`, anonymous: msg({
message: `A recipient was updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated a recipient`, identified: msg`${prefix} updated a recipient`,
})) }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED }, () => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED }, () => ({
anonymous: msg`Document created`, anonymous: msg({
message: `Document created`,
context: `Audit log format`,
}),
identified: msg`${prefix} created the document`, identified: msg`${prefix} created the document`,
})) }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED }, () => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED }, () => ({
anonymous: msg`Document deleted`, anonymous: msg({
message: `Document deleted`,
context: `Audit log format`,
}),
identified: msg`${prefix} deleted the document`, identified: msg`${prefix} deleted the document`,
})) }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, () => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, () => ({
anonymous: msg`Field signed`, anonymous: msg({
message: `Field signed`,
context: `Audit log format`,
}),
identified: msg`${prefix} signed a field`, identified: msg`${prefix} signed a field`,
})) }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, () => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, () => ({
anonymous: msg`Field unsigned`, anonymous: msg({
message: `Field unsigned`,
context: `Audit log format`,
}),
identified: msg`${prefix} unsigned a field`, identified: msg`${prefix} unsigned a field`,
})) }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, () => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, () => ({
anonymous: msg`Field prefilled by assistant`, anonymous: msg({
message: `Field prefilled by assistant`,
context: `Audit log format`,
}),
identified: msg`${prefix} prefilled a field`, identified: msg`${prefix} prefilled a field`,
})) }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED }, () => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED }, () => ({
anonymous: msg`Document visibility updated`, anonymous: msg({
message: `Document visibility updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document visibility`, identified: msg`${prefix} updated the document visibility`,
})) }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED }, () => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED }, () => ({
anonymous: msg`Document access auth updated`, anonymous: msg({
message: `Document access auth updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document access auth requirements`, identified: msg`${prefix} updated the document access auth requirements`,
})) }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED }, () => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED }, () => ({
anonymous: msg`Document signing auth updated`, anonymous: msg({
message: `Document signing auth updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document signing auth requirements`, identified: msg`${prefix} updated the document signing auth requirements`,
})) }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, () => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, () => ({
anonymous: msg`Document updated`, anonymous: msg({
message: `Document updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document`, identified: msg`${prefix} updated the document`,
})) }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED }, () => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED }, () => ({
anonymous: msg`Document opened`, anonymous: msg({
message: `Document opened`,
context: `Audit log format`,
}),
identified: msg`${prefix} opened the document`, identified: msg`${prefix} opened the document`,
})) }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VIEWED }, () => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VIEWED }, () => ({
anonymous: msg`Document viewed`, anonymous: msg({
message: `Document viewed`,
context: `Audit log format`,
}),
identified: msg`${prefix} viewed the document`, identified: msg`${prefix} viewed the document`,
})) }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, () => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, () => ({
anonymous: msg`Document title updated`, anonymous: msg({
message: `Document title updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document title`, identified: msg`${prefix} updated the document title`,
})) }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED }, () => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED }, () => ({
anonymous: msg`Document external ID updated`, anonymous: msg({
message: `Document external ID updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document external ID`, identified: msg`${prefix} updated the document external ID`,
})) }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT }, () => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT }, () => ({
anonymous: msg`Document sent`, anonymous: msg({
message: `Document sent`,
context: `Audit log format`,
}),
identified: msg`${prefix} sent the document`, identified: msg`${prefix} sent the document`,
})) }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM }, () => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM }, () => ({
anonymous: msg`Document moved to team`, anonymous: msg({
message: `Document moved to team`,
context: `Audit log format`,
}),
identified: msg`${prefix} moved the document to team`, identified: msg`${prefix} moved the document to team`,
})) }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, ({ data }) => { .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, ({ data }) => {
@ -420,8 +483,14 @@ export const formatDocumentAuditLogAction = (
: msg`${prefix} sent an email to ${data.recipientEmail}`, : msg`${prefix} sent an email to ${data.recipientEmail}`,
})) }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED }, () => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED }, () => ({
anonymous: msg`Document completed`, anonymous: msg({
identified: msg`Document completed`, message: `Document completed`,
context: `Audit log format`,
}),
identified: msg({
message: `Document completed`,
context: `Audit log format`,
}),
})) }))
.exhaustive(); .exhaustive();

View File

@ -1,5 +1,7 @@
import { type TransportTargetOptions, pino } from 'pino'; import { type TransportTargetOptions, pino } from 'pino';
import type { BaseApiLog } from '../types/api-logs';
import { extractRequestMetadata } from '../universal/extract-request-metadata';
import { env } from './env'; import { env } from './env';
const transports: TransportTargetOptions[] = []; const transports: TransportTargetOptions[] = [];
@ -33,3 +35,31 @@ export const logger = pino({
} }
: undefined, : undefined,
}); });
export const logDocumentAccess = ({
request,
documentId,
userId,
}: {
request: Request;
documentId: number;
userId: number;
}) => {
const metadata = extractRequestMetadata(request);
const data: BaseApiLog = {
ipAddress: metadata.ipAddress,
userAgent: metadata.userAgent,
path: new URL(request.url).pathname,
auth: 'session',
source: 'app',
userId,
};
logger.info({
...data,
input: {
documentId,
},
});
};

View File

@ -120,6 +120,7 @@ export const generateDefaultOrganisationSettings = (): Omit<
includeSenderDetails: true, includeSenderDetails: true,
includeSigningCertificate: true, includeSigningCertificate: true,
includeAuditLog: false,
typedSignatureEnabled: true, typedSignatureEnabled: true,
uploadSignatureEnabled: true, uploadSignatureEnabled: true,

View File

@ -170,6 +170,7 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
includeSenderDetails: null, includeSenderDetails: null,
includeSigningCertificate: null, includeSigningCertificate: null,
includeAuditLog: null,
typedSignatureEnabled: null, typedSignatureEnabled: null,
uploadSignatureEnabled: null, uploadSignatureEnabled: null,

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "OrganisationGlobalSettings" ADD COLUMN "includeAuditLog" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "includeAuditLog" BOOLEAN;

View File

@ -735,13 +735,13 @@ model OrganisationGlobalSettings {
id String @id id String @id
organisation Organisation? organisation Organisation?
documentVisibility DocumentVisibility @default(EVERYONE) documentVisibility DocumentVisibility @default(EVERYONE)
documentLanguage String @default("en") documentLanguage String @default("en")
documentTimezone String? // Nullable to allow using local timezones if not set. includeSenderDetails Boolean @default(true)
documentDateFormat String @default("yyyy-MM-dd hh:mm a") includeSigningCertificate Boolean @default(true)
includeAuditLog Boolean @default(false)
includeSenderDetails Boolean @default(true) documentTimezone String? // Nullable to allow using local timezones if not set.
includeSigningCertificate Boolean @default(true) documentDateFormat String @default("yyyy-MM-dd hh:mm a")
typedSignatureEnabled Boolean @default(true) typedSignatureEnabled Boolean @default(true)
uploadSignatureEnabled Boolean @default(true) uploadSignatureEnabled Boolean @default(true)
@ -772,6 +772,7 @@ model TeamGlobalSettings {
includeSenderDetails Boolean? includeSenderDetails Boolean?
includeSigningCertificate Boolean? includeSigningCertificate Boolean?
includeAuditLog Boolean?
typedSignatureEnabled Boolean? typedSignatureEnabled Boolean?
uploadSignatureEnabled Boolean? uploadSignatureEnabled Boolean?

View File

@ -0,0 +1,93 @@
import { DocumentDataType } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getPresignGetUrl } from '@documenso/lib/universal/upload/server-actions';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { authenticatedProcedure } from '../trpc';
import { ZDownloadDocumentRequestSchema, ZDownloadDocumentResponseSchema } from './schema';
export const downloadDocumentRoute = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/document/{documentId}/download-beta',
summary: 'Download document (beta)',
description: 'Get a pre-signed download URL for the original or signed version of a document',
tags: ['Document'],
},
})
.input(ZDownloadDocumentRequestSchema)
.output(ZDownloadDocumentResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { documentId, version } = input;
ctx.logger.info({
input: {
documentId,
version,
},
});
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document downloads are only available when S3 storage is configured.',
});
}
const document = await getDocumentById({
documentId,
userId: user.id,
teamId,
});
if (!document.documentData) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document data not found',
});
}
if (document.documentData.type !== DocumentDataType.S3_PATH) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document is not stored in S3 and cannot be downloaded via URL.',
});
}
if (version === 'signed' && !isDocumentCompleted(document.status)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document is not completed yet.',
});
}
try {
const documentData =
version === 'original'
? document.documentData.initialData || document.documentData.data
: document.documentData.data;
const { url } = await getPresignGetUrl(documentData);
const baseTitle = document.title.replace(/\.pdf$/, '');
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
const filename = `${baseTitle}${suffix}`;
return {
downloadUrl: url,
filename,
contentType: 'application/pdf',
};
} catch (error) {
ctx.logger.error({
error,
message: 'Failed to generate download URL',
documentId,
version,
});
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to generate download URL',
});
}
});

View File

@ -1,5 +1,4 @@
import { DocumentDataType } from '@prisma/client'; import { DocumentDataType } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { getServerLimits } from '@documenso/ee/server-only/limits/server';
@ -27,6 +26,7 @@ import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-action
import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { authenticatedProcedure, procedure, router } from '../trpc'; import { authenticatedProcedure, procedure, router } from '../trpc';
import { downloadDocumentRoute } from './download-document';
import { findInboxRoute } from './find-inbox'; import { findInboxRoute } from './find-inbox';
import { getInboxCountRoute } from './get-inbox-count'; import { getInboxCountRoute } from './get-inbox-count';
import { import {
@ -63,6 +63,7 @@ export const documentRouter = router({
getCount: getInboxCountRoute, getCount: getInboxCountRoute,
}, },
updateDocument: updateDocumentRoute, updateDocument: updateDocumentRoute,
downloadDocument: downloadDocumentRoute,
/** /**
* @private * @private
@ -636,8 +637,7 @@ export const documentRouter = router({
}).catch(() => null); }).catch(() => null);
if (!document || (teamId && document.teamId !== teamId)) { if (!document || (teamId && document.teamId !== teamId)) {
throw new TRPCError({ throw new AppError(AppErrorCode.UNAUTHORIZED, {
code: 'FORBIDDEN',
message: 'You do not have access to this document.', message: 'You do not have access to this document.',
}); });
} }

View File

@ -295,7 +295,7 @@ export const ZDistributeDocumentRequestSchema = z.object({
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(), redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(), language: ZDocumentMetaLanguageSchema.optional(),
emailId: z.string().nullish(), emailId: z.string().nullish(),
emailReplyTo: z.string().nullish(), emailReplyTo: z.string().email().nullish(),
emailSettings: ZDocumentEmailSettingsSchema.optional(), emailSettings: ZDocumentEmailSettingsSchema.optional(),
}) })
.optional(), .optional(),
@ -346,3 +346,22 @@ export const ZDownloadAuditLogsMutationSchema = z.object({
export const ZDownloadCertificateMutationSchema = z.object({ export const ZDownloadCertificateMutationSchema = z.object({
documentId: z.number(), documentId: z.number(),
}); });
export const ZDownloadDocumentRequestSchema = z.object({
documentId: z.number().describe('The ID of the document to download.'),
version: z
.enum(['original', 'signed'])
.describe(
'The version of the document to download. "signed" returns the completed document with signatures, "original" returns the original uploaded document.',
)
.default('signed'),
});
export const ZDownloadDocumentResponseSchema = z.object({
downloadUrl: z.string().describe('Pre-signed URL for downloading the PDF file'),
filename: z.string().describe('The filename of the PDF file'),
contentType: z.string().describe('MIME type of the file'),
});
export type TDownloadDocumentRequest = z.infer<typeof ZDownloadDocumentRequestSchema>;
export type TDownloadDocumentResponse = z.infer<typeof ZDownloadDocumentResponseSchema>;

View File

@ -62,7 +62,7 @@ export const ZUpdateDocumentRequestSchema = z.object({
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(), uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(), drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
emailId: z.string().nullish(), emailId: z.string().nullish(),
emailReplyTo: z.string().nullish(), emailReplyTo: z.string().email().nullish(),
emailSettings: ZDocumentEmailSettingsSchema.optional(), emailSettings: ZDocumentEmailSettingsSchema.optional(),
}) })
.optional(), .optional(),

View File

@ -54,7 +54,7 @@ export const createSubscriptionRoute = authenticatedProcedure
if (!customerId) { if (!customerId) {
const customer = await createCustomer({ const customer = await createCustomer({
name: organisation.name, name: organisation.owner.name || organisation.owner.email,
email: organisation.owner.email, email: organisation.owner.email,
}); });

View File

@ -77,7 +77,7 @@ export const manageSubscriptionRoute = authenticatedProcedure
// If the customer ID is still missing create a new customer. // If the customer ID is still missing create a new customer.
if (!customerId) { if (!customerId) {
const customer = await createCustomer({ const customer = await createCustomer({
name: organisation.name, name: organisation.owner.name || organisation.owner.email,
email: organisation.owner.email, email: organisation.owner.email,
}); });

View File

@ -1,5 +1,4 @@
import { TRPCError } from '@trpc/server'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createFolder } from '@documenso/lib/server-only/folder/create-folder'; import { createFolder } from '@documenso/lib/server-only/folder/create-folder';
import { deleteFolder } from '@documenso/lib/server-only/folder/delete-folder'; import { deleteFolder } from '@documenso/lib/server-only/folder/delete-folder';
import { findFolders } from '@documenso/lib/server-only/folder/find-folders'; import { findFolders } from '@documenso/lib/server-only/folder/find-folders';
@ -137,8 +136,7 @@ export const folderRouter = router({
type, type,
}); });
} catch (error) { } catch (error) {
throw new TRPCError({ throw new AppError(AppErrorCode.NOT_FOUND, {
code: 'NOT_FOUND',
message: 'Parent folder not found', message: 'Parent folder not found',
}); });
} }
@ -248,8 +246,7 @@ export const folderRouter = router({
type: currentFolder.type, type: currentFolder.type,
}); });
} catch (error) { } catch (error) {
throw new TRPCError({ throw new AppError(AppErrorCode.NOT_FOUND, {
code: 'NOT_FOUND',
message: 'Parent folder not found', message: 'Parent folder not found',
}); });
} }
@ -294,8 +291,7 @@ export const folderRouter = router({
type: FolderType.DOCUMENT, type: FolderType.DOCUMENT,
}); });
} catch (error) { } catch (error) {
throw new TRPCError({ throw new AppError(AppErrorCode.NOT_FOUND, {
code: 'NOT_FOUND',
message: 'Folder not found', message: 'Folder not found',
}); });
} }
@ -340,8 +336,7 @@ export const folderRouter = router({
type: FolderType.TEMPLATE, type: FolderType.TEMPLATE,
}); });
} catch (error) { } catch (error) {
throw new TRPCError({ throw new AppError(AppErrorCode.NOT_FOUND, {
code: 'NOT_FOUND',
message: 'Folder not found', message: 'Folder not found',
}); });
} }

View File

@ -30,6 +30,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
documentDateFormat, documentDateFormat,
includeSenderDetails, includeSenderDetails,
includeSigningCertificate, includeSigningCertificate,
includeAuditLog,
typedSignatureEnabled, typedSignatureEnabled,
uploadSignatureEnabled, uploadSignatureEnabled,
drawSignatureEnabled, drawSignatureEnabled,
@ -117,6 +118,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
documentDateFormat, documentDateFormat,
includeSenderDetails, includeSenderDetails,
includeSigningCertificate, includeSigningCertificate,
includeAuditLog,
typedSignatureEnabled, typedSignatureEnabled,
uploadSignatureEnabled, uploadSignatureEnabled,
drawSignatureEnabled, drawSignatureEnabled,

View File

@ -19,6 +19,7 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
documentDateFormat: ZDocumentMetaDateFormatSchema.optional(), documentDateFormat: ZDocumentMetaDateFormatSchema.optional(),
includeSenderDetails: z.boolean().optional(), includeSenderDetails: z.boolean().optional(),
includeSigningCertificate: z.boolean().optional(), includeSigningCertificate: z.boolean().optional(),
includeAuditLog: z.boolean().optional(),
typedSignatureEnabled: z.boolean().optional(), typedSignatureEnabled: z.boolean().optional(),
uploadSignatureEnabled: z.boolean().optional(), uploadSignatureEnabled: z.boolean().optional(),
drawSignatureEnabled: z.boolean().optional(), drawSignatureEnabled: z.boolean().optional(),

View File

@ -1,6 +1,7 @@
import { RecipientRole } from '@prisma/client'; import { RecipientRole } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import { import {
ZRecipientAccessAuthTypesSchema, ZRecipientAccessAuthTypesSchema,
ZRecipientActionAuthSchema, ZRecipientActionAuthSchema,
@ -186,7 +187,18 @@ export const ZSetTemplateRecipientsRequestSchema = z
recipients: z.array( recipients: z.array(
z.object({ z.object({
nativeId: z.number().optional(), nativeId: z.number().optional(),
email: z.string().toLowerCase().email().min(1), email: z
.string()
.toLowerCase()
.refine(
(email) => {
return (
isTemplateRecipientEmailPlaceholder(email) ||
z.string().email().safeParse(email).success
);
},
{ message: 'Please enter a valid email address' },
),
name: z.string(), name: z.string(),
role: z.nativeEnum(RecipientRole), role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(), signingOrder: z.number().optional(),
@ -196,9 +208,12 @@ export const ZSetTemplateRecipientsRequestSchema = z
}) })
.refine( .refine(
(schema) => { (schema) => {
const emails = schema.recipients.map((recipient) => recipient.email); // Filter out placeholder emails and only check uniqueness for actual emails
const nonPlaceholderEmails = schema.recipients
.map((recipient) => recipient.email)
.filter((email) => !isTemplateRecipientEmailPlaceholder(email));
return new Set(emails).size === emails.length; return new Set(nonPlaceholderEmails).size === nonPlaceholderEmails.length;
}, },
// Dirty hack to handle errors when .root is populated for an array type // Dirty hack to handle errors when .root is populated for an array type
{ message: 'Recipients must have unique emails', path: ['recipients__root'] }, { message: 'Recipients must have unique emails', path: ['recipients__root'] },

View File

@ -32,6 +32,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
documentDateFormat, documentDateFormat,
includeSenderDetails, includeSenderDetails,
includeSigningCertificate, includeSigningCertificate,
includeAuditLog,
typedSignatureEnabled, typedSignatureEnabled,
uploadSignatureEnabled, uploadSignatureEnabled,
drawSignatureEnabled, drawSignatureEnabled,
@ -110,6 +111,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
documentDateFormat, documentDateFormat,
includeSenderDetails, includeSenderDetails,
includeSigningCertificate, includeSigningCertificate,
includeAuditLog,
typedSignatureEnabled, typedSignatureEnabled,
uploadSignatureEnabled, uploadSignatureEnabled,
drawSignatureEnabled, drawSignatureEnabled,

View File

@ -23,6 +23,7 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
documentDateFormat: ZDocumentMetaDateFormatSchema.nullish(), documentDateFormat: ZDocumentMetaDateFormatSchema.nullish(),
includeSenderDetails: z.boolean().nullish(), includeSenderDetails: z.boolean().nullish(),
includeSigningCertificate: z.boolean().nullish(), includeSigningCertificate: z.boolean().nullish(),
includeAuditLog: z.boolean().nullish(),
typedSignatureEnabled: z.boolean().nullish(), typedSignatureEnabled: z.boolean().nullish(),
uploadSignatureEnabled: z.boolean().nullish(), uploadSignatureEnabled: z.boolean().nullish(),
drawSignatureEnabled: z.boolean().nullish(), drawSignatureEnabled: z.boolean().nullish(),

View File

@ -1,6 +1,5 @@
import type { Document } from '@prisma/client'; import type { Document } from '@prisma/client';
import { DocumentDataType } from '@prisma/client'; import { DocumentDataType } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
@ -556,9 +555,9 @@ export const templateRouter = router({
}); });
if (csv.length > 4 * 1024 * 1024) { if (csv.length > 4 * 1024 * 1024) {
throw new TRPCError({ throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
code: 'BAD_REQUEST',
message: 'File size exceeds 4MB limit', message: 'File size exceeds 4MB limit',
statusCode: 400,
}); });
} }
@ -569,8 +568,7 @@ export const templateRouter = router({
}); });
if (!template) { if (!template) {
throw new TRPCError({ throw new AppError(AppErrorCode.NOT_FOUND, {
code: 'NOT_FOUND',
message: 'Template not found', message: 'Template not found',
}); });
} }

View File

@ -65,7 +65,7 @@ export const ZTemplateMetaUpsertSchema = z.object({
dateFormat: ZDocumentMetaDateFormatSchema.optional(), dateFormat: ZDocumentMetaDateFormatSchema.optional(),
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(), distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
emailId: z.string().nullish(), emailId: z.string().nullish(),
emailReplyTo: z.string().nullish(), emailReplyTo: z.string().email().nullish(),
emailSettings: ZDocumentEmailSettingsSchema.optional(), emailSettings: ZDocumentEmailSettingsSchema.optional(),
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(), redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(), language: ZDocumentMetaLanguageSchema.optional(),

View File

@ -7,6 +7,7 @@ import { SigningStatus } from '@prisma/client';
import { Clock, EyeOffIcon } from 'lucide-react'; import { Clock, EyeOffIcon } from 'lucide-react';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document'; import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n'; import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
@ -21,6 +22,18 @@ import { PopoverHover } from '@documenso/ui/primitives/popover';
import { getRecipientColorStyles } from '../../lib/recipient-colors'; import { getRecipientColorStyles } from '../../lib/recipient-colors';
import { FieldContent } from '../../primitives/document-flow/field-content'; import { FieldContent } from '../../primitives/document-flow/field-content';
const getRecipientDisplayText = (recipient: { name: string; email: string }) => {
if (recipient.name && !isTemplateRecipientEmailPlaceholder(recipient.email)) {
return `${recipient.name} (${recipient.email})`;
}
if (recipient.name && isTemplateRecipientEmailPlaceholder(recipient.email)) {
return recipient.name;
}
return recipient.email;
};
export type DocumentReadOnlyFieldsProps = { export type DocumentReadOnlyFieldsProps = {
fields: DocumentField[]; fields: DocumentField[];
documentMeta?: Pick<DocumentMeta | TemplateMeta, 'dateFormat'>; documentMeta?: Pick<DocumentMeta | TemplateMeta, 'dateFormat'>;
@ -145,9 +158,7 @@ export const DocumentReadOnlyFields = ({
</p> </p>
<p className="text-muted-foreground mt-1 text-center text-xs"> <p className="text-muted-foreground mt-1 text-center text-xs">
{field.recipient.name {getRecipientDisplayText(field.recipient)}
? `${field.recipient.name} (${field.recipient.email})`
: field.recipient.email}{' '}
</p> </p>
<button <button

View File

@ -65,7 +65,11 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'bg-background animate-in data-[state=open]:fade-in-90 sm:zoom-in-90 data-[state=open]:slide-in-from-bottom-10 data-[state=open]:sm:slide-in-from-bottom-0 fixed z-50 grid w-full gap-4 rounded-b-lg border p-6 shadow-lg sm:max-w-lg sm:rounded-lg', 'bg-background animate-in data-[state=open]:fade-in-90 sm:zoom-in-90 data-[state=open]:slide-in-from-bottom-10 data-[state=open]:sm:slide-in-from-bottom-0 fixed z-50 grid w-full gap-4 border p-6 shadow-lg sm:max-w-lg sm:rounded-lg',
{
'rounded-b-xl': position === 'start',
'rounded-t-xl': position === 'end',
},
className, className,
)} )}
{...props} {...props}

View File

@ -6,7 +6,10 @@ import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-emai
export const ZAddSubjectFormSchema = z.object({ export const ZAddSubjectFormSchema = z.object({
meta: z.object({ meta: z.object({
emailId: z.string().nullable(), emailId: z.string().nullable(),
emailReplyTo: z.string().email().optional(), emailReplyTo: z.preprocess(
(val) => (val === '' ? undefined : val),
z.string().email().optional(),
),
// emailReplyName: z.string().optional(), // emailReplyName: z.string().optional(),
subject: z.string(), subject: z.string(),
message: z.string(), message: z.string(),

View File

@ -160,14 +160,14 @@ export const FieldContent = ({ field, documentMeta }: FieldIconProps) => {
); );
} }
let textToDisplay = fieldMeta?.label || _(FRIENDLY_FIELD_TYPE[type]) || ''; const labelToDisplay = fieldMeta?.label || _(FRIENDLY_FIELD_TYPE[type]) || '';
let textToDisplay: string | undefined;
const isSignatureField = const isSignatureField =
field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE; field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE;
// Trim default labels. if (field.type === FieldType.TEXT && field.fieldMeta?.type === 'text' && field.fieldMeta?.text) {
if (textToDisplay.length > 20) { textToDisplay = field.fieldMeta.text;
textToDisplay = textToDisplay.substring(0, 20) + '...';
} }
if (field.inserted) { if (field.inserted) {
@ -190,18 +190,19 @@ export const FieldContent = ({ field, documentMeta }: FieldIconProps) => {
const textAlign = fieldMeta && 'textAlign' in fieldMeta ? fieldMeta.textAlign : 'left'; const textAlign = fieldMeta && 'textAlign' in fieldMeta ? fieldMeta.textAlign : 'left';
return ( return (
<div <div className="flex h-full w-full items-center overflow-hidden">
className={cn( <p
'text-field-card-foreground flex h-full w-full items-center justify-center gap-x-1.5 overflow-clip whitespace-nowrap text-center text-[clamp(0.07rem,25cqw,0.825rem)]', className={cn(
{ 'text-foreground w-full whitespace-pre-wrap text-left text-[clamp(0.07rem,25cqw,0.825rem)] duration-200',
// Using justify instead of align because we also vertically center the text. {
'justify-start': field.inserted && !isSignatureField && textAlign === 'left', '!text-center': textAlign === 'center' || !textToDisplay,
'justify-end': field.inserted && !isSignatureField && textAlign === 'right', '!text-right': textAlign === 'right',
'font-signature text-[clamp(0.07rem,25cqw,1.125rem)]': isSignatureField, 'font-signature text-[clamp(0.07rem,25cqw,1.125rem)]': isSignatureField,
}, },
)} )}
> >
{textToDisplay} {textToDisplay || labelToDisplay}
</p>
</div> </div>
); );
}; };

View File

@ -96,7 +96,7 @@ export const DocumentDropzone = ({
return ( return (
<Button loading={loading} aria-disabled={disabled} {...getRootProps()} {...props}> <Button loading={loading} aria-disabled={disabled} {...getRootProps()} {...props}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input {...getInputProps()} /> <input data-testid="document-upload-input" {...getInputProps()} />
{!loading && <Upload className="h-4 w-4" />} {!loading && <Upload className="h-4 w-4" />}
{disabled ? _(disabledMessage) : _(heading[type])} {disabled ? _(disabledMessage) : _(heading[type])}
</div> </div>

View File

@ -24,6 +24,7 @@ import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-c
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element'; import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import { import {
type TFieldMetaSchema as FieldMeta, type TFieldMetaSchema as FieldMeta,
ZFieldMetaSchema, ZFieldMetaSchema,
@ -593,15 +594,21 @@ export const AddTemplateFieldsFormPartial = ({
selectedSignerStyles?.comboxBoxTrigger, selectedSignerStyles?.comboxBoxTrigger,
)} )}
> >
{selectedSigner?.email && ( {selectedSigner?.email &&
<span className="flex-1 truncate text-left"> !isTemplateRecipientEmailPlaceholder(selectedSigner.email) && (
{selectedSigner?.name} ({selectedSigner?.email}) <span className="flex-1 truncate text-left">
</span> {selectedSigner?.name} ({selectedSigner?.email})
)} </span>
)}
{selectedSigner?.email &&
isTemplateRecipientEmailPlaceholder(selectedSigner.email) && (
<span className="flex-1 truncate text-left">{selectedSigner?.name}</span>
)}
{!selectedSigner?.email && ( {!selectedSigner?.email && (
<span className="gradie flex-1 truncate text-left"> <span className="gradie flex-1 truncate text-left">
{selectedSigner?.email} No recipient selected
</span> </span>
)} )}
@ -657,15 +664,22 @@ export const AddTemplateFieldsFormPartial = ({
'text-foreground/80': recipient === selectedSigner, 'text-foreground/80': recipient === selectedSigner,
})} })}
> >
{recipient.name && ( {recipient.name &&
<span title={`${recipient.name} (${recipient.email})`}> !isTemplateRecipientEmailPlaceholder(recipient.email) && (
{recipient.name} ({recipient.email}) <span title={`${recipient.name} (${recipient.email})`}>
</span> {recipient.name} ({recipient.email})
)} </span>
)}
{!recipient.name && ( {recipient.name &&
<span title={recipient.email}>{recipient.email}</span> isTemplateRecipientEmailPlaceholder(recipient.email) && (
)} <span title={recipient.name}>{recipient.name}</span>
)}
{!recipient.name &&
!isTemplateRecipientEmailPlaceholder(recipient.email) && (
<span title={recipient.email}>{recipient.email}</span>
)}
</span> </span>
</CommandItem> </CommandItem>
))} ))}

View File

@ -14,6 +14,7 @@ import { useFieldArray, useForm } from 'react-hook-form';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { generateRecipientPlaceholder } from '@documenso/lib/utils/templates'; import { generateRecipientPlaceholder } from '@documenso/lib/utils/templates';
@ -247,62 +248,6 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
[form, watchedSigners, toast], [form, watchedSigners, toast],
); );
const triggerDragAndDrop = useCallback(
(fromIndex: number, toIndex: number) => {
if (!$sensorApi.current) {
return;
}
const draggableId = signers[fromIndex].id;
const preDrag = $sensorApi.current.tryGetLock(draggableId);
if (!preDrag) {
return;
}
const drag = preDrag.snapLift();
setTimeout(() => {
// Move directly to the target index
if (fromIndex < toIndex) {
for (let i = fromIndex; i < toIndex; i++) {
drag.moveDown();
}
} else {
for (let i = fromIndex; i > toIndex; i--) {
drag.moveUp();
}
}
setTimeout(() => {
drag.drop();
}, 500);
}, 0);
},
[signers],
);
const updateSigningOrders = useCallback(
(newIndex: number, oldIndex: number) => {
const updatedSigners = form.getValues('signers').map((signer, index) => {
if (index === oldIndex) {
return { ...signer, signingOrder: newIndex + 1 };
} else if (index >= newIndex && index < oldIndex) {
return { ...signer, signingOrder: (signer.signingOrder ?? index + 1) + 1 };
} else if (index <= newIndex && index > oldIndex) {
return { ...signer, signingOrder: Math.max(1, (signer.signingOrder ?? index + 1) - 1) };
}
return signer;
});
updatedSigners.forEach((signer, index) => {
form.setValue(`signers.${index}.signingOrder`, signer.signingOrder);
});
},
[form],
);
const handleSigningOrderChange = useCallback( const handleSigningOrderChange = useCallback(
(index: number, newOrderString: string) => { (index: number, newOrderString: string) => {
const trimmedOrderString = newOrderString.trim(); const trimmedOrderString = newOrderString.trim();
@ -592,7 +537,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
})} })}
> >
{!showAdvancedSettings && index === 0 && ( {!showAdvancedSettings && index === 0 && (
<FormLabel required> <FormLabel>
<Trans>Email</Trans> <Trans>Email</Trans>
</FormLabel> </FormLabel>
)} )}
@ -602,6 +547,11 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
type="email" type="email"
placeholder={_(msg`Email`)} placeholder={_(msg`Email`)}
{...field} {...field}
value={
isTemplateRecipientEmailPlaceholder(field.value)
? ''
: field.value
}
disabled={ disabled={
field.disabled || field.disabled ||
isSubmitting || isSubmitting ||

View File

@ -1,6 +1,7 @@
import { DocumentSigningOrder, RecipientRole } from '@prisma/client'; import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
import { TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX } from '@documenso/lib/constants/template';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth'; import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
export const ZAddTemplatePlacholderRecipientsFormSchema = z export const ZAddTemplatePlacholderRecipientsFormSchema = z
@ -10,7 +11,7 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z
formId: z.string().min(1), formId: z.string().min(1),
nativeId: z.number().optional(), nativeId: z.number().optional(),
email: z.string().min(1).email(), email: z.string().min(1).email(),
name: z.string(), name: z.string().min(1, { message: 'Name is required' }),
role: z.nativeEnum(RecipientRole), role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(), signingOrder: z.number().optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
@ -21,12 +22,25 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z
}) })
.refine( .refine(
(schema) => { (schema) => {
const emails = schema.signers.map((signer) => signer.email.toLowerCase()); const nonPlaceholderEmails = schema.signers
.map((signer) => signer.email.toLowerCase())
.filter((email) => !TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX.test(email));
return new Set(emails).size === emails.length; return new Set(nonPlaceholderEmails).size === nonPlaceholderEmails.length;
}, },
// Dirty hack to handle errors when .root is populated for an array type // Dirty hack to handle errors when .root is populated for an array type
{ message: 'Signers must have unique emails', path: ['signers__root'] }, { message: 'Signers must have unique emails', path: ['signers__root'] },
)
.refine(
/*
Since placeholder emails are empty, we need to check that the names are unique.
If we don't do this, the app will add duplicate signers and merge them in the next step, where you add fields.
*/
(schema) => {
const names = schema.signers.map((signer) => signer.name.trim());
return new Set(names).size === names.length;
},
{ message: 'Signers must have unique names', path: ['signers__root'] },
); );
export type TAddTemplatePlacholderRecipientsFormSchema = z.infer< export type TAddTemplatePlacholderRecipientsFormSchema = z.infer<

View File

@ -49,7 +49,10 @@ export const ZAddTemplateSettingsFormSchema = z.object({
.optional() .optional()
.default('en'), .default('en'),
emailId: z.string().nullable(), emailId: z.string().nullable(),
emailReplyTo: z.string().optional(), emailReplyTo: z.preprocess(
(val) => (val === '' ? undefined : val),
z.string().email().optional(),
),
emailSettings: ZDocumentEmailSettingsSchema, emailSettings: ZDocumentEmailSettingsSchema,
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, { signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
message: msg`At least one signature type must be enabled`.id, message: msg`At least one signature type must be enabled`.id,