Merge branch 'main' into feat/document-file-conversion

Resolved conflicts:
- create-document-data.ts: merged initialData + originalData/originalMimeType
- files.helpers.ts: kept both imports
- envelope-drop-zone-wrapper.tsx: adopted buildDropzoneRejectionDescription
- create-envelope-items.ts: adopted UNSAFE_createEnvelopeItems
- create-envelope.ts: integrated convertToPdfIfNeeded into createEnvelopeRouteCaller

Extended putPdfFileServerSide to accept { initialData, originalData,
originalMimeType } options; updated seal-document.handler call site.
This commit is contained in:
ephraimduncan
2026-04-20 10:11:35 +00:00
1127 changed files with 107456 additions and 22991 deletions
@@ -0,0 +1,197 @@
import { useEffect, useMemo, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminSwapSubscriptionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
sourceOrganisationId: string;
sourceOrganisationName: string;
userId: number;
};
export const AdminSwapSubscriptionDialog = ({
open,
onOpenChange,
sourceOrganisationId,
sourceOrganisationName,
userId,
}: AdminSwapSubscriptionDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [selectedOrgId, setSelectedOrgId] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { data: orgsData } = trpc.admin.organisation.find.useQuery(
{
ownerUserId: userId,
perPage: 100,
},
{
enabled: open,
},
);
const trpcUtils = trpc.useUtils();
const eligibleOrgs = useMemo(() => {
if (!orgsData?.data) {
return [];
}
return orgsData.data.filter((org) => {
if (org.id === sourceOrganisationId) {
return false;
}
const hasActiveSubscription =
org.subscription &&
(org.subscription.status === 'ACTIVE' || org.subscription.status === 'PAST_DUE');
return !hasActiveSubscription;
});
}, [orgsData, sourceOrganisationId]);
const selectedOrg = eligibleOrgs.find((org) => org.id === selectedOrgId);
const { mutateAsync: swapSubscription } = trpc.admin.organisation.swapSubscription.useMutation();
const onSubmit = async () => {
if (!selectedOrgId) {
return;
}
setIsSubmitting(true);
try {
await swapSubscription({
sourceOrganisationId,
targetOrganisationId: selectedOrgId,
});
await trpcUtils.admin.organisation.find.invalidate();
await trpcUtils.admin.organisation.get.invalidate();
onOpenChange(false);
toast({
title: t`Success`,
description: t`Subscription moved successfully`,
duration: 5000,
});
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: t`Error`,
description: t`Failed to move subscription. Please try again.`,
variant: 'destructive',
});
} finally {
setIsSubmitting(false);
}
};
useEffect(() => {
if (!open) {
setSelectedOrgId('');
}
}, [open]);
return (
<Dialog open={open} onOpenChange={(value) => !isSubmitting && onOpenChange(value)}>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Move Subscription</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Move the subscription from "{sourceOrganisationName}" to another organisation owned by
this user.
</Trans>
</DialogDescription>
</DialogHeader>
<fieldset className="flex flex-col space-y-4" disabled={isSubmitting}>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">
<Trans>Target Organisation</Trans>
</label>
<Select value={selectedOrgId} onValueChange={setSelectedOrgId}>
<SelectTrigger>
<SelectValue placeholder={t`Select an organisation`} />
</SelectTrigger>
<SelectContent>
{eligibleOrgs.map((org) => (
<SelectItem key={org.id} value={org.id}>
{org.name} ({org.url})
</SelectItem>
))}
</SelectContent>
</Select>
{eligibleOrgs.length === 0 && orgsData && (
<p className="text-sm text-muted-foreground">
<Trans>No eligible organisations found. The target must be on the free plan.</Trans>
</p>
)}
</div>
{selectedOrg && (
<Alert variant="warning">
<AlertDescription className="mt-0">
<Trans>
This will move the subscription from "{sourceOrganisationName}" to "
{selectedOrg.name}". The source organisation will be reset to the free plan.
</Trans>
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
onClick={onSubmit}
disabled={!selectedOrgId}
loading={isSubmitting}
>
<Trans>Move Subscription</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};
@@ -5,6 +5,7 @@ import { Trans, useLingui } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zEmail } from '@documenso/lib/utils/zod';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -43,7 +44,7 @@ type ConfirmationDialogProps = {
const ZNextSignerFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
email: zEmail('Invalid email address'),
});
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
@@ -115,10 +116,12 @@ export function AssistantConfirmationDialog({
<div className="mt-4 flex flex-col gap-4">
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
<p className="text-sm text-muted-foreground">
<Trans>
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
</Trans>
</p>
<Button
@@ -3,6 +3,7 @@ import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import type { z } from 'zod';
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { generateDefaultSubscriptionClaim } from '@documenso/lib/utils/organisations-claims';
import { trpc } from '@documenso/trpc/react';
import type { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
@@ -22,7 +23,11 @@ import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
export type CreateClaimFormValues = z.infer<typeof ZCreateSubscriptionClaimRequestSchema>;
export const ClaimCreateDialog = () => {
type ClaimCreateDialogProps = {
licenseFlags?: TLicenseClaim;
};
export const ClaimCreateDialog = ({ licenseFlags }: ClaimCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
@@ -52,7 +57,7 @@ export const ClaimCreateDialog = () => {
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogContent className="scrollbar-hidden max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Create Subscription Claim</Trans>
@@ -67,6 +72,7 @@ export const ClaimCreateDialog = () => {
...generateDefaultSubscriptionClaim(),
}}
onFormSubmit={createClaim}
licenseFlags={licenseFlags}
formSubmitTrigger={
<DialogFooter>
<Button
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { trpc } from '@documenso/trpc/react';
import type { TFindSubscriptionClaimsResponse } from '@documenso/trpc/server/admin-router/find-subscription-claims.types';
import { Button } from '@documenso/ui/primitives/button';
@@ -21,9 +22,10 @@ import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
export type ClaimUpdateDialogProps = {
claim: TFindSubscriptionClaimsResponse['data'][number];
trigger: React.ReactNode;
licenseFlags?: TLicenseClaim;
};
export const ClaimUpdateDialog = ({ claim, trigger }: ClaimUpdateDialogProps) => {
export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
@@ -51,7 +53,7 @@ export const ClaimUpdateDialog = ({ claim, trigger }: ClaimUpdateDialogProps) =>
{trigger}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogContent className="scrollbar-hidden max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Update Subscription Claim</Trans>
@@ -69,6 +71,7 @@ export const ClaimUpdateDialog = ({ claim, trigger }: ClaimUpdateDialogProps) =>
data,
})
}
licenseFlags={licenseFlags}
formSubmitTrigger={
<DialogFooter>
<Button
@@ -1,133 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useNavigate } from 'react-router';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
type DocumentDuplicateDialogProps = {
id: string;
token?: string;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DocumentDuplicateDialog = ({
id,
token,
open,
onOpenChange,
}: DocumentDuplicateDialogProps) => {
const navigate = useNavigate();
const { toast } = useToast();
const { _ } = useLingui();
const team = useCurrentTeam();
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
trpcReact.envelope.item.getManyByToken.useQuery(
{
envelopeId: id,
access: token ? { type: 'recipient', token } : { type: 'user' },
},
{
enabled: open,
},
);
const envelopeItems = envelopeItemsPayload?.data || [];
const documentsPath = formatDocumentsPath(team.url);
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
trpcReact.envelope.duplicate.useMutation({
onSuccess: async ({ id }) => {
toast({
title: _(msg`Document Duplicated`),
description: _(msg`Your document has been successfully duplicated.`),
duration: 5000,
});
await navigate(`${documentsPath}/${id}/edit`);
onOpenChange(false);
},
});
const onDuplicate = async () => {
try {
await duplicateEnvelope({ envelopeId: id });
} catch {
toast({
title: _(msg`Something went wrong`),
description: _(msg`This document could not be duplicated at this time. Please try again.`),
variant: 'destructive',
duration: 7500,
});
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isDuplicating && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Duplicate</Trans>
</DialogTitle>
</DialogHeader>
{isLoadingEnvelopeItems || !envelopeItems || envelopeItems.length === 0 ? (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
<Trans>Loading Document...</Trans>
</h1>
</div>
) : (
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
<PDFViewerLazy
key={envelopeItems[0].id}
envelopeItem={envelopeItems[0]}
token={undefined}
version="original"
/>
</div>
)}
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
className="flex-1"
>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
disabled={isDuplicating}
loading={isDuplicating}
onClick={onDuplicate}
className="flex-1"
>
<Trans>Duplicate</Trans>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -4,13 +4,14 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type Recipient, SigningStatus, type Team, type User } from '@prisma/client';
import { SigningStatus, type Team, type User } from '@prisma/client';
import { History } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Document } from '@documenso/prisma/types/document-legacy-schema';
import { trpc as trpcReact } from '@documenso/trpc/react';
@@ -45,10 +46,10 @@ const FORM_ID = 'resend-email';
export type DocumentResendDialogProps = {
document: Pick<Document, 'id' | 'userId' | 'teamId' | 'status'> & {
user: Pick<User, 'id' | 'name' | 'email'>;
recipients: Recipient[];
recipients: TRecipientLite[];
team: Pick<Team, 'id' | 'url'> | null;
};
recipients: Recipient[];
recipients: TRecipientLite[];
};
export const ZResendDocumentFormSchema = z.object({
@@ -183,7 +184,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
<DialogClose asChild>
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
className="flex-1 bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
variant="secondary"
disabled={isSubmitting}
>
@@ -1,9 +1,9 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { P, match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
@@ -12,61 +12,76 @@ 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 DocumentDeleteDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
type EnvelopeDeleteDialogProps = {
id: string;
type: EnvelopeType;
trigger?: React.ReactNode;
onDelete?: () => Promise<void> | void;
status: DocumentStatus;
documentTitle: string;
title: string;
canManageDocument: boolean;
};
export const DocumentDeleteDialog = ({
export const EnvelopeDeleteDialog = ({
id,
open,
onOpenChange,
type,
trigger,
onDelete,
status,
documentTitle,
title,
canManageDocument,
}: DocumentDeleteDialogProps) => {
}: EnvelopeDeleteDialogProps) => {
const { toast } = useToast();
const { refreshLimits } = useLimits();
const { _ } = useLingui();
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: deleteDocument, isPending } = trpcReact.document.delete.useMutation({
const isDocument = type === EnvelopeType.DOCUMENT;
const { mutateAsync: deleteEnvelope, isPending } = trpcReact.envelope.delete.useMutation({
onSuccess: async () => {
void refreshLimits();
toast({
title: _(msg`Document deleted`),
description: _(msg`"${documentTitle}" has been successfully deleted`),
title: canManageDocument
? isDocument
? t`Document deleted`
: t`Template deleted`
: isDocument
? t`Document hidden`
: t`Template hidden`,
description: canManageDocument
? t`"${title}" has been successfully deleted`
: t`"${title}" has been successfully hidden`,
duration: 5000,
});
await onDelete?.();
onOpenChange(false);
setOpen(false);
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`This document could not be deleted at this time. Please try again.`),
title: t`Something went wrong`,
description: isDocument
? t`This document could not be deleted at this time. Please try again.`
: t`This template could not be deleted at this time. Please try again.`,
variant: 'destructive',
duration: 7500,
});
@@ -82,11 +97,13 @@ export const DocumentDeleteDialog = ({
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
setIsDeleteEnabled(event.target.value === _(deleteMessage));
setIsDeleteEnabled(event.target.value === t(deleteMessage));
};
return (
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
@@ -96,11 +113,11 @@ export const DocumentDeleteDialog = ({
<DialogDescription>
{canManageDocument ? (
<Trans>
You are about to delete <strong>"{documentTitle}"</strong>
You are about to delete <strong>"{title}"</strong>
</Trans>
) : (
<Trans>
You are about to hide <strong>"{documentTitle}"</strong>
You are about to hide <strong>"{title}"</strong>
</Trans>
)}
</DialogDescription>
@@ -111,10 +128,17 @@ export const DocumentDeleteDialog = ({
{match(status)
.with(DocumentStatus.DRAFT, () => (
<AlertDescription>
<Trans>
Please note that this action is <strong>irreversible</strong>. Once confirmed,
this document will be permanently deleted.
</Trans>
{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, () => (
@@ -176,23 +200,25 @@ export const DocumentDeleteDialog = ({
type="text"
value={inputValue}
onChange={onInputChange}
placeholder={_(msg`Please type ${`'${_(deleteMessage)}'`} to confirm`)}
placeholder={t`Please type ${`'${t(deleteMessage)}'`} to confirm`}
/>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
type="button"
loading={isPending}
onClick={() => void deleteDocument({ documentId: id })}
onClick={() => void deleteEnvelope({ envelopeId: id })}
disabled={!isDeleteEnabled && canManageDocument}
variant="destructive"
>
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
{canManageDocument ? <Trans>Delete</Trans> : <Trans>Hide</Trans>}
</Button>
</DialogFooter>
</DialogContent>
@@ -3,13 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import {
DocumentDistributionMethod,
DocumentStatus,
EnvelopeType,
FieldType,
RecipientRole,
} from '@prisma/client';
import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
@@ -19,7 +13,10 @@ 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 { zEmail } from '@documenso/lib/utils/zod';
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import { cn } from '@documenso/ui/lib/utils';
@@ -66,10 +63,7 @@ export type EnvelopeDistributeDialogProps = {
export const ZEnvelopeDistributeFormSchema = z.object({
meta: z.object({
emailId: z.string().nullable(),
emailReplyTo: z.preprocess(
(val) => (val === '' ? undefined : val),
z.string().email().optional(),
),
emailReplyTo: z.preprocess((val) => (val === '' ? undefined : val), zEmail().optional()),
subject: z.string(),
message: z.string(),
distributionMethod: z
@@ -121,10 +115,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 || [];
@@ -140,14 +139,7 @@ export const EnvelopeDistributeDialog = ({
);
const recipientsMissingSignatureFields = useMemo(
() =>
recipientsWithIndex.filter(
(recipient) =>
recipient.role === RecipientRole.SIGNER &&
!envelope.fields.some(
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
),
),
() => getRecipientsWithMissingFields(recipientsWithIndex, envelope.fields),
[recipientsWithIndex, envelope.fields],
);
@@ -270,10 +262,10 @@ export const EnvelopeDistributeDialog = ({
>
<TabsList className="w-full">
<TabsTrigger className="w-full" value={DocumentDistributionMethod.EMAIL}>
Email
<Trans>Email</Trans>
</TabsTrigger>
<TabsTrigger className="w-full" value={DocumentDistributionMethod.NONE}>
None
<Trans>None</Trans>
</TabsTrigger>
</TabsList>
</Tabs>
@@ -1,7 +1,6 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { useNavigate } from 'react-router';
@@ -10,6 +9,7 @@ import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
@@ -41,19 +41,20 @@ export const EnvelopeDuplicateDialog = ({
const team = useCurrentTeam();
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
trpc.envelope.duplicate.useMutation({
onSuccess: async ({ id }) => {
toast({
title: t`Envelope Duplicated`,
description: t`Your envelope has been successfully duplicated.`,
title: isDocument ? t`Document Duplicated` : t`Template Duplicated`,
description: isDocument
? t`Your document has been successfully duplicated.`
: t`Your template has been successfully duplicated.`,
duration: 5000,
});
const path =
envelopeType === EnvelopeType.DOCUMENT
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
const path = isDocument ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
await navigate(`${path}/${id}/edit`);
setOpen(false);
@@ -66,7 +67,9 @@ export const EnvelopeDuplicateDialog = ({
} catch {
toast({
title: t`Something went wrong`,
description: t`This document could not be duplicated at this time. Please try again.`,
description: isDocument
? t`This document could not be duplicated at this time. Please try again.`
: t`This template could not be duplicated at this time. Please try again.`,
variant: 'destructive',
duration: 7500,
});
@@ -78,30 +81,25 @@ export const EnvelopeDuplicateDialog = ({
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent>
{envelopeType === EnvelopeType.DOCUMENT ? (
<DialogHeader>
<DialogTitle>
<Trans>Duplicate Document</Trans>
</DialogTitle>
<DialogDescription>
<DialogHeader>
<DialogTitle>
{isDocument ? <Trans>Duplicate Document</Trans> : <Trans>Duplicate Template</Trans>}
</DialogTitle>
<DialogDescription>
{isDocument ? (
<Trans>This document will be duplicated.</Trans>
</DialogDescription>
</DialogHeader>
) : (
<DialogHeader>
<DialogTitle>
<Trans>Duplicate Template</Trans>
</DialogTitle>
<DialogDescription>
) : (
<Trans>This template will be duplicated.</Trans>
</DialogDescription>
</DialogHeader>
)}
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="secondary" disabled={isDuplicating}>
<Trans>Cancel</Trans>
</Button>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isDuplicating}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button type="button" loading={isDuplicating} onClick={onDuplicate}>
<Trans>Duplicate</Trans>
@@ -0,0 +1,368 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { AlertTriangleIcon, FileIcon, UploadIcon, XIcon } from 'lucide-react';
import { type FileRejection, useDropzone } from 'react-dropzone';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { trpc } from '@documenso/trpc/react';
import { ZDocumentTitleSchema } from '@documenso/trpc/server/document-router/schema';
import type { TReplaceEnvelopeItemPdfPayload } from '@documenso/trpc/server/envelope-router/replace-envelope-item-pdf.types';
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
import { cn } from '@documenso/ui/lib/utils';
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 {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZEditEnvelopeItemFormSchema = z.object({
title: ZDocumentTitleSchema,
});
type TEditEnvelopeItemFormSchema = z.infer<typeof ZEditEnvelopeItemFormSchema>;
/**
* Note: This should only be visible if the envelope item is editable.
*/
export type EnvelopeItemEditDialogProps = {
envelopeItem: { id: string; title: string };
allowConfigureTitle: boolean;
trigger: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const EnvelopeItemEditDialog = ({
envelopeItem,
allowConfigureTitle,
trigger,
...props
}: EnvelopeItemEditDialogProps) => {
const { t, i18n } = useLingui();
const { toast } = useToast();
const { envelope, editorFields, setLocalEnvelope, isEmbedded } = useCurrentEnvelopeEditor();
const [isOpen, setIsOpen] = useState(false);
const [replacementFile, setReplacementFile] = useState<{ file: File; pageCount: number } | null>(
null,
);
const [isDropping, setIsDropping] = useState(false);
const form = useForm<TEditEnvelopeItemFormSchema>({
resolver: zodResolver(ZEditEnvelopeItemFormSchema),
defaultValues: {
title: envelopeItem.title,
},
});
const { mutateAsync: replaceEnvelopeItemPdf } = trpc.envelope.item.replacePdf.useMutation({
onSuccess: ({ data, fields }) => {
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.map((item) =>
item.id === data.id
? { ...item, documentDataId: data.documentDataId, title: data.title }
: item,
),
});
if (fields) {
setLocalEnvelope({ fields });
editorFields.resetForm(fields);
}
},
});
const fieldsOnExcessPages =
replacementFile !== null
? envelope.fields.filter(
(field) =>
field.envelopeItemId === envelopeItem.id && field.page > replacementFile.pageCount,
)
: [];
const onFileDropRejected = (fileRejections: FileRejection[]) => {
toast({
title: t`Upload failed`,
description: i18n._(buildDropzoneRejectionDescription(fileRejections)),
duration: 5000,
variant: 'destructive',
});
};
const onFileDrop = async (files: File[]) => {
const file = files[0];
if (!file || isDropping) {
return;
}
setIsDropping(true);
try {
const arrayBuffer = await file.arrayBuffer();
const fileData = new Uint8Array(arrayBuffer.slice(0));
const { PDF } = await import('@libpdf/core');
const pdfDoc = await PDF.load(fileData);
setReplacementFile({
file,
pageCount: pdfDoc.getPageCount(),
});
} catch (err) {
console.error(err);
toast({
title: t`Failed to read file`,
description: t`The file is not a valid PDF.`,
variant: 'destructive',
});
}
setIsDropping(false);
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: { 'application/pdf': ['.pdf'] },
maxFiles: 1,
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
disabled: form.formState.isSubmitting,
onDrop: (files) => void onFileDrop(files),
onDropRejected: onFileDropRejected,
});
const onSubmit = async (data: TEditEnvelopeItemFormSchema) => {
if (isDropping || !replacementFile) {
return;
}
try {
const { file, pageCount } = replacementFile;
if (isEmbedded) {
const arrayBuffer = await file.arrayBuffer();
const fileData = new Uint8Array(arrayBuffer.slice(0));
const remainingFields = envelope.fields.filter(
(field) => field.envelopeItemId !== envelopeItem.id || field.page <= pageCount,
);
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.map((item) =>
item.id === envelopeItem.id ? { ...item, title: data.title, data: fileData } : item,
),
fields: remainingFields,
});
editorFields.resetForm(remainingFields);
} else {
const payload = {
envelopeId: envelope.id,
envelopeItemId: envelopeItem.id,
title: data.title,
} satisfies TReplaceEnvelopeItemPdfPayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
await replaceEnvelopeItemPdf(formData);
}
setIsOpen(false);
} catch {
toast({
title: t`Failed to update item`,
description: t`Something went wrong while updating the envelope item.`,
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isOpen) {
form.reset({ title: envelopeItem.title });
setReplacementFile(null);
setIsDropping(false);
}
}, [isOpen, form, envelopeItem.title]);
const formatFileSize = (bytes: number) => {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<Dialog
{...props}
open={isOpen}
onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Edit Item</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Update the title or replace the PDF file.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Document Title</Trans>
</FormLabel>
<FormControl>
<Input
data-testid="envelope-item-edit-title-input"
placeholder={t`Document Title`}
disabled={!allowConfigureTitle}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel>
<Trans>Replace PDF</Trans>
</FormLabel>
{replacementFile ? (
<div className="mt-1.5 space-y-2">
<div
data-testid="envelope-item-edit-selected-file"
className="flex items-center justify-between rounded-md border border-border bg-muted/50 px-3 py-2"
>
<div className="flex min-w-0 items-center space-x-2">
<FileIcon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{replacementFile.file.name}
</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(replacementFile.file.size)}
{isDropping ? ' · …' : ' · '}
{!isDropping && replacementFile.pageCount !== null && (
<Plural
one="1 page"
other="# pages"
value={replacementFile.pageCount}
/>
)}
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
data-testid="envelope-item-edit-clear-file"
onClick={() => {
setReplacementFile(null);
}}
>
<XIcon className="h-4 w-4" />
</Button>
</div>
{fieldsOnExcessPages.length > 0 && (
<Alert variant="warning" padding="tight">
<AlertTriangleIcon className="h-4 w-4" />
<AlertDescription data-testid="envelope-item-edit-field-warning">
<Plural
one="1 field will be deleted because the new PDF has fewer pages than the current one."
other="# fields will be deleted because the new PDF has fewer pages than the current one."
value={fieldsOnExcessPages.length}
/>
</AlertDescription>
</Alert>
)}
</div>
) : (
<div
data-testid="envelope-item-edit-dropzone"
{...getRootProps()}
className={cn(
'mt-1.5 flex cursor-pointer items-center justify-center rounded-md border border-dashed border-border px-4 py-4 transition-colors',
isDragActive
? 'border-primary/50 bg-primary/5'
: 'hover:border-muted-foreground/50 hover:bg-muted/50',
)}
>
<input {...getInputProps()} />
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<UploadIcon className="h-4 w-4" />
<span>
<Trans>Drop PDF here or click to select</Trans>
</span>
</div>
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
type="submit"
loading={form.formState.isSubmitting}
disabled={isDropping || !replacementFile}
data-testid="envelope-item-edit-update-button"
>
<Trans>Update</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -4,12 +4,13 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType, type Recipient, SigningStatus } from '@prisma/client';
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import type { TEnvelopeRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -38,7 +39,7 @@ import { StackAvatar } from '../general/stack-avatar';
export type EnvelopeRedistributeDialogProps = {
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
recipients: Recipient[];
recipients: TEnvelopeRecipientLite[];
};
trigger?: React.ReactNode;
};
@@ -0,0 +1,130 @@
import { useEffect, useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { DOCUMENT_TITLE_MAX_LENGTH } from '@documenso/trpc/server/document-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EnvelopeRenameDialogProps = {
id: string;
initialTitle: string;
open: boolean;
onOpenChange: (_open: boolean) => void;
onSuccess?: () => Promise<void>;
envelopeType?: 'document' | 'template';
};
export const EnvelopeRenameDialog = ({
id,
initialTitle,
open,
onOpenChange,
onSuccess,
envelopeType = 'document',
}: EnvelopeRenameDialogProps) => {
const { toast } = useToast();
const { t } = useLingui();
const [title, setTitle] = useState(initialTitle);
useEffect(() => {
if (open) {
setTitle(initialTitle);
}
}, [open, initialTitle]);
const isTemplate = envelopeType === 'template';
const { mutate: updateEnvelope, isPending } = trpcReact.envelope.update.useMutation({
onSuccess: async () => {
await onSuccess?.();
toast({
title: isTemplate ? t`Template Renamed` : t`Document Renamed`,
description: isTemplate
? t`Your template has been successfully renamed.`
: t`Your document has been successfully renamed.`,
duration: 5000,
});
onOpenChange(false);
},
onError: () => {
toast({
description: t`Something went wrong. Please try again.`,
variant: 'destructive',
duration: 7500,
});
},
});
const trimmedTitle = title.trim();
const onRename = () => {
if (!trimmedTitle || trimmedTitle === initialTitle) {
onOpenChange(false);
return;
}
updateEnvelope({
envelopeId: id,
data: {
title: trimmedTitle,
},
});
};
return (
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isTemplate ? <Trans>Rename Template</Trans> : <Trans>Rename Document</Trans>}
</DialogTitle>
</DialogHeader>
<div className="py-2">
<Label htmlFor="title" className="sr-only">
<Trans>Title</Trans>
</Label>
<Input
id="title"
value={title}
placeholder={t`Enter a new title`}
onChange={(e) => setTitle(e.target.value)}
disabled={isPending}
maxLength={DOCUMENT_TITLE_MAX_LENGTH}
className="w-full"
autoFocus
/>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
disabled={isPending || !trimmedTitle || trimmedTitle === initialTitle}
loading={isPending}
onClick={() => void onRename()}
>
<Trans>Rename</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,181 @@
import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
type EnvelopeSaveAsTemplateDialogProps = {
envelopeId: string;
trigger?: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
};
export const EnvelopeSaveAsTemplateDialog = ({
envelopeId,
trigger,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
}: EnvelopeSaveAsTemplateDialogProps) => {
const navigate = useNavigate();
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = controlledOnOpenChange ?? setInternalOpen;
const { toast } = useToast();
const { t } = useLingui();
const team = useCurrentTeam();
const templatesPath = formatTemplatesPath(team.url);
const form = useForm({
defaultValues: {
includeRecipients: true,
includeFields: true,
},
});
const includeRecipients = form.watch('includeRecipients');
const { mutateAsync: saveAsTemplate, isPending } = trpc.envelope.saveAsTemplate.useMutation({
onSuccess: async ({ id }) => {
toast({
title: t`Template Created`,
description: t`Your document has been saved as a template.`,
duration: 5000,
});
await navigate(`${templatesPath}/${id}/edit`);
setOpen(false);
},
});
const onSubmit = async () => {
const { includeRecipients, includeFields } = form.getValues();
try {
await saveAsTemplate({
envelopeId,
includeRecipients,
includeFields: includeRecipients && includeFields,
});
} catch {
toast({
title: t`Something went wrong`,
description: t`This document could not be saved as a template at this time. Please try again.`,
variant: 'destructive',
duration: 7500,
});
}
};
return (
<Dialog
open={open}
onOpenChange={(value) => {
if (isPending) {
return;
}
setOpen(value);
if (!value) {
form.reset();
}
}}
>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Save as Template</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Create a template from this document.</Trans>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Controller
control={form.control}
name="includeRecipients"
render={({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="envelopeIncludeRecipients"
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked === true);
if (!checked) {
form.setValue('includeFields', false);
}
}}
/>
<Label htmlFor="envelopeIncludeRecipients">
<Trans>Include Recipients</Trans>
</Label>
</div>
)}
/>
<Controller
control={form.control}
name="includeFields"
render={({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="envelopeIncludeFields"
checked={field.value}
disabled={!includeRecipients}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
<Label
htmlFor="envelopeIncludeFields"
className={!includeRecipients ? 'opacity-50' : ''}
>
<Trans>Include Fields</Trans>
</Label>
</div>
)}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button type="button" loading={isPending} onClick={onSubmit}>
<Trans>Save as Template</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,175 @@
import { plural } from '@lingui/core/macro';
import { Plural, useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EnvelopesBulkDeleteDialogProps = {
envelopeIds: string[];
envelopeType: EnvelopeType;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const EnvelopesBulkDeleteDialog = ({
envelopeIds,
envelopeType,
open,
onOpenChange,
onSuccess,
...props
}: EnvelopesBulkDeleteDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const trpcUtils = trpc.useUtils();
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
const { mutateAsync: bulkDeleteEnvelopes, isPending } = trpc.envelope.bulk.delete.useMutation({
onSuccess: async (result) => {
// Invalidate the appropriate query based on envelope type.
if (isDocument) {
await trpcUtils.document.findDocumentsInternal.invalidate();
} else {
await trpcUtils.template.findTemplates.invalidate();
}
if (result.failedIds.length > 0) {
toast({
title: isDocument ? t`Documents partially deleted` : t`Templates partially deleted`,
description: t`${plural(result.deletedCount, {
one: '# item deleted.',
other: '# items deleted.',
})} ${plural(result.failedIds.length, {
one: '# item could not be deleted.',
other: '# items could not be deleted.',
})}`,
variant: 'destructive',
});
} else {
toast({
title: isDocument ? t`Documents deleted` : t`Templates deleted`,
description: plural(result.deletedCount, {
one: '# item has been deleted.',
other: '# items have been deleted.',
}),
variant: 'default',
});
}
onSuccess?.();
onOpenChange(false);
},
onError: () => {
toast({
title: t`Error`,
description: t`An error occurred while deleting the items.`,
variant: 'destructive',
});
},
});
return (
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isDocument ? <Trans>Delete Documents</Trans> : <Trans>Delete Templates</Trans>}
</DialogTitle>
<DialogDescription>
{isDocument ? (
<Plural
value={envelopeIds.length}
one="You are about to delete the selected document."
other="You are about to delete # documents."
/>
) : (
<Plural
value={envelopeIds.length}
one="You are about to delete the selected template."
other="You are about to delete # templates."
/>
)}
</DialogDescription>
</DialogHeader>
<Alert variant="warning">
<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">
{isDocument ? (
<>
<li>
<Trans>Selected documents will be permanently deleted</Trans>
</li>
<li>
<Trans>Pending documents will have their signing process cancelled</Trans>
</li>
<li>
<Trans>All recipients will be notified</Trans>
</li>
</>
) : (
<>
<li>
<Trans>Selected templates will be permanently deleted</Trans>
</li>
<li>
<Trans>Direct links associated with templates will be removed</Trans>
</li>
</>
)}
</ul>
</AlertDescription>
</Alert>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
<Trans>Cancel</Trans>
</Button>
<Button
onClick={(e) => {
e.preventDefault();
void bulkDeleteEnvelopes({ envelopeIds });
}}
loading={isPending}
variant="destructive"
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,256 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plural, useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EnvelopesBulkMoveDialogProps = {
envelopeIds: string[];
envelopeType: EnvelopeType;
open: boolean;
onOpenChange: (open: boolean) => void;
currentFolderId?: string;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZBulkMoveFormSchema = z.object({
folderId: z.string().nullable(),
});
type TBulkMoveFormSchema = z.infer<typeof ZBulkMoveFormSchema>;
export const EnvelopesBulkMoveDialog = ({
envelopeIds,
envelopeType,
open,
onOpenChange,
currentFolderId,
onSuccess,
...props
}: EnvelopesBulkMoveDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [searchTerm, setSearchTerm] = useState('');
const form = useForm<TBulkMoveFormSchema>({
resolver: zodResolver(ZBulkMoveFormSchema),
defaultValues: {
folderId: currentFolderId ?? null,
},
});
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
{
parentId: currentFolderId,
type: envelopeType,
},
{
enabled: open,
},
);
const { mutateAsync: bulkMoveEnvelopes } = trpc.envelope.bulk.move.useMutation();
const trpcUtils = trpc.useUtils();
useEffect(() => {
if (open) {
setSearchTerm('');
form.reset({
folderId: currentFolderId,
});
}
}, [open, currentFolderId]);
const onSubmit = async (data: TBulkMoveFormSchema) => {
try {
await bulkMoveEnvelopes({
envelopeIds,
folderId: data.folderId,
envelopeType,
});
// Invalidate the appropriate query based on envelope type.
if (isDocument) {
await trpcUtils.document.findDocumentsInternal.invalidate();
} else {
await trpcUtils.template.findTemplates.invalidate();
}
toast({
description: t`Selected items have been moved.`,
});
onSuccess?.();
onOpenChange(false);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(
AppErrorCode.NOT_FOUND,
() => t`The folder you are trying to move the items to does not exist.`,
)
.with(AppErrorCode.UNAUTHORIZED, () => t`You are not allowed to move these items.`)
.with(AppErrorCode.INVALID_BODY, () => t`All items must be of the same type.`)
.otherwise(() => t`An error occurred while moving the items.`);
toast({
description: errorMessage,
variant: 'destructive',
});
}
};
const filteredFolders = folders?.data.filter((folder) =>
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isDocument ? (
<Trans>Move Documents to Folder</Trans>
) : (
<Trans>Move Templates to Folder</Trans>
)}
</DialogTitle>
<DialogDescription>
{isDocument ? (
<Plural
value={envelopeIds.length}
one="Select a folder to move the selected document to."
other="Select a folder to move the # selected documents to."
/>
) : (
<Plural
value={envelopeIds.length}
one="Select a folder to move the selected template to."
other="Select a folder to move the # selected templates to."
/>
)}
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute left-2 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t`Search folders...`}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
<FormField
control={form.control}
name="folderId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Folder</Trans>
</FormLabel>
<FormControl>
<div className="max-h-96 space-y-2 overflow-y-auto">
{isFoldersLoading ? (
<div className="flex h-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<>
<Button
type="button"
variant={field.value === null ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(null)}
disabled={currentFolderId === undefined}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Home (No Folder)</Trans>
</Button>
{filteredFolders?.map((folder) => (
<Button
key={folder.id}
type="button"
variant={field.value === folder.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(folder.id)}
disabled={currentFolderId === folder.id}
>
<FolderIcon className="mr-2 h-4 w-4" />
{folder.name}
</Button>
))}
{searchTerm && filteredFolders?.length === 0 && (
<div className="px-2 py-2 text-center text-sm text-muted-foreground">
<Trans>No folders found</Trans>
</div>
)}
</>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
disabled={isFoldersLoading || form.formState.isSubmitting}
loading={form.formState.isSubmitting}
>
<Trans>Move</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -40,13 +40,21 @@ type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
export type FolderCreateDialogProps = {
type: FolderType;
trigger?: React.ReactNode;
parentFolderId?: string | null;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDialogProps) => {
export const FolderCreateDialog = ({
type,
trigger,
parentFolderId,
...props
}: FolderCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const { folderId } = useParams();
const parentId = parentFolderId ?? folderId;
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
@@ -62,7 +70,7 @@ export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDial
try {
await createFolder({
name: data.name,
parentId: folderId,
parentId,
type,
});
@@ -77,7 +77,7 @@ export const OrganisationGroupDeleteDialog = ({
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
<Trans context="Removing group from organisation">
You are about to remove the following group from{' '}
<span className="font-semibold">{organisation.name}</span>.
</Trans>
@@ -17,6 +17,7 @@ import { IS_BILLING_ENABLED, SUPPORT_EMAIL } from '@documenso/lib/constants/app'
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { zEmail } from '@documenso/lib/utils/zod';
import { trpc } from '@documenso/trpc/react';
import { ZCreateOrganisationMemberInvitesRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-member-invites.types';
import { cn } from '@documenso/ui/lib/utils';
@@ -94,7 +95,7 @@ type TabTypes = 'INDIVIDUAL' | 'BULK';
const ZImportOrganisationMemberSchema = z.array(
z.object({
email: z.string().email(),
email: zEmail(),
organisationRole: z.nativeEnum(OrganisationMemberRole),
}),
);
@@ -329,12 +330,12 @@ export const OrganisationMemberInviteDialog = ({
onValueChange={(value) => setInvitationType(value as TabTypes)}
>
<TabsList className="w-full">
<TabsTrigger value="INDIVIDUAL" className="hover:text-foreground w-full">
<TabsTrigger value="INDIVIDUAL" className="w-full hover:text-foreground">
<MailIcon size={20} className="mr-2" />
<Trans>Invite Members</Trans>
</TabsTrigger>
<TabsTrigger value="BULK" className="hover:text-foreground w-full">
<TabsTrigger value="BULK" className="w-full hover:text-foreground">
<UsersIcon size={20} className="mr-2" /> <Trans>Bulk Import</Trans>
</TabsTrigger>
</TabsList>
@@ -382,7 +383,7 @@ export const OrganisationMemberInviteDialog = ({
)}
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground max-w-[200px]">
<SelectTrigger className="max-w-[200px] text-muted-foreground">
<SelectValue />
</SelectTrigger>
@@ -447,7 +448,7 @@ export const OrganisationMemberInviteDialog = ({
<div className="mt-4 space-y-4">
<Card gradient className="h-32">
<CardContent
className="text-muted-foreground/80 hover:text-muted-foreground/90 flex h-full cursor-pointer flex-col items-center justify-center rounded-lg p-0 transition-colors"
className="flex h-full cursor-pointer flex-col items-center justify-center rounded-lg p-0 text-muted-foreground/80 transition-colors hover:text-muted-foreground/90"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-5 w-5" />
@@ -5,6 +5,7 @@ import { createCallable } from 'react-call';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zEmail } from '@documenso/lib/utils/zod';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -24,10 +25,7 @@ import {
import { Input } from '@documenso/ui/primitives/input';
const ZSignFieldEmailFormSchema = z.object({
email: z
.string()
.email()
.min(1, { message: msg`Email is required`.id }),
email: zEmail().min(1, { message: msg`Email is required`.id }),
});
type TSignFieldEmailFormSchema = z.infer<typeof ZSignFieldEmailFormSchema>;
@@ -127,7 +127,11 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
};
const mapTextToUrl = (text: string) => {
return text.toLowerCase().replace(/\s+/g, '-');
return text
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/\s+/g, '-');
};
const dialogState = useMemo(() => {
@@ -260,7 +264,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
<Input className="bg-background" {...field} />
</FormControl>
{!form.formState.errors.teamUrl && (
<span className="text-foreground/50 text-xs font-normal">
<span className="text-xs font-normal text-foreground/50">
{field.value ? (
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}`
) : (
@@ -288,7 +292,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
/>
<label
className="text-muted-foreground ml-2 text-sm"
className="ml-2 text-sm text-muted-foreground"
htmlFor="inherit-members"
>
<Trans>Allow all organisation members to access this team</Trans>
@@ -81,7 +81,7 @@ export const TeamGroupDeleteDialog = ({
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
<Trans context="Removing group from team">
You are about to remove the following group from{' '}
<span className="font-semibold">{team.name}</span>.
</Trans>
@@ -1,18 +1,21 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { InfoIcon } from 'lucide-react';
import { InfoIcon, UserPlusIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -44,6 +47,7 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { OrganisationMemberInviteDialog } from '~/components/dialogs/organisation-member-invite-dialog';
import { useCurrentTeam } from '~/providers/team';
export type TeamMemberCreateDialogProps = {
@@ -64,11 +68,20 @@ type TAddTeamMembersFormSchema = z.infer<typeof ZAddTeamMembersFormSchema>;
export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDialogProps) => {
const [open, setOpen] = useState(false);
const [step, setStep] = useState<'SELECT' | 'MEMBERS'>('SELECT');
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
const prevInviteDialogOpenRef = useRef(false);
const { t } = useLingui();
const { toast } = useToast();
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
const utils = trpc.useUtils();
const canInviteOrganisationMembers = canExecuteOrganisationAction(
'MANAGE_ORGANISATION',
organisation.currentOrganisationRole,
);
const form = useForm<TAddTeamMembersFormSchema>({
resolver: zodResolver(ZAddTeamMembersFormSchema),
@@ -96,7 +109,29 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
);
}, [organisationMemberQuery, teamMemberQuery]);
const hasNoAvailableMembers =
!organisationMemberQuery.isLoading && avaliableOrganisationMembers.length === 0;
const onFormSubmit = async ({ members }: TAddTeamMembersFormSchema) => {
if (members.length === 0) {
if (hasNoAvailableMembers && canInviteOrganisationMembers) {
setInviteDialogOpen(true);
return;
}
// Don't show error if on SELECT step - the disabled Next button already communicates this
if (step === 'SELECT') {
return;
}
toast({
title: t`No members selected`,
description: t`Please select at least one member to add to the team.`,
variant: 'destructive',
});
return;
}
try {
await createTeamMembers({
teamId: team.id,
@@ -123,9 +158,20 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
if (!open) {
form.reset();
setStep('SELECT');
setInviteDialogOpen(false);
}
}, [open, form]);
// Invalidate queries when invite dialog closes (transitions from true to false) to refresh available members
useEffect(() => {
if (prevInviteDialogOpenRef.current && !inviteDialogOpen) {
void utils.organisation.member.find.invalidate({
organisationId: team.organisationId,
});
}
prevInviteDialogOpenRef.current = inviteDialogOpen;
}, [inviteDialogOpen, utils, team.organisationId]);
return (
<Dialog
{...props}
@@ -134,9 +180,11 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
// Since it would be annoying to redo the whole process.
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
<Button variant="secondary" onClick={() => setOpen(true)}>
<Trans>Add members</Trans>
</Button>
{trigger ?? (
<Button variant="secondary" onClick={() => setOpen(true)}>
<Trans>Add members</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent hideClose={true} position="center">
@@ -149,7 +197,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
<TooltipTrigger asChild>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-xs">
<TooltipContent className="z-[99999] max-w-xs text-muted-foreground">
<Trans>
To be able to add members to a team, you must first add them to the
organisation. For more information, please see the{' '}
@@ -186,7 +234,18 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
.exhaustive()}
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<form
onSubmit={form.handleSubmit(onFormSubmit)}
onKeyDown={(e) => {
if (e.key === 'Enter' && form.getValues('members').length === 0) {
e.preventDefault();
if (hasNoAvailableMembers && canInviteOrganisationMembers) {
setInviteDialogOpen(true);
}
// Don't show toast - the disabled Next button already communicates this
}
}}
>
<fieldset disabled={form.formState.isSubmitting}>
{step === 'SELECT' && (
<>
@@ -194,46 +253,115 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
control={form.control}
name="members"
render={({ field }) => (
<FormItem>
<FormItem className="space-y-2">
<FormLabel>
<Trans>Members</Trans>
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={avaliableOrganisationMembers.map((member) => ({
label: member.name,
value: member.id,
}))}
loading={organisationMemberQuery.isLoading}
selectedValues={field.value.map(
(member) => member.organisationMemberId,
)}
onChange={(value) => {
field.onChange(
value.map((organisationMemberId) => ({
organisationMemberId,
teamRole:
field.value.find(
(member) =>
member.organisationMemberId === organisationMemberId,
)?.teamRole || TeamMemberRole.MEMBER,
})),
);
}}
className="bg-background w-full"
emptySelectionPlaceholder={t`Select members`}
/>
{hasNoAvailableMembers ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-muted-foreground/25 bg-muted/30 px-6 py-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<UserPlusIcon className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="mb-2 text-sm font-semibold">
<Trans>No organisation members available</Trans>
</h3>
<p className="mb-6 max-w-sm text-sm text-muted-foreground">
{canInviteOrganisationMembers ? (
<Trans>
To add members to this team, you must first add them to the
organisation.
</Trans>
) : (
<Trans>
To add members to this team, they must first be invited to the
organisation. Only organisation admins and managers can invite
new members please contact one of them to invite members on
your behalf.
</Trans>
)}
</p>
{canInviteOrganisationMembers && (
<OrganisationMemberInviteDialog
open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen}
trigger={
<Button type="button" variant="default">
<UserPlusIcon className="mr-2 h-4 w-4" />
<Trans>Invite organisation members</Trans>
</Button>
}
/>
)}
</div>
) : (
<MultiSelectCombobox
options={avaliableOrganisationMembers.map((member) => ({
label: member.name,
value: member.id,
}))}
loading={organisationMemberQuery.isLoading}
selectedValues={field.value.map(
(member) => member.organisationMemberId,
)}
onChange={(value) => {
field.onChange(
value.map((organisationMemberId) => ({
organisationMemberId,
teamRole:
field.value.find(
(member) =>
member.organisationMemberId === organisationMemberId,
)?.teamRole || TeamMemberRole.MEMBER,
})),
);
}}
className="w-full bg-background"
emptySelectionPlaceholder={t`Select members`}
/>
)}
</FormControl>
<FormDescription>
<Trans>Select members to add to this team</Trans>
</FormDescription>
{!hasNoAvailableMembers && (
<>
<FormDescription>
<Trans>Select members to add to this team</Trans>
</FormDescription>
{canInviteOrganisationMembers && (
<Alert
variant="neutral"
className="mt-2 flex items-center gap-2 space-y-0"
>
<div>
<UserPlusIcon className="h-5 w-5 text-muted-foreground" />
</div>
<AlertDescription className="mt-0 flex-1">
<Trans>Can't find someone?</Trans>{' '}
<OrganisationMemberInviteDialog
open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen}
trigger={
<Button
type="button"
variant="link"
className="h-auto p-0 text-sm font-medium text-documenso-700 hover:text-documenso-600"
>
<Trans>Invite them to the organisation first</Trans>
</Button>
}
/>
</AlertDescription>
</Alert>
)}
</>
)}
</FormItem>
)}
/>
<DialogFooter>
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
@@ -1,93 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateDeleteDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
onDelete?: () => Promise<void> | void;
};
export const TemplateDeleteDialog = ({
id,
open,
onOpenChange,
onDelete,
}: TemplateDeleteDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: deleteTemplate, isPending } = trpcReact.template.deleteTemplate.useMutation({
onSuccess: async () => {
await onDelete?.();
toast({
title: _(msg`Template deleted`),
description: _(msg`Your template has been successfully deleted.`),
duration: 5000,
});
onOpenChange(false);
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`This template could not be deleted at this time. Please try again.`),
variant: 'destructive',
duration: 7500,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Do you want to delete this template?</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Please note that this action is irreversible. Once confirmed, your template will be
permanently deleted.
</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="secondary"
disabled={isPending}
onClick={() => onOpenChange(false)}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
variant="destructive"
loading={isPending}
onClick={async () => deleteTemplate({ templateId: id })}
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type Recipient, RecipientRole, type TemplateDirectLink } from '@prisma/client';
import { RecipientRole, type TemplateDirectLink } from '@prisma/client';
import {
CircleDotIcon,
CircleIcon,
@@ -21,6 +21,7 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { DIRECT_TEMPLATE_DOCUMENTATION } from '@documenso/lib/constants/template';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
@@ -52,8 +53,10 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateDirectLinkDialogProps = {
templateId: number;
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
recipients: Recipient[];
recipients: TRecipientLite[];
trigger?: React.ReactNode;
onCreateSuccess?: () => Promise<void> | void;
onDeleteSuccess?: () => Promise<void> | void;
};
type TemplateDirectLinkStep = 'ONBOARD' | 'SELECT_RECIPIENT' | 'MANAGE' | 'CONFIRM_DELETE';
@@ -63,6 +66,8 @@ export const TemplateDirectLinkDialog = ({
directLink,
recipients,
trigger,
onCreateSuccess,
onDeleteSuccess,
}: TemplateDirectLinkDialogProps) => {
const { toast } = useToast();
const { quota, remaining } = useLimits();
@@ -97,6 +102,7 @@ export const TemplateDirectLinkDialog = ({
} = trpcReact.template.createTemplateDirectLink.useMutation({
onSuccess: async (data) => {
await revalidate();
await onCreateSuccess?.();
setToken(data.token);
setIsEnabled(data.enabled);
@@ -142,6 +148,7 @@ export const TemplateDirectLinkDialog = ({
trpcReact.template.deleteTemplateDirectLink.useMutation({
onSuccess: async () => {
await revalidate();
await onDeleteSuccess?.();
setOpen(false);
setToken(null);
@@ -234,7 +241,7 @@ export const TemplateDirectLinkDialog = ({
</div>
<h3 className="font-semibold">{_(step.title)}</h3>
<p className="text-muted-foreground mt-1 text-sm">{_(step.description)}</p>
<p className="mt-1 text-sm text-muted-foreground">{_(step.description)}</p>
</li>
))}
</ul>
@@ -320,13 +327,13 @@ export const TemplateDirectLinkDialog = ({
onClick={async () => onRecipientTableRowClick(row.id)}
>
<TableCell>
<div className="text-muted-foreground text-sm">
<div className="text-sm text-muted-foreground">
<p>{row.name}</p>
<p className="text-muted-foreground/70 text-xs">{row.email}</p>
<p className="text-xs text-muted-foreground/70">{row.email}</p>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
<TableCell className="text-sm text-muted-foreground">
{_(RECIPIENT_ROLES_DESCRIPTION[row.role].roleName)}
</TableCell>
@@ -350,7 +357,7 @@ export const TemplateDirectLinkDialog = ({
<DialogFooter className="mx-auto">
<div className="flex flex-col items-center justify-center">
{validDirectTemplateRecipients.length !== 0 && (
<p className="text-muted-foreground text-sm">
<p className="text-sm text-muted-foreground">
<Trans>Or</Trans>
</p>
)}
@@ -392,7 +399,7 @@ export const TemplateDirectLinkDialog = ({
<TooltipTrigger tabIndex={-1} className="ml-2">
<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">
<Trans>
Disabling direct link signing will prevent anyone from accessing the
link.
@@ -1,89 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateDuplicateDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const TemplateDuplicateDialog = ({
id,
open,
onOpenChange,
}: TemplateDuplicateDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: duplicateTemplate, isPending } =
trpcReact.template.duplicateTemplate.useMutation({
onSuccess: () => {
toast({
title: _(msg`Template duplicated`),
description: _(msg`Your template has been duplicated successfully.`),
duration: 5000,
});
onOpenChange(false);
},
onError: () => {
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while duplicating template.`),
variant: 'destructive',
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Do you want to duplicate this template?</Trans>
</DialogTitle>
<DialogDescription className="pt-2">
<Trans>Your template will be duplicated.</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
disabled={isPending}
variant="secondary"
onClick={() => onOpenChange(false)}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
loading={isPending}
onClick={async () =>
duplicateTemplate({
templateId: id,
})
}
>
<Trans>Duplicate</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -4,7 +4,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, X } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
@@ -21,7 +20,7 @@ import {
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -79,7 +78,7 @@ export type TemplateUseDialogProps = {
envelopeId: string;
templateId: number;
templateSigningOrder?: DocumentSigningOrder | null;
recipients: Recipient[];
recipients: TRecipientLite[];
documentDistributionMethod?: DocumentDistributionMethod;
documentRootPath: string;
trigger?: React.ReactNode;
@@ -1,4 +1,6 @@
import { Trans, useLingui } from '@lingui/react/macro';
import { t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentDistributionMethod } from '@prisma/client';
import { InfoIcon } from 'lucide-react';
import type { Control } from 'react-hook-form';
@@ -44,7 +46,7 @@ export const ConfigureDocumentAdvancedSettings = ({
control,
isSubmitting,
}: ConfigureDocumentAdvancedSettingsProps) => {
const { t } = useLingui();
const { _ } = useLingui();
const form = useFormContext<TConfigureEmbedFormSchema>();
const { features } = useConfigureDocument();
@@ -58,11 +60,11 @@ export const ConfigureDocumentAdvancedSettings = ({
return (
<div>
<h3 className="text-foreground mb-1 text-lg font-medium">
<h3 className="mb-1 text-lg font-medium text-foreground">
<Trans>Advanced Settings</Trans>
</h3>
<p className="text-muted-foreground mb-6 text-sm">
<p className="mb-6 text-sm text-muted-foreground">
<Trans>Configure additional options and preferences</Trans>
</p>
@@ -93,12 +95,12 @@ export const ConfigureDocumentAdvancedSettings = ({
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
label: t(option.label),
label: _(option.label),
value: option.value,
}))}
selectedValues={field.value}
onChange={field.onChange}
className="bg-background w-full"
className="w-full bg-background"
emptySelectionPlaceholder={t`Select signature types`}
/>
</FormControl>
@@ -125,7 +127,7 @@ export const ConfigureDocumentAdvancedSettings = ({
<SelectContent>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
<SelectItem key={code} value={code}>
{language.full}
{_(language.full)}
</SelectItem>
))}
</SelectContent>
@@ -202,7 +204,7 @@ export const ConfigureDocumentAdvancedSettings = ({
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
Add a URL to redirect the user to once the document is signed
</Trans>
@@ -277,7 +279,7 @@ export const ConfigureDocumentAdvancedSettings = ({
<FormControl>
<Input
id="subject"
className="bg-background mt-2"
className="mt-2 bg-background"
disabled={isSubmitting || !isEmailDistribution}
{...field}
/>
@@ -300,7 +302,7 @@ export const ConfigureDocumentAdvancedSettings = ({
<FormControl>
<Textarea
id="message"
className="bg-background mt-2 h-32 resize-none"
className="mt-2 h-32 resize-none bg-background"
disabled={isSubmitting || !isEmailDistribution}
{...field}
/>
@@ -4,10 +4,11 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Cloud, FileText, Loader, X } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { type FileRejection, useDropzone } from 'react-dropzone';
import { useFormContext } from 'react-hook-form';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -82,10 +83,10 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
}
};
const onDropRejected = () => {
const onDropRejected = (fileRejections: FileRejection[]) => {
toast({
title: _(msg`Your document failed to upload.`),
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
description: _(buildDropzoneRejectionDescription(fileRejections)),
duration: 5000,
variant: 'destructive',
});
@@ -144,7 +145,7 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
<div
{...getRootProps()}
className={cn(
'border-border bg-background relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition',
'relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-border bg-background transition',
{
'border-primary/50 bg-primary/5': isDragActive,
'hover:bg-muted/30':
@@ -193,21 +194,21 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
</FormControl>
{isLoading && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
<Loader className="text-muted-foreground h-10 w-10 animate-spin" />
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background/50">
<Loader className="h-10 w-10 animate-spin text-muted-foreground" />
</div>
)}
</div>
) : (
<div className="mt-2 rounded-lg border p-4">
<div className="flex items-center gap-x-4">
<div className="bg-primary/10 text-primary flex h-12 w-12 items-center justify-center rounded-md">
<div className="flex h-12 w-12 items-center justify-center rounded-md bg-primary/10 text-primary">
<FileText className="h-6 w-6" />
</div>
<div className="flex-1">
<div className="text-sm font-medium">{documentData.name}</div>
<div className="text-muted-foreground text-xs">
<div className="text-xs text-muted-foreground">
{formatFileSize(documentData.size)}
</div>
</div>
@@ -6,6 +6,7 @@ import {
ZDocumentMetaLanguageSchema,
} from '@documenso/lib/types/document-meta';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { zEmail } from '@documenso/lib/utils/zod';
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
// Define the schema for configuration
@@ -19,7 +20,7 @@ export const ZConfigureEmbedFormSchema = z.object({
nativeId: z.number().optional(),
formId: z.string(),
name: z.string(),
email: z.string().email('Invalid email address'),
email: zEmail('Invalid email address'),
role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
signingOrder: z.number().optional(),
disabled: z.boolean().optional(),
@@ -4,19 +4,20 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { EnvelopeItem, FieldType } from '@prisma/client';
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
import { base64 } from '@scure/base';
import { ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import { ChevronsUpDown } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
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, getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer';
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { nanoid } from '@documenso/lib/universal/id';
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FieldItem } from '@documenso/ui/primitives/document-flow/field-item';
@@ -24,14 +25,15 @@ import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/type
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { FieldSelector } from '@documenso/ui/primitives/field-selector';
import { Form } from '@documenso/ui/primitives/form/form';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { FieldAdvancedSettingsDrawer } from '~/components/embed/authoring/field-advanced-settings-drawer';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
import type { TConfigureFieldsFormSchema } from './configure-fields-view.types';
import { FieldAdvancedSettingsDrawer } from './field-advanced-settings-drawer';
const MIN_HEIGHT_PX = 12;
const MIN_WIDTH_PX = 36;
@@ -42,7 +44,7 @@ const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
export type ConfigureFieldsViewProps = {
configData: TConfigureEmbedFormSchema;
presignToken?: string | undefined;
envelopeItem?: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
envelopeItem?: Pick<EnvelopeItem, 'id' | 'envelopeId' | 'documentDataId'>;
defaultValues?: Partial<TConfigureFieldsFormSchema>;
onBack?: (data: TConfigureFieldsFormSchema) => void;
onSubmit: (data: TConfigureFieldsFormSchema) => void;
@@ -86,26 +88,25 @@ export const ConfigureFieldsView = ({
const normalizedDocumentData = useMemo(() => {
if (envelopeItem) {
return undefined;
return getDocumentDataUrlForPdfViewer({
envelopeId: envelopeItem.envelopeId,
envelopeItemId: envelopeItem.id,
documentDataId: envelopeItem.documentDataId,
version: 'current',
token: undefined,
presignToken,
});
}
if (!configData.documentData) {
return undefined;
}
return base64.encode(configData.documentData.data);
}, [configData.documentData]);
const normalizedEnvelopeItem = useMemo(() => {
if (envelopeItem) {
return envelopeItem;
}
return { id: '', envelopeId: '' };
}, [envelopeItem]);
return configData.documentData.data;
}, [configData.documentData, envelopeItem, presignToken]);
const recipients = useMemo(() => {
return configData.signers.map<Recipient>((signer, index) => ({
return configData.signers.map<TRecipientLite>((signer, index) => ({
id: signer.nativeId || index,
name: signer.name || '',
email: signer.email || '',
@@ -115,7 +116,9 @@ export const ConfigureFieldsView = ({
templateId: null,
token: '',
documentDeletedAt: null,
expired: null,
expired: null, // !: deprecated Not in use. To be removed in a future migration.
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: null,
rejectionReason: null,
@@ -126,7 +129,7 @@ export const ConfigureFieldsView = ({
}));
}, [configData.signers]);
const [selectedRecipient, setSelectedRecipient] = useState<Recipient | null>(
const [selectedRecipient, setSelectedRecipient] = useState<TRecipientLite | null>(
() => recipients.find((r) => r.signingStatus === SigningStatus.NOT_SIGNED) || null,
);
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
@@ -153,9 +156,7 @@ export const ConfigureFieldsView = ({
});
const selectedRecipientIndex = recipients.findIndex((r) => r.id === selectedRecipient?.id);
const selectedRecipientStyles = useRecipientColors(
selectedRecipientIndex === -1 ? 0 : selectedRecipientIndex,
);
const selectedRecipientStyles = getRecipientColorStyles(selectedRecipientIndex);
const form = useForm<TConfigureFieldsFormSchema>({
defaultValues: {
@@ -177,8 +178,6 @@ export const ConfigureFieldsView = ({
name: 'fields',
});
const highestPageNumber = Math.max(...localFields.map((field) => field.pageNumber));
const onFieldCopy = useCallback(
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
const { duplicate = false, duplicateAll = false } = options ?? {};
@@ -203,13 +202,15 @@ export const ConfigureFieldsView = ({
}
if (duplicateAll) {
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
const totalPages = getPdfPagesCount();
pages.forEach((_, index) => {
const pageNumber = index + 1;
if (totalPages < 1) {
return;
}
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
if (pageNumber === lastActiveField.pageNumber) {
return;
continue;
}
const newField: TConfigureFieldsFormSchema['fields'][0] = {
@@ -222,7 +223,7 @@ export const ConfigureFieldsView = ({
};
append(newField);
});
}
return;
}
@@ -546,17 +547,11 @@ export const ConfigureFieldsView = ({
<Form {...form}>
<div>
<PDFViewerLazy
presignToken={presignToken}
overrideData={normalizedDocumentData}
envelopeItem={normalizedEnvelopeItem}
token={undefined}
version="signed"
/>
{normalizedDocumentData && (
<PDFViewerLazy data={normalizedDocumentData} scrollParentRef="window" />
)}
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{localFields.map((field, index) => {
const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
@@ -1,11 +1,14 @@
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
export const EmbedClientLoading = () => {
return (
<div className="bg-background fixed left-0 top-0 z-[9999] flex h-full w-full items-center justify-center">
<div className="fixed left-0 top-0 z-[9999] flex h-full w-full items-center justify-center bg-background">
<Loader className="mr-2 h-4 w-4 animate-spin" />
<span>Loading...</span>
<span>
<Trans>Loading...</Trans>
</span>
</div>
);
};
@@ -3,37 +3,49 @@ import { useEffect, useLayoutEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, EnvelopeItem, Recipient, Signature } from '@prisma/client';
import { type Field, FieldType } from '@prisma/client';
import {
type DocumentMeta,
type EnvelopeItem,
type Field,
FieldType,
type Recipient,
type Signature,
} from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { DateTime } from 'luxon';
import { useSearchParams } from 'react-router';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { ZDirectTemplateEmbedDataSchema } from '@documenso/lib/types/embed-direct-template-schema';
import {
isFieldUnsignedAndRequired,
isRequiredField,
} from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { zEmail } from '@documenso/lib/utils/zod';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { BrandingLogo } from '~/components/general/branding-logo';
import { ZDirectTemplateEmbedDataSchema } from '~/types/embed-direct-template-schema';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { injectCss } from '~/utils/css-vars';
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
@@ -47,7 +59,7 @@ export type EmbedDirectTemplateClientPageProps = {
token: string;
envelopeId: string;
updatedAt: Date;
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId' | 'documentDataId'>[];
recipient: Recipient;
fields: Field[];
metadata?: DocumentMeta | null;
@@ -84,19 +96,18 @@ export const EmbedDirectTemplateClientPage = ({
const [isNameLocked, setIsNameLocked] = useState(false);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const [emailError, setEmailError] = useState<string | null>(null);
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields);
const [pendingFields, _completedFields] = [
localFields.filter((field) => isFieldUnsignedAndRequired(field)),
sortFieldsByPosition(localFields.filter((field) => isFieldUnsignedAndRequired(field))),
localFields.filter((field) => field.inserted),
];
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
const hasSignatureField = localFields.some((field) => isSignatureFieldType(field.type));
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
@@ -199,6 +210,14 @@ export const EmbedDirectTemplateClientPage = ({
return;
}
const { success: isEmailValid } = zEmail().safeParse(email);
if (!isEmailValid) {
setEmailError(_(msg`A valid email is required`));
setIsExpanded(true);
return;
}
let directTemplateExternalId = searchParams?.get('externalId') || undefined;
if (directTemplateExternalId) {
@@ -284,12 +303,19 @@ export const EmbedDirectTemplateClientPage = ({
cssVars: data.cssVars,
});
}
if (data.language && data.language !== APP_I18N_OPTIONS.sourceLang) {
void dynamicActivate(data.language).finally(() => {
setHasFinishedInit(true);
});
} else {
setHasFinishedInit(true);
}
} catch (err) {
console.error(err);
setHasFinishedInit(true);
}
setHasFinishedInit(true);
// !: While the two setters are stable we still want to ensure we're avoiding
// !: re-renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -335,9 +361,15 @@ export const EmbedDirectTemplateClientPage = ({
{/* Viewer */}
<div className="flex-1">
<PDFViewerLazy
envelopeItem={envelopeItems[0]}
token={recipient.token}
version="signed"
data={getDocumentDataUrlForPdfViewer({
envelopeId: envelopeItems[0]?.envelopeId,
envelopeItemId: envelopeItems[0]?.id,
documentDataId: envelopeItems[0]?.documentDataId,
version: 'current',
token: recipient.token,
presignToken: undefined,
})}
scrollParentRef="window"
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>
</div>
@@ -421,11 +453,23 @@ export const EmbedDirectTemplateClientPage = ({
<Input
type="email"
id="email"
className="mt-2 bg-background"
className={cn(
'mt-2 bg-background',
emailError && 'border-destructive ring-2 ring-destructive/20',
)}
disabled={isEmailLocked}
value={email}
onChange={(e) => !isEmailLocked && setEmail(e.target.value.trim())}
onChange={(e) => {
if (!isEmailLocked) {
setEmail(e.target.value.trim());
setEmailError(null);
}
}}
/>
{emailError && (
<p className="mt-2 text-xs font-medium text-destructive">{emailError}</p>
)}
</div>
{hasSignatureField && (
@@ -471,15 +515,15 @@ export const EmbedDirectTemplateClientPage = ({
</div>
</div>
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
>
{showPendingFieldTooltip && pendingFields.length > 0 && (
{showPendingFieldTooltip && pendingFields.length > 0 && (
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pendingFields[0].page}"]`}
>
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
<Trans>Click to insert field</Trans>
</FieldToolTip>
)}
</ElementVisible>
</ElementVisible>
)}
{/* Fields */}
<EmbedDocumentFields
@@ -492,7 +536,9 @@ export const EmbedDirectTemplateClientPage = ({
{!hidePoweredBy && (
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100">
<span>Powered by</span>
<span>
<Trans>Powered by</Trans>
</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
)}
@@ -50,10 +50,8 @@ export const EmbedDocumentFields = ({
onSignField,
onUnsignField,
}: EmbedDocumentFieldsProps) => {
const highestPageNumber = Math.max(...fields.map((field) => field.page));
return (
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map((field) =>
match(field.type)
.with(FieldType.SIGNATURE, () => (
@@ -4,13 +4,18 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, EnvelopeItem } from '@prisma/client';
import { type Field, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
import { type Field, RecipientRole, SigningStatus } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
import {
@@ -22,15 +27,14 @@ import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { BrandingLogo } from '~/components/general/branding-logo';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { injectCss } from '~/utils/css-vars';
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
@@ -44,7 +48,7 @@ export type EmbedSignDocumentV1ClientPageProps = {
token: string;
documentId: number;
envelopeId: string;
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
envelopeItems: (Pick<EnvelopeItem, 'id' | 'envelopeId'> & { documentData: { id: string } })[];
recipient: RecipientWithFields;
fields: Field[];
completedFields: DocumentField[];
@@ -72,7 +76,7 @@ export const EmbedSignDocumentV1ClientPage = ({
const { _ } = useLingui();
const { toast } = useToast();
const { fullName, email, signature, setFullName, setSignature } =
const { fullName, email, signature, setFullName, setEmail, setSignature } =
useRequiredDocumentSigningContext();
const [hasFinishedInit, setHasFinishedInit] = useState(false);
@@ -87,6 +91,7 @@ export const EmbedSignDocumentV1ClientPage = ({
const [isExpanded, setIsExpanded] = useState(false);
const [isNameLocked, setIsNameLocked] = useState(false);
const [isEmailLocked, setIsEmailLocked] = useState(!!email);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const [_showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
useState(false);
@@ -99,14 +104,14 @@ export const EmbedSignDocumentV1ClientPage = ({
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
const [pendingFields, _completedFields] = [
fields.filter(
(field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field),
sortFieldsByPosition(
fields.filter(
(field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field),
),
),
fields.filter((field) => field.inserted),
];
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
trpc.recipient.completeDocumentWithToken.useMutation();
@@ -115,7 +120,7 @@ export const EmbedSignDocumentV1ClientPage = ({
[fields],
);
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
const hasSignatureField = fields.some((field) => isSignatureFieldType(field.type));
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
@@ -203,13 +208,19 @@ export const EmbedSignDocumentV1ClientPage = ({
try {
const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
if (!isCompleted && data.name) {
if (!isCompleted && data.name && !fullName) {
setFullName(data.name);
}
// Since a recipient can be provided a name we can lock it without requiring
// a to be provided by the parent application, unlike direct templates.
setIsNameLocked(!!data.lockName);
if (!isCompleted && data.email && !email) {
setEmail(data.email);
setIsEmailLocked(!!data.lockEmail);
}
setAllowDocumentRejection(!!data.allowDocumentRejection);
setShowOtherRecipientsCompletedFields(!!data.showOtherRecipientsCompletedFields);
@@ -223,12 +234,19 @@ export const EmbedSignDocumentV1ClientPage = ({
cssVars: data.cssVars,
});
}
if (data.language && data.language !== APP_I18N_OPTIONS.sourceLang) {
void dynamicActivate(data.language).finally(() => {
setHasFinishedInit(true);
});
} else {
setHasFinishedInit(true);
}
} catch (err) {
console.error(err);
setHasFinishedInit(true);
}
setHasFinishedInit(true);
// !: While the two setters are stable we still want to ensure we're avoiding
// !: re-renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -287,9 +305,15 @@ export const EmbedSignDocumentV1ClientPage = ({
{/* Viewer */}
<div className="embed--DocumentViewer flex-1">
<PDFViewerLazy
envelopeItem={envelopeItems[0]}
token={token}
version="signed"
data={getDocumentDataUrlForPdfViewer({
envelopeId: envelopeItems[0]?.envelopeId,
envelopeItemId: envelopeItems[0]?.id,
documentDataId: envelopeItems[0]?.documentData.id,
version: 'current',
token: token,
presignToken: undefined,
})}
scrollParentRef="window"
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>
</div>
@@ -441,7 +465,8 @@ export const EmbedSignDocumentV1ClientPage = ({
id="email"
className="mt-2 bg-background"
value={email}
disabled
onChange={(e) => setEmail(e.target.value)}
disabled={isEmailLocked}
/>
</div>
@@ -490,15 +515,15 @@ export const EmbedSignDocumentV1ClientPage = ({
</div>
</div>
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
>
{showPendingFieldTooltip && pendingFields.length > 0 && (
{showPendingFieldTooltip && pendingFields.length > 0 && (
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pendingFields[0].page}"]`}
>
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
<Trans>Click to insert field</Trans>
</FieldToolTip>
)}
</ElementVisible>
</ElementVisible>
)}
{/* Fields */}
<EmbedDocumentFields fields={fields} metadata={metadata} />
@@ -509,7 +534,9 @@ export const EmbedSignDocumentV1ClientPage = ({
{!hidePoweredBy && (
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100">
<span>Powered by</span>
<span>
<Trans>Powered by</Trans>
</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
)}
@@ -1,10 +1,13 @@
import { useEffect, useLayoutEffect, useState } from 'react';
import { useLingui } from '@lingui/react';
import { EnvelopeType } from '@prisma/client';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { ZSignDocumentEmbedDataSchema } from '~/types/embed-document-sign-schema';
import { injectCss } from '~/utils/css-vars';
import { DocumentSigningPageViewV2 } from '../general/document-signing/document-signing-page-view-v2';
@@ -25,7 +28,7 @@ export const EmbedSignDocumentV2ClientPage = ({
}: EmbedSignDocumentV2ClientPageProps) => {
const { _ } = useLingui();
const { envelope, recipient, envelopeData, setFullName, fullName } =
const { envelope, recipient, envelopeData, setFullName, setEmail, fullName, email } =
useRequiredEnvelopeSigningContext();
const { isCompleted, isRejected, recipientSignature } = envelopeData;
@@ -35,6 +38,9 @@ export const EmbedSignDocumentV2ClientPage = ({
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
const [isNameLocked, setIsNameLocked] = useState(false);
const [isEmailLocked, setIsEmailLocked] = useState(
envelope.type === EnvelopeType.DOCUMENT && !!email,
);
const onDocumentCompleted = (data: {
token: string;
@@ -126,12 +132,26 @@ export const EmbedSignDocumentV2ClientPage = ({
const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
if (!isCompleted && data.name) {
setFullName(data.name);
// For documents, only use the hash name if the recipient doesn't already have one.
// For templates, always allow the hash name to be used.
if (envelope.type === EnvelopeType.TEMPLATE || !fullName) {
setFullName(data.name);
}
}
// Since a recipient can be provided a name we can lock it without requiring
// a to be provided by the parent application, unlike direct templates.
setIsNameLocked(!!data.lockName);
if (!isCompleted && data.email) {
// For documents, only use the hash email if the recipient doesn't already have one.
// For templates, always allow the hash email to be used.
if (envelope.type === EnvelopeType.TEMPLATE || !email) {
setEmail(data.email);
setIsEmailLocked(!!data.lockEmail);
}
}
setAllowDocumentRejection(!!data.allowDocumentRejection);
if (data.darkModeDisabled) {
@@ -144,12 +164,19 @@ export const EmbedSignDocumentV2ClientPage = ({
cssVars: data.cssVars,
});
}
if (data.language && data.language !== APP_I18N_OPTIONS.sourceLang) {
void dynamicActivate(data.language).finally(() => {
setHasFinishedInit(true);
});
} else {
setHasFinishedInit(true);
}
} catch (err) {
console.error(err);
setHasFinishedInit(true);
}
setHasFinishedInit(true);
// !: While the setters are stable we still want to ensure we're avoiding
// !: re-renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -213,6 +240,7 @@ export const EmbedSignDocumentV2ClientPage = ({
return (
<EmbedSigningProvider
isNameLocked={isNameLocked}
isEmailLocked={isEmailLocked}
hidePoweredBy={hidePoweredBy}
allowDocumentRejection={allowDocumentRejection}
onDocumentCompleted={onDocumentCompleted}
@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
export const EmbedRecipientExpired = () => {
const [hasPostedMessage, setHasPostedMessage] = useState(false);
useEffect(() => {
if (window.parent && !hasPostedMessage) {
window.parent.postMessage(
{
action: 'recipient-expired',
data: null,
},
'*',
);
}
setHasPostedMessage(true);
}, [hasPostedMessage]);
if (!hasPostedMessage) {
return null;
}
return (
<div className="embed--RecipientExpired relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<h3 className="text-center text-2xl font-bold text-foreground">
<Trans>Signing Window Expired</Trans>
</h3>
<div className="mt-8 max-w-[50ch] text-center">
<p className="text-sm text-muted-foreground">
<Trans>
Your signing window for this document has expired. Please contact the sender for a new
invitation.
</Trans>
</p>
<p className="mt-4 text-sm text-muted-foreground">
<Trans>Please check with the parent application for more information.</Trans>
</p>
</div>
</div>
);
};
@@ -3,12 +3,15 @@ import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, SigningStatus } from '@prisma/client';
import { DocumentStatus, SigningStatus } from '@prisma/client';
import { Loader, LucideChevronDown, LucideChevronUp, X } from 'lucide-react';
import { P, match } from 'ts-pattern';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
@@ -21,10 +24,11 @@ import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider';
import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog';
import { EmbedDocumentFields } from '../embed-document-fields';
@@ -83,17 +87,17 @@ export const MultiSignDocumentSigningView = ({
const { mutateAsync: completeDocumentWithToken } =
trpc.recipient.completeDocumentWithToken.useMutation();
const hasSignatureField = document?.fields.some((field) => field.type === FieldType.SIGNATURE);
const hasSignatureField = document?.fields.some((field) => isSignatureFieldType(field.type));
const [pendingFields, completedFields] = [
document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ??
[],
sortFieldsByPosition(
document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ??
[],
),
document?.fields.filter((field) => field.recipient.signingStatus === SigningStatus.SIGNED) ??
[],
];
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? [];
const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => {
@@ -177,8 +181,8 @@ export const MultiSignDocumentSigningView = ({
};
return (
<div className="min-h-screen overflow-hidden bg-background">
<div id="document-field-portal-root" className="relative h-full w-full overflow-y-auto p-8">
<div className="min-h-screen bg-background">
<div className="relative h-full w-full p-8">
{match({ isLoading, document })
.with({ isLoading: true }, () => (
<div className="flex min-h-[400px] w-full items-center justify-center">
@@ -226,9 +230,15 @@ export const MultiSignDocumentSigningView = ({
})}
>
<PDFViewerLazy
envelopeItem={document.envelopeItems[0]}
token={token}
version="signed"
data={getDocumentDataUrlForPdfViewer({
envelopeId: document.envelopeId,
envelopeItemId: document.envelopeItems[0]?.id,
documentDataId: document.documentData.id,
version: 'current',
token,
presignToken: undefined,
})}
scrollParentRef="window"
onDocumentLoad={() => {
setHasDocumentLoaded(true);
onDocumentReady?.();
@@ -359,13 +369,11 @@ export const MultiSignDocumentSigningView = ({
</div>
</div>
)}
</div>
{hasDocumentLoaded && (
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
>
{showPendingFieldTooltip && pendingFields.length > 0 && (
{hasDocumentLoaded && showPendingFieldTooltip && pendingFields.length > 0 && (
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pendingFields[0].page}"]`}
>
<FieldToolTip
key={pendingFields[0].id}
field={pendingFields[0]}
@@ -373,27 +381,27 @@ export const MultiSignDocumentSigningView = ({
>
<Trans>Click to insert field</Trans>
</FieldToolTip>
)}
</ElementVisible>
)}
</ElementVisible>
)}
{/* Fields */}
{hasDocumentLoaded && (
<EmbedDocumentFields
fields={pendingFields}
metadata={document.documentMeta}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
)}
{/* Fields */}
{hasDocumentLoaded && (
<EmbedDocumentFields
fields={pendingFields}
metadata={document.documentMeta}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
)}
{/* Completed fields */}
{document.status !== DocumentStatus.COMPLETED && (
<DocumentReadOnlyFields
documentMeta={document.documentMeta ?? undefined}
fields={completedFields}
/>
)}
{/* Completed fields */}
{document.status !== DocumentStatus.COMPLETED && (
<DocumentReadOnlyFields
documentMeta={document.documentMeta ?? undefined}
fields={completedFields}
/>
)}
</div>
</>
))
.otherwise(() => null)}
@@ -98,7 +98,7 @@ export function BrandingPreferencesForm({
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}`
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`;
setPreviewUrl(logoUrl);
setPreviewUrl(logoUrl + '?v=' + Date.now());
setHasLoadedPreview(true);
}
}
@@ -173,7 +173,7 @@ export function BrandingPreferencesForm({
/>
<div className="relative flex w-full flex-col gap-y-4">
{!isBrandingEnabled && <div className="bg-background/60 absolute inset-0 z-[9998]" />}
{!isBrandingEnabled && <div className="absolute inset-0 z-[9998] bg-background/60" />}
<FormField
control={form.control}
@@ -185,7 +185,7 @@ export function BrandingPreferencesForm({
</FormLabel>
<div className="flex flex-col gap-4">
<div className="border-border bg-background relative h-48 w-full overflow-hidden rounded-lg border">
<div className="relative h-48 w-full overflow-hidden rounded-lg border border-border bg-background">
{previewUrl ? (
<img
src={previewUrl}
@@ -193,12 +193,12 @@ export function BrandingPreferencesForm({
className="h-full w-full object-contain p-4"
/>
) : (
<div className="bg-muted/20 dark:bg-muted text-muted-foreground relative flex h-full w-full items-center justify-center text-sm">
<div className="relative flex h-full w-full items-center justify-center bg-muted/20 text-sm text-muted-foreground dark:bg-muted">
<Trans>Please upload a logo</Trans>
{!hasLoadedPreview && (
<div className="bg-muted dark:bg-muted absolute inset-0 z-[999] flex items-center justify-center">
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
<div className="absolute inset-0 z-[999] flex items-center justify-center bg-muted dark:bg-muted">
<Loader className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
</div>
@@ -243,7 +243,7 @@ export function BrandingPreferencesForm({
type="button"
variant="link"
size="sm"
className="text-destructive text-xs"
className="text-xs text-destructive"
onClick={() => {
setPreviewUrl('');
onChange(null);
@@ -1,6 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { msg, t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TeamGlobalSettings } from '@prisma/client';
import { DocumentVisibility, OrganisationType, type RecipientRole } from '@prisma/client';
@@ -11,6 +11,14 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
import {
type TEnvelopeExpirationPeriod,
ZEnvelopeExpirationPeriod,
} from '@documenso/lib/constants/envelope-expiration';
import {
type TEnvelopeReminderSettings,
ZEnvelopeReminderSettings,
} from '@documenso/lib/constants/envelope-reminder';
import {
SUPPORTED_LANGUAGES,
SUPPORTED_LANGUAGE_CODES,
@@ -27,6 +35,8 @@ import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { Alert } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@@ -70,6 +80,8 @@ export type TDocumentPreferencesFormSchema = {
defaultRecipients: TDefaultRecipients | null;
delegateDocumentOwnership: boolean | null;
aiFeaturesEnabled: boolean | null;
envelopeExpirationPeriod: TEnvelopeExpirationPeriod | null;
reminderSettings: TEnvelopeReminderSettings | null;
};
type SettingsSubset = Pick<
@@ -87,6 +99,8 @@ type SettingsSubset = Pick<
| 'defaultRecipients'
| 'delegateDocumentOwnership'
| 'aiFeaturesEnabled'
| 'envelopeExpirationPeriod'
| 'reminderSettings'
>;
export type DocumentPreferencesFormProps = {
@@ -102,7 +116,7 @@ export const DocumentPreferencesForm = ({
canInherit,
isAiFeaturesConfigured = false,
}: DocumentPreferencesFormProps) => {
const { t } = useLingui();
const { _ } = useLingui();
const { user, organisations } = useSession();
const currentOrganisation = useCurrentOrganisation();
const optionalTeam = useOptionalCurrentTeam();
@@ -126,6 +140,8 @@ export const DocumentPreferencesForm = ({
defaultRecipients: ZDefaultRecipientsSchema.nullable(),
delegateDocumentOwnership: z.boolean().nullable(),
aiFeaturesEnabled: z.boolean().nullable(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable(),
reminderSettings: ZEnvelopeReminderSettings.nullable(),
});
const form = useForm<TDocumentPreferencesFormSchema>({
@@ -146,6 +162,8 @@ export const DocumentPreferencesForm = ({
: null,
delegateDocumentOwnership: settings.delegateDocumentOwnership,
aiFeaturesEnabled: settings.aiFeaturesEnabled,
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
reminderSettings: settings.reminderSettings ?? null,
},
resolver: zodResolver(ZDocumentPreferencesFormSchema),
});
@@ -233,7 +251,7 @@ export const DocumentPreferencesForm = ({
<SelectContent>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
<SelectItem key={code} value={code}>
{language.full}
{_(language.full)}
</SelectItem>
))}
@@ -333,7 +351,7 @@ export const DocumentPreferencesForm = ({
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
label: t(option.label),
label: _(option.label),
value: option.value,
}))}
selectedValues={field.value}
@@ -669,6 +687,64 @@ export const DocumentPreferencesForm = ({
)}
/>
<FormField
control={form.control}
name="envelopeExpirationPeriod"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Envelope Expiration</Trans>
</FormLabel>
<FormControl>
<ExpirationPeriodPicker
value={field.value}
onChange={field.onChange}
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
/>
</FormControl>
<FormDescription>
<Trans>
Controls how long recipients have to complete signing before the document
expires. After expiration, recipients can no longer sign the document.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="reminderSettings"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Signing Reminders</Trans>
</FormLabel>
<FormControl>
<ReminderSettingsPicker
value={field.value}
onChange={field.onChange}
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
/>
</FormControl>
<FormDescription>
<Trans>
Controls when and how often reminder emails are sent to recipients who have not
yet completed signing.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{isAiFeaturesConfigured && (
<FormField
control={form.control}
@@ -184,7 +184,10 @@ export const EditorFieldCheckboxForm = ({
</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectTrigger
data-testid="field-form-direction"
className="w-full bg-background text-muted-foreground"
>
<SelectValue placeholder={t`Select direction`} />
</SelectTrigger>
<SelectContent position="popper">
@@ -214,7 +217,10 @@ export const EditorFieldCheckboxForm = ({
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectTrigger
data-testid="field-form-validationRule"
className="w-full bg-background text-muted-foreground"
>
<SelectValue placeholder={t`Select at least`} />
</SelectTrigger>
<SelectContent position="popper">
@@ -260,7 +266,10 @@ export const EditorFieldCheckboxForm = ({
void form.trigger();
}}
>
<SelectTrigger className="text-muted-foreground bg-background mt-5 w-full">
<SelectTrigger
data-testid="field-form-validationLength"
className="mt-5 w-full bg-background text-muted-foreground"
>
<SelectValue placeholder={t`Pick a number`} />
</SelectTrigger>
<SelectContent position="popper">
@@ -295,7 +304,7 @@ export const EditorFieldCheckboxForm = ({
<Trans>Checkbox values</Trans>
</p>
<button type="button" onClick={() => addValue()}>
<button type="button" data-testid="field-form-values-add" onClick={() => addValue()}>
<PlusIcon className="h-4 w-4" />
</button>
</div>
@@ -310,7 +319,8 @@ export const EditorFieldCheckboxForm = ({
<FormItem>
<FormControl>
<Checkbox
className="data-[state=checked]:bg-primary border-foreground/30 h-5 w-5"
data-testid={`field-form-values-${index}-checked`}
className="h-5 w-5 border-foreground/30 data-[state=checked]:bg-primary"
checked={field.value}
onCheckedChange={field.onChange}
/>
@@ -325,7 +335,11 @@ export const EditorFieldCheckboxForm = ({
render={({ field }) => (
<FormItem>
<FormControl>
<Input className="w-full" {...field} />
<Input
data-testid={`field-form-values-${index}-value`}
className="w-full"
{...field}
/>
</FormControl>
</FormItem>
)}
@@ -333,6 +347,7 @@ export const EditorFieldCheckboxForm = ({
<button
type="button"
data-testid={`field-form-values-${index}-remove`}
className="flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => removeValue(index)}
>
@@ -96,7 +96,7 @@ export const EditorFieldDropdownForm = ({
mode: 'onChange',
defaultValues: {
defaultValue: value.defaultValue,
values: value.values || [{ value: 'Option 1' }],
values: value.values || [{ value: t`Option 1` }],
required: value.required || false,
readOnly: value.readOnly || false,
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
@@ -110,13 +110,13 @@ export const EditorFieldDropdownForm = ({
const addValue = () => {
const currentValues = form.getValues('values') || [];
let newValue = 'New option';
let newValue = t`New option`;
// Iterate to create a unique value
for (let i = 0; i < currentValues.length; i++) {
newValue = `New option ${i + 1}`;
if (currentValues.some((item) => item.value === `New option ${i + 1}`)) {
newValue = `New option ${i + 1}`;
newValue = t`New option ${i + 1}`;
if (currentValues.some((item) => item.value === t`New option ${i + 1}`)) {
newValue = t`New option ${i + 1}`;
} else {
break;
}
@@ -176,7 +176,10 @@ export const EditorFieldDropdownForm = ({
value={field.value ?? '-1'}
onValueChange={(value) => field.onChange(value === '-1' ? undefined : value)}
>
<SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectTrigger
data-testid="field-form-defaultValue"
className="w-full bg-background text-muted-foreground"
>
<SelectValue placeholder={t`Default Value`} />
</SelectTrigger>
<SelectContent position="popper">
@@ -215,7 +218,7 @@ export const EditorFieldDropdownForm = ({
<Trans>Dropdown values</Trans>
</p>
<button type="button" onClick={addValue}>
<button type="button" data-testid="field-form-values-add" onClick={addValue}>
<PlusIcon className="h-4 w-4" />
</button>
</div>
@@ -229,7 +232,7 @@ export const EditorFieldDropdownForm = ({
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Input {...field} />
<Input data-testid={`field-form-values-${index}-value`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -238,6 +241,7 @@ export const EditorFieldDropdownForm = ({
<button
type="button"
data-testid={`field-form-values-${index}-remove`}
className="flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => removeValue(index)}
>
@@ -50,6 +50,7 @@ export const EditorGenericFontSizeField = ({
</FormLabel>
<FormControl>
<Input
data-testid="field-form-fontSize"
type="number"
min={8}
max={96}
@@ -88,7 +89,7 @@ export const EditorGenericTextAlignField = ({
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger>
<SelectTrigger data-testid="field-form-textAlign">
<SelectValue placeholder={t`Select text align`} />
</SelectTrigger>
<SelectContent>
@@ -131,7 +132,7 @@ export const EditorGenericVerticalAlignField = ({
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger>
<SelectTrigger data-testid="field-form-verticalAlign">
<SelectValue placeholder={t`Select vertical align`} />
</SelectTrigger>
<SelectContent>
@@ -174,6 +175,7 @@ export const EditorGenericLineHeightField = ({
</FormLabel>
<FormControl>
<Input
data-testid="field-form-lineHeight"
type="number"
min={FIELD_MIN_LINE_HEIGHT}
max={FIELD_MAX_LINE_HEIGHT}
@@ -209,6 +211,7 @@ export const EditorGenericLetterSpacingField = ({
</FormLabel>
<FormControl>
<Input
data-testid="field-form-letterSpacing"
type="number"
min={FIELD_MIN_LETTER_SPACING}
max={FIELD_MAX_LETTER_SPACING}
@@ -250,12 +253,13 @@ export const EditorGenericRequiredField = ({
<FormControl>
<div className="flex items-center">
<Checkbox
data-testid="field-form-required"
id="field-required"
checked={field.value}
onCheckedChange={field.onChange}
/>
<label className="text-muted-foreground ml-2 text-sm" htmlFor="field-required">
<label className="ml-2 text-sm text-muted-foreground" htmlFor="field-required">
<Trans>Required Field</Trans>
</label>
</div>
@@ -293,12 +297,13 @@ export const EditorGenericReadOnlyField = ({
<FormControl>
<div className="flex items-center">
<Checkbox
data-testid="field-form-readOnly"
id="field-read-only"
checked={field.value}
onCheckedChange={field.onChange}
/>
<label className="text-muted-foreground ml-2 text-sm" htmlFor="field-read-only">
<label className="ml-2 text-sm text-muted-foreground" htmlFor="field-read-only">
<Trans>Read Only</Trans>
</label>
</div>
@@ -329,7 +334,7 @@ export const EditorGenericLabelField = ({
<Trans>Label</Trans>
</FormLabel>
<FormControl>
<Input placeholder={t`Field label`} {...field} />
<Input data-testid="field-form-label" placeholder={t`Field label`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -167,7 +167,12 @@ export const EditorFieldNumberForm = ({
<Trans>Placeholder</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" placeholder={t`Placeholder`} {...field} />
<Input
data-testid="field-form-placeholder"
className="bg-background"
placeholder={t`Placeholder`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -183,7 +188,12 @@ export const EditorFieldNumberForm = ({
<Trans>Value</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" placeholder={t`Value`} {...field} />
<Input
data-testid="field-form-value"
className="bg-background"
placeholder={t`Value`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -203,7 +213,10 @@ export const EditorFieldNumberForm = ({
value={field.value === null ? '-1' : field.value}
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
>
<SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectTrigger
data-testid="field-form-numberFormat"
className="w-full bg-background text-muted-foreground"
>
<SelectValue placeholder={t`Field format`} />
</SelectTrigger>
<SelectContent position="popper">
@@ -257,8 +270,9 @@ export const EditorFieldNumberForm = ({
</FormLabel>
<FormControl>
<Input
data-testid="field-form-minValue"
className="bg-background"
placeholder="E.g. 0"
placeholder={t`E.g. 0`}
{...field}
value={field.value ?? ''}
onChange={(e) =>
@@ -281,8 +295,9 @@ export const EditorFieldNumberForm = ({
</FormLabel>
<FormControl>
<Input
data-testid="field-form-maxValue"
className="bg-background"
placeholder="E.g. 100"
placeholder={t`E.g. 100`}
{...field}
value={field.value ?? ''}
onChange={(e) =>
@@ -79,7 +79,7 @@ export const EditorFieldRadioForm = ({
mode: 'onChange',
defaultValues: {
label: value.label || '',
values: value.values || [{ id: 1, checked: false, value: 'Default value' }],
values: value.values || [{ id: 1, checked: false, value: t`Default value` }],
required: value.required || false,
readOnly: value.readOnly || false,
direction: value.direction || 'vertical',
@@ -140,7 +140,10 @@ export const EditorFieldRadioForm = ({
</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectTrigger
data-testid="field-form-direction"
className="w-full bg-background text-muted-foreground"
>
<SelectValue placeholder={t`Select direction`} />
</SelectTrigger>
<SelectContent position="popper">
@@ -172,7 +175,7 @@ export const EditorFieldRadioForm = ({
<Trans>Radio values</Trans>
</p>
<button type="button" onClick={addValue}>
<button type="button" data-testid="field-form-values-add" onClick={addValue}>
<PlusIcon className="h-4 w-4" />
</button>
</div>
@@ -187,7 +190,8 @@ export const EditorFieldRadioForm = ({
<FormItem>
<FormControl>
<Checkbox
className="data-[state=checked]:bg-primary border-foreground/30 h-5 w-5"
data-testid={`field-form-values-${index}-checked`}
className="h-5 w-5 border-foreground/30 data-[state=checked]:bg-primary"
checked={field.value}
onCheckedChange={(value) => {
// Uncheck all other values.
@@ -216,7 +220,11 @@ export const EditorFieldRadioForm = ({
render={({ field }) => (
<FormItem>
<FormControl>
<Input className="w-full" {...field} />
<Input
data-testid={`field-form-values-${index}-value`}
className="w-full"
{...field}
/>
</FormControl>
</FormItem>
)}
@@ -224,6 +232,7 @@ export const EditorFieldRadioForm = ({
<button
type="button"
data-testid={`field-form-values-${index}-remove`}
className="flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => removeValue(index)}
>
@@ -134,7 +134,7 @@ export const EditorFieldTextForm = ({
<Trans>Label</Trans>
</FormLabel>
<FormControl>
<Input placeholder={t`Field label`} {...field} />
<Input data-testid="field-form-label" placeholder={t`Field label`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -150,7 +150,11 @@ export const EditorFieldTextForm = ({
<Trans>Placeholder</Trans>
</FormLabel>
<FormControl>
<Input placeholder={t`Field placeholder`} {...field} />
<Input
data-testid="field-form-placeholder"
placeholder={t`Field placeholder`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -167,6 +171,7 @@ export const EditorFieldTextForm = ({
</FormLabel>
<FormControl>
<Textarea
data-testid="field-form-text"
className="h-auto"
placeholder={t`Add text to the field`}
{...field}
@@ -200,6 +205,7 @@ export const EditorFieldTextForm = ({
</FormLabel>
<FormControl>
<Input
data-testid="field-form-characterLimit"
className="bg-background"
placeholder={t`Character limit`}
{...field}
@@ -10,6 +10,7 @@ import {
DEFAULT_DOCUMENT_EMAIL_SETTINGS,
ZDocumentEmailSettingsSchema,
} from '@documenso/lib/types/document-email';
import { zEmail } from '@documenso/lib/utils/zod';
import { trpc } from '@documenso/trpc/react';
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
import { Button } from '@documenso/ui/primitives/button';
@@ -33,7 +34,7 @@ import {
const ZEmailPreferencesFormSchema = z.object({
emailId: z.string().nullable(),
emailReplyTo: z.string().email().nullable(),
emailReplyTo: zEmail().nullable(),
// emailReplyToName: z.string(),
emailDocumentSettings: ZDocumentEmailSettingsSchema.nullable(),
});
@@ -219,7 +220,8 @@ export const EmailPreferencesForm = ({
<FormDescription>
<Trans>
Controls the default email settings when new documents or templates are created
Controls the default email settings when new documents or templates are created.
Updating these settings will not affect existing documents or templates.
</Trans>
</FormDescription>
</FormItem>
@@ -7,6 +7,7 @@ import { useNavigate } from 'react-router';
import { z } from 'zod';
import { authClient } from '@documenso/auth/client';
import { zEmail } from '@documenso/lib/utils/zod';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -21,7 +22,7 @@ import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZForgotPasswordFormSchema = z.object({
email: z.string().email().min(1),
email: zEmail().min(1),
});
export type TForgotPasswordFormSchema = z.infer<typeof ZForgotPasswordFormSchema>;
@@ -6,6 +6,7 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { authClient } from '@documenso/auth/client';
import { zEmail } from '@documenso/lib/utils/zod';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -20,7 +21,7 @@ import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZSendConfirmationEmailFormSchema = z.object({
email: z.string().email().min(1),
email: zEmail().min(1),
});
export type TSendConfirmationEmailFormSchema = z.infer<typeof ZSendConfirmationEmailFormSchema>;
+31 -3
View File
@@ -1,10 +1,12 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TurnstileInstance } from '@marsidev/react-turnstile';
import { Turnstile } from '@marsidev/react-turnstile';
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
import { KeyRoundIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
@@ -16,7 +18,9 @@ import { z } from 'zod';
import { authClient } from '@documenso/auth/client';
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
import { AppError } from '@documenso/lib/errors/app-error';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod';
import { trpc } from '@documenso/trpc/react';
import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils';
@@ -58,7 +62,7 @@ const handleFallbackErrorMessages = (code: string) => {
const LOGIN_REDIRECT_PATH = '/';
export const ZSignInFormSchema = z.object({
email: z.string().email().min(1),
email: zEmail().min(1),
password: ZCurrentPasswordSchema,
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
@@ -100,6 +104,10 @@ export const SignInForm = ({
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY');
const turnstileRef = useRef<TurnstileInstance>(null);
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
const redirectPath = useMemo(() => {
@@ -216,6 +224,7 @@ export const SignInForm = ({
password,
totpCode,
backupCode,
captchaToken: captchaToken ?? undefined,
redirectPath,
});
} catch (err) {
@@ -250,6 +259,10 @@ export const SignInForm = ({
AuthenticationErrorCode.InvalidTwoFactorCode,
() => msg`The two-factor authentication code provided is incorrect.`,
)
.with(
AppErrorCode.INVALID_CAPTCHA,
() => msg`We were unable to verify that you're human. Please try again.`,
)
.otherwise(() => handleFallbackErrorMessages(error.code));
toast({
@@ -257,6 +270,9 @@ export const SignInForm = ({
description: _(errorMessage),
variant: 'destructive',
});
turnstileRef.current?.reset();
setCaptchaToken(null);
}
};
@@ -377,6 +393,18 @@ export const SignInForm = ({
)}
/>
{turnstileSiteKey && (
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
onSuccess={setCaptchaToken}
onExpire={() => setCaptchaToken(null)}
options={{
size: 'invisible',
}}
/>
)}
<Button
type="submit"
size="lg"
+86 -73
View File
@@ -1,10 +1,12 @@
import { useEffect } from 'react';
import { useEffect, useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TurnstileInstance } from '@marsidev/react-turnstile';
import { Turnstile } from '@marsidev/react-turnstile';
import { useForm } from 'react-hook-form';
import { FaIdCardClip } from 'react-icons/fa6';
import { FcGoogle } from 'react-icons/fc';
@@ -15,6 +17,8 @@ import communityCardsImage from '@documenso/assets/images/community-cards.png';
import { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -39,7 +43,7 @@ export const ZSignUpFormSchema = z
.string()
.trim()
.min(1, { message: msg`Please enter a valid name.`.id }),
email: z.string().email().min(1),
email: zEmail().min(1),
password: ZPasswordSchema,
signature: z.string().min(1, { message: msg`We need your signature to sign documents`.id }),
})
@@ -54,8 +58,8 @@ export const ZSignUpFormSchema = z
},
);
export const signupErrorMessages: Record<string, MessageDescriptor> = {
SIGNUP_DISABLED: msg`Signups are disabled.`,
export const SIGNUP_ERROR_MESSAGES: Record<string, MessageDescriptor> = {
SIGNUP_DISABLED: msg`Signup is currently disabled or not available for your email domain.`,
[AppErrorCode.ALREADY_EXISTS]: msg`User with this email already exists. Please use a different email address.`,
[AppErrorCode.INVALID_REQUEST]: msg`We were unable to create your account. Please review the information you provided and try again.`,
};
@@ -88,6 +92,11 @@ export const SignUpForm = ({
const utmSrc = searchParams.get('utm_source') ?? null;
const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY');
const turnstileRef = useRef<TurnstileInstance>(null);
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
const form = useForm<TSignUpFormSchema>({
@@ -110,6 +119,7 @@ export const SignUpForm = ({
email,
password,
signature,
captchaToken: captchaToken ?? undefined,
});
await navigate(returnTo ? returnTo : '/unverified-account');
@@ -130,13 +140,17 @@ export const SignUpForm = ({
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = signupErrorMessages[error.code] ?? signupErrorMessages.INVALID_REQUEST;
const errorMessage =
SIGNUP_ERROR_MESSAGES[error.code] ?? SIGNUP_ERROR_MESSAGES.INVALID_REQUEST;
toast({
title: _(msg`An error occurred`),
description: _(errorMessage),
variant: 'destructive',
});
turnstileRef.current?.reset();
setCaptchaToken(null);
}
};
@@ -196,7 +210,7 @@ export const SignUpForm = ({
return (
<div className={cn('flex justify-center gap-x-12', className)}>
<div className="border-border relative hidden flex-1 overflow-hidden rounded-xl border xl:flex">
<div className="relative hidden flex-1 overflow-hidden rounded-xl border border-border xl:flex">
<div className="absolute -inset-8 -z-[2] backdrop-blur">
<img
src={communityCardsImage}
@@ -205,17 +219,17 @@ export const SignUpForm = ({
/>
</div>
<div className="bg-background/50 absolute -inset-8 -z-[1] backdrop-blur-[2px]" />
<div className="absolute -inset-8 -z-[1] bg-background/50 backdrop-blur-[2px]" />
<div className="relative flex h-full w-full flex-col items-center justify-evenly">
<div className="bg-background rounded-2xl border px-4 py-1 text-sm font-medium">
<div className="rounded-2xl border bg-background px-4 py-1 text-sm font-medium">
<Trans>User profiles are here!</Trans>
</div>
<div className="w-full max-w-md">
<UserProfileTimur
rows={2}
className="bg-background border-border rounded-2xl border shadow-md"
className="rounded-2xl border border-border bg-background shadow-md"
/>
</div>
@@ -223,13 +237,13 @@ export const SignUpForm = ({
</div>
</div>
<div className="border-border dark:bg-background relative z-10 flex min-h-[min(850px,80vh)] w-full max-w-lg flex-col rounded-xl border bg-neutral-100 p-6">
<div className="relative z-10 flex min-h-[min(850px,80vh)] w-full max-w-lg flex-col rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
<div className="h-20">
<h1 className="text-xl font-semibold md:text-2xl">
<Trans>Create a new account</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-xs md:text-sm">
<p className="mt-2 text-xs text-muted-foreground md:text-sm">
<Trans>
Create your account and start using state-of-the-art document signing. Open and
beautiful signing is within your grasp.
@@ -244,13 +258,7 @@ export const SignUpForm = ({
className="flex w-full flex-1 flex-col gap-y-4"
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset
className={cn(
'flex h-[550px] w-full flex-col gap-y-4',
hasSocialAuthEnabled && 'h-[650px]',
)}
disabled={isSubmitting}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
name="name"
@@ -322,71 +330,76 @@ export const SignUpForm = ({
)}
/>
{turnstileSiteKey && (
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
onSuccess={setCaptchaToken}
onExpire={() => setCaptchaToken(null)}
options={{
size: 'flexible',
appearance: 'interaction-only',
}}
/>
)}
{hasSocialAuthEnabled && (
<>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">
<Trans>Or</Trans>
</span>
<div className="bg-border h-px flex-1" />
</div>
</>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="h-px flex-1 bg-border" />
<span className="bg-transparent text-muted-foreground">
<Trans>Or</Trans>
</span>
<div className="h-px flex-1 bg-border" />
</div>
)}
{isGoogleSSOEnabled && (
<>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
<Trans>Sign Up with Google</Trans>
</Button>
</>
<Button
type="button"
size="lg"
variant={'outline'}
className="border bg-background text-muted-foreground"
disabled={isSubmitting}
onClick={onSignUpWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
<Trans>Sign Up with Google</Trans>
</Button>
)}
{isMicrosoftSSOEnabled && (
<>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithMicrosoftClick}
>
<img
className="mr-2 h-4 w-4"
alt="Microsoft Logo"
src={'/static/microsoft.svg'}
/>
<Trans>Sign Up with Microsoft</Trans>
</Button>
</>
<Button
type="button"
size="lg"
variant={'outline'}
className="border bg-background text-muted-foreground"
disabled={isSubmitting}
onClick={onSignUpWithMicrosoftClick}
>
<img
className="mr-2 h-4 w-4"
alt="Microsoft Logo"
src={'/static/microsoft.svg'}
/>
<Trans>Sign Up with Microsoft</Trans>
</Button>
)}
{isOIDCSSOEnabled && (
<>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithOIDCClick}
>
<FaIdCardClip className="mr-2 h-5 w-5" />
<Trans>Sign Up with OIDC</Trans>
</Button>
</>
<Button
type="button"
size="lg"
variant={'outline'}
className="border bg-background text-muted-foreground"
disabled={isSubmitting}
onClick={onSignUpWithOIDCClick}
>
<FaIdCardClip className="mr-2 h-5 w-5" />
<Trans>Sign Up with OIDC</Trans>
</Button>
)}
<p className="text-muted-foreground mt-4 text-sm">
<p className="mt-4 text-sm text-muted-foreground">
<Trans>
Already have an account?{' '}
<Link to="/signin" className="text-documenso-700 duration-200 hover:opacity-70">
@@ -402,11 +415,11 @@ export const SignUpForm = ({
size="lg"
className="mt-6 w-full"
>
<Trans>Complete</Trans>
<Trans>Create account</Trans>
</Button>
</form>
</Form>
<p className="text-muted-foreground mt-6 text-xs">
<p className="mt-6 text-xs text-muted-foreground">
<Trans>
By proceeding, you agree to our{' '}
<Link
@@ -2,10 +2,13 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type { SubscriptionClaim } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { Link } from 'react-router';
import type { z } from 'zod';
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
import { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Form,
@@ -24,15 +27,22 @@ type SubscriptionClaimFormProps = {
subscriptionClaim: Omit<SubscriptionClaim, 'id' | 'createdAt' | 'updatedAt'>;
onFormSubmit: (data: SubscriptionClaimFormValues) => Promise<void>;
formSubmitTrigger?: React.ReactNode;
licenseFlags?: TLicenseClaim;
};
export const SubscriptionClaimForm = ({
subscriptionClaim,
onFormSubmit,
formSubmitTrigger,
licenseFlags,
}: SubscriptionClaimFormProps) => {
const { t } = useLingui();
const hasRestrictedEnterpriseFeatures = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).some(
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
(flag) => flag.isEnterprise && !licenseFlags?.[flag.key as keyof TLicenseClaim],
);
const form = useForm<SubscriptionClaimFormValues>({
resolver: zodResolver(ZCreateSubscriptionClaimRequestSchema),
defaultValues: {
@@ -142,34 +152,59 @@ export const SubscriptionClaimForm = ({
</FormLabel>
<div className="mt-2 space-y-2 rounded-md border p-4">
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label }) => (
<FormField
key={key}
control={form.control}
name={`flags.${key}`}
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center">
<Checkbox
id={`flag-${key}`}
checked={field.value}
onCheckedChange={field.onChange}
/>
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(
({ key, label, isEnterprise }) => {
const isRestrictedFeature =
isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
htmlFor={`flag-${key}`}
>
{label}
</label>
</div>
</FormControl>
</FormItem>
)}
/>
))}
return (
<FormField
key={key}
control={form.control}
name={`flags.${key}`}
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center">
<Checkbox
id={`flag-${key}`}
checked={field.value}
onCheckedChange={field.onChange}
disabled={isRestrictedFeature && !field.value} // Allow disabling of restricted features.
/>
<label
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={`flag-${key}`}
>
{label}
{isRestrictedFeature && ' ¹'}
</label>
</div>
</FormControl>
</FormItem>
)}
/>
);
},
)}
</div>
{hasRestrictedEnterpriseFeatures && (
<Alert variant="neutral" className="mt-4">
<AlertDescription>
<span>¹&nbsp;</span>
<Trans>Your current license does not include these features.</Trans>{' '}
<Link
to="https://docs.documenso.com/users/licenses/enterprise-edition"
target="_blank"
className="text-foreground underline hover:opacity-80"
>
<Trans>Learn more</Trans>
</Link>
</AlertDescription>
</Alert>
)}
</div>
{formSubmitTrigger}
@@ -0,0 +1,45 @@
import type { ReactNode } from 'react';
import { cn } from '@documenso/ui/lib/utils';
export type DetailsCardProps = {
label: ReactNode;
action?: ReactNode;
children: ReactNode;
};
export const DetailsCard = ({ label, action, children }: DetailsCardProps) => {
return (
<div className="rounded-md border bg-muted/30 px-3 py-2">
<div className="flex min-h-9 items-center justify-between gap-3">
<span className="text-muted-foreground">{label}</span>
{action ?? null}
</div>
<div className="mt-2 min-h-9">{children}</div>
</div>
);
};
export type DetailsValueProps = {
children: ReactNode;
isMono?: boolean;
isSelectable?: boolean;
};
export const DetailsValue = ({
children,
isMono = true,
isSelectable = false,
}: DetailsValueProps) => {
return (
<div
className={cn(
'flex min-h-10 items-center break-all rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground',
isMono && 'font-mono',
isSelectable && 'select-all',
)}
>
{children}
</div>
);
};
@@ -0,0 +1,163 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client';
import { DOCUMENT_VISIBILITY } from '@documenso/lib/constants/document-visibility';
import {
type TDocumentEmailSettings,
ZDocumentEmailSettingsSchema,
} from '@documenso/lib/types/document-email';
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
const EMAIL_SETTINGS_LABELS: Record<keyof TDocumentEmailSettings, MessageDescriptor> = {
recipientSigningRequest: msg`Recipient signing request`,
recipientRemoved: msg`Recipient removed`,
recipientSigned: msg`Recipient signed`,
documentPending: msg`Document pending`,
documentCompleted: msg`Document completed`,
documentDeleted: msg`Document deleted`,
ownerDocumentCompleted: msg`Owner document completed`,
ownerRecipientExpired: msg`Owner recipient expired`,
ownerDocumentCreated: msg`Owner document created`,
};
const emailSettingsKeys = Object.keys(EMAIL_SETTINGS_LABELS) as (keyof TDocumentEmailSettings)[];
type AdminGlobalSettingsSectionProps = {
settings: TeamGlobalSettings | OrganisationGlobalSettings | null;
isTeam?: boolean;
};
export const AdminGlobalSettingsSection = ({
settings,
isTeam = false,
}: AdminGlobalSettingsSectionProps) => {
const { _ } = useLingui();
const notSetLabel = isTeam ? <Trans>Inherited</Trans> : <Trans>Not set</Trans>;
if (!settings) {
return null;
}
const textValue = (value: string | null | undefined) => {
if (value === null || value === undefined) {
return notSetLabel;
}
return value;
};
const brandingTextValue = (value: string | null | undefined) => {
if (value === null || value === undefined || value.trim() === '') {
return notSetLabel;
}
return value;
};
const booleanValue = (value: boolean | null | undefined) => {
if (value === null || value === undefined) {
return notSetLabel;
}
return value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>;
};
const parsedEmailSettings = ZDocumentEmailSettingsSchema.safeParse(
settings.emailDocumentSettings,
);
return (
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
<DetailsCard label={<Trans>Document visibility</Trans>}>
<DetailsValue>
{settings.documentVisibility != null
? _(DOCUMENT_VISIBILITY[settings.documentVisibility].value)
: notSetLabel}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Document language</Trans>}>
<DetailsValue>{textValue(settings.documentLanguage)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Document timezone</Trans>}>
<DetailsValue>{textValue(settings.documentTimezone)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Date format</Trans>}>
<DetailsValue>{textValue(settings.documentDateFormat)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include sender details</Trans>}>
<DetailsValue>{booleanValue(settings.includeSenderDetails)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include signing certificate</Trans>}>
<DetailsValue>{booleanValue(settings.includeSigningCertificate)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include audit log</Trans>}>
<DetailsValue>{booleanValue(settings.includeAuditLog)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Delegate document ownership</Trans>}>
<DetailsValue>{booleanValue(settings.delegateDocumentOwnership)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Typed signature</Trans>}>
<DetailsValue>{booleanValue(settings.typedSignatureEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Upload signature</Trans>}>
<DetailsValue>{booleanValue(settings.uploadSignatureEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Draw signature</Trans>}>
<DetailsValue>{booleanValue(settings.drawSignatureEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding</Trans>}>
<DetailsValue>{booleanValue(settings.brandingEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding logo</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingLogo)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding URL</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingUrl)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding company details</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingCompanyDetails)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Email reply-to</Trans>}>
<DetailsValue>{textValue(settings.emailReplyTo)}</DetailsValue>
</DetailsCard>
{isTeam && parsedEmailSettings.success && (
<DetailsCard label={<Trans>Email document settings</Trans>}>
<div className="mt-1 space-y-1 pb-2 pr-3 text-xs">
{emailSettingsKeys.map((key) => (
<div key={key} className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">{_(EMAIL_SETTINGS_LABELS[key])}</span>
<span>
{parsedEmailSettings.data[key] ? <Trans>On</Trans> : <Trans>Off</Trans>}
</span>
</div>
))}
</div>
</DetailsCard>
)}
<DetailsCard label={<Trans>AI features</Trans>}>
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled)}</DetailsValue>
</DetailsCard>
</div>
);
};
@@ -0,0 +1,237 @@
import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import {
ArrowRightIcon,
CheckCircle2Icon,
EyeIcon,
EyeOffIcon,
KeyRoundIcon,
Loader2Icon,
RefreshCwIcon,
XCircleIcon,
} from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, useRevalidator } from 'react-router';
import { match } from 'ts-pattern';
import type { TCachedLicense } from '@documenso/lib/types/license';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { CardMetric } from './metric-card';
type AdminLicenseCardProps = {
licenseData: TCachedLicense | null;
};
export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
const { t, i18n } = useLingui();
const [isLicenseKeyVisible, setIsLicenseKeyVisible] = useState(false);
const { license } = licenseData || {};
if (!license) {
return (
<div className="relative">
<div className="absolute right-3 top-3 z-10">
<AdminLicenseResyncButton />
</div>
<CardMetric icon={KeyRoundIcon} title={t`License`} className="h-fit max-h-fit">
<div className="mt-1 flex items-center justify-center gap-2">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-dashed border-muted-foreground/30 bg-muted/50">
<KeyRoundIcon className="h-5 w-5 text-muted-foreground/50" />
</div>
<div className="flex flex-col gap-0.5">
{licenseData?.requestedLicenseKey ? (
<>
<p className="text-sm font-medium text-destructive">
<Trans>Invalid License Key</Trans>
</p>
{/* Don't need to hide invalid license keys. */}
<p className="text-xs text-muted-foreground">{licenseData.requestedLicenseKey}</p>
</>
) : (
<p className="text-sm font-medium text-muted-foreground">
<Trans>No License Configured</Trans>
</p>
)}
<Link
to="https://docs.documenso.com/users/licenses/enterprise-edition"
target="_blank"
className="flex flex-row items-center text-xs text-muted-foreground hover:text-muted-foreground/80"
>
<Trans>Learn more</Trans> <ArrowRightIcon className="h-3 w-3" />
</Link>
</div>
</div>
</CardMetric>
</div>
);
}
const enabledFlags = Object.entries(license.flags).filter(([, enabled]) => enabled);
return (
<div className="relative max-w-full overflow-hidden rounded-lg border border-border bg-background px-4 pb-6 pt-4 shadow shadow-transparent duration-200 hover:shadow-border/80">
<div className="absolute right-3 top-3">
<AdminLicenseResyncButton />
</div>
<div className="flex items-start gap-2">
<div className="h-4 w-4">
<KeyRoundIcon className="h-4 w-4 text-muted-foreground" />
</div>
<h3 className="text-primary-forground mb-2 flex items-end text-sm font-medium leading-tight">
<Trans>Documenso License</Trans>
</h3>
{match(license.status)
.with('ACTIVE', () => (
<Badge variant="default" size="small">
<CheckCircle2Icon className="mr-1 h-3 w-3" />
<Trans context="Subscription status">Active</Trans>
</Badge>
))
.with('PAST_DUE', () => (
<Badge variant="warning" size="small">
<XCircleIcon className="mr-1 h-3 w-3" />
<Trans context="Subscription status">Past Due</Trans>
</Badge>
))
.with('EXPIRED', () => (
<Badge variant="destructive" size="small">
<XCircleIcon className="mr-1 h-3 w-3" />
<Trans context="Subscription status">Expired</Trans>
</Badge>
))
.otherwise(() => null)}
</div>
<div className="mt-4 grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-foreground">
<Trans>License</Trans>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">{license.name}</p>
</div>
<div>
<p className="text-sm font-medium text-foreground">
<Trans>Expires</Trans>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{i18n.date(license.periodEnd, DateTime.DATE_MED)}
</p>
</div>
<div>
<p className="text-sm font-medium text-foreground">
<Trans>License Key</Trans>
</p>
<div className="mt-0.5 flex items-center gap-1">
<p className="min-w-0 break-all text-xs text-muted-foreground">
{isLicenseKeyVisible ? license.licenseKey : '•'.repeat(license.licenseKey.length)}
</p>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground"
aria-label={isLicenseKeyVisible ? t`Hide license key` : t`Show license key`}
onClick={() => setIsLicenseKeyVisible((prevState) => !prevState)}
>
{isLicenseKeyVisible ? (
<EyeOffIcon className="h-3.5 w-3.5" />
) : (
<EyeIcon className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
<div>
<p className="text-sm font-medium text-foreground">
<Trans>Features</Trans>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{enabledFlags.length > 0 ? (
enabledFlags
.map(
([flag]) =>
SUBSCRIPTION_CLAIM_FEATURE_FLAGS[
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
flag as keyof typeof SUBSCRIPTION_CLAIM_FEATURE_FLAGS
]?.label || flag,
)
.join(', ')
) : (
<Trans>No features enabled</Trans>
)}
</p>
</div>
</div>
</div>
);
};
const AdminLicenseResyncButton = () => {
const { t } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const { mutate: resyncLicense, isPending: isResyncingLicense } =
trpc.admin.license.resync.useMutation({
onSuccess: async () => {
toast({
title: t`License synced`,
});
await revalidate();
},
onError: () => {
toast({
title: t`Failed to sync license`,
variant: 'destructive',
});
},
});
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
disabled={isResyncingLicense}
onClick={() => resyncLicense()}
>
{isResyncingLicense ? (
<Loader2Icon className="h-4 w-4 animate-spin" />
) : (
<RefreshCwIcon className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<Trans>Sync license from server</Trans>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
@@ -0,0 +1,78 @@
import { Trans } from '@lingui/react/macro';
import { AlertTriangleIcon, KeyRoundIcon } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import type { TCachedLicense } from '@documenso/lib/types/license';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type AdminLicenseStatusBannerProps = {
license: TCachedLicense | null;
};
export const AdminLicenseStatusBanner = ({ license }: AdminLicenseStatusBannerProps) => {
const licenseStatus = license?.derivedStatus;
if (!license || licenseStatus === 'ACTIVE' || licenseStatus === 'NOT_FOUND') {
return null;
}
return (
<div
className={cn('mb-8 rounded-lg bg-yellow-200 text-yellow-900 dark:bg-yellow-400', {
'bg-destructive text-destructive-foreground':
licenseStatus === 'EXPIRED' || licenseStatus === 'UNAUTHORIZED',
})}
>
<div className="flex items-center justify-between gap-x-4 px-4 py-3 text-sm font-medium">
<div className="flex items-center">
<AlertTriangleIcon className="mr-2.5 h-5 w-5" />
{match(licenseStatus)
.with('PAST_DUE', () => (
<Trans>
License Payment Overdue - Please update your payment to avoid service disruptions.
</Trans>
))
.with('EXPIRED', () => (
<Trans>
License Expired - Please renew your license to continue using enterprise features.
</Trans>
))
.with('UNAUTHORIZED', () =>
license ? (
<Trans>
Invalid License Type - Your Documenso instance is using features that are not part
of your license.
</Trans>
) : (
<Trans>
Missing License - Your Documenso instance is using features that require a
license.
</Trans>
),
)
.otherwise(() => null)}
</div>
<Button
variant="outline"
size="sm"
className={cn({
'border-yellow-900/30 text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500':
licenseStatus === 'PAST_DUE',
'border-destructive-foreground/30 text-destructive-foreground hover:bg-destructive/80':
licenseStatus === 'EXPIRED' || licenseStatus === 'UNAUTHORIZED',
})}
asChild
>
<Link to="https://docs.documenso.com/users/licenses/enterprise-edition" target="_blank">
<KeyRoundIcon className="mr-1.5 h-4 w-4" />
<Trans>See Documentation</Trans>
</Link>
</Button>
</div>
</div>
);
};
@@ -4,6 +4,7 @@ import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { keepPreviousData } from '@tanstack/react-query';
import { CheckIcon, Loader, Monitor, Moon, Sun } from 'lucide-react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useNavigate } from 'react-router';
@@ -65,14 +66,16 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
const [pages, setPages] = useState<string[]>([]);
const debouncedSearch = useDebouncedValue(search, 200);
const hasValidSearch = debouncedSearch.trim().length > 0;
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
const { data: searchDocumentsData, isFetching: isFetchingDocuments } =
trpcReact.document.search.useQuery(
{
query: debouncedSearch,
},
{
placeholderData: (previousData) => previousData,
enabled: open === true && hasValidSearch,
placeholderData: keepPreviousData,
// Do not batch this due to relatively long request time compared to
// other queries which are generally batched with this.
...SKIP_QUERY_BATCH_META,
@@ -80,6 +83,19 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
},
);
const { data: searchTemplatesData, isFetching: isFetchingTemplates } =
trpcReact.template.search.useQuery(
{
query: debouncedSearch,
},
{
enabled: open === true && hasValidSearch,
placeholderData: keepPreviousData,
...SKIP_QUERY_BATCH_META,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const teamUrl = useMemo(() => {
let teamUrl = currentTeam?.url || null;
@@ -134,17 +150,23 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
];
}, [currentTeam, organisations]);
const searchResults = useMemo(() => {
if (!searchDocumentsData) {
return [];
}
const documentSearchResults =
hasValidSearch && searchDocumentsData
? searchDocumentsData.map((document) => ({
label: document.title,
path: document.path,
value: document.value,
}))
: [];
return searchDocumentsData.map((document) => ({
label: document.title,
path: document.path,
value: document.value,
}));
}, [searchDocumentsData]);
const templateSearchResults =
hasValidSearch && searchTemplatesData
? searchTemplatesData.map((template) => ({
label: template.title,
path: template.path,
value: template.value,
}))
: [];
const currentPage = pages[pages.length - 1];
@@ -222,19 +244,9 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
/>
<CommandList>
{isSearchingDocuments ? (
<CommandEmpty>
<div className="flex items-center justify-center">
<span className="animate-spin">
<Loader />
</span>
</div>
</CommandEmpty>
) : (
<CommandEmpty>
<Trans>No results found.</Trans>
</CommandEmpty>
)}
<CommandEmpty>
<Trans>No results found.</Trans>
</CommandEmpty>
{!currentPage && (
<>
@@ -256,16 +268,34 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Preferences`)}>
<CommandItem className="-mx-2 -my-1 rounded-lg" onSelect={() => addPage('language')}>
Change language
{_(msg`Change language`)}
</CommandItem>
<CommandItem className="-mx-2 -my-1 rounded-lg" onSelect={() => addPage('theme')}>
Change theme
{_(msg`Change theme`)}
</CommandItem>
</CommandGroup>
{searchResults.length > 0 && (
{(isFetchingDocuments || documentSearchResults.length > 0) && (
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Your documents`)}>
<Commands push={push} pages={searchResults} />
{isFetchingDocuments ? (
<div className="flex items-center justify-center py-2">
<Loader className="h-4 w-4 animate-spin" />
</div>
) : (
<Commands push={push} pages={documentSearchResults} />
)}
</CommandGroup>
)}
{(isFetchingTemplates || templateSearchResults.length > 0) && (
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Your templates`)}>
{isFetchingTemplates ? (
<div className="flex items-center justify-center py-2">
<Loader className="h-4 w-4 animate-spin" />
</div>
) : (
<Commands push={push} pages={templateSearchResults} />
)}
</CommandGroup>
)}
</>
@@ -367,15 +397,14 @@ const LanguageCommands = () => {
return Object.values(SUPPORTED_LANGUAGES).map((language) => (
<CommandItem
disabled={isLoading}
key={language.full}
key={language.short}
onSelect={async () => setLanguage(language.short)}
className="-my-1 mx-2 rounded-lg first:mt-2 last:mb-2"
>
<CheckIcon
className={cn('mr-2 h-4 w-4', i18n.locale === language.short ? 'opacity-100' : 'opacity-0')}
/>
{language.full}
{_(language.full)}
</CommandItem>
));
};
@@ -97,13 +97,13 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
{menuNavigationLinks.map(({ href, text }) => (
<Link
key={href}
className="text-foreground hover:text-foreground/80 flex items-center gap-2 text-2xl font-semibold"
className="flex items-center gap-2 text-2xl font-semibold text-foreground hover:text-foreground/80"
to={href}
onClick={() => handleMenuItemClick()}
>
{text}
{href === '/inbox' && unreadCountData && unreadCountData.count > 0 && (
<span className="bg-primary text-primary-foreground flex h-6 min-w-[1.5rem] items-center justify-center rounded-full px-1.5 text-xs font-semibold">
<span className="flex h-6 min-w-[1.5rem] items-center justify-center rounded-full bg-primary px-1.5 text-xs font-semibold text-primary-foreground">
{unreadCountData.count > 99 ? '99+' : unreadCountData.count}
</span>
)}
@@ -111,7 +111,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
))}
<button
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
className="text-2xl font-semibold text-foreground hover:text-foreground/80"
onClick={async () => authClient.signOut()}
>
<Trans>Sign Out</Trans>
@@ -123,8 +123,10 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
<ThemeSwitcher />
</div>
<p className="text-muted-foreground text-sm">
© {new Date().getFullYear()} Documenso, Inc. <br /> All rights reserved.
<p className="text-sm text-muted-foreground">
© {new Date().getFullYear()} Documenso, Inc.
<br />
<Trans>All rights reserved.</Trans>
</p>
</div>
</SheetContent>
@@ -1,12 +1,12 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type { Recipient } from '@prisma/client';
import { DocumentStatus } from '@prisma/client';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -14,7 +14,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { StackAvatar } from './stack-avatar';
export type AvatarWithRecipientProps = {
recipient: Recipient;
recipient: TRecipientLite;
documentStatus: DocumentStatus;
};
@@ -118,7 +118,9 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
{price.product.features && price.product.features.length > 0 && (
<div className="mt-4 text-muted-foreground">
<div className="text-sm font-medium">Includes:</div>
<div className="text-sm font-medium">
<Trans>Includes:</Trans>
</div>
<ul className="mt-1 divide-y text-sm">
{price.product.features.map((feature, index) => (
@@ -9,6 +9,7 @@ import { z } from 'zod';
import { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError } from '@documenso/lib/errors/app-error';
import { zEmail } from '@documenso/lib/utils/zod';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -23,7 +24,7 @@ import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { signupErrorMessages } from '~/components/forms/signup';
import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup';
export type ClaimAccountProps = {
defaultName: string;
@@ -37,7 +38,7 @@ export const ZClaimAccountFormSchema = z
.string()
.trim()
.min(1, { message: msg`Please enter a valid name.`.id }),
email: z.string().email().min(1),
email: zEmail().min(1),
password: ZPasswordSchema,
})
.refine(
@@ -90,7 +91,8 @@ export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) =
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = signupErrorMessages[error.code] ?? signupErrorMessages.INVALID_REQUEST;
const errorMessage =
SIGNUP_ERROR_MESSAGES[error.code] ?? SIGNUP_ERROR_MESSAGES.INVALID_REQUEST;
toast({
title: _(msg`An error occurred`),
@@ -1,10 +1,13 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans, useLingui as useLinguiMacro } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import type { TDefaultRecipient } from '@documenso/lib/types/default-recipients';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DefaultRecipientsMultiSelectComboboxProps = {
listValues: TDefaultRecipient[];
@@ -20,6 +23,8 @@ export const DefaultRecipientsMultiSelectCombobox = ({
organisationId,
}: DefaultRecipientsMultiSelectComboboxProps) => {
const { _ } = useLingui();
const { t } = useLinguiMacro();
const { toast } = useToast();
const { data: organisationData, isLoading: isLoadingOrganisation } =
trpc.organisation.member.find.useQuery(
@@ -60,31 +65,56 @@ export const DefaultRecipientsMultiSelectCombobox = ({
}));
const onSelectionChange = (selected: Option[]) => {
const updatedRecipients = selected.map((option) => {
const existingRecipient = listValues.find((r) => r.email === option.value);
const member = members?.find((m) => m.email === option.value);
const invalidEmails = selected.filter(
(option) => !isRecipientEmailValidForSending({ email: option.value }),
);
return {
email: option.value,
name: member?.name || option.value,
role: existingRecipient?.role ?? RecipientRole.CC,
};
});
if (invalidEmails.length > 0) {
toast({
title: t`Invalid email`,
description: t`"${invalidEmails[0].value}" is not a valid email address.`,
variant: 'destructive',
});
}
const updatedRecipients = selected
.filter((option) => !invalidEmails.includes(option))
.map((option) => {
const existingRecipient = listValues.find((r) => r.email === option.value);
const member = members?.find((m) => m.email === option.value);
return {
email: option.value,
name: member?.name || option.value,
role: existingRecipient?.role ?? RecipientRole.CC,
};
});
onChange(updatedRecipients);
};
return (
<MultiSelect
commandProps={{ label: _(msg`Select recipients`) }}
commandProps={{ label: _(msg`Select or add recipients`) }}
options={options}
value={value}
onChange={onSelectionChange}
placeholder={_(msg`Select recipients`)}
placeholder={_(msg`Select or enter email address`)}
hideClearAllButton
hidePlaceholderWhenSelected
loadingIndicator={isLoading ? <p className="text-center text-sm">Loading...</p> : undefined}
emptyIndicator={<p className="text-center text-sm">No members found</p>}
creatable
loadingIndicator={
isLoading ? (
<p className="text-center text-sm">
<Trans>Loading...</Trans>
</p>
) : undefined
}
emptyIndicator={
<p className="text-center text-sm">
<Trans>Type an email address to add a recipient</Trans>
</p>
}
/>
);
};
@@ -7,6 +7,7 @@ import { z } from 'zod';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import type { TTemplate } from '@documenso/lib/types/template';
import { zEmail } from '@documenso/lib/utils/zod';
import {
DocumentReadOnlyFields,
mapFieldsWithRecipients,
@@ -33,7 +34,7 @@ import { useStep } from '@documenso/ui/primitives/stepper';
import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider';
const ZDirectTemplateConfigureFormSchema = z.object({
email: z.string().email('Email is invalid'),
email: zEmail('Email is invalid'),
});
export type TDirectTemplateConfigureFormSchema = z.infer<typeof ZDirectTemplateConfigureFormSchema>;
@@ -9,16 +9,17 @@ import { useNavigate, useSearchParams } from 'react-router';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TTemplate } from '@documenso/lib/types/template';
import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { trpc } from '@documenso/trpc/react';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider';
import { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import {
DirectTemplateConfigureForm,
@@ -153,9 +154,15 @@ export const DirectTemplatePageView = ({
<CardContent className="p-2">
<PDFViewerLazy
key={template.id}
envelopeItem={template.envelopeItems[0]}
token={directTemplateRecipient.token}
version="signed"
data={getDocumentDataUrlForPdfViewer({
envelopeId: template.envelopeId,
envelopeItemId: template.envelopeItems[0]?.id,
documentDataId: template.templateDocumentDataId,
version: 'current',
token: directTemplateRecipient.token,
presignToken: undefined,
})}
scrollParentRef="window"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>
@@ -82,8 +82,6 @@ export const DirectTemplateSigningForm = ({
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const highestPageNumber = Math.max(...localFields.map((field) => field.page));
const fieldsRequiringValidation = useMemo(() => {
return localFields.filter((field) => isFieldUnsignedAndRequired(field));
}, [localFields]);
@@ -250,9 +248,7 @@ export const DirectTemplateSigningForm = ({
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
<DocumentFlowFormContainerContent>
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{validateUninsertedFields && uninsertedFields[0] && (
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
<Trans>Click to insert field</Trans>
@@ -4,6 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
@@ -27,7 +28,6 @@ import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-p
export type DocumentSigningAuth2FAProps = {
actionTarget?: 'FIELD' | 'DOCUMENT';
actionVerb?: string;
open: boolean;
onOpenChange: (value: boolean) => void;
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
@@ -44,7 +44,6 @@ type T2FAAuthFormSchema = z.infer<typeof Z2FAAuthFormSchema>;
export const DocumentSigningAuth2FA = ({
actionTarget = 'FIELD',
actionVerb = 'sign',
onReauthFormSubmit,
open,
onOpenChange,
@@ -101,14 +100,39 @@ export const DocumentSigningAuth2FA = ({
<Alert variant="warning">
<AlertDescription>
<p>
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' ? (
<Trans>You need to setup 2FA to mark this document as viewed.</Trans>
) : (
// Todo: Translate
`You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`
)}
{match({ role: recipient.role, actionTarget })
.with({ role: RecipientRole.SIGNER, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup 2FA to sign this field.</Trans>
))
.with({ role: RecipientRole.SIGNER, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup 2FA to sign this document.</Trans>
))
.with({ role: RecipientRole.APPROVER, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup 2FA to approve this field.</Trans>
))
.with({ role: RecipientRole.APPROVER, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup 2FA to approve this document.</Trans>
))
.with({ role: RecipientRole.VIEWER, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup 2FA to view this field.</Trans>
))
.with({ role: RecipientRole.VIEWER, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup 2FA to mark this document as viewed.</Trans>
))
.with({ role: RecipientRole.CC, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup 2FA to view this field.</Trans>
))
.with({ role: RecipientRole.CC, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup 2FA to view this document.</Trans>
))
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup 2FA to assist with this field.</Trans>
))
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup 2FA to assist with this document.</Trans>
))
.exhaustive()}
</p>
<p className="mt-2">
<Trans>
By enabling 2FA, you will be required to enter a code from your authenticator app
@@ -138,7 +162,9 @@ export const DocumentSigningAuth2FA = ({
name="token"
render={({ field }) => (
<FormItem>
<FormLabel required>2FA token</FormLabel>
<FormLabel required>
<Trans>2FA token</Trans>
</FormLabel>
<FormControl>
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { authClient } from '@documenso/auth/client';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
@@ -13,13 +14,11 @@ import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-p
export type DocumentSigningAuthAccountProps = {
actionTarget?: 'FIELD' | 'DOCUMENT';
actionVerb?: string;
onOpenChange: (value: boolean) => void;
};
export const DocumentSigningAuthAccount = ({
actionTarget = 'FIELD',
actionVerb = 'sign',
onOpenChange,
}: DocumentSigningAuthAccountProps) => {
const { recipient, isDirectTemplate } = useRequiredDocumentSigningAuthContext();
@@ -55,32 +54,110 @@ export const DocumentSigningAuthAccount = ({
<fieldset disabled={isSigningOut} className="space-y-4">
<Alert variant="warning">
<AlertDescription>
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
<span>
{isDirectTemplate ? (
<Trans>To mark this document as viewed, you need to be logged in.</Trans>
) : (
<Trans>
To mark this document as viewed, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
)}
</span>
) : (
<span>
{isDirectTemplate ? (
<Trans>
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
logged in.
</Trans>
) : (
<Trans>
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
logged in as <strong>{recipient.email}</strong>
</Trans>
)}
</span>
)}
<span>
{match({ role: recipient.role, actionTarget })
.with({ role: RecipientRole.SIGNER, actionTarget: 'FIELD' }, () =>
isDirectTemplate ? (
<Trans>To sign this field, you need to be logged in.</Trans>
) : (
<Trans>
To sign this field, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.SIGNER, actionTarget: 'DOCUMENT' }, () =>
isDirectTemplate ? (
<Trans>To sign this document, you need to be logged in.</Trans>
) : (
<Trans>
To sign this document, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.APPROVER, actionTarget: 'FIELD' }, () =>
isDirectTemplate ? (
<Trans>To approve this field, you need to be logged in.</Trans>
) : (
<Trans>
To approve this field, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.APPROVER, actionTarget: 'DOCUMENT' }, () =>
isDirectTemplate ? (
<Trans>To approve this document, you need to be logged in.</Trans>
) : (
<Trans>
To approve this document, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.VIEWER, actionTarget: 'FIELD' }, () =>
isDirectTemplate ? (
<Trans>To view this field, you need to be logged in.</Trans>
) : (
<Trans>
To view this field, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.VIEWER, actionTarget: 'DOCUMENT' }, () =>
isDirectTemplate ? (
<Trans>To mark this document as viewed, you need to be logged in.</Trans>
) : (
<Trans>
To mark this document as viewed, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.CC, actionTarget: 'FIELD' }, () =>
isDirectTemplate ? (
<Trans>To view this field, you need to be logged in.</Trans>
) : (
<Trans>
To view this field, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.CC, actionTarget: 'DOCUMENT' }, () =>
isDirectTemplate ? (
<Trans>To view this document, you need to be logged in.</Trans>
) : (
<Trans>
To view this document, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'FIELD' }, () =>
isDirectTemplate ? (
<Trans>To assist with this field, you need to be logged in.</Trans>
) : (
<Trans>
To assist with this field, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'DOCUMENT' }, () =>
isDirectTemplate ? (
<Trans>To assist with this document, you need to be logged in.</Trans>
) : (
<Trans>
To assist with this document, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.exhaustive()}
</span>
</AlertDescription>
</Alert>
@@ -8,6 +8,7 @@ import { RecipientRole } from '@prisma/client';
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
@@ -38,7 +39,6 @@ import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-p
export type DocumentSigningAuthPasskeyProps = {
actionTarget?: 'FIELD' | 'DOCUMENT';
actionVerb?: string;
open: boolean;
onOpenChange: (value: boolean) => void;
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
@@ -52,7 +52,6 @@ type TPasskeyAuthFormSchema = z.infer<typeof ZPasskeyAuthFormSchema>;
export const DocumentSigningAuthPasskey = ({
actionTarget = 'FIELD',
actionVerb = 'sign',
onReauthFormSubmit,
open,
onOpenChange,
@@ -128,9 +127,62 @@ export const DocumentSigningAuthPasskey = ({
<div className="space-y-4">
<Alert variant="warning">
<AlertDescription>
{/* Todo: Translate */}
Your browser does not support passkeys, which is required to {actionVerb.toLowerCase()}{' '}
this {actionTarget.toLowerCase()}.
{match({ role: recipient.role, actionTarget })
.with({ role: RecipientRole.SIGNER, actionTarget: 'FIELD' }, () => (
<Trans>
Your browser does not support passkeys, which is required to sign this field.
</Trans>
))
.with({ role: RecipientRole.SIGNER, actionTarget: 'DOCUMENT' }, () => (
<Trans>
Your browser does not support passkeys, which is required to sign this document.
</Trans>
))
.with({ role: RecipientRole.APPROVER, actionTarget: 'FIELD' }, () => (
<Trans>
Your browser does not support passkeys, which is required to approve this field.
</Trans>
))
.with({ role: RecipientRole.APPROVER, actionTarget: 'DOCUMENT' }, () => (
<Trans>
Your browser does not support passkeys, which is required to approve this
document.
</Trans>
))
.with({ role: RecipientRole.VIEWER, actionTarget: 'FIELD' }, () => (
<Trans>
Your browser does not support passkeys, which is required to view this field.
</Trans>
))
.with({ role: RecipientRole.VIEWER, actionTarget: 'DOCUMENT' }, () => (
<Trans>
Your browser does not support passkeys, which is required to mark this document as
viewed.
</Trans>
))
.with({ role: RecipientRole.CC, actionTarget: 'FIELD' }, () => (
<Trans>
Your browser does not support passkeys, which is required to view this field.
</Trans>
))
.with({ role: RecipientRole.CC, actionTarget: 'DOCUMENT' }, () => (
<Trans>
Your browser does not support passkeys, which is required to view this document.
</Trans>
))
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'FIELD' }, () => (
<Trans>
Your browser does not support passkeys, which is required to assist with this
field.
</Trans>
))
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'DOCUMENT' }, () => (
<Trans>
Your browser does not support passkeys, which is required to assist with this
document.
</Trans>
))
.exhaustive()}
</AlertDescription>
</Alert>
@@ -178,10 +230,38 @@ export const DocumentSigningAuthPasskey = ({
<div className="space-y-4">
<Alert variant="warning">
<AlertDescription>
{/* Todo: Translate */}
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT'
? 'You need to setup a passkey to mark this document as viewed.'
: `You need to setup a passkey to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`}
{match({ role: recipient.role, actionTarget })
.with({ role: RecipientRole.SIGNER, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup a passkey to sign this field.</Trans>
))
.with({ role: RecipientRole.SIGNER, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup a passkey to sign this document.</Trans>
))
.with({ role: RecipientRole.APPROVER, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup a passkey to approve this field.</Trans>
))
.with({ role: RecipientRole.APPROVER, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup a passkey to approve this document.</Trans>
))
.with({ role: RecipientRole.VIEWER, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup a passkey to view this field.</Trans>
))
.with({ role: RecipientRole.VIEWER, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup a passkey to mark this document as viewed.</Trans>
))
.with({ role: RecipientRole.CC, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup a passkey to view this field.</Trans>
))
.with({ role: RecipientRole.CC, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup a passkey to view this document.</Trans>
))
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup a passkey to assist with this field.</Trans>
))
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup a passkey to assist with this document.</Trans>
))
.exhaustive()}
</AlertDescription>
</Alert>
@@ -213,7 +293,9 @@ export const DocumentSigningAuthPasskey = ({
name="passkeyId"
render={({ field }) => (
<FormItem>
<FormLabel required>Passkey</FormLabel>
<FormLabel required>
<Trans>Passkey</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
@@ -241,20 +323,24 @@ export const DocumentSigningAuthPasskey = ({
{formErrorCode && (
<Alert variant="destructive">
<AlertTitle>Unauthorized</AlertTitle>
<AlertTitle>
<Trans>Unauthorized</Trans>
</AlertTitle>
<AlertDescription>
We were unable to verify your details. Please try again or contact support
<Trans>
We were unable to verify your details. Please try again or contact support
</Trans>
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isCurrentlyAuthenticating}>
Sign
<Trans>Sign</Trans>
</Button>
</DialogFooter>
</div>
@@ -23,8 +23,6 @@ import { Input } from '@documenso/ui/primitives/input';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
export type DocumentSigningAuthPasswordProps = {
actionTarget?: 'FIELD' | 'DOCUMENT';
actionVerb?: string;
open: boolean;
onOpenChange: (value: boolean) => void;
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
@@ -40,8 +38,6 @@ const ZPasswordAuthFormSchema = z.object({
type TPasswordAuthFormSchema = z.infer<typeof ZPasswordAuthFormSchema>;
export const DocumentSigningAuthPassword = ({
actionTarget = 'FIELD',
actionVerb = 'sign',
onReauthFormSubmit,
open,
onOpenChange,
@@ -141,7 +141,7 @@ export const DocumentSigningAuthProvider = ({
if (
derivedRecipientActionAuth.includes(DocumentAuth.ACCOUNT) &&
user?.email == recipient.email
user?.email === recipient.email
) {
return {
type: DocumentAuth.ACCOUNT,
@@ -162,10 +162,12 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Automatically sign fields</DialogTitle>
<DialogTitle>
<Trans>Automatically sign fields</Trans>
</DialogTitle>
</DialogHeader>
<div className="text-muted-foreground max-w-[50ch]">
<div className="max-w-[50ch] text-muted-foreground">
<p>
<Trans>
When you sign a document, we can automatically fill in and sign the following fields
@@ -14,6 +14,7 @@ import {
ZDocumentAccessAuthSchema,
} from '@documenso/lib/types/document-auth';
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { zEmail } from '@documenso/lib/utils/zod';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -68,7 +69,7 @@ export type DocumentSigningCompleteDialogProps = {
const ZNextSignerFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
email: zEmail('Invalid email address'),
accessAuthOptions: ZDocumentAccessAuthSchema.optional(),
});
@@ -76,7 +77,7 @@ type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
const ZDirectRecipientFormSchema = z.object({
name: z.string(),
email: z.string().email('Invalid email address'),
email: zEmail('Invalid email address'),
});
type TDirectRecipientFormSchema = z.infer<typeof ZDirectRecipientFormSchema>;
@@ -117,7 +118,7 @@ export const DocumentSigningCompleteDialog = ({
const recipientForm = useForm<TDirectRecipientFormSchema>({
resolver: zodResolver(ZDirectRecipientFormSchema),
defaultValues: {
values: {
name: recipientPayload?.name ?? '',
email: recipientPayload?.email ?? '',
},
@@ -157,6 +158,10 @@ export const DocumentSigningCompleteDialog = ({
}
recipientOverridePayload = recipientForm.getValues();
} else if (recipientPayload && recipientPayload.email && !recipient.email) {
// Form is hidden because we have an email (e.g. from embed context),
// but the DB recipient doesn't have one yet — send the override.
recipientOverridePayload = recipientPayload;
}
// Check if 2FA is required
@@ -9,7 +9,7 @@ import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { RECIPIENT_COLOR_STYLES } from '@documenso/ui/lib/recipient-colors';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
@@ -130,69 +130,65 @@ export const DocumentSigningFieldContainer = ({
};
return (
<div className={cn('[container-type:size]')}>
<FieldRootContainer
color={
field.fieldMeta?.readOnly ? RECIPIENT_COLOR_STYLES.readOnly : RECIPIENT_COLOR_STYLES.green
}
field={field}
>
{!field.inserted && !loading && !readOnlyField && (
<button
type="submit"
className="absolute inset-0 z-10 h-full w-full rounded-[2px]"
onClick={async () => handleInsertField()}
/>
)}
<FieldRootContainer
color={getRecipientColorStyles(field.fieldMeta?.readOnly ? 'readOnly' : 0)}
field={field}
>
{!field.inserted && !loading && !readOnlyField && (
<button
type="submit"
className="absolute inset-0 z-10 h-full w-full rounded-[2px]"
onClick={async () => handleInsertField()}
/>
)}
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<button
className="absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
onClick={() => void onClearCheckBoxValues(type)}
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<button
className="absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
onClick={() => void onClearCheckBoxValues(type)}
>
<span className="rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
<X className="h-4 w-4" />
</span>
</button>
)}
{type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button className="absolute inset-0 z-10" onClick={onRemoveSignedFieldClick}></button>
</TooltipTrigger>
<TooltipContent
className="border-0 bg-orange-300 fill-orange-300 text-orange-900"
sideOffset={2}
>
<span className="rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
<X className="h-4 w-4" />
</span>
</button>
{tooltipText && <p>{tooltipText}</p>}
<Trans>Remove</Trans>
<TooltipArrow />
</TooltipContent>
</Tooltip>
)}
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
field.fieldMeta?.label && (
<div
className={cn(
'absolute -top-16 left-0 right-0 rounded-md p-2 text-center text-xs text-gray-700',
{
'border border-border bg-foreground/5': !field.inserted,
},
{
'border border-primary bg-documenso-200': field.inserted,
},
)}
>
{field.fieldMeta.label}
</div>
)}
{type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button className="absolute inset-0 z-10" onClick={onRemoveSignedFieldClick}></button>
</TooltipTrigger>
<TooltipContent
className="border-0 bg-orange-300 fill-orange-300 text-orange-900"
sideOffset={2}
>
{tooltipText && <p>{tooltipText}</p>}
<Trans>Remove</Trans>
<TooltipArrow />
</TooltipContent>
</Tooltip>
)}
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
field.fieldMeta?.label && (
<div
className={cn(
'absolute -top-16 left-0 right-0 rounded-md p-2 text-center text-xs text-gray-700',
{
'bg-foreground/5 border-border border': !field.inserted,
},
{
'bg-documenso-200 border-primary border': field.inserted,
},
)}
>
{field.fieldMeta.label}
</div>
)}
{children}
</FieldRootContainer>
</div>
{children}
</FieldRootContainer>
);
};
@@ -3,7 +3,7 @@ import { useId, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client';
import { type Field, type Recipient, RecipientRole } from '@prisma/client';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
@@ -11,6 +11,7 @@ import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button';
@@ -78,7 +79,7 @@ export const DocumentSigningForm = ({
[fields],
);
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
const hasSignatureField = fields.some((field) => isSignatureFieldType(field.type));
const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(fieldsRequiringValidation.filter((field) => !field.inserted));
@@ -122,7 +122,9 @@ export const DocumentSigningMobileWidget = () => {
{!hidePoweredBy && (
<div className="mt-2 inline-block rounded bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100 lg:hidden">
<span>Powered by</span>
<span>
<Trans>Powered by</Trans>
</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
)}
@@ -22,6 +22,7 @@ import {
} from '@documenso/lib/types/field-meta';
import type { CompletedField } from '@documenso/lib/types/fields';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
@@ -30,7 +31,6 @@ import { DocumentReadOnlyFields } from '@documenso/ui/components/document/docume
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
@@ -46,6 +46,7 @@ import { DocumentSigningRadioField } from '~/components/general/document-signing
import { DocumentSigningRejectDialog } from '~/components/general/document-signing/document-signing-reject-dialog';
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
@@ -162,8 +163,6 @@ export const DocumentSigningPageViewV1 = ({
: undefined;
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
const highestPageNumber = Math.max(...fields.map((field) => field.page));
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
const hasPendingFields = pendingFields.length > 0;
@@ -275,10 +274,16 @@ export const DocumentSigningPageViewV1 = ({
<Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2">
<PDFViewerLazy
key={document.envelopeItems[0].id}
envelopeItem={document.envelopeItems[0]}
token={recipient.token}
version="signed"
key={document.envelopeItems[0]?.id}
data={getDocumentDataUrlForPdfViewer({
envelopeId: document.envelopeId,
envelopeItemId: document.envelopeItems[0]?.id,
documentDataId: document.envelopeItems[0]?.documentData.id,
version: 'current',
token: recipient.token,
presignToken: undefined,
})}
scrollParentRef="window"
/>
</CardContent>
</Card>
@@ -400,9 +405,7 @@ export const DocumentSigningPageViewV1 = ({
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
)}
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields
.filter(
(field) =>
@@ -1,15 +1,23 @@
import { lazy, useMemo } from 'react';
import { useMemo, useRef, useState } from 'react';
import { Plural, Trans } from '@lingui/react/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { motion } from 'framer-motion';
import { ArrowLeftIcon, BanIcon, DownloadCloudIcon, PaperclipIcon } from 'lucide-react';
import {
ArrowLeftIcon,
BanIcon,
DownloadCloudIcon,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PaperclipIcon,
} from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
@@ -23,6 +31,8 @@ import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-di
import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog';
import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog';
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
import { EnvelopeSignerPageRenderer } from '~/components/general/envelope-signing/envelope-signer-page-renderer';
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
import { BrandingLogo } from '../branding-logo';
import { DocumentSigningAttachmentsPopover } from '../document-signing/document-signing-attachments-popover';
@@ -33,13 +43,11 @@ import { DocumentSigningMobileWidget } from './document-signing-mobile-widget';
import { DocumentSigningRejectDialog } from './document-signing-reject-dialog';
import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
const EnvelopeSignerPageRenderer = lazy(
async () => import('~/components/general/envelope-signing/envelope-signer-page-renderer'),
);
export const DocumentSigningPageViewV2 = () => {
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const {
isDirectTemplate,
envelope,
@@ -57,6 +65,9 @@ export const DocumentSigningPageViewV2 = () => {
onDocumentRejected,
} = useEmbedSigningContext() || {};
const { t } = useLingui();
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
/**
* The total remaining fields remaining for the current recipient or selected assistant recipient.
*
@@ -86,120 +97,159 @@ export const DocumentSigningPageViewV2 = () => {
{/* Main Content Area */}
<div className="flex h-[calc(100vh-4rem)] w-screen">
{/* Left Section - Step Navigation */}
<div className="embed--DocumentWidgetContainer hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4 lg:flex">
<div className="px-4">
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
{match(recipient.role)
.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)}
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
<Plural
value={recipientFieldsRemaining.length}
one="1 Field Remaining"
other="# Fields Remaining"
/>
</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 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
}}
/>
</div>
<div className="embed--DocumentWidgetContent mt-6 space-y-3">
<EnvelopeSignerForm />
</div>
</div>
<Separator className="my-6" />
{/* Quick Actions. */}
{!isDirectTemplate && (
<div className="embed--Actions space-y-3 px-4">
<h4 className="text-sm font-semibold text-foreground">
<Trans>Actions</Trans>
</h4>
<DocumentSigningAttachmentsPopover
envelopeId={envelope.id}
token={recipient.token}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<PaperclipIcon className="mr-2 h-4 w-4" />
<Trans>Attachments</Trans>
</Button>
}
/>
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
envelopeItems={envelope.envelopeItems}
token={recipient.token}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<DownloadCloudIcon className="mr-2 h-4 w-4" />
<Trans>Download PDF</Trans>
</Button>
}
/>
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection && (
<DocumentSigningRejectDialog
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
token={recipient.token}
onRejected={
onDocumentRejected &&
((reason) =>
onDocumentRejected({
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
envelopeId: envelope.id,
recipientId: recipient.id,
reason,
}))
}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start hover:text-destructive"
>
<BanIcon className="mr-2 h-4 w-4" />
<Trans>Reject Document</Trans>
</Button>
}
/>
)}
<div
className={cn(
'embed--DocumentWidgetContainer hidden flex-shrink-0 flex-col border-r border-border bg-background transition-[width] duration-300 lg:flex',
isSidebarCollapsed ? 'w-12' : 'w-80',
)}
>
{isSidebarCollapsed && (
<div className="flex justify-center pt-4">
<Button
variant="ghost"
className="h-7 w-7 p-0"
aria-label={t`Expand sidebar`}
onClick={() => setIsSidebarCollapsed(false)}
>
<PanelLeftOpenIcon className="h-4 w-4" />
</Button>
</div>
)}
<div className="embed--DocumentWidgetFooter mt-auto">
{/* Footer of left sidebar. */}
{!isEmbed && (
<div className="px-4">
<Button asChild variant="ghost" className="w-full justify-start">
<Link to="/">
<ArrowLeftIcon className="mr-2 h-4 w-4" />
<Trans>Return</Trans>
</Link>
</Button>
<div
className={cn(
'flex flex-1 flex-col overflow-hidden py-4',
isSidebarCollapsed && 'invisible w-0',
)}
>
<div className="px-4">
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
{match(recipient.role)
.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)}
<div className="ml-2 flex items-center gap-1">
<span className="rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
<Plural
value={recipientFieldsRemaining.length}
one="1 Field Remaining"
other="# Fields Remaining"
/>
</span>
<Button
variant="ghost"
className="h-7 w-7 p-0"
aria-label={t`Collapse sidebar`}
onClick={() => setIsSidebarCollapsed(true)}
>
<PanelLeftCloseIcon className="h-4 w-4" />
</Button>
</div>
</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 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
}}
/>
</div>
<div className="embed--DocumentWidgetContent mt-6 space-y-3">
<EnvelopeSignerForm />
</div>
</div>
<Separator className="my-6" />
{/* Quick Actions. */}
{!isDirectTemplate && (
<div className="embed--Actions space-y-3 px-4">
<h4 className="text-sm font-semibold text-foreground">
<Trans>Actions</Trans>
</h4>
<DocumentSigningAttachmentsPopover
envelopeId={envelope.id}
token={recipient.token}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<PaperclipIcon className="mr-2 h-4 w-4" />
<Trans>Attachments</Trans>
</Button>
}
/>
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
envelopeItems={envelope.envelopeItems}
token={recipient.token}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<DownloadCloudIcon className="mr-2 h-4 w-4" />
<Trans>Download PDF</Trans>
</Button>
}
/>
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection && (
<DocumentSigningRejectDialog
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
token={recipient.token}
onRejected={
onDocumentRejected &&
((reason) =>
onDocumentRejected({
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
envelopeId: envelope.id,
recipientId: recipient.id,
reason,
}))
}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start hover:text-destructive"
>
<BanIcon className="mr-2 h-4 w-4" />
<Trans>Reject Document</Trans>
</Button>
}
/>
)}
</div>
)}
<div className="embed--DocumentWidgetFooter mt-auto">
{/* Footer of left sidebar. */}
{!isEmbed && (
<div className="px-4">
<Button asChild variant="ghost" className="w-full justify-start">
<Link to="/">
<ArrowLeftIcon className="mr-2 h-4 w-4" />
<Trans>Return</Trans>
</Link>
</Button>
</div>
)}
</div>
</div>
</div>
<div className="embed--DocumentContainer flex-1 overflow-y-auto">
<div
className="embed--DocumentContainer min-w-0 flex-1 overflow-y-auto"
ref={scrollableContainerRef}
>
<div className="flex flex-col">
{/* Horizontal envelope item selector */}
{envelopeItems.length > 1 && (
@@ -228,15 +278,16 @@ export const DocumentSigningPageViewV2 = () => {
{/* Document View */}
<div className="embed--DocumentViewer flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
{currentEnvelopeItem ? (
<PDFViewerKonvaLazy
renderer="signing"
<EnvelopePdfViewer
key={currentEnvelopeItem.id}
customPageRenderer={EnvelopeSignerPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.signing}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
<p className="text-sm text-foreground">
<Trans>No documents found</Trans>
<Trans>No document selected</Trans>
</p>
</div>
)}
@@ -252,7 +303,9 @@ export const DocumentSigningPageViewV2 = () => {
target="_blank"
className="fixed bottom-0 right-0 z-40 hidden cursor-pointer rounded-tl bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100 lg:block"
>
<span>Powered by</span>
<span>
<Trans>Powered by</Trans>
</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</a>
)}
@@ -8,9 +8,12 @@ import {
RecipientRole,
SigningStatus,
} from '@prisma/client';
import { DateTime } from 'luxon';
import { prop, sortBy } from 'remeda';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { isBase64Image } from '@documenso/lib/constants/signatures';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -83,6 +86,54 @@ export interface EnvelopeSigningProviderProps {
children: React.ReactNode;
}
/**
* Inject prefilled date fields for the current recipient.
*
* The dates are filled in correctly when the recipient "completes" the document.
*/
const prefillDateFields = (data: EnvelopeForSigningResponse): EnvelopeForSigningResponse => {
const { timezone, dateFormat } = data.envelope.documentMeta;
const formattedDate = DateTime.now()
.setZone(timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
.toFormat(dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT);
const prefillField = <
T extends { type: FieldType; inserted: boolean; customText: string; fieldMeta: unknown },
>(
field: T,
): T => {
if (field.type !== FieldType.DATE || field.inserted) {
return field;
}
return {
...field,
customText: formattedDate,
inserted: true,
fieldMeta: {
...(typeof field.fieldMeta === 'object' ? field.fieldMeta : {}),
readOnly: true,
},
};
};
return {
...data,
envelope: {
...data.envelope,
recipients: data.envelope.recipients.map((recipient) => ({
...recipient,
fields: recipient.fields.map(prefillField),
})),
},
recipient: {
...data.recipient,
fields: data.recipient.fields.map(prefillField),
},
};
};
export const EnvelopeSigningProvider = ({
fullName: initialFullName,
email: initialEmail,
@@ -90,7 +141,7 @@ export const EnvelopeSigningProvider = ({
envelopeData: initialEnvelopeData,
children,
}: EnvelopeSigningProviderProps) => {
const [envelopeData, setEnvelopeData] = useState(initialEnvelopeData);
const [envelopeData, setEnvelopeData] = useState(() => prefillDateFields(initialEnvelopeData));
const { envelope, recipient } = envelopeData;
@@ -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';
@@ -36,6 +37,7 @@ const ZAttachmentFormSchema = z.object({
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
// NOTE: REMEMBER TO UPDATE THE EMBEDDED VERSION OF THIS COMPONENT TOO.
export const DocumentAttachmentsPopover = ({
envelopeId,
buttonClassName,
@@ -49,9 +51,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 +152,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 +162,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 +170,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>
@@ -3,6 +3,8 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DownloadIcon } from 'lucide-react';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { base64 } from '@documenso/lib/universal/base64';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -25,36 +27,15 @@ export const DocumentAuditLogDownloadButton = ({
const onDownloadAuditLogsClick = async () => {
try {
const { url } = await downloadAuditLogs({ documentId });
const { data, envelopeTitle } = await downloadAuditLogs({ documentId });
const iframe = Object.assign(document.createElement('iframe'), {
src: url,
const buffer = new Uint8Array(base64.decode(data));
const blob = new Blob([buffer], { type: 'application/pdf' });
downloadFile({
data: blob,
filename: `${envelopeTitle} - Audit Logs.pdf`,
});
Object.assign(iframe.style, {
position: 'fixed',
top: '0',
left: '0',
width: '0',
height: '0',
});
const onLoaded = () => {
if (iframe.contentDocument?.readyState === 'complete') {
iframe.contentWindow?.print();
iframe.contentWindow?.addEventListener('afterprint', () => {
document.body.removeChild(iframe);
});
}
};
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
document.body.appendChild(iframe);
onLoaded();
} catch (error) {
console.error(error);
@@ -4,6 +4,8 @@ import { Trans } from '@lingui/react/macro';
import type { DocumentStatus } from '@prisma/client';
import { DownloadIcon } from 'lucide-react';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { base64 } from '@documenso/lib/universal/base64';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -29,36 +31,15 @@ export const DocumentCertificateDownloadButton = ({
const onDownloadCertificatesClick = async () => {
try {
const { url } = await downloadCertificate({ documentId });
const { data, envelopeTitle } = await downloadCertificate({ documentId });
const iframe = Object.assign(document.createElement('iframe'), {
src: url,
const buffer = new Uint8Array(base64.decode(data));
const blob = new Blob([buffer], { type: 'application/pdf' });
downloadFile({
data: blob,
filename: `${envelopeTitle} - Certificate.pdf`,
});
Object.assign(iframe.style, {
position: 'fixed',
top: '0',
left: '0',
width: '0',
height: '0',
});
const onLoaded = () => {
if (iframe.contentDocument?.readyState === 'complete') {
iframe.contentWindow?.print();
iframe.contentWindow?.addEventListener('afterprint', () => {
document.body.removeChild(iframe);
});
}
};
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
document.body.appendChild(iframe);
onLoaded();
} catch (error) {
console.error(error);
@@ -1,4 +1,4 @@
import { lazy, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client';
@@ -9,9 +9,10 @@ import {
EnvelopeRenderProvider,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -21,15 +22,13 @@ import {
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { EnvelopeRendererFileSelector } from '../envelope-editor/envelope-file-selector';
const EnvelopeGenericPageRenderer = lazy(
async () => import('~/components/general/envelope-editor/envelope-generic-page-renderer'),
);
import { EnvelopeGenericPageRenderer } from '../envelope-editor/envelope-generic-page-renderer';
export type DocumentCertificateQRViewProps = {
documentId: number;
@@ -104,11 +103,13 @@ export const DocumentCertificateQRView = ({
{internalVersion === 2 ? (
<EnvelopeRenderProvider
version="current"
envelope={{
envelopeItems,
id: envelopeItems[0].envelopeId,
status: DocumentStatus.COMPLETED,
type: EnvelopeType.DOCUMENT,
}}
envelopeItems={envelopeItems}
token={token}
>
<DocumentCertificateQrV2
@@ -150,10 +151,16 @@ export const DocumentCertificateQRView = ({
<div className="mt-12 w-full">
<PDFViewerLazy
key={envelopeItems[0].id}
envelopeItem={envelopeItems[0]}
token={token}
version="signed"
key={envelopeItems[0]?.id}
data={getDocumentDataUrlForPdfViewer({
envelopeId: envelopeItems[0]?.envelopeId,
envelopeItemId: envelopeItems[0]?.id,
documentDataId: envelopeItems[0]?.documentDataId,
version: 'current',
token,
presignToken: undefined,
})}
scrollParentRef="window"
/>
</div>
</>
@@ -175,7 +182,7 @@ const DocumentCertificateQrV2 = ({
formattedDate,
token,
}: DocumentCertificateQrV2Props) => {
const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender();
const { envelopeItems } = useCurrentEnvelopeRender();
return (
<div className="flex min-h-screen flex-col items-start">
@@ -210,7 +217,11 @@ const DocumentCertificateQrV2 = ({
<div className="mt-12 w-full">
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
<PDFViewerKonvaLazy renderer="preview" customPageRenderer={EnvelopeGenericPageRenderer} />
<EnvelopePdfViewer
scrollParentRef="window"
customPageRenderer={EnvelopeGenericPageRenderer}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
/>
</div>
</div>
);
@@ -14,6 +14,7 @@ import {
} from '@documenso/lib/constants/trpc';
import type { TDocument } from '@documenso/lib/types/document';
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@@ -27,10 +28,10 @@ import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/ad
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { useCurrentTeam } from '~/providers/team';
export type DocumentEditFormProps = {
@@ -441,10 +442,16 @@ export const DocumentEditForm = ({
>
<CardContent className="p-2">
<PDFViewerLazy
key={document.envelopeItems[0].id}
envelopeItem={document.envelopeItems[0]}
token={undefined}
version="signed"
key={document.envelopeItems[0]?.id}
data={getDocumentDataUrlForPdfViewer({
envelopeId: document.envelopeId,
envelopeItemId: document.envelopeItems[0]?.id,
documentDataId: initialDocument.documentDataId,
version: 'current',
token: undefined,
presignToken: undefined,
})}
scrollParentRef="window"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>
@@ -1,14 +1,15 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import {
Copy,
Download,
Edit,
FileOutputIcon,
Loader,
MoreHorizontal,
Pencil,
ScrollTextIcon,
Share,
Trash2,
@@ -18,8 +19,12 @@ import { Link, useNavigate } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import {
getEnvelopeItemPermissions,
mapSecondaryIdToDocumentId,
} from '@documenso/lib/utils/envelope';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import {
DropdownMenu,
@@ -28,12 +33,13 @@ import {
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
import { EnvelopeRenameDialog } from '~/components/dialogs/envelope-rename-dialog';
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
import { useCurrentTeam } from '~/providers/team';
@@ -43,14 +49,14 @@ export type DocumentPageViewDropdownProps = {
export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownProps) => {
const { user } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const navigate = useNavigate();
const team = useCurrentTeam();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
const trpcUtils = trpcReact.useUtils();
const [isRenameDialogOpen, setRenameDialogOpen] = useState(false);
const [isSaveAsTemplateDialogOpen, setSaveAsTemplateDialogOpen] = useState(false);
const recipient = envelope.recipients.find((recipient) => recipient.email === user.email);
@@ -62,14 +68,16 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
const isCurrentTeamDocument = team && envelope.teamId === team.id;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
const { canTitleBeChanged } = getEnvelopeItemPermissions(envelope, []);
const documentsPath = formatDocumentsPath(team.url);
const nonSignedRecipients = envelope.recipients.filter((item) => item.signingStatus !== 'SIGNED');
return (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
<DropdownMenuTrigger data-testid="document-page-view-action-btn">
<MoreHorizontal className="h-5 w-5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="end" forceMount>
@@ -86,6 +94,13 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
</DropdownMenuItem>
)}
{canManageDocument && canTitleBeChanged && (
<DropdownMenuItem onClick={() => setRenameDialogOpen(true)}>
<Pencil className="mr-2 h-4 w-4" />
<Trans>Rename</Trans>
</DropdownMenuItem>
)}
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
@@ -108,15 +123,42 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" />
<Trans>Duplicate</Trans>
<EnvelopeDuplicateDialog
envelopeId={envelope.id}
envelopeType={EnvelopeType.DOCUMENT}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<Copy className="mr-2 h-4 w-4" />
<Trans>Duplicate</Trans>
</div>
</DropdownMenuItem>
}
/>
<DropdownMenuItem onClick={() => setSaveAsTemplateDialogOpen(true)}>
<FileOutputIcon className="mr-2 h-4 w-4" />
<Trans>Save as Template</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={isDeleted}>
<Trash2 className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
</DropdownMenuItem>
<EnvelopeDeleteDialog
id={envelope.id}
type={EnvelopeType.DOCUMENT}
status={envelope.status}
title={envelope.title}
canManageDocument={canManageDocument}
onDelete={() => {
void navigate(documentsPath);
}}
trigger={
<DropdownMenuItem asChild disabled={isDeleted} onSelect={(e) => e.preventDefault()}>
<div>
<Trash2 className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
</div>
</DropdownMenuItem>
}
/>
<DropdownMenuLabel>
<Trans>Share</Trans>
@@ -159,26 +201,21 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
/>
</DropdownMenuContent>
<DocumentDeleteDialog
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
status={envelope.status}
documentTitle={envelope.title}
open={isDeleteDialogOpen}
canManageDocument={canManageDocument}
onOpenChange={setDeleteDialogOpen}
onDelete={() => {
void navigate(documentsPath);
}}
<EnvelopeSaveAsTemplateDialog
envelopeId={envelope.id}
open={isSaveAsTemplateDialogOpen}
onOpenChange={setSaveAsTemplateDialogOpen}
/>
{isDuplicateDialogOpen && (
<DocumentDuplicateDialog
id={envelope.id}
token={recipient?.token}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
/>
)}
<EnvelopeRenameDialog
id={envelope.id}
initialTitle={envelope.title}
open={isRenameDialogOpen}
onOpenChange={setRenameDialogOpen}
onSuccess={async () => {
await trpcUtils.envelope.get.invalidate();
}}
/>
</DropdownMenu>
);
};
@@ -9,19 +9,21 @@ import {
AlertTriangle,
CheckIcon,
Clock,
Clock8Icon,
MailIcon,
MailOpenIcon,
PenIcon,
PlusIcon,
UserIcon,
} from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { formatSigningLink, isRecipientExpired } from '@documenso/lib/utils/recipients';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@@ -44,7 +46,7 @@ export const DocumentPageViewRecipients = ({
envelope,
documentRootPath,
}: DocumentPageViewRecipientsProps) => {
const { _ } = useLingui();
const { _, i18n } = useLingui();
const { toast } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
@@ -66,9 +68,9 @@ export const DocumentPageViewRecipients = ({
}, [searchParams, setSearchParams]);
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<section className="flex flex-col rounded-xl border border-border bg-widget dark:bg-background">
<div className="flex flex-row items-center justify-between px-4 py-3">
<h1 className="text-foreground font-medium">
<h1 className="font-medium text-foreground">
<Trans>Recipients</Trans>
</h1>
@@ -87,7 +89,7 @@ export const DocumentPageViewRecipients = ({
)}
</div>
<ul className="text-muted-foreground divide-y border-t">
<ul className="divide-y border-t text-muted-foreground">
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">
<Trans>No recipients</Trans>
@@ -98,9 +100,9 @@ export const DocumentPageViewRecipients = ({
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
primaryText={<p className="text-sm text-muted-foreground">{recipient.email}</p>}
secondaryText={
<p className="text-muted-foreground/70 text-xs">
<p className="text-xs text-muted-foreground/70">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
}
@@ -154,12 +156,41 @@ export const DocumentPageViewRecipients = ({
)}
{envelope.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
isRecipientExpired(recipient) && (
<Badge variant="destructive">
<Clock8Icon className="mr-1 h-3 w-3" />
<Trans>Expired</Trans>
</Badge>
)}
{envelope.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
!isRecipientExpired(recipient) &&
(recipient.expiresAt ? (
<PopoverHover
trigger={
<Badge variant="secondary">
<Clock className="mr-1 h-3 w-3" />
<Trans>Pending</Trans>
</Badge>
}
>
<p className="text-xs text-muted-foreground">
<Trans>
Expires{' '}
{recipient.expiresAt
? i18n.date(recipient.expiresAt, DateTime.DATETIME_MED)
: 'N/A'}
</Trans>
</p>
</PopoverHover>
) : (
<Badge variant="secondary">
<Clock className="mr-1 h-3 w-3" />
<Trans>Pending</Trans>
</Badge>
)}
))}
{envelope.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.REJECTED && (
@@ -175,7 +206,7 @@ export const DocumentPageViewRecipients = ({
<Trans>Reason for rejection: </Trans>
</p>
<p className="text-muted-foreground mt-1 text-sm">
<p className="mt-1 text-sm text-muted-foreground">
{recipient.rejectionReason}
</p>
</PopoverHover>
@@ -183,7 +214,8 @@ export const DocumentPageViewRecipients = ({
{envelope.status === DocumentStatus.PENDING &&
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
recipient.role !== RecipientRole.CC && (
recipient.role !== RecipientRole.CC &&
!isRecipientExpired(recipient) && (
<TooltipProvider>
<Tooltip open={shouldHighlightCopyButtons && i === 0}>
<TooltipTrigger asChild>
@@ -3,7 +3,6 @@ import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { useSearchParams } from 'react-router';
@@ -11,6 +10,7 @@ import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@@ -29,7 +29,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentRecipientLinkCopyDialogProps = {
trigger?: React.ReactNode;
recipients: Recipient[];
recipients: TRecipientLite[];
};
export const DocumentRecipientLinkCopyDialog = ({
@@ -88,7 +88,7 @@ export const DocumentRecipientLinkCopyDialog = ({
</DialogDescription>
</DialogHeader>
<ul className="text-muted-foreground divide-y rounded-lg border">
<ul className="divide-y rounded-lg border text-muted-foreground">
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">
<Trans>No recipients</Trans>
@@ -99,9 +99,9 @@ export const DocumentRecipientLinkCopyDialog = ({
<li key={recipient.id} className="flex items-center justify-between px-4 py-3 text-sm">
<AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
primaryText={<p className="text-sm text-muted-foreground">{recipient.email}</p>}
secondaryText={
<p className="text-muted-foreground/70 text-xs">
<p className="text-xs text-muted-foreground/70">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
}
@@ -4,6 +4,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import type { FileRejection } from 'react-dropzone';
import { useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern';
@@ -11,13 +12,13 @@ import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
import { cn } from '@documenso/ui/lib/utils';
import { DocumentUploadButton as DocumentUploadButtonPrimitive } from '@documenso/ui/primitives/document-upload-button';
import {
@@ -162,10 +163,10 @@ export const DocumentUploadButtonLegacy = ({
}
};
const onFileDropRejected = () => {
const onFileDropRejected = (fileRejections: FileRejection[]) => {
toast({
title: _(msg`Your document failed to upload.`),
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
description: _(buildDropzoneRejectionDescription(fileRejections)),
duration: 5000,
variant: 'destructive',
});
@@ -0,0 +1,215 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Paperclip, Plus, X } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { nanoid } from '@documenso/lib/universal/id';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EmbeddedEditorAttachmentPopoverProps = {
buttonClassName?: string;
buttonSize?: 'sm' | 'default';
};
const ZAttachmentFormSchema = z.object({
label: z.string().min(1, 'Label is required'),
url: z.string().url('Must be a valid URL'),
});
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
// NOTE: REMEMBER TO UPDATE THE NON-EMBEDDED VERSION OF THIS COMPONENT TOO.
export const EmbeddedEditorAttachmentPopover = ({
buttonClassName,
buttonSize,
}: EmbeddedEditorAttachmentPopoverProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const [isOpen, setIsOpen] = useState(false);
const [isAdding, setIsAdding] = useState(false);
const { envelope, setLocalEnvelope } = useCurrentEnvelopeEditor();
const attachments = envelope.attachments ?? [];
const form = useForm<TAttachmentFormSchema>({
resolver: zodResolver(ZAttachmentFormSchema),
defaultValues: {
label: '',
url: '',
},
});
const onSubmit = (data: TAttachmentFormSchema) => {
setLocalEnvelope({
attachments: [
...attachments,
{
id: nanoid(),
type: 'link',
label: data.label,
data: data.url,
},
],
});
form.reset();
setIsAdding(false);
toast({
title: _(msg`Success`),
description: _(msg`Attachment added successfully.`),
});
};
const onDeleteAttachment = (id: string) => {
setLocalEnvelope({
attachments: attachments.filter((a) => a.id !== id),
});
toast({
title: _(msg`Success`),
description: _(msg`Attachment removed successfully.`),
});
};
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className={cn('gap-2', buttonClassName)} size={buttonSize}>
<Paperclip className="h-4 w-4" />
<span>
<Trans>Attachments</Trans>
{attachments.length > 0 && <span className="ml-1">({attachments.length})</span>}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-96" align="end">
<div className="space-y-4">
<div>
<h4 className="font-medium">
<Trans>Attachments</Trans>
</h4>
<p className="mt-1 text-sm text-muted-foreground">
<Trans>Add links to relevant documents or resources.</Trans>
</p>
</div>
{attachments.length > 0 && (
<div className="space-y-2">
{attachments.map((attachment) => (
<div
key={attachment.id}
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>
<a
href={attachment.data}
target="_blank"
rel="noopener noreferrer"
className="truncate text-xs text-muted-foreground underline hover:text-foreground"
>
{attachment.data}
</a>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onDeleteAttachment(attachment.id)}
className="ml-2 h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{!isAdding && (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setIsAdding(true)}
>
<Plus className="mr-2 h-4 w-4" />
<Trans>Add Attachment</Trans>
</Button>
)}
{isAdding && (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
<FormField
control={form.control}
name="label"
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder={_(msg`Label`)} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormControl>
<Input type="url" placeholder={_(msg`URL`)} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="flex-1"
onClick={() => {
setIsAdding(false);
form.reset();
}}
>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" size="sm" className="flex-1">
<Trans>Add</Trans>
</Button>
</div>
</form>
</Form>
)}
</div>
</PopoverContent>
</Popover>
);
};
@@ -23,7 +23,7 @@ import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import { nanoid } from '@documenso/lib/universal/id';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { RECIPIENT_COLOR_STYLES } from '@documenso/ui/lib/recipient-colors';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
@@ -175,15 +175,6 @@ export const EnvelopeEditorFieldDragDrop = ({
const { top, left, height, width } = getBoundingClientRect($page);
console.log({
top,
left,
height,
width,
rawPageX: event.pageX,
rawPageY: event.pageY,
});
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
// Calculate x and y as a percentage of the page width and height
@@ -262,9 +253,10 @@ export const EnvelopeEditorFieldDragDrop = ({
};
}, [onMouseClick, onMouseMove, selectedField]);
const selectedRecipientColor = useMemo(() => {
return selectedRecipientId ? getRecipientColorKey(selectedRecipientId) : 'green';
}, [selectedRecipientId, getRecipientColorKey]);
const selectedRecipientStyles = useMemo(
() => getRecipientColorStyles(getRecipientColorKey(selectedRecipientId ?? -1)),
[selectedRecipientId, getRecipientColorKey],
);
return (
<>
@@ -278,22 +270,15 @@ export const EnvelopeEditorFieldDragDrop = ({
onMouseDown={() => setSelectedField(field.type)}
data-selected={selectedField === field.type ? true : undefined}
className={cn(
'border-border group flex h-12 cursor-pointer items-center justify-center rounded-lg border px-4 transition-colors',
RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton,
'group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-border px-4 transition-colors',
selectedRecipientStyles.fieldButton,
)}
>
<p
className={cn(
'text-muted-foreground font-noto group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
'flex items-center justify-center gap-x-1.5 font-noto text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
field.className,
{
'group-hover:text-recipient-green': selectedRecipientColor === 'green',
'group-hover:text-recipient-blue': selectedRecipientColor === 'blue',
'group-hover:text-recipient-purple': selectedRecipientColor === 'purple',
'group-hover:text-recipient-orange': selectedRecipientColor === 'orange',
'group-hover:text-recipient-yellow': selectedRecipientColor === 'yellow',
'group-hover:text-recipient-pink': selectedRecipientColor === 'pink',
},
selectedRecipientStyles.fieldButtonText,
)}
>
{field.type !== FieldType.SIGNATURE && <field.icon className="h-4 w-4" />}
@@ -306,8 +291,8 @@ export const EnvelopeEditorFieldDragDrop = ({
{selectedField && (
<div
className={cn(
'text-muted-foreground dark:text-muted-background font-noto pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]',
RECIPIENT_COLOR_STYLES[selectedRecipientColor].base,
'dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size]',
selectedRecipientStyles.base,
selectedField === FieldType.SIGNATURE && 'font-signature',
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
@@ -10,7 +10,10 @@ import { CopyPlusIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import {
type PageRenderData,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import {
MIN_FIELD_HEIGHT_PX,
@@ -25,7 +28,7 @@ import { CommandDialog } from '@documenso/ui/primitives/command';
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector';
export default function EnvelopeEditorFieldsPageRenderer() {
export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
const { t, i18n } = useLingui();
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
@@ -37,34 +40,22 @@ export default function EnvelopeEditorFieldsPageRenderer() {
const [isFieldChanging, setIsFieldChanging] = useState(false);
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
const {
stage,
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
const { stage, pageLayer, konvaContainer, scaledViewport, unscaledViewport } = usePageRenderer(
({ stage, pageLayer }) => createPageCanvas(stage, pageLayer),
pageData,
);
const { _className, scale } = pageContext;
const { scale, pageNumber } = pageData;
const localPageFields = useMemo(
() =>
editorFields.localFields.filter(
(field) =>
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
),
[editorFields.localFields, pageContext.pageNumber],
[editorFields.localFields, pageNumber, currentEnvelopeItem?.id],
);
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
const { current: container } = canvasElement;
if (!container) {
return;
}
const isDragEvent = event.type === 'dragend';
const fieldGroup = event.target as Konva.Group;
@@ -344,7 +335,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
// Create a field if no items are selected or the size is too small.
if (
selectedFieldGroups.length === 0 &&
canvasElement.current &&
unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
editorFields.selectedRecipient &&
@@ -434,15 +424,31 @@ export default function EnvelopeEditorFieldsPageRenderer() {
renderFieldOnLayer(field);
});
// Reconcile selection state with live field nodes after flush/sync updates.
const liveSelectedFieldGroups = selectedKonvaFieldGroups.filter((fieldGroup) => {
if (!fieldGroup.getStage() || !fieldGroup.getParent()) {
return false;
}
return localPageFields.some((field) => field.formId === fieldGroup.id());
});
if (liveSelectedFieldGroups.length !== selectedKonvaFieldGroups.length) {
setSelectedFields(liveSelectedFieldGroups);
}
// Rerender the transformer
interactiveTransformer.current?.forceUpdate();
pageLayer.current.batchDraw();
}, [localPageFields]);
}, [localPageFields, selectedKonvaFieldGroups]);
const setSelectedFields = (nodes: Konva.Node[]) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const fieldGroups = nodes.filter((node) => node.hasName('field-group')) as Konva.Group[];
const fieldGroups = nodes.filter(
(node) =>
node.hasName('field-group') && Boolean(node.getStage()) && Boolean(node.getParent()),
) as Konva.Group[];
interactiveTransformer.current?.nodes(fieldGroups);
setSelectedKonvaFieldGroups(fieldGroups);
@@ -515,7 +521,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
removePendingField();
if (!canvasElement.current || !currentEnvelopeItem || !editorFields.selectedRecipient) {
if (!currentEnvelopeItem || !editorFields.selectedRecipient) {
return;
}
@@ -530,7 +536,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
editorFields.addField({
envelopeItemId: currentEnvelopeItem.id,
page: pageContext.pageNumber,
page: pageNumber,
type,
positionX: fieldX,
positionY: fieldY,
@@ -559,10 +565,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}
return (
<div
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
<>
{selectedKonvaFieldGroups.length > 0 &&
interactiveTransformer.current &&
!isFieldChanging && (
@@ -624,17 +627,9 @@ export default function EnvelopeEditorFieldsPageRenderer() {
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */}
<canvas
className={`${_className}__canvas z-0`}
ref={canvasElement}
height={scaledViewport.height}
width={scaledViewport.width}
/>
</div>
</>
);
}
};
type FieldActionButtonsProps = React.HTMLAttributes<HTMLDivElement> & {
handleDuplicateSelectedFields: () => void;
@@ -674,6 +669,10 @@ const FieldActionButtons = ({
selectedFieldFormId.includes(field.formId),
);
if (fields.length === 0) {
return null;
}
const recipient = envelope.recipients.find(
(recipient) => recipient.id === fields[0].recipientId,
);
@@ -689,7 +688,7 @@ const FieldActionButtons = ({
}
return null;
}, [editorFields.localFields]);
}, [editorFields.localFields, envelope.recipients, selectedFieldFormId]);
return (
<div className="flex flex-col items-center" {...props}>
@@ -1,17 +1,18 @@
import { lazy, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon, SparklesIcon } from 'lucide-react';
import { Link, useRevalidator, useSearchParams } from 'react-router';
import { FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
import {
FIELD_META_DEFAULT_VALUES,
@@ -27,15 +28,17 @@ import {
type TSignatureFieldMeta,
type TTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
import { AiFieldDetectionDialog } from '~/components/dialogs/ai-field-detection-dialog';
import { EnvelopeItemEditDialog } from '~/components/dialogs/envelope-item-edit-dialog';
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
import { EditorFieldDropdownForm } from '~/components/forms/editor/editor-field-dropdown-form';
@@ -46,16 +49,14 @@ import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-nu
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form';
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
import { useCurrentTeam } from '~/providers/team';
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
import { EnvelopeEditorFieldsPageRenderer } from './envelope-editor-fields-page-renderer';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
const EnvelopeEditorFieldsPageRenderer = lazy(
async () => import('~/components/general/envelope-editor/envelope-editor-fields-page-renderer'),
);
const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
[FieldType.SIGNATURE]: msg`Signature Settings`,
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
@@ -75,7 +76,9 @@ export const EnvelopeEditorFieldsPage = () => {
const team = useCurrentTeam();
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@@ -85,6 +88,11 @@ export const EnvelopeEditorFieldsPage = () => {
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
const { revalidate } = useRevalidator();
const envelopeItemPermissions = useMemo(
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
[envelope, envelope.recipients],
);
const selectedField = useMemo(
() => structuredClone(editorFields.selectedField),
[editorFields.selectedField],
@@ -97,14 +105,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');
}
};
@@ -156,12 +160,47 @@ export const EnvelopeEditorFieldsPage = () => {
return (
<div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto">
<div
className="flex h-full w-full flex-col overflow-y-auto px-2"
ref={scrollableContainerRef}
>
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
<EnvelopeRendererFileSelector
className="px-0"
fields={editorFields.localFields}
renderItemAction={
editorConfig.envelopeItems !== null &&
editorConfig.envelopeItems.allowReplace &&
envelopeItemPermissions.canFileBeChanged
? (item) => (
<div className="relative flex h-5 w-5 flex-shrink-0 items-center justify-center">
<div
className={cn(
'h-2 w-2 rounded-full transition-opacity duration-150 group-hover:opacity-0',
{ 'bg-green-500': currentEnvelopeItem?.id === item.id },
)}
/>
<EnvelopeItemEditDialog
envelopeItem={item}
allowConfigureTitle={editorConfig.envelopeItems?.allowConfigureTitle ?? false}
trigger={
<span
className="absolute inset-0 flex cursor-pointer items-center justify-center opacity-0 transition-opacity duration-150 group-hover:opacity-100"
onClick={(e) => e.stopPropagation()}
data-testid={`envelope-item-edit-button-${item.id}`}
>
<PencilIcon className="h-3.5 w-3.5" />
</span>
}
/>
</div>
)
: undefined
}
/>
{/* Document View */}
<div className="mt-4 flex flex-col items-center justify-center">
<div className="mt-4 flex h-full flex-col items-center justify-center">
{envelope.recipients.length === 0 && (
<Alert
variant="neutral"
@@ -176,18 +215,17 @@ export const EnvelopeEditorFieldsPage = () => {
</AlertDescription>
</div>
<Button asChild variant="outline">
<Link to={`${relativePath.editorPath}`}>
<Trans>Add Recipients</Trans>
</Link>
<Button variant="outline" onClick={() => void navigateToStep('upload')}>
<Trans>Add Recipients</Trans>
</Button>
</Alert>
)}
{currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy
renderer="editor"
<EnvelopePdfViewer
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
@@ -249,36 +287,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. */}
@@ -295,20 +337,42 @@ export const EnvelopeEditorFieldsPage = () => {
</h3>
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-sm text-foreground">
{selectedField.id && (
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Field ID:</Trans>
</span>{' '}
{selectedField.id}
</p>
)}
<p>
<span className="min-w-12 text-muted-foreground">Pos X:&nbsp;</span>
<span className="min-w-12 text-muted-foreground">
<Trans>Recipient ID:</Trans>
</span>{' '}
{selectedField.recipientId}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos X:</Trans>
</span>{' '}
{selectedField.positionX.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">Pos Y:&nbsp;</span>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos Y:</Trans>
</span>{' '}
{selectedField.positionY.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">Width:&nbsp;</span>
<span className="min-w-12 text-muted-foreground">
<Trans>Width:</Trans>
</span>{' '}
{selectedField.width.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">Height:&nbsp;</span>
<span className="min-w-12 text-muted-foreground">
<Trans>Height:</Trans>
</span>{' '}
{selectedField.height.toFixed(2)}
</p>
</div>
@@ -1,7 +1,10 @@
import { useMemo } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { DocumentStatus, EnvelopeType, TemplateType } from '@prisma/client';
import {
AlertTriangleIcon,
Building2Icon,
Globe2Icon,
LockIcon,
RefreshCwIcon,
@@ -12,7 +15,10 @@ import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import {
getEnvelopeItemPermissions,
mapSecondaryIdToTemplateId,
} from '@documenso/lib/utils/envelope';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
@@ -22,6 +28,7 @@ import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redist
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
import { BrandingLogo } from '~/components/general/branding-logo';
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
import { EmbeddedEditorAttachmentPopover } from '~/components/general/document/embedded-editor-attachment-popover';
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
import { TemplateDirectLinkBadge } from '../template/template-direct-link-badge';
@@ -30,21 +37,62 @@ 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 {
embedded,
general: { allowConfigureEnvelopeTitle },
actions: { allowAttachments, allowDistributing },
} = editorConfig;
const envelopeItemPermissions = useMemo(
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
[envelope, envelope.recipients],
);
const handleCreateEmbeddedEnvelope = async () => {
const latestEnvelope = await flushAutosave();
embedded?.onCreate?.(latestEnvelope);
};
const handleUpdateEmbeddedEnvelope = async () => {
const latestEnvelope = await flushAutosave();
embedded?.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.embedded?.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}
dataTestId="envelope-title-input"
disabled={!envelopeItemPermissions.canTitleBeChanged || !allowConfigureEnvelopeTitle}
value={envelope.title}
onChange={(title) => {
updateEnvelope({
@@ -58,12 +106,19 @@ export default function EnvelopeEditorHeader() {
{envelope.type === EnvelopeType.TEMPLATE && (
<>
{envelope.templateType === 'PRIVATE' ? (
{envelope.templateType === TemplateType.PRIVATE && (
<Badge variant="secondary">
<LockIcon className="mr-2 h-4 w-4 text-blue-600 dark:text-blue-300" />
<Trans>Private Template</Trans>
</Badge>
) : (
)}
{envelope.templateType === TemplateType.ORGANISATION && (
<Badge variant="orange">
<Building2Icon className="mr-2 size-4" />
<Trans>Organisation Template</Trans>
</Badge>
)}
{envelope.templateType === TemplateType.PUBLIC && (
<Badge variant="default">
<Globe2Icon className="mr-2 h-4 w-4 text-green-500 dark:text-green-300" />
<Trans>Public Template</Trans>
@@ -127,54 +182,75 @@ export default function EnvelopeEditorHeader() {
</div>
<div className="flex items-center space-x-2">
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
{allowAttachments &&
(isEmbedded ? (
<EmbeddedEditorAttachmentPopover buttonSize="sm" />
) : (
<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>
}
/>
</>
)}
{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)}
{embedded?.mode === 'create' && (
<Button size="sm" onClick={handleCreateEmbeddedEnvelope}>
{isDocument ? <Trans>Create Document</Trans> : <Trans>Create Template</Trans>}
</Button>
)}
{embedded?.mode === 'edit' && (
<Button size="sm" onClick={handleUpdateEmbeddedEnvelope}>
{isDocument ? <Trans>Update Document</Trans> : <Trans>Update Template</Trans>}
</Button>
)}
</div>
</div>
</nav>
@@ -1,6 +1,6 @@
import { lazy, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { faker } from '@faker-js/faker/locale/en';
import type { Faker } from '@faker-js/faker';
import { Trans } from '@lingui/react/macro';
import { FieldType, SigningStatus } from '@prisma/client';
import { FileTextIcon } from 'lucide-react';
@@ -11,33 +11,46 @@ import {
EnvelopeRenderProvider,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Separator } from '@documenso/ui/primitives/separator';
import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
const EnvelopeGenericPageRenderer = lazy(
async () => import('~/components/general/envelope-editor/envelope-generic-page-renderer'),
);
// Todo: Envelopes - Dynamically import faker
export const EnvelopeEditorPreviewPage = () => {
const { envelope, editorFields } = useCurrentEnvelopeEditor();
const { envelope, editorFields, editorConfig } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
'recipient',
);
const [fakerInstance, setFakerInstance] = useState<Faker | null>(null);
useEffect(() => {
void import('@faker-js/faker/locale/en').then((mod) => {
setFakerInstance(mod.faker);
});
}, []);
const fieldsWithPlaceholders = useMemo(() => {
if (!fakerInstance) {
return [];
}
const faker = fakerInstance;
return fields.map((field) => {
const fieldMeta = ZFieldAndMetaSchema.parse(field);
@@ -188,7 +201,7 @@ export const EnvelopeEditorPreviewPage = () => {
.exhaustive(),
};
});
}, [fields, envelope, envelope.recipients, envelope.documentMeta]);
}, [fields, envelope, envelope.recipients, envelope.documentMeta, fakerInstance]);
/**
* Set the selected recipient to the first recipient in the envelope.
@@ -200,37 +213,44 @@ export const EnvelopeEditorPreviewPage = () => {
// Override the parent renderer provider so we can inject custom fields.
return (
<EnvelopeRenderProvider
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={undefined}
fields={fieldsWithPlaceholders}
recipients={envelope.recipients.map((recipient) => ({
...recipient,
signingStatus: SigningStatus.SIGNED,
}))}
presignToken={editorConfig?.embedded?.presignToken}
overrideSettings={{
mode: 'export',
}}
>
<div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto">
<div
className="flex h-full w-full flex-col overflow-y-auto px-2"
ref={scrollableContainerRef}
>
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
<EnvelopeRendererFileSelector className="px-0" fields={editorFields.localFields} />
<Alert variant="warning" className="mx-auto max-w-[800px]">
<AlertTitle>
<Trans>Preview Mode</Trans>
</AlertTitle>
<AlertDescription>
<Trans>Preview what the signed document will look like with placeholder data</Trans>
</AlertDescription>
</Alert>
{/* Document View */}
<div className="mt-4 flex flex-col items-center justify-center">
<Alert variant="warning" className="mb-4 max-w-[800px]">
<AlertTitle>
<Trans>Preview Mode</Trans>
</AlertTitle>
<AlertDescription>
<Trans>Preview what the signed document will look like with placeholder data</Trans>
</AlertDescription>
</Alert>
<div className="mt-4 flex h-full flex-col items-center justify-center">
{currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy
renderer="editor"
<EnvelopePdfViewer
customPageRenderer={EnvelopeGenericPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
DragDropContext,
@@ -7,32 +7,26 @@ import {
Droppable,
type SensorAPI,
} from '@hello-pangea/dnd';
import { zodResolver } from '@hookform/resolvers/zod';
import { plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react';
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
import { useFieldArray, useWatch } from 'react-hook-form';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual, prop, sortBy } from 'remeda';
import { z } from 'zod';
import { isDeepEqual } from 'remeda';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
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 {
ZRecipientActionAuthTypesSchema,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
import {
RecipientAutoCompleteInput,
@@ -67,26 +61,15 @@ import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
import { useCurrentTeam } from '~/providers/team';
const ZEnvelopeRecipientsForm = z.object({
signers: z.array(
z.object({
formId: z.string().min(1),
id: z.number().optional(),
email: ZRecipientEmailSchema,
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}),
),
signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean().default(false),
});
type TEnvelopeRecipientsForm = z.infer<typeof ZEnvelopeRecipientsForm>;
export const EnvelopeEditorRecipientForm = () => {
const { envelope, setRecipientsDebounced, updateEnvelope } = useCurrentEnvelopeEditor();
const {
envelope,
setRecipientsDebounced,
updateEnvelope,
editorRecipients,
isEmbedded,
editorConfig,
} = useCurrentEnvelopeEditor();
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
@@ -94,7 +77,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('');
@@ -145,7 +130,6 @@ export const EnvelopeEditorRecipientForm = () => {
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
const initialId = useId();
const $sensorApi = useRef<SensorAPI | null>(null);
const isFirstRender = useRef(true);
const { recipients, fields } = envelope;
@@ -155,48 +139,14 @@ export const EnvelopeEditorRecipientForm = () => {
query: debouncedRecipientSearchQuery,
},
{
enabled: debouncedRecipientSearchQuery.length > 1,
enabled: debouncedRecipientSearchQuery.length > 1 && !isEmbedded,
retry: false,
},
);
const recipientSuggestions = recipientSuggestionsData?.results || [];
const defaultRecipients = [
{
formId: initialId,
name: '',
email: '',
role: RecipientRole.SIGNER,
signingOrder: 1,
actionAuth: [],
},
];
const form = useForm<TEnvelopeRecipientsForm>({
resolver: zodResolver(ZEnvelopeRecipientsForm),
mode: 'onChange', // Used for autosave purposes, maybe can try onBlur instead?
defaultValues: {
signers:
recipients.length > 0
? sortBy(
recipients.map((recipient, index) => ({
id: recipient.id,
formId: String(recipient.id),
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder ?? index + 1,
actionAuth:
ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
})),
[prop('signingOrder'), 'asc'],
[prop('id'), 'asc'],
)
: defaultRecipients,
signingOrder: envelope.documentMeta.signingOrder,
allowDictateNextSigner: envelope.documentMeta.allowDictateNextSigner,
},
});
const { form } = editorRecipients;
const recipientHasAuthSettings = useMemo(() => {
const recipientHasAuthOptions = recipients.find((recipient) => {
@@ -588,7 +538,7 @@ export const EnvelopeEditorRecipientForm = () => {
return;
}
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse(formValues);
const validatedFormValues = ZEditorRecipientsFormSchema.safeParse(formValues);
if (!validatedFormValues.success) {
return;
@@ -652,42 +602,50 @@ export const EnvelopeEditorRecipientForm = () => {
<Card backdropBlur={false} className="border">
<CardHeader className="flex flex-row justify-between">
<div>
<CardTitle>Recipients</CardTitle>
<CardDescription className="mt-1.5">Add recipients to your document</CardDescription>
<CardTitle>
<Trans>Recipients</Trans>
</CardTitle>
<CardDescription className="mt-1.5">
<Trans>Add recipients to your document</Trans>
</CardDescription>
</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"
@@ -704,26 +662,32 @@ export const EnvelopeEditorRecipientForm = () => {
</CardHeader>
<CardContent>
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
<Form {...form}>
<div className="-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4">
{organisation.organisationClaim.flags.cfr21 && (
<div className="flex flex-row items-center">
<Checkbox
id="showAdvancedRecipientSettings"
checked={showAdvancedSettings}
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
/>
<Form {...form}>
<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
id="showAdvancedRecipientSettings"
checked={showAdvancedSettings}
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
/>
<label
className="ml-2 text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
htmlFor="showAdvancedRecipientSettings"
>
<Trans>Show advanced settings</Trans>
</label>
</div>
)}
<label
className="ml-2 text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
htmlFor="showAdvancedRecipientSettings"
>
<Trans>Show advanced settings</Trans>
</label>
</div>
)}
{editorConfig.recipients?.allowConfigureSigningOrder && (
<FormField
control={form.control}
name="signingOrder"
@@ -782,73 +746,78 @@ export const EnvelopeEditorRecipientForm = () => {
</FormItem>
)}
/>
)}
{isSigningOrderSequential && (
<FormField
control={form.control}
name="allowDictateNextSigner"
render={({ field: { value, ...field } }) => (
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="allowDictateNextSigner"
checked={value}
onCheckedChange={(checked) => {
field.onChange(checked);
}}
disabled={
isSubmitting || hasDocumentBeenSent || !isSigningOrderSequential
}
/>
</FormControl>
{isSigningOrderSequential && (
<FormField
control={form.control}
name="allowDictateNextSigner"
render={({ field: { value, ...field } }) => (
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="allowDictateNextSigner"
checked={value}
onCheckedChange={(checked) => {
field.onChange(checked);
}}
disabled={isSubmitting || hasDocumentBeenSent || !isSigningOrderSequential}
/>
</FormControl>
<div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
<FormLabel
htmlFor="allowDictateNextSigner"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Allow signers to dictate next signer</Trans>
</FormLabel>
<div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
<FormLabel
htmlFor="allowDictateNextSigner"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Allow signers to dictate next signer</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>
When enabled, signers can choose who should sign next in the
sequence instead of following the predefined order.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
</FormItem>
)}
/>
)}
</div>
<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>
When enabled, signers can choose who should sign next in the sequence
instead of following the predefined order.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
</FormItem>
)}
/>
)}
</div>
<DragDropContext
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
$sensorApi.current = api;
},
]}
>
<Droppable droppableId="signers">
{(provided) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className="flex w-full flex-col gap-y-2"
>
{signers.map((signer, index) => (
<DragDropContext
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
$sensorApi.current = api;
},
]}
>
<Droppable droppableId="signers">
{(provided) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className="flex w-full flex-col gap-y-2"
>
{signers.map((signer, index) => {
const isDirectRecipient =
envelope.type === EnvelopeType.TEMPLATE &&
envelope.directLink !== null &&
signer.id === envelope.directLink.directTemplateRecipientId;
return (
<Draggable
key={`${signer.nativeId}-${signer.signingOrder}`}
draggableId={signer['nativeId']}
@@ -953,7 +922,8 @@ export const EnvelopeEditorRecipientForm = () => {
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
!canRecipientBeModified(signer.id) ||
isDirectRecipient
}
options={recipientSuggestions}
onSelect={(suggestion) =>
@@ -999,7 +969,8 @@ export const EnvelopeEditorRecipientForm = () => {
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
!canRecipientBeModified(signer.id) ||
isDirectRecipient
}
options={recipientSuggestions}
onSelect={(suggestion) =>
@@ -1033,6 +1004,14 @@ 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
@@ -1062,7 +1041,8 @@ export const EnvelopeEditorRecipientForm = () => {
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
signers.length === 1
signers.length === 1 ||
isDirectRecipient
}
onClick={() => onRemoveSigner(index)}
>
@@ -1105,21 +1085,21 @@ export const EnvelopeEditorRecipientForm = () => {
</div>
)}
</Draggable>
))}
);
})}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
<FormErrorMessage
className="mt-2"
// Dirty hack to handle errors when .root is populated for an array type
error={'signers__root' in errors && errors['signers__root']}
/>
</Form>
</AnimateGenericFadeInOut>
<FormErrorMessage
className="mt-2"
// Dirty hack to handle errors when .root is populated for an array type
error={'signers__root' in errors && errors['signers__root']}
/>
</Form>
<SigningOrderConfirmation
open={showSigningOrderConfirmation}
@@ -1127,13 +1107,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
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
fields={envelope.fields}
recipients={envelope.recipients}
token={token}
presignToken={presignedToken}
>
{children}
</EnvelopeRenderProvider>
);
};
File diff suppressed because it is too large Load Diff
@@ -9,6 +9,7 @@ export type EnvelopeItemTitleInputProps = {
className?: string;
placeholder?: string;
disabled?: boolean;
dataTestId?: string;
};
export const EnvelopeItemTitleInput = ({
@@ -17,6 +18,7 @@ export const EnvelopeItemTitleInput = ({
className,
placeholder,
disabled,
dataTestId,
}: EnvelopeItemTitleInputProps) => {
const [envelopeItemTitle, setEnvelopeItemTitle] = useState(value);
const [isError, setIsError] = useState(false);
@@ -63,6 +65,7 @@ export const EnvelopeItemTitleInput = ({
{envelopeItemTitle || placeholder}
</span>
<input
data-testid={dataTestId}
data-1p-ignore
autoComplete="off"
ref={inputRef}
@@ -72,7 +75,7 @@ export const EnvelopeItemTitleInput = ({
disabled={disabled}
style={{ width: `${inputWidth}px` }}
className={cn(
'text-foreground hover:outline-muted-foreground focus:outline-muted-foreground rounded-sm border-0 bg-transparent p-1 text-sm font-medium outline-none hover:outline hover:outline-1 focus:outline focus:outline-1',
'rounded-sm border-0 bg-transparent p-1 text-sm font-medium text-foreground outline-none hover:outline hover:outline-1 hover:outline-muted-foreground focus:outline focus:outline-1 focus:outline-muted-foreground',
className,
{
'outline-red-500': isError,
@@ -1,26 +1,26 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import type { DropResult } from '@hello-pangea/dnd';
import { msg, plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
import { X } from 'lucide-react';
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
import { Link } from 'react-router';
import { FileWarningIcon, GripVerticalIcon, Loader2Icon, PencilIcon, XIcon } from 'lucide-react';
import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import {
useCurrentEnvelopeEditor,
useDebounceFunction,
} from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useEnvelopeAutosave } from '@documenso/lib/client-only/hooks/use-envelope-autosave';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
import { nanoid } from '@documenso/lib/universal/id';
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
import type { TReplaceEnvelopeItemPdfPayload } from '@documenso/trpc/server/envelope-router/replace-envelope-item-pdf.types';
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
import { Button } from '@documenso/ui/primitives/button';
import {
Card,
@@ -42,17 +42,30 @@ type LocalFile = {
title: string;
envelopeItemId: string | null;
isUploading: boolean;
isReplacing: boolean;
isError: boolean;
};
export const EnvelopeEditorUploadPage = () => {
const organisation = useCurrentOrganisation();
const { t } = useLingui();
const { envelope, setLocalEnvelope, relativePath, editorFields } = useCurrentEnvelopeEditor();
const { t, i18n } = useLingui();
const { maximumEnvelopeItemCount, remaining } = useLimits();
const { toast } = useToast();
const {
envelope,
setLocalEnvelope,
editorFields,
editorConfig,
isEmbedded,
navigateToStep,
registerExternalFlush,
registerPendingMutation,
} = useCurrentEnvelopeEditor();
const { envelopeItems: uploadConfig } = editorConfig;
const [localFiles, setLocalFiles] = useState<LocalFile[]>(
envelope.envelopeItems
.sort((a, b) => a.order - b.order)
@@ -61,10 +74,36 @@ export const EnvelopeEditorUploadPage = () => {
title: item.title,
envelopeItemId: item.id,
isUploading: false,
isReplacing: false,
isError: false,
})),
);
const replacingItemIdRef = useRef<string | null>(null);
const { open: openReplaceFilePicker, getInputProps: getReplaceInputProps } = useDropzone({
accept: { 'application/pdf': ['.pdf'] },
maxFiles: 1,
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
multiple: false,
noClick: true,
noKeyboard: true,
noDrag: true,
onDrop: (acceptedFiles) => {
const file = acceptedFiles[0];
const replacingItemId = replacingItemIdRef.current;
if (file && replacingItemId) {
void onReplacePdf(replacingItemId, file);
replacingItemIdRef.current = null;
}
},
onDropRejected: (fileRejections) => void onFileDropRejected(fileRejections),
onFileDialogCancel: () => {
replacingItemIdRef.current = null;
},
});
const { mutateAsync: createEnvelopeItems, isPending: isCreatingEnvelopeItems } =
trpc.envelope.item.createMany.useMutation({
onSuccess: ({ data }) => {
@@ -97,23 +136,71 @@ export const EnvelopeEditorUploadPage = () => {
},
});
const canItemsBeModified = useMemo(
() => canEnvelopeItemsBeModified(envelope, envelope.recipients),
const envelopeItemPermissions = useMemo(
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
[envelope, envelope.recipients],
);
const { mutateAsync: replaceEnvelopeItemPdf } = trpc.envelope.item.replacePdf.useMutation({
onSuccess: ({ data, fields }) => {
// Update the envelope item with the new documentDataId.
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.map((item) =>
item.id === data.id ? { ...item, documentDataId: data.documentDataId } : item,
),
});
// When fields were created or deleted during the replacement,
// the server returns the full updated field list.
if (fields) {
setLocalEnvelope({ fields });
editorFields.resetForm(fields);
}
},
});
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: TEditorEnvelope['envelopeItems'][number]['data'] | 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,
isReplacing: false,
// Clone the buffer so it can be read multiple times (File.arrayBuffer() consumes the stream once)
data: isEmbedded ? new Uint8Array((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!,
documentDataId: '',
})),
],
});
return;
}
const payload = {
envelopeId: envelope.id,
} satisfies TCreateEnvelopeItemsPayload;
@@ -126,7 +213,11 @@ export const EnvelopeEditorUploadPage = () => {
formData.append('files', file);
}
const { data } = await createEnvelopeItems(formData).catch((error) => {
const createPromise = createEnvelopeItems(formData);
registerPendingMutation(createPromise);
const { data } = await createPromise.catch((error) => {
console.error(error);
// Set error state on files in batch upload.
@@ -153,17 +244,84 @@ export const EnvelopeEditorUploadPage = () => {
envelopeItemId: item.id,
title: item.title,
isUploading: false,
isReplacing: false,
isError: false,
})),
);
});
};
const onReplacePdf = async (envelopeItemId: string, file: File) => {
setLocalFiles((prev) =>
prev.map((f) => (f.envelopeItemId === envelopeItemId ? { ...f, isReplacing: true } : f)),
);
try {
if (isEmbedded) {
// For embedded mode, store the file data locally on the envelope item.
// The actual replacement will happen when the embed flow submits.
const arrayBuffer = await file.arrayBuffer();
const data = new Uint8Array(arrayBuffer.slice(0));
// Count pages in the new PDF to remove out-of-bounds fields.
const { PDF } = await import('@libpdf/core');
const pdfDoc = await PDF.load(data);
const newPageCount = pdfDoc.getPageCount();
// Remove fields that are on pages beyond the new PDF's page count.
const remainingFields = envelope.fields.filter(
(field) => field.envelopeItemId !== envelopeItemId || field.page <= newPageCount,
);
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.map((item) =>
item.id === envelopeItemId ? { ...item, data } : item,
),
fields: remainingFields,
});
editorFields.resetForm(remainingFields);
return;
}
// Normal mode: upload immediately via tRPC.
const payload = {
envelopeId: envelope.id,
envelopeItemId,
} satisfies TReplaceEnvelopeItemPdfPayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const replacePromise = replaceEnvelopeItemPdf(formData);
registerPendingMutation(replacePromise);
await replacePromise;
} catch (error) {
console.error(error);
toast({
title: t`Replace failed`,
description: t`Something went wrong while replacing the PDF`,
duration: 5000,
variant: 'destructive',
});
} finally {
setLocalFiles((prev) =>
prev.map((f) => (f.envelopeItemId === envelopeItemId ? { ...f, isReplacing: false } : f)),
);
}
};
/**
* Hide the envelope item from the list on deletion.
*/
const onFileDelete = (envelopeItemId: string) => {
setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.id !== envelopeItemId));
setLocalFiles((prev) =>
prev.filter((uploadingFile) => uploadingFile.envelopeItemId !== envelopeItemId),
);
const fieldsWithoutDeletedItem = envelope.fields.filter(
(field) => field.envelopeItemId !== envelopeItemId,
@@ -194,18 +352,60 @@ export const EnvelopeEditorUploadPage = () => {
debouncedUpdateEnvelopeItems(items);
};
const debouncedUpdateEnvelopeItems = useDebounceFunction((files: LocalFile[]) => {
void updateEnvelopeItems({
envelopeId: envelope.id,
data: files
.filter((item) => item.envelopeItemId)
.map((item, index) => ({
envelopeItemId: item.envelopeItemId || '',
order: index + 1,
title: item.title,
})),
});
}, 1000);
const { triggerSave: debouncedUpdateEnvelopeItems, flush: flushUpdateEnvelopeItems } =
useEnvelopeAutosave(
async (files: LocalFile[]) => {
if (isEmbedded) {
const nextEnvelopeItems = files
.filter((item) => item.envelopeItemId)
.map((item, index) => {
const originalEnvelopeItem = envelope.envelopeItems.find(
(envelopeItem) => envelopeItem.id === item.envelopeItemId,
);
return {
id: item.envelopeItemId || '',
title: item.title,
order: index + 1,
envelopeId: envelope.id,
data: originalEnvelopeItem?.data,
documentDataId: originalEnvelopeItem?.documentDataId || '',
};
});
setLocalEnvelope({
envelopeItems: nextEnvelopeItems,
});
return;
}
await updateEnvelopeItems({
envelopeId: envelope.id,
data: files
.filter((item) => item.envelopeItemId)
.map((item, index) => ({
envelopeItemId: item.envelopeItemId || '',
order: index + 1,
title: item.title,
})),
});
},
isEmbedded ? 0 : 1000,
);
const flushUpdateEnvelopeItemsRef = useRef(flushUpdateEnvelopeItems);
flushUpdateEnvelopeItemsRef.current = flushUpdateEnvelopeItems;
// Register the flush callback with the provider so flushAutosave can await
// pending envelope item mutations. We intentionally do NOT unregister on unmount
// because the upload page is unmounted (replaced with a spinner) before
// flushAutosave runs during step transitions. The hook's internal refs survive
// unmounting, so the flush callback remains valid.
useEffect(() => {
registerExternalFlush('envelopeItems', async () => flushUpdateEnvelopeItemsRef.current());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onEnvelopeItemTitleChange = (envelopeItemId: string, title: string) => {
const newLocalFilesValue = localFiles.map((uploadingFile) =>
@@ -217,7 +417,7 @@ export const EnvelopeEditorUploadPage = () => {
};
const dropzoneDisabledMessage = useMemo(() => {
if (!canItemsBeModified) {
if (!envelopeItemPermissions.canFileBeChanged) {
return msg`Cannot upload items after the document has been sent`;
}
@@ -258,7 +458,7 @@ export const EnvelopeEditorUploadPage = () => {
toast({
title: t`Upload failed`,
description: t`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
description: i18n._(buildDropzoneRejectionDescription(fileRejections)),
duration: 5000,
variant: 'destructive',
});
@@ -266,6 +466,7 @@ export const EnvelopeEditorUploadPage = () => {
return (
<div className="mx-auto max-w-4xl space-y-6 p-8">
<input {...getReplaceInputProps()} />
<Card backdropBlur={false} className="border">
<CardHeader className="pb-3">
<CardTitle>
@@ -277,32 +478,46 @@ 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
data-testid="envelope-item-dropzone"
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">
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="files">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="space-y-2">
<div
data-testid="envelope-items-list"
{...provided.droppableProps}
ref={provided.innerRef}
className="space-y-2"
>
{localFiles.map((localFile, index) => (
<Draggable
key={localFile.id}
isDragDisabled={isCreatingEnvelopeItems || !canItemsBeModified}
isDragDisabled={
isCreatingEnvelopeItems ||
!envelopeItemPermissions.canOrderBeChanged ||
localFile.isReplacing ||
!uploadConfig?.allowConfigureOrder
}
draggableId={localFile.id}
index={index}
>
{(provided, snapshot) => (
<div
data-testid={`envelope-item-row-${localFile.id}`}
ref={provided.innerRef}
{...provided.draggableProps}
style={provided.draggableProps.style}
@@ -311,18 +526,26 @@ 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}
data-testid={`envelope-item-drag-handle-${localFile.id}`}
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={
!envelopeItemPermissions.canTitleBeChanged ||
!uploadConfig?.allowConfigureTitle ||
localFile.isReplacing
}
value={localFile.title}
dataTestId={`envelope-item-title-input-${localFile.id}`}
placeholder={t`Document Title`}
onChange={(title) => {
onEnvelopeItemTitleChange(localFile.envelopeItemId!, title);
@@ -337,15 +560,14 @@ export const EnvelopeEditorUploadPage = () => {
<Trans>Uploading</Trans>
) : localFile.isError ? (
<Trans>Something went wrong while uploading this file</Trans>
) : // <div className="text-xs text-gray-500">2.4 MB • 3 pages</div>
null}
) : null}
</div>
</div>
</div>
<div className="flex items-center space-x-2">
{localFile.isUploading && (
<div className="flex h-6 w-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<Loader2Icon className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
@@ -355,20 +577,58 @@ 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.envelopeItemId &&
envelopeItemPermissions.canFileBeChanged &&
uploadConfig?.allowReplace && (
<Button
variant="ghost"
size="sm"
data-testid={`envelope-item-replace-button-${localFile.id}`}
disabled={localFile.isReplacing || localFile.isUploading}
onClick={() => {
replacingItemIdRef.current = localFile.envelopeItemId;
openReplaceFilePicker();
}}
>
{localFile.isReplacing ? (
<Loader2Icon className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<PencilIcon className="h-4 w-4" />
)}
</Button>
)}
{localFile.envelopeItemId &&
uploadConfig?.allowDelete &&
(isEmbedded ? (
<Button
variant="ghost"
size="sm"
data-testid={`envelope-item-remove-button-${localFile.id}`}
onClick={() => onFileDelete(localFile.envelopeItemId!)}
disabled={localFile.isReplacing || localFile.isUploading}
>
<XIcon className="h-4 w-4" />
</Button>
) : (
<EnvelopeItemDeleteDialog
canItemBeDeleted={envelopeItemPermissions.canFileBeChanged}
envelopeId={envelope.id}
envelopeItemId={localFile.envelopeItemId}
envelopeItemTitle={localFile.title}
onDelete={onFileDelete}
trigger={
<Button
variant="ghost"
size="sm"
data-testid={`envelope-item-remove-button-${localFile.id}`}
disabled={localFile.isReplacing || localFile.isUploading}
>
<XIcon className="h-4 w-4" />
</Button>
}
/>
))}
</div>
</div>
)}
@@ -386,13 +646,13 @@ export const EnvelopeEditorUploadPage = () => {
{/* Recipients Section */}
<EnvelopeEditorRecipientForm />
<div className="flex justify-end">
<Button asChild>
<Link to={`${relativePath.editorPath}?step=addFields`}>
{editorConfig.general.allowAddFieldsStep && (
<div className="flex justify-end">
<Button type="button" onClick={() => void navigateToStep('addFields')}>
<Trans>Add Fields</Trans>
</Link>
</Button>
</div>
</Button>
</div>
)}
</div>
);
};
@@ -1,40 +1,42 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useRef, 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,
CopyPlusIcon,
DownloadCloudIcon,
EyeIcon,
FileOutputIcon,
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 type { EnvelopeEditorStep } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import {
mapSecondaryIdToDocumentId,
mapSecondaryIdToTemplateId,
} from '@documenso/lib/utils/envelope';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
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 { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
@@ -43,52 +45,93 @@ import EnvelopeEditorHeader from './envelope-editor-header';
import { EnvelopeEditorPreviewPage } from './envelope-editor-preview-page';
import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page';
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
type EnvelopeEditorStepData = {
id: string;
title: MessageDescriptor;
icon: LucideIcon;
description: MessageDescriptor;
};
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`,
},
];
const UPLOAD_STEP = {
id: 'upload',
title: msg`Document & Recipients`,
icon: UploadIcon,
description: msg`Upload documents and add recipients`,
};
export default function EnvelopeEditor() {
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,
flushAutosave,
relativePath,
editorFields,
navigateToStep,
syncEnvelope,
flushAutosave,
resetForms,
} = useCurrentEnvelopeEditor();
const [searchParams, setSearchParams] = useSearchParams();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isStepLoading, setIsStepLoading] = useState(false);
const [currentStep, setCurrentStep] = useState<EnvelopeEditorStep>(() => {
const {
general: {
minimizeLeftSidebar,
allowUploadAndRecipientStep,
allowAddFieldsStep,
allowPreviewStep,
},
actions: {
allowDistributing,
allowDirectLink,
allowDuplication,
allowSaveAsTemplate,
allowDownloadPDF,
allowDeletion,
},
} = 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 searchParamsStep = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const searchParamStep = searchParams.get('step') as EnvelopeEditorStep | undefined;
// Empty URL param equals upload, otherwise use the step URL param
@@ -103,31 +146,28 @@ export default function EnvelopeEditor() {
}
return 'upload';
});
}, [searchParams]);
const navigateToStep = (step: EnvelopeEditorStep) => {
setCurrentStep(step);
const [pageToRender, setPageToRender] = useState<EnvelopeEditorStep | 'loading'>(
searchParamsStep,
);
void flushAutosave();
const latestStepChangeTime = useRef(0);
if (!isStepLoading && isAutosaving) {
setIsStepLoading(true);
}
const handleStepChange = async (step: EnvelopeEditorStep) => {
setPageToRender('loading');
// Update URL params: empty for upload, otherwise set the step
if (step === 'upload') {
setSearchParams((prev) => {
const newParams = new URLSearchParams(prev);
newParams.delete('step');
return newParams;
});
} else {
setSearchParams((prev) => {
const newParams = new URLSearchParams(prev);
newParams.set('step', step);
return newParams;
});
}
const currentTime = Date.now();
latestStepChangeTime.current = currentTime;
await flushAutosave().then(() => {
if (currentTime !== latestStepChangeTime.current) {
return;
}
resetForms();
setPageToRender(step);
});
};
// Watch the URL params and setStep if the step changes.
@@ -136,79 +176,140 @@ export default function EnvelopeEditor() {
const foundStep = envelopeEditorSteps.find((step) => step.id === stepParam);
if (foundStep && foundStep.id !== currentStep) {
if (foundStep && foundStep.id !== pageToRender) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
navigateToStep(foundStep.id as EnvelopeEditorStep);
void handleStepChange(foundStep.id as EnvelopeEditorStep);
}
}, [searchParams]);
useEffect(() => {
if (!isAutosaving) {
setIsStepLoading(false);
}
}, [isAutosaving]);
const currentStepData =
envelopeEditorSteps.find((step) => step.id === currentStep) || envelopeEditorSteps[0];
envelopeEditorSteps.find((step) => step.id === searchParamsStep) || envelopeEditorSteps[0];
return (
<div className="h-screen w-screen bg-gray-50 dark:bg-background">
<div className="h-screen w-screen bg-envelope-editor-background">
<EnvelopeEditorHeader />
{/* 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 = searchParamsStep === step.id;
return (
<button
key={step.id}
data-testid={`envelope-editor-step-${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={() => void 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,158 +322,292 @@ 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}
recipients={envelope.recipients}
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>
{allowSaveAsTemplate && isDocument && (
<EnvelopeSaveAsTemplateDialog
envelopeId={envelope.id}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Save as Template`)}
>
<FileOutputIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Save as Template</Trans>
</span>
)}
</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 () => {
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>
{!editorConfig.embedded && (
<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 })
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
.with({ currentStep: 'upload' }, () => <EnvelopeEditorUploadPage />)
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorFieldsPage />)
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPreviewPage />)
.exhaustive()}
</AnimateGenericFadeInOut>
<div className="flex-1 overflow-y-auto">
{match({
pageToRender,
allowUploadAndRecipientStep,
allowAddFieldsStep,
allowPreviewStep,
})
.with({ pageToRender: 'loading' }, () => <SpinnerBox className="py-32" />)
.with({ pageToRender: 'upload', allowUploadAndRecipientStep: true }, () => (
<EnvelopeEditorUploadPage />
))
.with({ pageToRender: 'addFields', allowAddFieldsStep: true }, () => (
<EnvelopeEditorFieldsPage />
))
.with({ pageToRender: 'preview', allowPreviewStep: true }, () => (
<EnvelopeEditorPreviewPage />
))
.otherwise(() => null)}
</div>
</div>
</div>
);
}
};
@@ -9,6 +9,7 @@ type EnvelopeItemSelectorProps = {
secondaryText: React.ReactNode;
isSelected: boolean;
buttonProps: React.ButtonHTMLAttributes<HTMLButtonElement>;
actionSlot?: React.ReactNode;
};
export const EnvelopeItemSelector = ({
@@ -17,11 +18,12 @@ export const EnvelopeItemSelector = ({
secondaryText,
isSelected,
buttonProps,
actionSlot,
}: EnvelopeItemSelectorProps) => {
return (
<button
title={typeof primaryText === 'string' ? primaryText : undefined}
className={`flex h-fit max-w-72 flex-shrink-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${
className={`group flex h-fit max-w-72 flex-shrink-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${
isSelected
? 'border-green-200 bg-green-50 text-green-900 dark:border-green-400/30 dark:bg-green-400/10 dark:text-green-400'
: 'border-border bg-muted/50 hover:bg-muted/70'
@@ -39,11 +41,13 @@ export const EnvelopeItemSelector = ({
<div className="truncate text-sm font-medium">{primaryText}</div>
<div className="text-xs text-gray-500">{secondaryText}</div>
</div>
<div
className={cn('h-2 w-2 flex-shrink-0 rounded-full', {
'bg-green-500': isSelected,
})}
></div>
{actionSlot ?? (
<div
className={cn('h-2 w-2 flex-shrink-0 rounded-full', {
'bg-green-500': isSelected,
})}
/>
)}
</button>
);
};
@@ -52,17 +56,24 @@ type EnvelopeRendererFileSelectorProps = {
fields: { envelopeItemId: string }[];
className?: string;
secondaryOverride?: React.ReactNode;
renderItemAction?: (item: { id: string; title: string }) => React.ReactNode;
};
export const EnvelopeRendererFileSelector = ({
fields,
className,
secondaryOverride,
renderItemAction,
}: EnvelopeRendererFileSelectorProps) => {
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
return (
<div className={cn('flex h-fit flex-shrink-0 space-x-2 overflow-x-auto p-4', className)}>
<div
className={cn(
'scrollbar-hidden flex h-fit flex-shrink-0 space-x-2 overflow-x-auto p-4',
className,
)}
>
{envelopeItems.map((doc, i) => (
<EnvelopeItemSelector
key={doc.id}
@@ -81,6 +92,7 @@ export const EnvelopeRendererFileSelector = ({
buttonProps={{
onClick: () => setCurrentEnvelopeItem(doc.id),
}}
actionSlot={renderItemAction?.(doc)}
/>
))}
</div>
@@ -5,7 +5,10 @@ import { DocumentStatus, type Recipient, SigningStatus } from '@prisma/client';
import type Konva from 'konva';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import {
type PageRenderData,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
@@ -15,7 +18,7 @@ type GenericLocalField = TEnvelope['fields'][number] & {
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
};
export default function EnvelopeGenericPageRenderer() {
export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
const { i18n } = useLingui();
const {
@@ -28,19 +31,14 @@ export default function EnvelopeGenericPageRenderer() {
overrideSettings,
} = useCurrentEnvelopeRender();
const {
stage,
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => {
createPageCanvas(stage, pageLayer);
});
const { stage, pageLayer, konvaContainer, unscaledViewport } = usePageRenderer(
({ stage, pageLayer }) => {
createPageCanvas(stage, pageLayer);
},
pageData,
);
const { _className, scale } = pageContext;
const { scale, pageNumber } = pageData;
const localPageFields = useMemo((): GenericLocalField[] => {
if (envelopeStatus === DocumentStatus.COMPLETED) {
@@ -49,8 +47,7 @@ export default function EnvelopeGenericPageRenderer() {
return fields
.filter(
(field) =>
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
)
.map((field) => {
const recipient = recipients.find((recipient) => recipient.id === field.recipientId);
@@ -73,7 +70,7 @@ export default function EnvelopeGenericPageRenderer() {
(recipient.signingStatus === SigningStatus.SIGNED ? inserted : true) ||
fieldMeta?.readOnly,
);
}, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]);
}, [fields, pageNumber, currentEnvelopeItem?.id, recipients, envelopeStatus]);
const unsafeRenderFieldOnLayer = (field: GenericLocalField) => {
if (!pageLayer.current) {
@@ -160,11 +157,9 @@ export default function EnvelopeGenericPageRenderer() {
}
return (
<div
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
<>
{overrideSettings?.showRecipientTooltip &&
pageData.imageLoadingState === 'loaded' &&
localPageFields.map((field) => (
<EnvelopeRecipientFieldTooltip
key={field.id}
@@ -176,14 +171,6 @@ export default function EnvelopeGenericPageRenderer() {
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */}
<canvas
className={`${_className}__canvas z-0`}
ref={canvasElement}
height={scaledViewport.height}
width={scaledViewport.width}
/>
</div>
</>
);
}
};

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