This commit is contained in:
David Nguyen
2026-02-12 11:14:36 +11:00
parent ab3e8a4074
commit 6541f2778b
27 changed files with 1778 additions and 890 deletions
@@ -0,0 +1,215 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { P, match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
type EnvelopeDeleteDialogProps = {
id: string;
type: EnvelopeType;
trigger?: React.ReactNode;
onDelete?: () => Promise<void> | void;
status: DocumentStatus;
title: string;
canManageDocument: boolean;
};
export const EnvelopeDeleteDialog = ({
id,
type,
trigger,
onDelete,
status,
title,
canManageDocument,
}: EnvelopeDeleteDialogProps) => {
const { toast } = useToast();
const { refreshLimits } = useLimits();
const { t } = useLingui();
const deleteMessage = msg`delete`;
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState('');
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
const { mutateAsync: deleteEnvelope, isPending } = trpcReact.envelope.delete.useMutation({
onSuccess: async () => {
void refreshLimits();
toast({
title: t`Document deleted`,
description: t`"${title}" has been successfully deleted`,
duration: 5000,
});
await onDelete?.();
setOpen(false);
},
onError: () => {
toast({
title: t`Something went wrong`,
description: t`This document could not be deleted at this time. Please try again.`,
variant: 'destructive',
duration: 7500,
});
},
});
useEffect(() => {
if (open) {
setInputValue('');
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
}
}, [open, status]);
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
setIsDeleteEnabled(event.target.value === t(deleteMessage));
};
return (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription>
{canManageDocument ? (
<Trans>
You are about to delete <strong>"{title}"</strong>
</Trans>
) : (
<Trans>
You are about to hide <strong>"{title}"</strong>
</Trans>
)}
</DialogDescription>
</DialogHeader>
{canManageDocument ? (
<Alert variant="warning" className="-mt-1">
{match(status)
.with(DocumentStatus.DRAFT, () => (
<AlertDescription>
{type === EnvelopeType.DOCUMENT ? (
<Trans>
Please note that this action is <strong>irreversible</strong>. Once confirmed,
this document will be permanently deleted.
</Trans>
) : (
<Trans>
Please note that this action is <strong>irreversible</strong>. Once confirmed,
this template will be permanently deleted.
</Trans>
)}
</AlertDescription>
))
.with(DocumentStatus.PENDING, () => (
<AlertDescription>
<p>
<Trans>
Please note that this action is <strong>irreversible</strong>.
</Trans>
</p>
<p className="mt-1">
<Trans>Once confirmed, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>
<Trans>Document will be permanently deleted</Trans>
</li>
<li>
<Trans>Document signing process will be cancelled</Trans>
</li>
<li>
<Trans>All inserted signatures will be voided</Trans>
</li>
<li>
<Trans>All recipients will be notified</Trans>
</li>
</ul>
</AlertDescription>
))
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
<AlertDescription>
<p>
<Trans>By deleting this document, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>
<Trans>The document will be hidden from your account</Trans>
</li>
<li>
<Trans>Recipients will still retain their copy of the document</Trans>
</li>
</ul>
</AlertDescription>
))
.exhaustive()}
</Alert>
) : (
<Alert variant="warning" className="-mt-1">
<AlertDescription>
<Trans>Please contact support if you would like to revert this action.</Trans>
</AlertDescription>
</Alert>
)}
{status !== DocumentStatus.DRAFT && canManageDocument && (
<Input
type="text"
value={inputValue}
onChange={onInputChange}
placeholder={t`Please type ${`'${t(deleteMessage)}'`} to confirm`}
/>
)}
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
type="button"
loading={isPending}
onClick={() => void deleteEnvelope({ envelopeId: id })}
disabled={!isDeleteEnabled && canManageDocument}
variant="destructive"
>
{canManageDocument ? <Trans>Delete</Trans> : <Trans>Hide</Trans>}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -13,6 +13,7 @@ import * as z from 'zod';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
@@ -116,10 +117,15 @@ export const EnvelopeDistributeDialog = ({
} = form;
const { data: emailData, isLoading: isLoadingEmails } =
trpc.enterprise.organisation.email.find.useQuery({
organisationId: organisation.id,
perPage: 100,
});
trpc.enterprise.organisation.email.find.useQuery(
{
organisationId: organisation.id,
perPage: 100,
},
{
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const emails = emailData?.data || [];
@@ -8,6 +8,7 @@ import { Paperclip, Plus, X } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -49,9 +50,16 @@ export const DocumentAttachmentsPopover = ({
const utils = trpc.useUtils();
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
envelopeId,
});
const { data: attachments } = trpc.envelope.attachment.find.useQuery(
{
envelopeId,
},
{
// Note: The invalidation of the query is manually handled by the onSuccess
// callbacks below for create and delete mutations.
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const { mutateAsync: createAttachment, isPending: isCreating } =
trpc.envelope.attachment.create.useMutation({
@@ -143,7 +151,7 @@ export const DocumentAttachmentsPopover = ({
<h4 className="font-medium">
<Trans>Attachments</Trans>
</h4>
<p className="text-muted-foreground mt-1 text-sm">
<p className="mt-1 text-sm text-muted-foreground">
<Trans>Add links to relevant documents or resources.</Trans>
</p>
</div>
@@ -153,7 +161,7 @@ export const DocumentAttachmentsPopover = ({
{attachments?.data.map((attachment) => (
<div
key={attachment.id}
className="border-border flex items-center justify-between rounded-md border p-2"
className="flex items-center justify-between rounded-md border border-border p-2"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{attachment.label}</p>
@@ -161,7 +169,7 @@ export const DocumentAttachmentsPopover = ({
href={attachment.data}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground truncate text-xs underline"
className="truncate text-xs text-muted-foreground underline hover:text-foreground"
>
{attachment.data}
</a>
@@ -108,10 +108,10 @@ export const DocumentCertificateQRView = ({
version="current"
envelope={{
id: envelopeItems[0].envelopeId,
envelopeItems,
status: DocumentStatus.COMPLETED,
type: EnvelopeType.DOCUMENT,
}}
envelopeItems={envelopeItems}
token={token}
>
<DocumentCertificateQrV2
@@ -75,7 +75,7 @@ export const EnvelopeEditorFieldsPage = () => {
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
const { envelope, editorFields, relativePath, editorConfig } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@@ -97,14 +97,10 @@ export const EnvelopeEditorFieldsPage = () => {
const isMetaSame = isDeepEqual(selectedField.fieldMeta, fieldMeta);
// Todo: Envelopes - Clean up console logs.
if (!isMetaSame) {
console.log('TRIGGER UPDATE');
editorFields.updateFieldByFormId(selectedField.formId, {
fieldMeta,
});
} else {
console.log('DATA IS SAME, NO UPDATE');
}
};
@@ -250,36 +246,40 @@ export const EnvelopeEditorFieldsPage = () => {
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
{editorConfig.fields?.allowAIDetection && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</>
)}
</section>
{/* Field details section. */}
@@ -30,21 +30,56 @@ import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
export default function EnvelopeEditorHeader() {
const { t } = useLingui();
const { envelope, isDocument, isTemplate, updateEnvelope, autosaveError, relativePath } =
useCurrentEnvelopeEditor();
const {
envelope,
isDocument,
isTemplate,
isEmbedded,
updateEnvelope,
autosaveError,
relativePath,
editorConfig,
flushAutosave,
} = useCurrentEnvelopeEditor();
const {
embeded,
general: { allowConfigureEnvelopeTitle },
actions: { allowAttachments, allowDistributing },
} = editorConfig;
const handleCreateEmbeddedEnvelope = async () => {
const latestEnvelope = await flushAutosave();
embeded?.onCreate?.(latestEnvelope);
};
const handleUpdateEmbeddedEnvelope = async () => {
const latestEnvelope = await flushAutosave();
embeded?.onUpdate?.(latestEnvelope);
};
return (
<nav className="w-full border-b border-border bg-background px-4 py-3 md:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Link to="/">
<BrandingLogo className="h-6 w-auto" />
</Link>
{editorConfig.embeded?.customBrandingLogo ? (
<img
src={`/api/branding/logo/team/${envelope.teamId}`}
alt="Logo"
className="h-6 w-auto"
/>
) : (
<Link to="/">
<BrandingLogo className="h-6 w-auto" />
</Link>
)}
<Separator orientation="vertical" className="h-6" />
<div className="flex items-center space-x-2">
<EnvelopeItemTitleInput
disabled={envelope.status !== DocumentStatus.DRAFT}
disabled={envelope.status !== DocumentStatus.DRAFT || !allowConfigureEnvelopeTitle}
value={envelope.title}
onChange={(title) => {
updateEnvelope({
@@ -127,54 +162,72 @@ export default function EnvelopeEditorHeader() {
</div>
<div className="flex items-center space-x-2">
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
<EnvelopeEditorSettingsDialog
trigger={
<Button variant="outline" size="sm">
<SettingsIcon className="h-4 w-4" />
</Button>
}
/>
{isDocument && (
<>
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send Document</Trans>
</Button>
}
/>
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Resend Document</Trans>
</Button>
}
/>
</>
{allowAttachments && (
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
)}
{isTemplate && (
<TemplateUseDialog
envelopeId={envelope.id}
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
templateSigningOrder={envelope.documentMeta?.signingOrder}
recipients={envelope.recipients}
documentRootPath={relativePath.documentRootPath}
{editorConfig.settings && (
<EnvelopeEditorSettingsDialog
trigger={
<Button size="sm">
<Trans>Use Template</Trans>
<Button variant="outline" size="sm">
<SettingsIcon className="h-4 w-4" />
</Button>
}
/>
)}
{match({ isEmbedded, isDocument, isTemplate, allowDistributing })
.with({ isEmbedded: false, isDocument: true, allowDistributing: true }, () => (
<>
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send Document</Trans>
</Button>
}
/>
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Resend Document</Trans>
</Button>
}
/>
</>
))
.with({ isEmbedded: false, isTemplate: true, allowDistributing: true }, () => (
<TemplateUseDialog
envelopeId={envelope.id}
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
templateSigningOrder={envelope.documentMeta?.signingOrder}
recipients={envelope.recipients}
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<Trans>Use Template</Trans>
</Button>
}
/>
))
.otherwise(() => null)}
{embeded?.mode === 'create' && (
<Button size="sm" onClick={handleCreateEmbeddedEnvelope}>
{isDocument ? <Trans>Create Document</Trans> : <Trans>Create Template</Trans>}
</Button>
)}
{embeded?.mode === 'edit' && (
<Button size="sm" onClick={handleUpdateEmbeddedEnvelope}>
{isDocument ? <Trans>Update Document</Trans> : <Trans>Update Template</Trans>}
</Button>
)}
</div>
</div>
</nav>
@@ -205,6 +205,7 @@ export const EnvelopeEditorPreviewPage = () => {
<EnvelopeRenderProvider
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={undefined}
fields={fieldsWithPlaceholders}
recipients={envelope.recipients.map((recipient) => ({
@@ -21,7 +21,7 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
import { ZEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
@@ -63,8 +63,14 @@ import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-de
import { useCurrentTeam } from '~/providers/team';
export const EnvelopeEditorRecipientForm = () => {
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients } =
useCurrentEnvelopeEditor();
const {
envelope,
setRecipientsDebounced,
updateEnvelope,
editorRecipients,
isEmbedded,
editorConfig,
} = useCurrentEnvelopeEditor();
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
@@ -72,7 +78,9 @@ export const EnvelopeEditorRecipientForm = () => {
const { t } = useLingui();
const { toast } = useToast();
const { remaining } = useLimits();
const { user } = useSession();
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const [searchParams, setSearchParams] = useSearchParams();
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
@@ -603,37 +611,41 @@ export const EnvelopeEditorRecipientForm = () => {
</div>
<div className="flex flex-row items-center space-x-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
type="button"
size="sm"
disabled={isSubmitting}
onClick={onDetectRecipientsClick}
>
<SparklesIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
{editorConfig.recipients?.allowAIDetection && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
type="button"
size="sm"
disabled={isSubmitting}
onClick={onDetectRecipientsClick}
>
<SparklesIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{team.preferences.aiFeaturesEnabled ? (
<Trans>Detect recipients with AI</Trans>
) : (
<Trans>Enable AI detection</Trans>
)}
</TooltipContent>
</Tooltip>
<TooltipContent>
{team.preferences.aiFeaturesEnabled ? (
<Trans>Detect recipients with AI</Trans>
) : (
<Trans>Enable AI detection</Trans>
)}
</TooltipContent>
</Tooltip>
)}
<Button
variant="outline"
className="flex flex-row items-center"
size="sm"
disabled={isSubmitting || isUserAlreadyARecipient}
onClick={() => onAddSelfSigner()}
>
<Trans>Add Myself</Trans>
</Button>
{!isEmbedded && (
<Button
variant="outline"
className="flex flex-row items-center"
size="sm"
disabled={isSubmitting || isUserAlreadyARecipient}
onClick={() => onAddSelfSigner()}
>
<Trans>Add Myself</Trans>
</Button>
)}
<Button
variant="outline"
@@ -652,7 +664,13 @@ export const EnvelopeEditorRecipientForm = () => {
<CardContent>
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
<Form {...form}>
<div className="-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4">
<div
className={cn('-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4', {
hidden:
!editorConfig.recipients?.allowConfigureSigningOrder &&
!organisation.organisationClaim.flags.cfr21,
})}
>
{organisation.organisationClaim.flags.cfr21 && (
<div className="flex flex-row items-center">
<Checkbox
@@ -670,64 +688,66 @@ export const EnvelopeEditorRecipientForm = () => {
</div>
)}
<FormField
control={form.control}
name="signingOrder"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="signingOrder"
checked={field.value === DocumentSigningOrder.SEQUENTIAL}
onCheckedChange={(checked) => {
if (!checked && hasAssistantRole) {
setShowSigningOrderConfirmation(true);
return;
}
{editorConfig.recipients?.allowConfigureSigningOrder && (
<FormField
control={form.control}
name="signingOrder"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="signingOrder"
checked={field.value === DocumentSigningOrder.SEQUENTIAL}
onCheckedChange={(checked) => {
if (!checked && hasAssistantRole) {
setShowSigningOrderConfirmation(true);
return;
}
field.onChange(
checked
? DocumentSigningOrder.SEQUENTIAL
: DocumentSigningOrder.PARALLEL,
);
field.onChange(
checked
? DocumentSigningOrder.SEQUENTIAL
: DocumentSigningOrder.PARALLEL,
);
// If sequential signing is turned off, disable dictate next signer
if (!checked) {
form.setValue('allowDictateNextSigner', false, {
shouldValidate: true,
shouldDirty: true,
});
}
}}
disabled={isSubmitting || hasDocumentBeenSent}
/>
</FormControl>
// If sequential signing is turned off, disable dictate next signer
if (!checked) {
form.setValue('allowDictateNextSigner', false, {
shouldValidate: true,
shouldDirty: true,
});
}
}}
disabled={isSubmitting || hasDocumentBeenSent}
/>
</FormControl>
<div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
<FormLabel
htmlFor="signingOrder"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Enable signing order</Trans>
</FormLabel>
<div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
<FormLabel
htmlFor="signingOrder"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Enable signing order</Trans>
</FormLabel>
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-1 cursor-help text-muted-foreground">
<HelpCircleIcon className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 p-4">
<p>
<Trans>Add 2 or more signers to enable signing order.</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
</FormItem>
)}
/>
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-1 cursor-help text-muted-foreground">
<HelpCircleIcon className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 p-4">
<p>
<Trans>Add 2 or more signers to enable signing order.</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
</FormItem>
)}
/>
)}
{isSigningOrderSequential && (
<FormField
@@ -987,6 +1007,16 @@ export const EnvelopeEditorRecipientForm = () => {
<FormControl>
<RecipientRoleSelect
{...field}
hideAssistantRole={
!editorConfig.recipients?.allowAssistantRole
}
hideCCerRole={!editorConfig.recipients?.allowCCerRole}
hideViewerRole={
!editorConfig.recipients?.allowViewerRole
}
hideApproverRole={
!editorConfig.recipients?.allowApproverRole
}
isAssistantEnabled={isSigningOrderSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@@ -1083,13 +1113,15 @@ export const EnvelopeEditorRecipientForm = () => {
onConfirm={handleSigningOrderDisable}
/>
<AiRecipientDetectionDialog
open={isAiDialogOpen}
onOpenChange={onAiDialogOpenChange}
onComplete={onAiDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
{editorConfig.recipients?.allowAIDetection && (
<AiRecipientDetectionDialog
open={isAiDialogOpen}
onOpenChange={onAiDialogOpenChange}
onComplete={onAiDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
)}
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
@@ -0,0 +1,28 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
export const EnvelopeEditorRenderProviderWrapper = ({
children,
token,
presignedToken,
}: {
children: React.ReactNode;
token?: string;
presignedToken?: string;
}) => {
const { envelope } = useCurrentEnvelopeEditor();
return (
<EnvelopeRenderProvider
envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={token}
presignToken={presignedToken}
version="current"
fields={envelope.fields}
recipients={envelope.recipients}
>
{children}
</EnvelopeRenderProvider>
);
};
@@ -28,6 +28,7 @@ import {
isValidLanguageCode,
} from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import {
ZDocumentAccessAuthTypesSchema,
@@ -174,7 +175,9 @@ export const EnvelopeEditorSettingsDialog = ({
const { t, i18n } = useLingui();
const { toast } = useToast();
const { envelope, updateEnvelopeAsync } = useCurrentEnvelopeEditor();
const { envelope, updateEnvelopeAsync, editorConfig } = useCurrentEnvelopeEditor();
const { settings } = editorConfig;
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
@@ -223,10 +226,15 @@ export const EnvelopeEditorSettingsDialog = ({
const emailSettings = form.watch('meta.emailSettings');
const { data: emailData, isLoading: isLoadingEmails } =
trpc.enterprise.organisation.email.find.useQuery({
organisationId: organisation.id,
perPage: 100,
});
trpc.enterprise.organisation.email.find.useQuery(
{
organisationId: organisation.id,
perPage: 100,
},
{
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const emails = emailData?.data || [];
@@ -319,7 +327,7 @@ export const EnvelopeEditorSettingsDialog = ({
const selectedTab = tabs.find((tab) => tab.id === activeTab);
if (!selectedTab) {
if (!selectedTab || !settings) {
return null;
}
@@ -347,19 +355,25 @@ export const EnvelopeEditorSettingsDialog = ({
</DialogHeader>
<nav className="col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 px-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2">
{tabs.map((tab) => (
<Button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
variant="ghost"
className={cn('w-full justify-start', {
'bg-secondary': activeTab === tab.id,
})}
>
<tab.icon className="mr-2 h-5 w-5" />
{t(tab.title)}
</Button>
))}
{tabs.map((tab) => {
if (tab.id === 'email' && !settings.allowConfigureDistribution) {
return null;
}
return (
<Button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
variant="ghost"
className={cn('w-full justify-start', {
'bg-secondary': activeTab === tab.id,
})}
>
<tab.icon className="mr-2 h-5 w-5" />
{t(tab.title)}
</Button>
);
})}
</nav>
</div>
@@ -377,137 +391,151 @@ export const EnvelopeEditorSettingsDialog = ({
disabled={form.formState.isSubmitting}
key={activeTab}
>
{match(activeTab)
.with('general', () => (
{match({ activeTab, settings })
.with({ activeTab: 'general' }, () => (
<>
<FormField
control={form.control}
name="meta.language"
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center">
<Trans>Language</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
{settings.allowConfigureLanguage && (
<FormField
control={form.control}
name="meta.language"
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center">
<Trans>Language</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<Trans>
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.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<Trans>
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.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<FormControl>
<Select
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
<SelectItem key={code} value={code}>
{language.full}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.signatureTypes"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Allowed Signature Types</Trans>
<DocumentSignatureSettingsTooltip />
</FormLabel>
<SelectContent>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
<SelectItem key={code} value={code}>
{language.full}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
label: t(option.label),
value: option.value,
}))}
selectedValues={field.value}
onChange={field.onChange}
className="w-full bg-background"
emptySelectionPlaceholder="Select signature types"
/>
</FormControl>
{settings.allowConfigureSignatureTypes && (
<FormField
control={form.control}
name="meta.signatureTypes"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Allowed Signature Types</Trans>
<DocumentSignatureSettingsTooltip />
</FormLabel>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.dateFormat"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Date Format</Trans>
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map(
(option) => ({
label: t(option.label),
value: option.value,
}),
)}
selectedValues={field.value}
onChange={field.onChange}
className="w-full bg-background"
emptySelectionPlaceholder="Select signature types"
/>
</FormControl>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={envelopeHasBeenSent}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<FormMessage />
</FormItem>
)}
/>
)}
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
{settings.allowConfigureDateFormat && (
<FormField
control={form.control}
name="meta.dateFormat"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Date Format</Trans>
</FormLabel>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.timezone"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Time Zone</Trans>
</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={envelopeHasBeenSent}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<FormControl>
<Combobox
className="bg-background"
options={TIME_ZONES}
value={field.value}
onChange={(value) => value && field.onChange(value)}
disabled={envelopeHasBeenSent}
/>
</FormControl>
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{settings.allowConfigureTimezone && (
<FormField
control={form.control}
name="meta.timezone"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Time Zone</Trans>
</FormLabel>
<FormControl>
<Combobox
className="bg-background"
options={TIME_ZONES}
value={field.value}
onChange={(value) => value && field.onChange(value)}
disabled={envelopeHasBeenSent}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="externalId"
@@ -538,143 +566,29 @@ export const EnvelopeEditorSettingsDialog = ({
)}
/>
<FormField
control={form.control}
name="meta.redirectUrl"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Redirect URL</Trans>{' '}
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
Add a URL to redirect the user to once the document is signed
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.distributionMethod"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Document Distribution Method</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Document Distribution Method</Trans>
</strong>
</h2>
<p>
<Trans>
This is how the document will reach the recipients once the
document is ready for signing.
</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<Trans>
<strong>Email</strong> - The recipient will be emailed the
document to sign, approve, etc.
</Trans>
</li>
<li>
<Trans>
<strong>None</strong> - We will generate links which you can
send to the recipients manually.
</Trans>
</li>
</ul>
<Trans>
<strong>Note</strong> - If you use Links in combination with
direct templates, you will need to manually send the links to
the remaining recipients.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue data-testid="documentDistributionMethodSelectValue" />
</SelectTrigger>
<SelectContent position="popper">
{Object.values(DOCUMENT_DISTRIBUTION_METHODS).map(
({ value, description }) => (
<SelectItem key={value} value={value}>
{i18n._(description)}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</>
))
.with('email', () => (
<>
{organisation.organisationClaim.flags.emailDomains && (
{settings.allowConfigureRedirectUrl && (
<FormField
control={form.control}
name="meta.emailId"
name="meta.redirectUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email Sender</Trans>
<FormLabel className="flex flex-row items-center">
<Trans>Redirect URL</Trans>{' '}
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
Add a URL to redirect the user to once the document is signed
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value}
onValueChange={(value) =>
field.onChange(value === '-1' ? null : value)
}
>
<SelectTrigger
loading={isLoadingEmails}
className="bg-background"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{emails.map((email) => (
<SelectItem key={email.id} value={email.id}>
{email.email}
</SelectItem>
))}
<SelectItem value={'-1'}>Documenso</SelectItem>
</SelectContent>
</Select>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
@@ -683,82 +597,204 @@ export const EnvelopeEditorSettingsDialog = ({
/>
)}
<FormField
control={form.control}
name="meta.emailReplyTo"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Reply To Email{' '}
<span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
{settings.allowConfigureDistribution && (
<FormField
control={form.control}
name="meta.distributionMethod"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Document Distribution Method</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<FormControl>
<Input {...field} />
</FormControl>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Document Distribution Method</Trans>
</strong>
</h2>
<FormMessage />
</FormItem>
)}
/>
<p>
<Trans>
This is how the document will reach the recipients once the
document is ready for signing.
</Trans>
</p>
<FormField
control={form.control}
name="meta.subject"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Subject <span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<Trans>
<strong>Email</strong> - The recipient will be emailed the
document to sign, approve, etc.
</Trans>
</li>
<li>
<Trans>
<strong>None</strong> - We will generate links which you
can send to the recipients manually.
</Trans>
</li>
</ul>
<FormControl>
<Input {...field} />
</FormControl>
<Trans>
<strong>Note</strong> - If you use Links in combination with
direct templates, you will need to manually send the links to
the remaining recipients.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormMessage />
</FormItem>
)}
/>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue data-testid="documentDistributionMethodSelectValue" />
</SelectTrigger>
<FormField
control={form.control}
name="meta.message"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>
Message <span className="text-muted-foreground">(Optional)</span>
</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="p-4 text-muted-foreground">
<DocumentSendEmailMessageHelper />
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Textarea className="h-16 resize-none bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DocumentEmailCheckboxes
value={emailSettings}
onChange={(value) => form.setValue('meta.emailSettings', value)}
/>
<SelectContent position="popper">
{Object.values(DOCUMENT_DISTRIBUTION_METHODS).map(
({ value, description }) => (
<SelectItem key={value} value={value}>
{i18n._(description)}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
)}
</>
))
.with('security', () => (
.with(
{ activeTab: 'email', settings: { allowConfigureDistribution: true } },
() => (
<>
{organisation.organisationClaim.flags.emailDomains && (
<FormField
control={form.control}
name="meta.emailId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email Sender</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value}
onValueChange={(value) =>
field.onChange(value === '-1' ? null : value)
}
>
<SelectTrigger
loading={isLoadingEmails}
className="bg-background"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{emails.map((email) => (
<SelectItem key={email.id} value={email.id}>
{email.email}
</SelectItem>
))}
<SelectItem value={'-1'}>Documenso</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="meta.emailReplyTo"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Reply To Email{' '}
<span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.subject"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Subject <span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.message"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>
Message <span className="text-muted-foreground">(Optional)</span>
</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="p-4 text-muted-foreground">
<DocumentSendEmailMessageHelper />
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Textarea className="h-16 resize-none bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DocumentEmailCheckboxes
value={emailSettings}
onChange={(value) => form.setValue('meta.emailSettings', value)}
/>
</>
),
)
.with({ activeTab: 'security' }, () => (
<>
{organisation.organisationClaim.flags.cfr21 && (
<FormField
@@ -827,7 +863,7 @@ export const EnvelopeEditorSettingsDialog = ({
/>
</>
))
.exhaustive()}
.otherwise(() => null)}
</fieldset>
<div className="flex flex-row justify-end gap-4 p-6">
@@ -18,6 +18,7 @@ import {
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { nanoid } from '@documenso/lib/universal/id';
import { PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config';
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
@@ -49,10 +50,14 @@ export const EnvelopeEditorUploadPage = () => {
const organisation = useCurrentOrganisation();
const { t } = useLingui();
const { envelope, setLocalEnvelope, relativePath, editorFields } = useCurrentEnvelopeEditor();
const { maximumEnvelopeItemCount, remaining } = useLimits();
const { toast } = useToast();
const { envelope, setLocalEnvelope, relativePath, editorFields, editorConfig, isEmbedded } =
useCurrentEnvelopeEditor();
const { envelopeItems: uploadConfig } = editorConfig;
const [localFiles, setLocalFiles] = useState<LocalFile[]>(
envelope.envelopeItems
.sort((a, b) => a.order - b.order)
@@ -103,17 +108,43 @@ export const EnvelopeEditorUploadPage = () => {
);
const onFileDrop = async (files: File[]) => {
const newUploadingFiles: (LocalFile & { file: File })[] = files.map((file) => ({
id: nanoid(),
envelopeItemId: null,
title: file.name,
file,
isUploading: true,
isError: false,
}));
const newUploadingFiles: (LocalFile & { file: File; data: ArrayBuffer | null })[] =
await Promise.all(
files.map(async (file) => {
return {
id: nanoid(),
envelopeItemId: isEmbedded ? `${PRESIGNED_ENVELOPE_ITEM_ID_PREFIX}${nanoid()}` : null,
title: file.name,
file,
isUploading: isEmbedded ? false : true,
// Clone the buffer so it can be read multiple times (File.arrayBuffer() consumes the stream once)
data: isEmbedded ? (await file.arrayBuffer()).slice(0) : null,
isError: false,
};
}),
);
setLocalFiles((prev) => [...prev, ...newUploadingFiles]);
// Directly commit the files for embedded documents since those are not uploaded
// until the end of the embedded flow.
if (isEmbedded) {
setLocalEnvelope({
envelopeItems: [
...envelope.envelopeItems,
...newUploadingFiles.map((file) => ({
id: file.envelopeItemId!,
title: file.title,
order: envelope.envelopeItems.length + 1,
envelopeId: envelope.id,
data: file.data!,
})),
],
});
return;
}
const payload = {
envelopeId: envelope.id,
} satisfies TCreateEnvelopeItemsPayload;
@@ -277,16 +308,18 @@ export const EnvelopeEditorUploadPage = () => {
</CardHeader>
<CardContent>
<DocumentDropzone
onDrop={onFileDrop}
allowMultiple
className="pb-4 pt-6"
disabled={dropzoneDisabledMessage !== null}
disabledMessage={dropzoneDisabledMessage || undefined}
disabledHeading={msg`Upload disabled`}
maxFiles={maximumEnvelopeItemCount - localFiles.length}
onDropRejected={onFileDropRejected}
/>
{uploadConfig?.allowUpload && (
<DocumentDropzone
onDrop={onFileDrop}
allowMultiple
className="pb-4 pt-6"
disabled={dropzoneDisabledMessage !== null}
disabledMessage={dropzoneDisabledMessage || undefined}
disabledHeading={msg`Upload disabled`}
maxFiles={maximumEnvelopeItemCount - localFiles.length}
onDropRejected={onFileDropRejected}
/>
)}
{/* Uploaded Files List */}
<div className="mt-4">
@@ -297,7 +330,11 @@ export const EnvelopeEditorUploadPage = () => {
{localFiles.map((localFile, index) => (
<Draggable
key={localFile.id}
isDragDisabled={isCreatingEnvelopeItems || !canItemsBeModified}
isDragDisabled={
isCreatingEnvelopeItems ||
!canItemsBeModified ||
!uploadConfig?.allowConfigureOrder
}
draggableId={localFile.id}
index={index}
>
@@ -311,17 +348,22 @@ export const EnvelopeEditorUploadPage = () => {
}`}
>
<div className="flex items-center space-x-3">
<div
{...provided.dragHandleProps}
className="cursor-grab active:cursor-grabbing"
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
</div>
{uploadConfig?.allowConfigureOrder && (
<div
{...provided.dragHandleProps}
className="cursor-grab active:cursor-grabbing"
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
</div>
)}
<div>
{localFile.envelopeItemId !== null ? (
<EnvelopeItemTitleInput
disabled={envelope.status !== DocumentStatus.DRAFT}
disabled={
envelope.status !== DocumentStatus.DRAFT ||
!uploadConfig?.allowConfigureTitle
}
value={localFile.title}
placeholder={t`Document Title`}
onChange={(title) => {
@@ -355,20 +397,22 @@ export const EnvelopeEditorUploadPage = () => {
</div>
)}
{!localFile.isUploading && localFile.envelopeItemId && (
<EnvelopeItemDeleteDialog
canItemBeDeleted={canItemsBeModified}
envelopeId={envelope.id}
envelopeItemId={localFile.envelopeItemId}
envelopeItemTitle={localFile.title}
onDelete={onFileDelete}
trigger={
<Button variant="ghost" size="sm">
<X className="h-4 w-4" />
</Button>
}
/>
)}
{!localFile.isUploading &&
localFile.envelopeItemId &&
uploadConfig?.allowDelete && (
<EnvelopeItemDeleteDialog
canItemBeDeleted={canItemsBeModified}
envelopeId={envelope.id}
envelopeItemId={localFile.envelopeItemId}
envelopeItemTitle={localFile.title}
onDelete={onFileDelete}
trigger={
<Button variant="ghost" size="sm">
<X className="h-4 w-4" />
</Button>
}
/>
)}
</div>
</div>
)}
@@ -386,13 +430,15 @@ export const EnvelopeEditorUploadPage = () => {
{/* Recipients Section */}
<EnvelopeEditorRecipientForm />
<div className="flex justify-end">
<Button asChild>
<Link to={`${relativePath.editorPath}?step=addFields`}>
<Trans>Add Fields</Trans>
</Link>
</Button>
</div>
{editorConfig.general.allowAddFieldsStep && (
<div className="flex justify-end">
<Button asChild>
<Link to={`${relativePath.editorPath}?step=addFields`}>
<Trans>Add Fields</Trans>
</Link>
</Button>
</div>
)}
</div>
);
};
@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { motion } from 'framer-motion';
import {
ArrowLeftIcon,
@@ -9,32 +11,30 @@ import {
DownloadCloudIcon,
EyeIcon,
LinkIcon,
MousePointer,
type LucideIcon,
MousePointerIcon,
SendIcon,
SettingsIcon,
Trash2Icon,
Upload,
UploadIcon,
} from 'lucide-react';
import { useNavigate, useSearchParams } from 'react-router';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import {
mapSecondaryIdToDocumentId,
mapSecondaryIdToTemplateId,
} from '@documenso/lib/utils/envelope';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribute-dialog';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
import { TemplateDeleteDialog } from '~/components/dialogs/template-delete-dialog';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
@@ -45,37 +45,42 @@ import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page';
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
const envelopeEditorSteps = [
{
id: 'upload',
order: 1,
title: msg`Document & Recipients`,
icon: Upload,
description: msg`Upload documents and add recipients`,
},
{
id: 'addFields',
order: 2,
title: msg`Add Fields`,
icon: MousePointer,
description: msg`Place and configure form fields in the document`,
},
{
id: 'preview',
order: 3,
title: msg`Preview`,
icon: EyeIcon,
description: msg`Preview the document before sending`,
},
];
type EnvelopeEditorStepData = {
id: string;
title: MessageDescriptor;
icon: LucideIcon;
description: MessageDescriptor;
};
export default function EnvelopeEditor() {
const UPLOAD_STEP = {
id: 'upload',
title: msg`Document & Recipients`,
icon: UploadIcon,
description: msg`Upload documents and add recipients`,
};
const ADD_FIELDS_STEP = {
id: 'addFields',
title: msg`Add Fields`,
icon: MousePointerIcon,
description: msg`Place and configure form fields in the document`,
};
const PREVIEW_STEP = {
id: 'preview',
title: msg`Preview`,
icon: EyeIcon,
description: msg`Preview the document before sending`,
};
export const EnvelopeEditor = () => {
const { t } = useLingui();
const navigate = useNavigate();
const {
envelope,
editorConfig,
isDocument,
isTemplate,
isAutosaving,
@@ -85,9 +90,46 @@ export default function EnvelopeEditor() {
} = useCurrentEnvelopeEditor();
const [searchParams, setSearchParams] = useSearchParams();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isStepLoading, setIsStepLoading] = useState(false);
const {
general: {
minimizeLeftSidebar,
allowUploadAndRecipientStep,
allowAddFieldsStep,
allowPreviewStep,
},
actions: {
allowDistributing,
allowDirectLink,
allowDuplication,
allowDownloadPDF,
allowDeletion,
allowReturnToPreviousPage,
},
} = editorConfig;
const envelopeEditorSteps = useMemo(() => {
const steps: EnvelopeEditorStepData[] = [];
if (allowUploadAndRecipientStep) {
steps.push(UPLOAD_STEP);
}
if (allowAddFieldsStep) {
steps.push(ADD_FIELDS_STEP);
}
if (allowPreviewStep) {
steps.push(PREVIEW_STEP);
}
return steps.map((step, index) => ({
...step,
order: index + 1,
}));
}, [editorConfig]);
const [currentStep, setCurrentStep] = useState<EnvelopeEditorStep>(() => {
const searchParamStep = searchParams.get('step') as EnvelopeEditorStep | undefined;
@@ -158,57 +200,123 @@ export default function EnvelopeEditor() {
{/* Main Content Area */}
<div className="flex h-[calc(100vh-4rem)] w-screen">
{/* Left Section - Step Navigation */}
<div className="flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4">
<div
className={cn(
'flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4',
{
'w-14': minimizeLeftSidebar,
},
)}
>
{/* Left section step selector. */}
<div className="px-4">
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
<Trans context="The step counter">
Step {currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span>
</h3>
<div className="relative my-4 h-[4px] rounded-md bg-muted">
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
}}
/>
{minimizeLeftSidebar ? (
<div className="flex justify-center px-4">
<div className="relative flex h-10 w-10 items-center justify-center">
<svg className="size-10 -rotate-90" viewBox="0 0 40 40" aria-hidden>
{/* Track circle */}
<circle
cx="20"
cy="20"
r="16"
fill="none"
stroke="currentColor"
strokeWidth="3"
className="text-muted"
/>
{/* Progress arc */}
<motion.circle
cx="20"
cy="20"
r="16"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
className="text-primary"
strokeDasharray={2 * Math.PI * 16}
initial={false}
animate={{
strokeDashoffset:
2 *
Math.PI *
16 *
(1 - (currentStepData.order ?? 0) / envelopeEditorSteps.length),
}}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-foreground">
<Trans context="The step counter">
{currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span>
</div>
</div>
) : (
<div className="px-4">
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
<div className="space-y-3">
{envelopeEditorSteps.map((step) => {
const Icon = step.icon;
const isActive = currentStep === step.id;
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
<Trans context="The step counter">
Step {currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span>
</h3>
return (
<div
key={step.id}
className={`cursor-pointer rounded-lg p-3 transition-colors ${
<div className="relative my-4 h-[4px] rounded-md bg-muted">
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
}}
/>
</div>
</div>
)}
<div
className={cn('space-y-3', {
'px-4': !minimizeLeftSidebar,
'mt-4 flex flex-col items-center': minimizeLeftSidebar,
})}
>
{envelopeEditorSteps.map((step) => {
const Icon = step.icon;
const isActive = currentStep === step.id;
return (
<button
key={step.id}
type="button"
className={cn(
`cursor-pointer rounded-lg text-left transition-colors ${
isActive
? 'border border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
: 'border border-gray-200 hover:bg-gray-50 dark:border-gray-400/20 dark:hover:bg-gray-400/10'
}`}
onClick={() => navigateToStep(step.id as EnvelopeEditorStep)}
>
<div className="flex items-center space-x-3">
<div
className={`rounded border p-2 ${
isActive
? 'border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
: 'border-gray-100 bg-gray-100 dark:border-gray-400/20 dark:bg-gray-400/10'
}`}
>
<Icon
className={`h-4 w-4 ${isActive ? 'text-green-600' : 'text-gray-600'}`}
/>
</div>
}`,
{
'p-3': !minimizeLeftSidebar,
},
)}
onClick={() => navigateToStep(step.id as EnvelopeEditorStep)}
>
<div className="flex items-center space-x-3">
<div
className={`rounded border p-2 ${
isActive
? 'border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
: 'border-gray-100 bg-gray-100 dark:border-gray-400/20 dark:bg-gray-400/10'
}`}
>
<Icon
className={`h-4 w-4 ${isActive ? 'text-green-600' : 'text-gray-600'}`}
/>
</div>
{!minimizeLeftSidebar && (
<div>
<div
className={`text-sm font-medium ${
@@ -221,59 +329,101 @@ export default function EnvelopeEditor() {
</div>
<div className="text-xs text-muted-foreground">{t(step.description)}</div>
</div>
</div>
)}
</div>
);
})}
</div>
</button>
);
})}
</div>
<Separator className="my-6" />
<Separator
className={cn('my-6', {
'mx-auto mb-4 w-4/5': minimizeLeftSidebar,
})}
/>
{/* Quick Actions. */}
<div className="space-y-3 px-4">
<h4 className="text-sm font-semibold text-foreground">
<Trans>Quick Actions</Trans>
</h4>
<EnvelopeEditorSettingsDialog
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<SettingsIcon className="mr-2 h-4 w-4" />
{isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>}
</Button>
}
/>
<div
className={cn('space-y-3 px-4 [&_.lucide]:text-muted-foreground', {
'px-2': minimizeLeftSidebar,
})}
>
{!minimizeLeftSidebar && (
<h4 className="text-sm font-semibold text-foreground">
<Trans>Quick Actions</Trans>
</h4>
)}
{isDocument && (
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
{editorConfig.settings && (
<EnvelopeEditorSettingsDialog
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send Document</Trans>
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Settings`)}
>
<SettingsIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? (
<Trans>Document Settings</Trans>
) : (
<Trans>Template Settings</Trans>
)}
</span>
)}
</Button>
}
/>
)}
{isDocument && (
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Resend Document</Trans>
</Button>
}
/>
{isDocument && allowDistributing && (
<>
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Send Envelope`)}
>
<SendIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Send Document</Trans>
</span>
)}
</Button>
}
/>
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Resend Envelope`)}
>
<SendIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Resend Document</Trans>
</span>
)}
</Button>
}
/>
</>
)}
{/* <Button variant="ghost" size="sm" className="w-full justify-start">
<FileText className="mr-2 h-4 w-4" />
Save as Template
</Button> */}
{isTemplate && (
{isTemplate && allowDirectLink && (
<TemplateDirectLinkDialog
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
directLink={envelope.directLink}
@@ -281,100 +431,170 @@ export default function EnvelopeEditor() {
onCreateSuccess={async () => await syncEnvelope()}
onDeleteSuccess={async () => await syncEnvelope()}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<LinkIcon className="mr-2 h-4 w-4" />
<Trans>Direct Link</Trans>
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Direct Link`)}
>
<LinkIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Direct Link</Trans>
</span>
)}
</Button>
}
/>
)}
<EnvelopeDuplicateDialog
envelopeId={envelope.id}
envelopeType={envelope.type}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<CopyPlusIcon className="mr-2 h-4 w-4" />
{isDocument ? (
<Trans>Duplicate Document</Trans>
) : (
<Trans>Duplicate Template</Trans>
)}
</Button>
}
/>
{allowDuplication && (
<EnvelopeDuplicateDialog
envelopeId={envelope.id}
envelopeType={envelope.type}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Duplicate Envelope`)}
>
<CopyPlusIcon className="h-4 w-4" />
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
envelopeItems={envelope.envelopeItems}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<DownloadCloudIcon className="mr-2 h-4 w-4" />
<Trans>Download PDF</Trans>
</Button>
}
/>
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? (
<Trans>Duplicate Document</Trans>
) : (
<Trans>Duplicate Template</Trans>
)}
</span>
)}
</Button>
}
/>
)}
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={() => setDeleteDialogOpen(true)}
>
<Trash2Icon className="mr-2 h-4 w-4" />
{isDocument ? <Trans>Delete Document</Trans> : <Trans>Delete Template</Trans>}
</Button>
{allowDownloadPDF && (
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
envelopeItems={envelope.envelopeItems}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Download PDF`)}
>
<DownloadCloudIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Download PDF</Trans>
</span>
)}
</Button>
}
/>
)}
{/* Check envelope ID since it can be in embedded create mode. */}
{allowDeletion && envelope.id && (
<EnvelopeDeleteDialog
id={envelope.id}
type={envelope.type}
status={envelope.status}
title={envelope.title}
canManageDocument={true}
trigger={
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Delete Envelope`)}
>
<Trash2Icon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? (
<Trans>Delete Document</Trans>
) : (
<Trans>Delete Template</Trans>
)}
</span>
)}
</Button>
}
onDelete={async () => {
// Todo: Embed - Where to navigate?
await navigate(
envelope.type === EnvelopeType.DOCUMENT
? relativePath.documentRootPath
: relativePath.templateRootPath,
);
}}
/>
)}
</div>
{isDocument ? (
<DocumentDeleteDialog
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
status={envelope.status}
documentTitle={envelope.title}
canManageDocument={true}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onDelete={async () => {
await navigate(relativePath.documentRootPath);
}}
/>
) : (
<TemplateDeleteDialog
id={mapSecondaryIdToTemplateId(envelope.secondaryId)}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onDelete={async () => {
await navigate(relativePath.templateRootPath);
}}
/>
)}
{/* Footer of left sidebar. */}
<div className="mt-auto px-4">
<Button variant="ghost" className="w-full justify-start" asChild>
<Link to={relativePath.basePath}>
<ArrowLeftIcon className="mr-2 h-4 w-4" />
{isDocument ? (
<Trans>Return to documents</Trans>
) : (
<Trans>Return to templates</Trans>
)}
</Link>
</Button>
</div>
{allowReturnToPreviousPage && (
<div
className={cn('mt-auto px-4', {
'px-2': minimizeLeftSidebar,
})}
>
<Button
variant="ghost"
className={cn('w-full justify-start', {
'flex items-center justify-center': minimizeLeftSidebar,
})}
asChild
>
<Link to={relativePath.basePath}>
<ArrowLeftIcon className="h-4 w-4 flex-shrink-0" />
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? (
<Trans>Return to documents</Trans>
) : (
<Trans>Return to templates</Trans>
)}
</span>
)}
</Link>
</Button>
</div>
)}
</div>
{/* Main Content - Changes based on current step */}
<AnimateGenericFadeInOut className="flex-1 overflow-y-auto" key={currentStep}>
{match({ currentStep, isStepLoading })
{match({
currentStep,
isStepLoading,
allowUploadAndRecipientStep,
allowAddFieldsStep,
allowPreviewStep,
})
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
.with({ currentStep: 'upload' }, () => <EnvelopeEditorUploadPage />)
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorFieldsPage />)
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPreviewPage />)
.exhaustive()}
.with({ currentStep: 'upload', allowUploadAndRecipientStep: true }, () => (
<EnvelopeEditorUploadPage />
))
.with({ currentStep: 'addFields', allowAddFieldsStep: true }, () => (
<EnvelopeEditorFieldsPage />
))
.with({ currentStep: 'preview', allowPreviewStep: true }, () => (
<EnvelopeEditorPreviewPage />
))
.otherwise(() => null)}
</AnimateGenericFadeInOut>
</div>
</div>
);
}
};
@@ -10,6 +10,7 @@ import { match } from 'ts-pattern';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
@@ -56,9 +57,14 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
data: envelope,
isLoading: isLoadingEnvelope,
isError: isErrorEnvelope,
} = trpc.envelope.get.useQuery({
envelopeId: params.id,
});
} = trpc.envelope.get.useQuery(
{
envelopeId: params.id,
},
{
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
if (isLoadingEnvelope) {
return (
@@ -157,6 +163,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
<EnvelopeRenderProvider
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={undefined}
fields={envelope.fields}
recipients={envelope.recipients}
@@ -6,13 +6,14 @@ import { EnvelopeType } from '@prisma/client';
import { Link, useNavigate } from 'react-router';
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Spinner } from '@documenso/ui/primitives/spinner';
import EnvelopeEditor from '~/components/general/envelope-editor/envelope-editor';
import { EnvelopeEditor } from '~/components/general/envelope-editor/envelope-editor';
import { EnvelopeEditorRenderProviderWrapper } from '~/components/general/envelope-editor/envelope-editor-renderer-provider-wrapper';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { useCurrentTeam } from '~/providers/team';
@@ -32,6 +33,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
},
{
retry: false,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
@@ -98,15 +100,9 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
return (
<EnvelopeEditorProvider initialEnvelope={envelope}>
<EnvelopeRenderProvider
version="current"
envelope={envelope}
token={undefined}
fields={envelope.fields}
recipients={envelope.recipients}
>
<EnvelopeEditorRenderProviderWrapper>
<EnvelopeEditor />
</EnvelopeRenderProvider>
</EnvelopeEditorRenderProviderWrapper>
</EnvelopeEditorProvider>
);
}
@@ -9,6 +9,7 @@ import { Link, useNavigate } from 'react-router';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
@@ -51,9 +52,14 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
data: envelope,
isLoading: isLoadingEnvelope,
isError: isErrorEnvelope,
} = trpc.envelope.get.useQuery({
envelopeId: params.id,
});
} = trpc.envelope.get.useQuery(
{
envelopeId: params.id,
},
{
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
if (isLoadingEnvelope) {
return (
@@ -176,6 +182,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
<EnvelopeRenderProvider
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={undefined}
fields={envelope.fields}
recipients={envelope.recipients}
@@ -246,7 +246,12 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
recipient={recipient}
user={user}
>
<EnvelopeRenderProvider version="current" envelope={envelope} token={recipient.token}>
<EnvelopeRenderProvider
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={recipient.token}
>
<DocumentSigningPageViewV2 />
</EnvelopeRenderProvider>
</DocumentSigningAuthProvider>
@@ -494,7 +494,12 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
recipient={recipient}
user={user}
>
<EnvelopeRenderProvider version="current" envelope={envelope} token={recipient.token}>
<EnvelopeRenderProvider
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={recipient.token}
>
<DocumentSigningPageViewV2 />
</EnvelopeRenderProvider>
</DocumentSigningAuthProvider>
@@ -320,7 +320,12 @@ const EmbedDirectTemplatePageV2 = ({
user={user}
isDirectTemplate={true}
>
<EnvelopeRenderProvider version="current" envelope={envelope} token={recipient.token}>
<EnvelopeRenderProvider
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={recipient.token}
>
<EmbedSignDocumentV2ClientPage
hidePoweredBy={hidePoweredBy}
allowWhitelabelling={allowEmbedSigningWhitelabel}
@@ -382,7 +382,12 @@ const EmbedSignDocumentPageV2 = ({
recipient={recipient}
user={user}
>
<EnvelopeRenderProvider version="current" envelope={envelope} token={token}>
<EnvelopeRenderProvider
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={token}
>
<EmbedSignDocumentV2ClientPage
hidePoweredBy={hidePoweredBy}
allowWhitelabelling={allowEmbedSigningWhitelabel}
@@ -0,0 +1,176 @@
import { useLayoutEffect } from 'react';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole, OrganisationType, TeamMemberRole } from '@prisma/client';
import { Outlet, isRouteErrorResponse, useLoaderData } from 'react-router';
import { match } from 'ts-pattern';
import { PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
import { TrpcProvider } from '@documenso/trpc/react';
import type { OrganisationSession } from '@documenso/trpc/server/organisation-router/get-organisation-session.types';
import { TeamProvider } from '~/providers/team';
import { ZBaseEmbedDataSchema } from '~/types/embed-base-schemas';
import { injectCss } from '~/utils/css-vars';
import type { Route } from './+types/_layout';
export const loader = async ({ request }: Route.LoaderArgs) => {
const url = new URL(request.url);
const token = url.searchParams.get('token');
if (!token) {
throw new Response('Invalid token', { status: 404 });
}
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
if (!result) {
throw new Response('Invalid token', { status: 404 });
}
const organisationClaim = await getOrganisationClaimByTeamId({
teamId: result.teamId,
});
return {
token,
userId: result.userId,
teamId: result.teamId,
organisationClaim,
};
};
export default function AuthoringLayout() {
const { token, teamId, organisationClaim } = useLoaderData<typeof loader>();
const allowEmbedAuthoringWhiteLabel = organisationClaim.flags.embedAuthoringWhiteLabel ?? false;
useLayoutEffect(() => {
try {
const hash = window.location.hash.slice(1);
const result = ZBaseEmbedDataSchema.safeParse(JSON.parse(decodeURIComponent(atob(hash))));
if (!result.success) {
return;
}
const { css, cssVars, darkModeDisabled } = result.data;
if (darkModeDisabled) {
document.documentElement.classList.add('dark-mode-disabled');
}
if (allowEmbedAuthoringWhiteLabel) {
injectCss({
css,
cssVars,
});
}
} catch (error) {
console.error(error);
}
}, []);
/**
* Dummy data for providers.
*/
const team: OrganisationSession['teams'][number] = {
id: teamId,
name: '',
url: '',
createdAt: new Date(),
avatarImageId: null,
organisationId: '',
currentTeamRole: TeamMemberRole.MEMBER,
preferences: {
aiFeaturesEnabled: true, // Todo: Embed
},
};
/**
* Dummy data for providers.
*/
const organisation: OrganisationSession = {
id: '',
createdAt: new Date(),
updatedAt: new Date(),
type: OrganisationType.ORGANISATION,
name: '',
url: '',
avatarImageId: null,
customerId: null,
ownerUserId: -1,
organisationClaim,
teams: [team],
subscription: null,
currentOrganisationRole: OrganisationMemberRole.ADMIN,
};
return (
// Todo: Embed
// Session provider? fuck me
// If someone ever uses useSession it's going to break
// We need E2E Tests
<OrganisationProvider organisation={organisation}>
<TeamProvider team={team}>
<TrpcProvider
headers={{ authorization: `Bearer ${token}`, 'x-team-Id': team.id.toString() }}
>
<LimitsProvider
bypassLimits={true}
initialValue={{
quota: PAID_PLAN_LIMITS,
remaining: PAID_PLAN_LIMITS,
maximumEnvelopeItemCount: organisationClaim.envelopeItemCount,
}}
teamId={team.id}
>
<Outlet />
</LimitsProvider>
</TrpcProvider>
</TeamProvider>
</OrganisationProvider>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
return (
<div>
{match(errorCode)
.with(404, () => (
<div>
<p>
<Trans>Token Not Found</Trans>
</p>
<ul>
<li>
<Trans>Ensure that you are using the embedding token, not the API token</Trans>
</li>
<li>
<Trans>
If you are using staging, ensure that you have set the host prop on the embedding
component to the staging domain (https://stg-app.documenso.com)
</Trans>
</li>
</ul>
</div>
))
.otherwise(() => (
<p>
<Trans>An error occurred</Trans>
{errorCode}
</p>
))}
</div>
);
}
@@ -22,6 +22,12 @@ export const useLimits = () => {
export type LimitsProviderProps = {
initialValue?: TLimitsResponseSchema;
/**
* Bypass limits for embed authoring. This is just client side bypass since
* all embeds should be paid plans.
*/
bypassLimits?: boolean;
teamId: number;
children?: React.ReactNode;
};
@@ -32,12 +38,17 @@ export const LimitsProvider = ({
remaining: FREE_PLAN_LIMITS,
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
},
bypassLimits,
teamId,
children,
}: LimitsProviderProps) => {
const [limits, setLimits] = useState(() => initialValue);
const refreshLimits = useCallback(async () => {
if (bypassLimits) {
return;
}
const newLimits = await getLimits({ teamId });
setLimits((oldLimits) => {
@@ -54,6 +65,10 @@ export const LimitsProvider = ({
}, [refreshLimits]);
useEffect(() => {
if (bypassLimits) {
return;
}
const onFocus = () => {
void refreshLimits();
};
@@ -6,11 +6,10 @@ import { FieldType } from '@prisma/client';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { nanoid } from '@documenso/lib/universal/id';
import type { TEnvelope } from '../../types/envelope';
export const ZLocalFieldSchema = z.object({
// This is the actual ID of the field if created.
id: z.number().optional(),
@@ -37,7 +36,7 @@ const ZEditorFieldsFormSchema = z.object({
export type TEditorFieldsFormSchema = z.infer<typeof ZEditorFieldsFormSchema>;
type EditorFieldsProps = {
envelope: TEnvelope;
envelope: TEditorEnvelope;
handleFieldsUpdate: (fields: TLocalField[]) => unknown;
};
@@ -11,10 +11,9 @@ import {
ZRecipientActionAuthTypesSchema,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import type { TEnvelope } from '../../types/envelope';
const LocalRecipientSchema = z.object({
formId: z.string().min(1),
id: z.number().optional(),
@@ -36,12 +35,12 @@ export const ZEditorRecipientsFormSchema = z.object({
export type TEditorRecipientsFormSchema = z.infer<typeof ZEditorRecipientsFormSchema>;
type EditorRecipientsProps = {
envelope: TEnvelope;
envelope: TEditorEnvelope;
};
type ResetFormOptions = {
recipients?: Recipient[];
documentMeta?: TEnvelope['documentMeta'];
documentMeta?: TEditorEnvelope['documentMeta'];
};
type UseEditorRecipientsResponse = {
@@ -1,17 +1,21 @@
import { router } from '../trpc';
import { createEmbeddingDocumentRoute } from './create-embedding-document';
import { createEmbeddingEnvelopeRoute } from './create-embedding-envelope';
import { createEmbeddingPresignTokenRoute } from './create-embedding-presign-token';
import { createEmbeddingTemplateRoute } from './create-embedding-template';
import { getMultiSignDocumentRoute } from './get-multi-sign-document';
import { updateEmbeddingDocumentRoute } from './update-embedding-document';
import { updateEmbeddingEnvelopeRoute } from './update-embedding-envelope';
import { updateEmbeddingTemplateRoute } from './update-embedding-template';
import { verifyEmbeddingPresignTokenRoute } from './verify-embedding-presign-token';
export const embeddingPresignRouter = router({
createEmbeddingPresignToken: createEmbeddingPresignTokenRoute,
verifyEmbeddingPresignToken: verifyEmbeddingPresignTokenRoute,
createEmbeddingEnvelope: createEmbeddingEnvelopeRoute,
createEmbeddingDocument: createEmbeddingDocumentRoute,
createEmbeddingTemplate: createEmbeddingTemplateRoute,
updateEmbeddingEnvelope: updateEmbeddingEnvelopeRoute,
updateEmbeddingDocument: updateEmbeddingDocumentRoute,
updateEmbeddingTemplate: updateEmbeddingTemplateRoute,
// applyMultiSignSignature: applyMultiSignSignatureRoute,
@@ -4,19 +4,19 @@ import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { ZRecipientEmailSchema, ZRecipientLiteSchema } from '@documenso/lib/types/recipient';
export const ZSetEnvelopeRecipientSchema = z.object({
id: z.number().optional(),
email: ZRecipientEmailSchema,
name: z.string().max(255),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
});
export const ZSetEnvelopeRecipientsRequestSchema = z.object({
envelopeId: z.string(),
envelopeType: z.nativeEnum(EnvelopeType),
recipients: z.array(
z.object({
id: z.number().optional(),
email: ZRecipientEmailSchema,
name: z.string().max(255),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}),
),
recipients: ZSetEnvelopeRecipientSchema.array(),
});
export const ZSetEnvelopeRecipientsResponseSchema = z.object({
@@ -12,14 +12,28 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
import { cn } from '../../lib/utils';
export type RecipientRoleSelectProps = SelectProps & {
hideCCRecipients?: boolean;
hideAssistantRole?: boolean;
hideCCerRole?: boolean;
hideViewerRole?: boolean;
hideApproverRole?: boolean;
isAssistantEnabled?: boolean;
};
export const RecipientRoleSelect = forwardRef<HTMLButtonElement, RecipientRoleSelectProps>(
({ hideCCRecipients, isAssistantEnabled = true, ...props }, ref) => (
(
{
hideAssistantRole,
hideCCerRole,
hideViewerRole,
hideApproverRole,
isAssistantEnabled = true,
...props
},
ref,
) => (
<Select {...props}>
<SelectTrigger ref={ref} className="bg-background w-[50px] p-2">
<SelectTrigger ref={ref} className="w-[50px] bg-background p-2">
{/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */}
{ROLE_ICONS[props.value as RecipientRole]}
</SelectTrigger>
@@ -35,7 +49,7 @@ export const RecipientRoleSelect = forwardRef<HTMLButtonElement, RecipientRoleSe
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<TooltipContent className="z-9999 max-w-md p-4 text-foreground">
<p>
<Trans>
The recipient is required to sign the document for it to be completed.
@@ -46,49 +60,53 @@ export const RecipientRoleSelect = forwardRef<HTMLButtonElement, RecipientRoleSe
</div>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
<Trans>Needs to approve</Trans>
{!hideApproverRole && (
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
<Trans>Needs to approve</Trans>
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="z-9999 max-w-md p-4 text-foreground">
<p>
<Trans>
The recipient is required to approve the document for it to be completed.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
<Trans>
The recipient is required to approve the document for it to be completed.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
</SelectItem>
)}
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
<Trans>Needs to view</Trans>
{!hideViewerRole && (
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
<Trans>Needs to view</Trans>
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="z-9999 max-w-md p-4 text-foreground">
<p>
<Trans>
The recipient is required to view the document for it to be completed.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
<Trans>
The recipient is required to view the document for it to be completed.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
</SelectItem>
)}
{!hideCCRecipients && (
{!hideCCerRole && (
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
@@ -99,7 +117,7 @@ export const RecipientRoleSelect = forwardRef<HTMLButtonElement, RecipientRoleSe
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<TooltipContent className="z-9999 max-w-md p-4 text-foreground">
<p>
<Trans>
The recipient is not required to take any action and receives a copy of the
@@ -112,41 +130,43 @@ export const RecipientRoleSelect = forwardRef<HTMLButtonElement, RecipientRoleSe
</SelectItem>
)}
<SelectItem
value={RecipientRole.ASSISTANT}
disabled={!isAssistantEnabled}
className={cn(
!isAssistantEnabled &&
'cursor-not-allowed opacity-50 data-[disabled]:pointer-events-auto',
)}
>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.ASSISTANT]}</span>
<Trans>Can prepare</Trans>
{!hideAssistantRole && (
<SelectItem
value={RecipientRole.ASSISTANT}
disabled={!isAssistantEnabled}
className={cn(
!isAssistantEnabled &&
'cursor-not-allowed opacity-50 data-[disabled]:pointer-events-auto',
)}
>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.ASSISTANT]}</span>
<Trans>Can prepare</Trans>
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="z-9999 max-w-md p-4 text-foreground">
<p>
{isAssistantEnabled ? (
<Trans>
The recipient can prepare the document for later signers by pre-filling
suggest values.
</Trans>
) : (
<Trans>
Assistant role is only available when the document is in sequential signing
mode.
</Trans>
)}
</p>
</TooltipContent>
</Tooltip>
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
{isAssistantEnabled ? (
<Trans>
The recipient can prepare the document for later signers by pre-filling
suggest values.
</Trans>
) : (
<Trans>
Assistant role is only available when the document is in sequential signing
mode.
</Trans>
)}
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
</SelectItem>
)}
</SelectContent>
</Select>
),
@@ -533,7 +533,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground ml-1 cursor-help">
<span className="ml-1 cursor-help text-muted-foreground">
<HelpCircle className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
@@ -586,7 +586,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
{...provided.draggableProps}
{...provided.dragHandleProps}
className={cn('py-1', {
'bg-widget-foreground pointer-events-none rounded-md pt-2':
'pointer-events-none rounded-md bg-widget-foreground pt-2':
snapshot.isDragging,
})}
>
@@ -754,7 +754,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
handleRoleChange(index, value as RecipientRole);
}}
disabled={isSubmitting}
hideCCRecipients={isSignerDirectRecipient(signer)}
hideCCerRole={isSignerDirectRecipient(signer)}
/>
</FormControl>
@@ -768,11 +768,11 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
<TooltipTrigger className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80">
<Link2Icon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<h3 className="text-foreground text-lg font-semibold">
<TooltipContent className="z-9999 max-w-md p-4 text-foreground">
<h3 className="text-lg font-semibold text-foreground">
<Trans>Direct link receiver</Trans>
</h3>
<p className="text-muted-foreground mt-1">
<p className="mt-1 text-muted-foreground">
<Trans>
This field cannot be modified or deleted. When you share
this template's direct link or add it to your public
@@ -829,7 +829,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 bg-black/5 hover:bg-black/10"
className="bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
variant="secondary"
disabled={
isSubmitting ||
@@ -852,7 +852,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
/>
<label
className="text-muted-foreground ml-2 text-sm"
className="ml-2 text-sm text-muted-foreground"
htmlFor="showAdvancedRecipientSettings"
>
<Trans>Show advanced settings</Trans>