Merge branch 'main' into feat/prefetch-intent-navigation-links

This commit is contained in:
ephraimduncan
2026-04-20 00:20:51 +00:00
341 changed files with 12631 additions and 2208 deletions
@@ -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,7 +116,7 @@ export function AssistantConfirmationDialog({
<div className="mt-4 flex flex-col gap-4">
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
<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> (
@@ -1,201 +0,0 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import { P, match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} 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;
onDelete?: () => Promise<void> | void;
status: DocumentStatus;
documentTitle: string;
canManageDocument: boolean;
};
export const DocumentDeleteDialog = ({
id,
open,
onOpenChange,
onDelete,
status,
documentTitle,
canManageDocument,
}: DocumentDeleteDialogProps) => {
const { toast } = useToast();
const { refreshLimits } = useLimits();
const { _ } = useLingui();
const deleteMessage = msg`delete`;
const [inputValue, setInputValue] = useState('');
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
const { mutateAsync: deleteDocument, isPending } = trpcReact.document.delete.useMutation({
onSuccess: async () => {
void refreshLimits();
toast({
title: _(msg`Document deleted`),
description: _(msg`"${documentTitle}" has been successfully deleted`),
duration: 5000,
});
await onDelete?.();
onOpenChange(false);
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`This document could not be deleted at this time. Please try again.`),
variant: 'destructive',
duration: 7500,
});
},
});
useEffect(() => {
if (open) {
setInputValue('');
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
}
}, [open, status]);
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
setIsDeleteEnabled(event.target.value === _(deleteMessage));
};
return (
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription>
{canManageDocument ? (
<Trans>
You are about to delete <strong>"{documentTitle}"</strong>
</Trans>
) : (
<Trans>
You are about to hide <strong>"{documentTitle}"</strong>
</Trans>
)}
</DialogDescription>
</DialogHeader>
{canManageDocument ? (
<Alert variant="warning" className="-mt-1">
{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>
</AlertDescription>
))
.with(DocumentStatus.PENDING, () => (
<AlertDescription>
<p>
<Trans>
Please note that this action is <strong>irreversible</strong>.
</Trans>
</p>
<p className="mt-1">
<Trans>Once confirmed, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>
<Trans>Document will be permanently deleted</Trans>
</li>
<li>
<Trans>Document signing process will be cancelled</Trans>
</li>
<li>
<Trans>All inserted signatures will be voided</Trans>
</li>
<li>
<Trans>All recipients will be notified</Trans>
</li>
</ul>
</AlertDescription>
))
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
<AlertDescription>
<p>
<Trans>By deleting this document, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>
<Trans>The document will be hidden from your account</Trans>
</li>
<li>
<Trans>Recipients will still retain their copy of the document</Trans>
</li>
</ul>
</AlertDescription>
))
.exhaustive()}
</Alert>
) : (
<Alert variant="warning" className="-mt-1">
<AlertDescription>
<Trans>Please contact support if you would like to revert this action.</Trans>
</AlertDescription>
</Alert>
)}
{status !== DocumentStatus.DRAFT && canManageDocument && (
<Input
type="text"
value={inputValue}
onChange={onInputChange}
placeholder={_(msg`Please type ${`'${_(deleteMessage)}'`} to confirm`)}
/>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
loading={isPending}
onClick={() => void deleteDocument({ documentId: id })}
disabled={!isDeleteEnabled && canManageDocument}
variant="destructive"
>
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,103 +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 { 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 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>
<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}
>
@@ -52,13 +52,23 @@ export const EnvelopeDeleteDialog = ({
const [inputValue, setInputValue] = useState('');
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
const isDocument = type === EnvelopeType.DOCUMENT;
const { mutateAsync: deleteEnvelope, isPending } = trpcReact.envelope.delete.useMutation({
onSuccess: async () => {
void refreshLimits();
toast({
title: t`Document deleted`,
description: t`"${title}" 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,
});
@@ -69,7 +79,9 @@ export const EnvelopeDeleteDialog = ({
onError: () => {
toast({
title: t`Something went wrong`,
description: t`This document could not be deleted at this time. Please try again.`,
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,
});
@@ -16,6 +16,7 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
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';
@@ -62,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
@@ -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>
);
};
@@ -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,
});
@@ -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>;
@@ -10,8 +10,10 @@ 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';
@@ -73,8 +75,14 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
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),
defaultValues: {
@@ -106,7 +114,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
const onFormSubmit = async ({ members }: TAddTeamMembersFormSchema) => {
if (members.length === 0) {
if (hasNoAvailableMembers) {
if (hasNoAvailableMembers && canInviteOrganisationMembers) {
setInviteDialogOpen(true);
return;
}
@@ -231,7 +239,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
onKeyDown={(e) => {
if (e.key === 'Enter' && form.getValues('members').length === 0) {
e.preventDefault();
if (hasNoAvailableMembers) {
if (hasNoAvailableMembers && canInviteOrganisationMembers) {
setInviteDialogOpen(true);
}
// Don't show toast - the disabled Next button already communicates this
@@ -260,21 +268,32 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
<Trans>No organisation members available</Trans>
</h3>
<p className="mb-6 max-w-sm text-sm text-muted-foreground">
<Trans>
To add members to this team, you must first add them to the
organisation.
</Trans>
{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>
<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>
}
/>
{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
@@ -310,30 +329,32 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
<Trans>Select members to add to this team</Trans>
</FormDescription>
<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>
{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>
@@ -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,7 +53,7 @@ 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;
@@ -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;
@@ -60,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>
@@ -100,7 +100,7 @@ export const ConfigureDocumentAdvancedSettings = ({
}))}
selectedValues={field.value}
onChange={field.onChange}
className="bg-background w-full"
className="w-full bg-background"
emptySelectionPlaceholder={t`Select signature types`}
/>
</FormControl>
@@ -204,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>
@@ -279,7 +279,7 @@ export const ConfigureDocumentAdvancedSettings = ({
<FormControl>
<Input
id="subject"
className="bg-background mt-2"
className="mt-2 bg-background"
disabled={isSubmitting || !isEmailDistribution}
{...field}
/>
@@ -302,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,7 +4,7 @@ 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 { ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import { ChevronsUpDown } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
@@ -13,6 +13,7 @@ import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-c
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
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 { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
@@ -105,7 +106,7 @@ export const ConfigureFieldsView = ({
}, [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 || '',
@@ -128,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);
@@ -3,7 +3,7 @@ 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>
@@ -17,6 +17,7 @@ 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';
@@ -26,6 +27,8 @@ import {
} from '@documenso/lib/utils/advanced-fields-helpers';
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 {
@@ -33,6 +36,7 @@ import type {
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';
@@ -92,6 +96,7 @@ 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);
@@ -205,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) {
@@ -290,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
@@ -433,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 && (
@@ -8,11 +8,13 @@ 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 { 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';
@@ -232,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
@@ -3,8 +3,10 @@ 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 { injectCss } from '~/utils/css-vars';
@@ -162,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
@@ -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);
@@ -15,6 +15,10 @@ 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,
@@ -32,6 +36,7 @@ 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';
@@ -76,6 +81,7 @@ export type TDocumentPreferencesFormSchema = {
delegateDocumentOwnership: boolean | null;
aiFeaturesEnabled: boolean | null;
envelopeExpirationPeriod: TEnvelopeExpirationPeriod | null;
reminderSettings: TEnvelopeReminderSettings | null;
};
type SettingsSubset = Pick<
@@ -94,6 +100,7 @@ type SettingsSubset = Pick<
| 'delegateDocumentOwnership'
| 'aiFeaturesEnabled'
| 'envelopeExpirationPeriod'
| 'reminderSettings'
>;
export type DocumentPreferencesFormProps = {
@@ -134,6 +141,7 @@ export const DocumentPreferencesForm = ({
delegateDocumentOwnership: z.boolean().nullable(),
aiFeaturesEnabled: z.boolean().nullable(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable(),
reminderSettings: ZEnvelopeReminderSettings.nullable(),
});
const form = useForm<TDocumentPreferencesFormSchema>({
@@ -155,6 +163,7 @@ export const DocumentPreferencesForm = ({
delegateDocumentOwnership: settings.delegateDocumentOwnership,
aiFeaturesEnabled: settings.aiFeaturesEnabled,
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
reminderSettings: settings.reminderSettings ?? null,
},
resolver: zodResolver(ZDocumentPreferencesFormSchema),
});
@@ -707,6 +716,35 @@ export const DocumentPreferencesForm = ({
)}
/>
<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}
@@ -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"
+29 -9
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 }),
})
@@ -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');
@@ -138,6 +148,9 @@ export const SignUpForm = ({
description: _(errorMessage),
variant: 'destructive',
});
turnstileRef.current?.reset();
setCaptchaToken(null);
}
};
@@ -245,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"
@@ -323,6 +330,19 @@ 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="h-px flex-1 bg-border" />
@@ -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>
);
};
@@ -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;
};
@@ -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 {
@@ -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(
@@ -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>;
@@ -141,7 +141,7 @@ export const DocumentSigningAuthProvider = ({
if (
derivedRecipientActionAuth.includes(DocumentAuth.ACCOUNT) &&
user?.email == recipient.email
user?.email === recipient.email
) {
return {
type: DocumentAuth.ACCOUNT,
@@ -167,7 +167,7 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
</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>;
@@ -131,16 +131,16 @@ export const DocumentSigningFieldContainer = ({
return (
<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()}
/>
)}
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
@@ -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;
@@ -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,13 +68,15 @@ 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>
<DropdownMenuTrigger data-testid="document-page-view-action-btn">
<MoreHorizontal className="h-5 w-5 text-muted-foreground" />
</DropdownMenuTrigger>
@@ -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>
);
};
@@ -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',
});
@@ -5,7 +5,7 @@ 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 { FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
@@ -28,14 +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 { 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';
@@ -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],
@@ -157,7 +165,39 @@ export const EnvelopeEditorFieldsPage = () => {
ref={scrollableContainerRef}
>
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector className="px-0" 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 h-full flex-col items-center justify-center">
@@ -297,32 +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">
<Trans>Recipient ID:</Trans>
</span>{' '}
{selectedField.recipientId}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos X:</Trans>
</span>
&nbsp;
</span>{' '}
{selectedField.positionX.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos Y:</Trans>
</span>
&nbsp;
</span>{' '}
{selectedField.positionY.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Width:</Trans>
</span>
&nbsp;
</span>{' '}
{selectedField.width.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Height:</Trans>
</span>
&nbsp;
</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';
@@ -49,6 +55,11 @@ export default function EnvelopeEditorHeader() {
actions: { allowAttachments, allowDistributing },
} = editorConfig;
const envelopeItemPermissions = useMemo(
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
[envelope, envelope.recipients],
);
const handleCreateEmbeddedEnvelope = async () => {
const latestEnvelope = await flushAutosave();
@@ -80,7 +91,8 @@ export default function EnvelopeEditorHeader() {
<div className="flex items-center space-x-2">
<EnvelopeItemTitleInput
disabled={envelope.status !== DocumentStatus.DRAFT || !allowConfigureEnvelopeTitle}
dataTestId="envelope-title-input"
disabled={!envelopeItemPermissions.canTitleBeChanged || !allowConfigureEnvelopeTitle}
value={envelope.title}
onChange={(title) => {
updateEnvelope({
@@ -94,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>
@@ -8,10 +8,12 @@ import {
DocumentDistributionMethod,
DocumentVisibility,
EnvelopeType,
RecipientRole,
SendStatus,
TemplateType,
} from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { InfoIcon, MailIcon, SettingsIcon, ShieldIcon } from 'lucide-react';
import { BellRingIcon, InfoIcon, MailIcon, SettingsIcon, ShieldIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
@@ -24,6 +26,7 @@ import {
DOCUMENT_SIGNATURE_TYPES,
} from '@documenso/lib/constants/document';
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
import {
SUPPORTED_LANGUAGES,
SUPPORTED_LANGUAGE_CODES,
@@ -49,6 +52,7 @@ import {
canAccessTeamDocument,
extractTeamSignatureSettings,
} from '@documenso/lib/utils/teams';
import { zEmail } from '@documenso/lib/utils/zod';
import { trpc } from '@documenso/trpc/react';
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
import {
@@ -66,6 +70,11 @@ import {
DocumentVisibilityTooltip,
} from '@documenso/ui/components/document/document-visibility-select';
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
import {
TemplateTypeSelect,
TemplateTypeTooltip,
} from '@documenso/ui/components/template/template-type-select';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
@@ -102,6 +111,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export const ZAddSettingsFormSchema = z.object({
templateType: z.nativeEnum(TemplateType).optional(),
externalId: z.string().optional(),
visibility: z.nativeEnum(DocumentVisibility).optional(),
globalAccessAuth: z
@@ -131,19 +141,17 @@ export const ZAddSettingsFormSchema = z.object({
.optional()
.default('en'),
emailId: z.string().nullable(),
emailReplyTo: z.preprocess(
(val) => (val === '' ? undefined : val),
z.string().email().optional(),
),
emailReplyTo: z.preprocess((val) => (val === '' ? undefined : val), zEmail().optional()),
emailSettings: ZDocumentEmailSettingsSchema,
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
message: msg`At least one signature type must be enabled`.id,
}),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
reminderSettings: ZEnvelopeReminderSettings.nullish(),
}),
});
type EnvelopeEditorSettingsTabType = 'general' | 'email' | 'security';
type EnvelopeEditorSettingsTabType = 'general' | 'reminders' | 'email' | 'security';
const tabs = [
{
@@ -152,6 +160,12 @@ const tabs = [
icon: SettingsIcon,
description: msg`Configure document settings and options before sending.`,
},
{
id: 'reminders',
title: msg`Reminders`,
icon: BellRingIcon,
description: msg`Configure signing reminder settings for the document.`,
},
{
id: 'email',
title: msg`Email`,
@@ -196,6 +210,7 @@ export const EnvelopeEditorSettingsDialog = ({
const createDefaultValues = () => {
return {
templateType: envelope.templateType || TemplateType.PRIVATE,
externalId: envelope.externalId || '',
visibility: envelope.visibility || '',
globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
@@ -216,6 +231,7 @@ export const EnvelopeEditorSettingsDialog = ({
emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings),
signatureTypes: extractTeamSignatureSettings(envelope.documentMeta),
envelopeExpirationPeriod: envelope.documentMeta?.envelopeExpirationPeriod ?? null,
reminderSettings: envelope.documentMeta?.reminderSettings ?? null,
},
};
};
@@ -227,7 +243,10 @@ export const EnvelopeEditorSettingsDialog = ({
const envelopeHasBeenSent =
envelope.type === EnvelopeType.DOCUMENT &&
envelope.recipients.some((recipient) => recipient.sendStatus === SendStatus.SENT);
envelope.recipients.some(
(recipient) =>
recipient.role !== RecipientRole.CC && recipient.sendStatus === SendStatus.SENT,
);
const emailSettings = form.watch('meta.emailSettings');
@@ -261,6 +280,7 @@ export const EnvelopeEditorSettingsDialog = ({
subject,
emailReplyTo,
envelopeExpirationPeriod,
reminderSettings,
} = data.meta;
const parsedGlobalAccessAuth = z
@@ -270,6 +290,7 @@ export const EnvelopeEditorSettingsDialog = ({
try {
await updateEnvelopeAsync({
data: {
templateType: envelope.type === EnvelopeType.TEMPLATE ? data.templateType : undefined,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
@@ -290,6 +311,7 @@ export const EnvelopeEditorSettingsDialog = ({
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
envelopeExpirationPeriod,
reminderSettings,
},
});
@@ -371,6 +393,10 @@ export const EnvelopeEditorSettingsDialog = ({
return null;
}
if (tab.id === 'reminders' && !settings.allowConfigureReminders) {
return null;
}
return (
<Button
key={tab.id}
@@ -606,6 +632,31 @@ export const EnvelopeEditorSettingsDialog = ({
)}
/>
{envelope.type === EnvelopeType.TEMPLATE && (
<FormField
control={form.control}
name="templateType"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Template type</Trans>
<TemplateTypeTooltip
organisationTeamCount={organisation.teams.length}
/>
</FormLabel>
<FormControl>
<TemplateTypeSelect
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
{settings.allowConfigureDistribution && (
<FormField
control={form.control}
@@ -716,6 +767,44 @@ export const EnvelopeEditorSettingsDialog = ({
)}
</>
))
.with(
{ activeTab: 'reminders', settings: { allowConfigureReminders: true } },
() => (
<FormField
control={form.control}
name="meta.reminderSettings"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Signing Reminders</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
Configure when and how often reminder emails are sent to
recipients who have not yet completed signing. Uses the team
default when set to inherit.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<ReminderSettingsPicker
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
),
)
.with(
{ activeTab: 'email', settings: { allowConfigureDistribution: true } },
() => (
@@ -4,10 +4,8 @@ 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 { 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 { useEnvelopeAutosave } from '@documenso/lib/client-only/hooks/use-envelope-autosave';
@@ -16,10 +14,13 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
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 { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config';
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
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,
@@ -41,13 +42,14 @@ type LocalFile = {
title: string;
envelopeItemId: string | null;
isUploading: boolean;
isReplacing: boolean;
isError: boolean;
};
export const EnvelopeEditorUploadPage = () => {
const organisation = useCurrentOrganisation();
const { t } = useLingui();
const { t, i18n } = useLingui();
const { maximumEnvelopeItemCount, remaining } = useLimits();
const { toast } = useToast();
@@ -72,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 }) => {
@@ -108,11 +136,29 @@ 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;
@@ -125,6 +171,7 @@ export const EnvelopeEditorUploadPage = () => {
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,
@@ -197,12 +244,77 @@ 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.
*/
@@ -305,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`;
}
@@ -346,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',
});
@@ -354,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>
@@ -395,7 +508,8 @@ export const EnvelopeEditorUploadPage = () => {
key={localFile.id}
isDragDisabled={
isCreatingEnvelopeItems ||
!canItemsBeModified ||
!envelopeItemPermissions.canOrderBeChanged ||
localFile.isReplacing ||
!uploadConfig?.allowConfigureOrder
}
draggableId={localFile.id}
@@ -426,8 +540,9 @@ export const EnvelopeEditorUploadPage = () => {
{localFile.envelopeItemId !== null ? (
<EnvelopeItemTitleInput
disabled={
envelope.status !== DocumentStatus.DRAFT ||
!uploadConfig?.allowConfigureTitle
!envelopeItemPermissions.canTitleBeChanged ||
!uploadConfig?.allowConfigureTitle ||
localFile.isReplacing
}
value={localFile.title}
dataTestId={`envelope-item-title-input-${localFile.id}`}
@@ -445,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>
)}
@@ -463,8 +577,28 @@ export const EnvelopeEditorUploadPage = () => {
</div>
)}
{!localFile.isUploading &&
localFile.envelopeItemId &&
{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
@@ -472,12 +606,13 @@ export const EnvelopeEditorUploadPage = () => {
size="sm"
data-testid={`envelope-item-remove-button-${localFile.id}`}
onClick={() => onFileDelete(localFile.envelopeItemId!)}
disabled={localFile.isReplacing || localFile.isUploading}
>
<X className="h-4 w-4" />
<XIcon className="h-4 w-4" />
</Button>
) : (
<EnvelopeItemDeleteDialog
canItemBeDeleted={canItemsBeModified}
canItemBeDeleted={envelopeItemPermissions.canFileBeChanged}
envelopeId={envelope.id}
envelopeItemId={localFile.envelopeItemId}
envelopeItemTitle={localFile.title}
@@ -487,8 +622,9 @@ export const EnvelopeEditorUploadPage = () => {
variant="ghost"
size="sm"
data-testid={`envelope-item-remove-button-${localFile.id}`}
disabled={localFile.isReplacing || localFile.isUploading}
>
<X className="h-4 w-4" />
<XIcon className="h-4 w-4" />
</Button>
}
/>
@@ -10,6 +10,7 @@ import {
CopyPlusIcon,
DownloadCloudIcon,
EyeIcon,
FileOutputIcon,
LinkIcon,
type LucideIcon,
MousePointerIcon,
@@ -35,6 +36,7 @@ import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribu
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 { 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';
@@ -101,6 +103,7 @@ export const EnvelopeEditor = () => {
allowDistributing,
allowDirectLink,
allowDuplication,
allowSaveAsTemplate,
allowDownloadPDF,
allowDeletion,
},
@@ -466,6 +469,28 @@ export const EnvelopeEditor = () => {
/>
)}
{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}
@@ -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,12 +56,14 @@ 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();
@@ -86,6 +92,7 @@ export const EnvelopeRendererFileSelector = ({
buttonProps={{
onClick: () => setCurrentEnvelopeItem(doc.id),
}}
actionSlot={renderItemAction?.(doc)}
/>
))}
</div>
@@ -4,12 +4,13 @@ import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client';
import type { Field } from '@prisma/client';
import { RecipientRole, SendStatus } from '@prisma/client';
import { Check, ChevronsUpDown, Info } from 'lucide-react';
import { sortBy } from 'remeda';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TEnvelopeRecipientLite } from '@documenso/lib/types/recipient';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
@@ -26,9 +27,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
export interface EnvelopeRecipientSelectorProps {
className?: string;
selectedRecipient: Recipient | null;
onSelectedRecipientChange: (recipient: Recipient) => void;
recipients: Recipient[];
selectedRecipient: TEnvelopeRecipientLite | null;
onSelectedRecipientChange: (recipient: TEnvelopeRecipientLite) => void;
recipients: TEnvelopeRecipientLite[];
fields: Field[];
align?: 'center' | 'end' | 'start';
}
@@ -46,7 +47,7 @@ export const EnvelopeRecipientSelector = ({
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const getRecipientLabel = useCallback(
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
(recipient: TEnvelopeRecipientLite) => extractRecipientLabel(recipient, recipients, i18n),
[recipients],
);
@@ -91,9 +92,9 @@ export const EnvelopeRecipientSelector = ({
interface EnvelopeRecipientSelectorCommandProps {
className?: string;
selectedRecipient: Recipient | null;
onSelectedRecipientChange: (recipient: Recipient) => void;
recipients: Recipient[];
selectedRecipient: TEnvelopeRecipientLite | null;
onSelectedRecipientChange: (recipient: TEnvelopeRecipientLite) => void;
recipients: TEnvelopeRecipientLite[];
fields: Field[];
placeholder?: string;
}
@@ -109,7 +110,7 @@ export const EnvelopeRecipientSelectorCommand = ({
const { t, i18n } = useLingui();
const recipientsByRole = useCallback(() => {
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
const recipientsByRole: Record<RecipientRole, TEnvelopeRecipientLite[]> = {
CC: [],
VIEWER: [],
SIGNER: [],
@@ -141,7 +142,7 @@ export const EnvelopeRecipientSelectorCommand = ({
[(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'],
[(r) => r.id, 'asc'],
),
] as [RecipientRole, Recipient[]],
] as [RecipientRole, TEnvelopeRecipientLite[]],
);
}, [recipientsByRole]);
@@ -156,7 +157,7 @@ export const EnvelopeRecipientSelectorCommand = ({
);
const getRecipientLabel = useCallback(
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
(recipient: TEnvelopeRecipientLite) => extractRecipientLabel(recipient, recipients, i18n),
[recipients],
);
@@ -247,7 +248,11 @@ export const EnvelopeRecipientSelectorCommand = ({
);
};
const extractRecipientLabel = (recipient: Recipient, recipients: Recipient[], i18n: I18n) => {
const extractRecipientLabel = (
recipient: TEnvelopeRecipientLite,
recipients: TEnvelopeRecipientLite[],
i18n: I18n,
) => {
if (recipient.name && recipient.email) {
return `${recipient.name} (${recipient.email})`;
}
@@ -5,12 +5,7 @@ import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { Loader } from 'lucide-react';
import {
ErrorCode as DropzoneErrorCode,
ErrorCode,
type FileRejection,
useDropzone,
} from 'react-dropzone';
import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
import { Link, useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern';
@@ -25,6 +20,7 @@ import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -41,7 +37,7 @@ export const EnvelopeDropZoneWrapper = ({
type,
className,
}: EnvelopeDropZoneWrapperProps) => {
const { t } = useLingui();
const { t, i18n } = useLingui();
const { toast } = useToast();
const { user } = useSession();
const { folderId } = useParams();
@@ -167,42 +163,9 @@ export const EnvelopeDropZoneWrapper = ({
return;
}
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
const { file, errors } = fileRejections[0];
if (!errors.length) {
return;
}
const errorNodes = errors.map((error, index) => (
<span key={index} className="block">
{match(error.code)
.with(ErrorCode.FileTooLarge, () => (
<Trans>File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB</Trans>
))
.with(ErrorCode.FileInvalidType, () => <Trans>Only PDF files are allowed</Trans>)
.with(ErrorCode.FileTooSmall, () => <Trans>File is too small</Trans>)
.with(ErrorCode.TooManyFiles, () => (
<Trans>Only one file can be uploaded at a time</Trans>
))
.otherwise(() => (
<Trans>Unknown error</Trans>
))}
</span>
));
const description = (
<>
<span className="font-medium">
<Trans>{file.name} couldn't be uploaded:</Trans>
</span>
{errorNodes}
</>
);
toast({
title: t`Upload failed`,
description,
description: i18n._(buildDropzoneRejectionDescription(fileRejections)),
duration: 5000,
variant: 'destructive',
});
@@ -11,12 +11,12 @@ import { match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
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 { 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 { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
import { cn } from '@documenso/ui/lib/utils';
import { DocumentUploadButton } from '@documenso/ui/primitives/document-upload-button';
import {
@@ -39,7 +39,7 @@ export type EnvelopeUploadButtonProps = {
* Upload an envelope
*/
export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUploadButtonProps) => {
const { t } = useLingui();
const { t, i18n } = useLingui();
const { toast } = useToast();
const { user } = useSession();
@@ -168,7 +168,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
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',
});
@@ -53,6 +53,10 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
const rootPath =
type === FolderType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
if (parentId) {
return `${rootPath}/folders?parentId=${parentId}`;
}
return `${rootPath}/folders`;
};
@@ -193,14 +197,14 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
</div>
)}
{foldersData.folders.length > 12 && (
{unpinnedFolders.length > 12 && (
<div className="mt-2 flex items-center justify-center">
<Link
prefetch="intent"
className="text-sm font-medium text-muted-foreground hover:text-foreground"
to={formatViewAllFoldersPath()}
>
View all folders
<Trans>View all folders</Trans>
</Link>
</div>
)}
@@ -2,10 +2,11 @@ import { useMemo } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type DocumentStatus, type Recipient } from '@prisma/client';
import type { DocumentStatus } from '@prisma/client';
import { RecipientStatusType, getRecipientType } from '@documenso/lib/client-only/recipient-type';
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 { PopoverHover } from '@documenso/ui/primitives/popover';
@@ -15,7 +16,7 @@ import { StackAvatars } from './stack-avatars';
export type StackAvatarsWithTooltipProps = {
documentStatus: DocumentStatus;
recipients: Recipient[];
recipients: TRecipientLite[];
position?: 'top' | 'bottom';
children?: React.ReactNode;
};
@@ -74,7 +75,7 @@ export const StackAvatarsWithTooltip = ({
<h1 className="text-base font-medium">
<Trans>Completed</Trans>
</h1>
{completedRecipients.map((recipient: Recipient) => (
{completedRecipients.map((recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
@@ -98,7 +99,7 @@ export const StackAvatarsWithTooltip = ({
<h1 className="text-base font-medium">
<Trans>Rejected</Trans>
</h1>
{rejectedRecipients.map((recipient: Recipient) => (
{rejectedRecipients.map((recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
@@ -122,7 +123,7 @@ export const StackAvatarsWithTooltip = ({
<h1 className="text-base font-medium">
<Trans>Waiting</Trans>
</h1>
{waitingRecipients.map((recipient: Recipient) => (
{waitingRecipients.map((recipient) => (
<AvatarWithRecipient
key={recipient.id}
recipient={recipient}
@@ -137,7 +138,7 @@ export const StackAvatarsWithTooltip = ({
<h1 className="text-base font-medium">
<Trans>Opened</Trans>
</h1>
{openedRecipients.map((recipient: Recipient) => (
{openedRecipients.map((recipient) => (
<AvatarWithRecipient
key={recipient.id}
recipient={recipient}
@@ -152,7 +153,7 @@ export const StackAvatarsWithTooltip = ({
<h1 className="text-base font-medium">
<Trans>Uncompleted</Trans>
</h1>
{uncompletedRecipients.map((recipient: Recipient) => (
{uncompletedRecipients.map((recipient) => (
<AvatarWithRecipient
key={recipient.id}
recipient={recipient}
@@ -1,22 +1,21 @@
import React from 'react';
import type { Recipient } from '@prisma/client';
import {
getExtraRecipientsType,
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 { StackAvatar } from './stack-avatar';
export function StackAvatars({ recipients }: { recipients: Recipient[] }) {
const renderStackAvatars = (recipients: Recipient[]) => {
export function StackAvatars({ recipients }: { recipients: TRecipientLite[] }) {
const renderStackAvatars = (recipients: TRecipientLite[]) => {
const zIndex = 50;
const itemsToRender = recipients.slice(0, 5);
const remainingItems = recipients.length - itemsToRender.length;
return itemsToRender.map((recipient: Recipient, index: number) => {
return itemsToRender.map((recipient, index: number) => {
const first = index === 0;
if (index === 4 && remainingItems > 0) {
@@ -136,6 +136,7 @@ export const TemplateEditForm = ({
templateId: template.id,
data: {
title: data.title,
type: data.templateType,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
@@ -1,49 +1,53 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import { PenIcon, PlusIcon } from 'lucide-react';
import { Link } from 'react-router';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
export type TemplatePageViewRecipientsProps = {
recipients: Recipient[];
recipients: TRecipientLite[];
envelopeId: string;
templateRootPath: string;
readOnly?: boolean;
};
export const TemplatePageViewRecipients = ({
recipients,
envelopeId,
templateRootPath,
readOnly = false,
}: TemplatePageViewRecipientsProps) => {
const { _ } = useLingui();
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>
<Link
to={`${templateRootPath}/${envelopeId}/edit?step=signers`}
title={_(msg`Modify recipients`)}
className="flex flex-row items-center justify-between"
>
{recipients.length === 0 ? (
<PlusIcon className="ml-2 h-4 w-4" />
) : (
<PenIcon className="ml-2 h-3 w-3" />
)}
</Link>
{!readOnly && (
<Link
to={`${templateRootPath}/${envelopeId}/edit?step=signers`}
title={_(msg`Modify recipients`)}
className="flex flex-row items-center justify-between"
>
{recipients.length === 0 ? (
<PlusIcon className="ml-2 h-4 w-4" />
) : (
<PenIcon className="ml-2 h-3 w-3" />
)}
</Link>
)}
</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>
@@ -60,13 +64,13 @@ export const TemplatePageViewRecipients = ({
}
primaryText={
isTemplateRecipientEmailPlaceholder(recipient.email) ? (
<p className="text-muted-foreground text-sm">{recipient.name}</p>
<p className="text-sm text-muted-foreground">{recipient.name}</p>
) : (
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<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,7 +4,7 @@ import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type { TemplateType as TemplateTypePrisma } from '@prisma/client';
import { Globe2, Lock } from 'lucide-react';
import { Building2, Globe2, Lock } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import { cn } from '@documenso/ui/lib/utils';
@@ -28,6 +28,11 @@ const TEMPLATE_TYPES: Record<TemplateTypes, TemplateTypeIcon> = {
icon: Globe2,
color: 'text-green-500 dark:text-green-300',
},
ORGANISATION: {
label: msg`Organisation`,
icon: Building2,
color: 'text-orange-500 dark:text-orange-300',
},
};
export type TemplateTypeProps = HTMLAttributes<HTMLSpanElement> & {
@@ -21,17 +21,17 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk
return (
<div
className={cn(
'dark:bg-background flex flex-col items-center rounded-xl bg-neutral-100 p-4',
'flex flex-col items-center rounded-xl bg-neutral-100 p-4 dark:bg-background',
className,
)}
>
<div className="border-border bg-background text-muted-foreground inline-block max-w-full truncate rounded-md border px-2.5 py-1.5 text-sm lowercase">
<div className="inline-block max-w-full truncate rounded-md border border-border bg-background px-2.5 py-1.5 text-sm lowercase text-muted-foreground">
{baseUrl.host}/u/{user.url}
</div>
<div className="mt-4">
<div className="bg-primary/10 rounded-full p-1.5">
<div className="bg-background flex h-20 w-20 items-center justify-center rounded-full border-2">
<div className="rounded-full bg-primary/10 p-1.5">
<div className="flex h-20 w-20 items-center justify-center rounded-full border-2 bg-background">
<User2 className="h-12 w-12 text-[hsl(228,10%,90%)]" />
</div>
</div>
@@ -41,16 +41,16 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk
<div className="flex items-center justify-center gap-x-2">
<h2 className="max-w-[12rem] truncate text-2xl font-semibold">{user.name}</h2>
<VerifiedIcon className="text-primary h-8 w-8" />
<VerifiedIcon className="h-8 w-8 text-primary" />
</div>
<div className="dark:bg-foreground/30 mx-auto mt-4 h-2 w-52 rounded-full bg-neutral-300" />
<div className="dark:bg-foreground/20 mx-auto mt-2 h-2 w-36 rounded-full bg-neutral-200" />
<div className="mx-auto mt-4 h-2 w-52 rounded-full bg-neutral-300 dark:bg-foreground/30" />
<div className="mx-auto mt-2 h-2 w-36 rounded-full bg-neutral-200 dark:bg-foreground/20" />
</div>
<div className="mt-8 w-full">
<div className="dark:divide-foreground/30 dark:border-foreground/30 divide-y-2 divide-neutral-200 overflow-hidden rounded-lg border-2 border-neutral-200">
<div className="text-muted-foreground dark:bg-foreground/20 bg-neutral-50 p-4 font-medium">
<div className="divide-y-2 divide-neutral-200 overflow-hidden rounded-lg border-2 border-neutral-200 dark:divide-foreground/30 dark:border-foreground/30">
<div className="bg-neutral-50 p-4 font-medium text-muted-foreground dark:bg-foreground/20">
<Trans>Documents</Trans>
</div>
@@ -59,14 +59,14 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk
.map((_, index) => (
<div
key={index}
className="bg-background flex items-center justify-between gap-x-6 p-4"
className="flex items-center justify-between gap-x-6 bg-background p-4"
>
<div className="flex items-center gap-x-2">
<File className="text-muted-foreground/80 h-8 w-8" strokeWidth={1.5} />
<File className="h-8 w-8 text-muted-foreground/80" strokeWidth={1.5} />
<div className="space-y-2">
<div className="dark:bg-foreground/30 h-1.5 w-24 rounded-full bg-neutral-300 md:w-36" />
<div className="dark:bg-foreground/20 h-1.5 w-16 rounded-full bg-neutral-200 md:w-24" />
<div className="h-1.5 w-24 rounded-full bg-neutral-300 md:w-36 dark:bg-foreground/30" />
<div className="h-1.5 w-16 rounded-full bg-neutral-200 md:w-24 dark:bg-foreground/20" />
</div>
</div>
@@ -14,6 +14,7 @@ import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod';
import { zEmail } from '@documenso/lib/utils/zod';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
@@ -46,7 +47,7 @@ const RECIPIENT_ROLE_LABELS: Record<RecipientRole, string> = {
const ZAdminUpdateRecipientFormSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
email: zEmail(),
role: z.nativeEnum(RecipientRole),
});
@@ -7,6 +7,7 @@ import { match } from 'ts-pattern';
import { useSession } from '@documenso/lib/client-only/providers/session';
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { findRecipientByEmail } from '@documenso/lib/utils/recipients';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { Button } from '@documenso/ui/primitives/button';
@@ -23,7 +24,11 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
const team = useCurrentTeam();
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
const recipient = findRecipientByEmail({
recipients: row.recipients,
userEmail: user.email,
teamEmail: team.teamEmail?.email,
});
const isOwner = row.user.id === user.id;
const isRecipient = !!recipient;
@@ -3,13 +3,14 @@ import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, RecipientRole } from '@prisma/client';
import { DocumentStatus, EnvelopeType, RecipientRole } from '@prisma/client';
import {
CheckCircle,
Copy,
Download,
Edit,
EyeIcon,
FileOutputIcon,
FolderInput,
Loader,
MoreHorizontal,
@@ -22,7 +23,10 @@ import { Link } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
import { findRecipientByEmail } from '@documenso/lib/utils/recipients';
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,
@@ -32,13 +36,15 @@ import {
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
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 { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-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';
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
import { EnvelopeRenameDialog } from '../dialogs/envelope-rename-dialog';
export type DocumentsTableActionDropdownProps = {
row: TDocumentRow;
@@ -53,11 +59,16 @@ export const DocumentsTableActionDropdown = ({
const team = useCurrentTeam();
const { _ } = useLingui();
const trpcUtils = trpcReact.useUtils();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
const [isRenameDialogOpen, setRenameDialogOpen] = useState(false);
const [isSaveAsTemplateDialogOpen, setSaveAsTemplateDialogOpen] = useState(false);
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
const recipient = findRecipientByEmail({
recipients: row.recipients,
userEmail: user.email,
teamEmail: team.teamEmail?.email,
});
const isOwner = row.user.id === user.id;
// const isRecipient = !!recipient;
@@ -68,6 +79,16 @@ export const DocumentsTableActionDropdown = ({
const isCurrentTeamDocument = team && row.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
const { canTitleBeChanged } = getEnvelopeItemPermissions(
{
completedAt: row.completedAt,
deletedAt: row.deletedAt,
type: EnvelopeType.DOCUMENT,
status: row.status,
},
[],
);
const documentsPath = formatDocumentsPath(team.url);
const formatPath = `${documentsPath}/${row.envelopeId}/edit`;
@@ -121,6 +142,13 @@ export const DocumentsTableActionDropdown = ({
</Link>
</DropdownMenuItem>
{canManageDocument && canTitleBeChanged && (
<DropdownMenuItem onClick={() => setRenameDialogOpen(true)}>
<Pencil className="mr-2 h-4 w-4" />
<Trans>Rename</Trans>
</DropdownMenuItem>
)}
<EnvelopeDownloadDialog
envelopeId={row.envelopeId}
envelopeStatus={row.status}
@@ -135,9 +163,22 @@ export const DocumentsTableActionDropdown = ({
}
/>
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" />
<Trans>Duplicate</Trans>
<EnvelopeDuplicateDialog
envelopeId={row.envelopeId}
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>
{onMoveDocument && canManageDocument && (
@@ -153,10 +194,21 @@ export const DocumentsTableActionDropdown = ({
Void
</DropdownMenuItem> */}
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)}>
<Trash2 className="mr-2 h-4 w-4" />
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
</DropdownMenuItem>
<EnvelopeDeleteDialog
id={row.envelopeId}
type={EnvelopeType.DOCUMENT}
status={row.status}
title={row.title}
canManageDocument={canManageDocument}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<Trash2 className="mr-2 h-4 w-4" />
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
</div>
</DropdownMenuItem>
}
/>
<DropdownMenuLabel>
<Trans>Share</Trans>
@@ -192,20 +244,20 @@ export const DocumentsTableActionDropdown = ({
/>
</DropdownMenuContent>
<DocumentDeleteDialog
id={row.id}
status={row.status}
documentTitle={row.title}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
canManageDocument={canManageDocument}
<EnvelopeSaveAsTemplateDialog
envelopeId={row.envelopeId}
open={isSaveAsTemplateDialogOpen}
onOpenChange={setSaveAsTemplateDialogOpen}
/>
<DocumentDuplicateDialog
<EnvelopeRenameDialog
id={row.envelopeId}
token={recipient?.token}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
initialTitle={row.title}
open={isRenameDialogOpen}
onOpenChange={setRenameDialogOpen}
onSuccess={async () => {
await trpcUtils.document.findDocumentsInternal.invalidate();
}}
/>
</DropdownMenu>
);
@@ -3,8 +3,11 @@ import { match } from 'ts-pattern';
import { useSession } from '@documenso/lib/client-only/providers/session';
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
import { findRecipientByEmail } from '@documenso/lib/utils/recipients';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { useCurrentTeam } from '~/providers/team';
export type DataTableTitleProps = {
row: TDocumentRow;
teamUrl: string;
@@ -12,8 +15,13 @@ export type DataTableTitleProps = {
export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
const { user } = useSession();
const team = useCurrentTeam();
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
const recipient = findRecipientByEmail({
recipients: row.recipients,
userEmail: user.email,
teamEmail: team.teamEmail?.email,
});
const isOwner = row.user.id === user.id;
const isRecipient = !!recipient;
@@ -10,6 +10,7 @@ import { match } from 'ts-pattern';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { findRecipientByEmail } from '@documenso/lib/utils/recipients';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/find-documents.types';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
@@ -91,7 +92,13 @@ export const DocumentsTable = ({
},
{
header: _(msg`Title`),
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
cell: ({ row }) => (
<DataTableTitle
row={row.original}
teamUrl={team?.url}
teamEmail={team?.teamEmail?.email}
/>
),
},
{
id: 'sender',
@@ -213,12 +220,17 @@ export const DocumentsTable = ({
type DataTableTitleProps = {
row: DocumentsTableRow;
teamUrl: string;
teamEmail?: string;
};
const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
const DataTableTitle = ({ row, teamUrl, teamEmail }: DataTableTitleProps) => {
const { user } = useSession();
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
const recipient = findRecipientByEmail({
recipients: row.recipients,
userEmail: user.email,
teamEmail,
});
const isOwner = row.user.id === user.id;
const isRecipient = !!recipient;
@@ -62,7 +62,14 @@ export const OrganisationInsightsTable = ({
{
header: _(msg`Team Name`),
accessorKey: 'name',
cell: ({ row }) => <span className="block max-w-full truncate">{row.getValue('name')}</span>,
cell: ({ row }) => (
<Link
className="block max-w-full truncate hover:underline"
to={`/admin/teams/${row.original.id}`}
>
{row.getValue('name')}
</Link>
),
size: 240,
},
{
@@ -276,12 +283,12 @@ const SummaryCard = ({
value: number;
subtitle?: string;
}) => (
<div className="bg-card flex items-start gap-x-2 rounded-lg border px-4 py-3">
<Icon className="text-muted-foreground h-4 w-4 items-start" />
<div className="flex items-start gap-x-2 rounded-lg border bg-card px-4 py-3">
<Icon className="h-4 w-4 items-start text-muted-foreground" />
<div className="-mt-0.5 space-y-2">
<p className="text-muted-foreground text-sm font-medium">{title}</p>
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<p className="text-2xl font-bold">{value}</p>
{subtitle && <p className="text-muted-foreground text-xs">{subtitle}</p>}
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
</div>
</div>
);
@@ -11,6 +11,7 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
@@ -108,7 +109,7 @@ export const OrganisationMemberInvitesTable = () => {
avatarClass="h-12 w-12"
avatarFallback={row.original.email.slice(0, 1).toUpperCase()}
primaryText={
<span className="text-foreground/80 font-semibold">{row.original.email}</span>
<span className="font-semibold text-foreground/80">{row.original.email}</span>
}
/>
);
@@ -129,7 +130,7 @@ export const OrganisationMemberInvitesTable = () => {
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
<MoreHorizontal className="h-5 w-5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
@@ -149,17 +150,22 @@ export const OrganisationMemberInvitesTable = () => {
<Trans>Resend</Trans>
</DropdownMenuItem>
<DropdownMenuItem
onClick={async () =>
deleteOrganisationMemberInvitations({
organisationId: organisation.id,
invitationIds: [row.original.id],
})
}
>
<Trash2 className="mr-2 h-4 w-4" />
<Trans>Remove</Trans>
</DropdownMenuItem>
{isOrganisationRoleWithinUserHierarchy(
organisation.currentOrganisationRole,
row.original.organisationRole,
) && (
<DropdownMenuItem
onClick={async () =>
deleteOrganisationMemberInvitations({
organisationId: organisation.id,
invitationIds: [row.original.id],
})
}
>
<Trash2 className="mr-2 h-4 w-4" />
<Trans>Remove</Trans>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
),
@@ -74,7 +74,7 @@ export const SettingsPublicProfileTemplatesTable = () => {
return (
<div>
<div className="dark:divide-foreground/30 dark:border-foreground/30 mt-6 divide-y divide-neutral-200 overflow-hidden rounded-lg border border-neutral-200">
<div className="mt-6 divide-y divide-neutral-200 overflow-hidden rounded-lg border border-neutral-200 dark:divide-foreground/30 dark:border-foreground/30">
{/* Loading and error handling states. */}
{publicDirectTemplates.length === 0 && (
<>
@@ -84,10 +84,10 @@ export const SettingsPublicProfileTemplatesTable = () => {
.map((_, index) => (
<div
key={index}
className="bg-background flex items-center justify-between gap-x-6 p-4"
className="flex items-center justify-between gap-x-6 bg-background p-4"
>
<div className="flex gap-x-2">
<FileIcon className="text-muted-foreground/40 h-8 w-8" strokeWidth={1.5} />
<FileIcon className="h-8 w-8 text-muted-foreground/40" strokeWidth={1.5} />
<div className="space-y-2">
<Skeleton className="h-4 w-24" />
@@ -95,12 +95,12 @@ export const SettingsPublicProfileTemplatesTable = () => {
</div>
</div>
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
<MoreHorizontalIcon className="h-5 w-5 text-muted-foreground" />
</div>
))}
{isLoadingError && (
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center text-sm">
<div className="flex h-32 flex-col items-center justify-center text-sm text-muted-foreground">
<Trans>Unable to load your public profile templates at this time</Trans>
<button
onClick={(e) => {
@@ -114,12 +114,12 @@ export const SettingsPublicProfileTemplatesTable = () => {
)}
{!isLoading && (
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center text-sm">
<div className="flex h-32 flex-col items-center justify-center text-sm text-muted-foreground">
<Trans>No public profile templates found</Trans>
<ManagePublicTemplateDialog
directTemplates={privateDirectTemplates}
trigger={
<button className="hover:text-muted-foreground/80 mt-1 text-xs">
<button className="mt-1 text-xs hover:text-muted-foreground/80">
<Trans>Click here to get started</Trans>
</button>
}
@@ -133,23 +133,23 @@ export const SettingsPublicProfileTemplatesTable = () => {
{publicDirectTemplates.map((template) => (
<div
key={template.id}
className="bg-background flex items-center justify-between gap-x-6 p-4"
className="flex items-center justify-between gap-x-6 bg-background p-4"
>
<div className="flex gap-x-2">
<FileIcon
className="text-muted-foreground/40 h-8 w-8 flex-shrink-0"
className="h-8 w-8 flex-shrink-0 text-muted-foreground/40"
strokeWidth={1.5}
/>
<div>
<p className="text-sm break-all">{template.publicTitle}</p>
<p className="text-xs text-neutral-400 break-all">{template.publicDescription}</p>
<p className="break-all text-sm">{template.publicTitle}</p>
<p className="break-all text-xs text-neutral-400">{template.publicDescription}</p>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
<MoreHorizontalIcon className="h-5 w-5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="center" side="left">
@@ -1,11 +1,21 @@
import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import type { Recipient, TemplateDirectLink } from '@prisma/client';
import { Copy, Edit, FolderIcon, MoreHorizontal, Share2Icon, Trash2, Upload } from 'lucide-react';
import { DocumentStatus, EnvelopeType, type TemplateDirectLink } from '@prisma/client';
import {
Copy,
Edit,
FolderIcon,
MoreHorizontal,
Pencil,
Share2Icon,
Trash2,
Upload,
} from 'lucide-react';
import { Link } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { trpc as trpcReact } from '@documenso/trpc/react';
import {
DropdownMenu,
DropdownMenuContent,
@@ -14,10 +24,11 @@ import {
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { EnvelopeDeleteDialog } from '../dialogs/envelope-delete-dialog';
import { EnvelopeDuplicateDialog } from '../dialogs/envelope-duplicate-dialog';
import { EnvelopeRenameDialog } from '../dialogs/envelope-rename-dialog';
import { TemplateBulkSendDialog } from '../dialogs/template-bulk-send-dialog';
import { TemplateDeleteDialog } from '../dialogs/template-delete-dialog';
import { TemplateDirectLinkDialog } from '../dialogs/template-direct-link-dialog';
import { TemplateDuplicateDialog } from '../dialogs/template-duplicate-dialog';
import { TemplateMoveToFolderDialog } from '../dialogs/template-move-to-folder-dialog';
export type TemplatesTableActionDropdownProps = {
@@ -29,7 +40,7 @@ export type TemplatesTableActionDropdownProps = {
folderId?: string | null;
envelopeId: string;
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
recipients: Recipient[];
recipients: TRecipientLite[];
};
templateRootPath: string;
teamId: number;
@@ -42,14 +53,13 @@ export const TemplatesTableActionDropdown = ({
teamId,
onDelete,
}: TemplatesTableActionDropdownProps) => {
const { user } = useSession();
const trpcUtils = trpcReact.useUtils();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
const [isRenameDialogOpen, setRenameDialogOpen] = useState(false);
const [isMoveToFolderDialogOpen, setMoveToFolderDialogOpen] = useState(false);
const isOwner = row.userId === user.id;
const isTeamTemplate = row.teamId === teamId;
const canMutate = isTeamTemplate;
const formatPath = `${templateRootPath}/${row.envelopeId}/edit`;
@@ -62,74 +72,90 @@ export const TemplatesTableActionDropdown = ({
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
<DropdownMenuItem disabled={!canMutate} asChild>
<Link prefetch="intent" to={formatPath}>
<Edit className="mr-2 h-4 w-4" />
<Trans>Edit</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!isOwner && !isTeamTemplate}
onClick={() => setDuplicateDialogOpen(true)}
>
<Copy className="mr-2 h-4 w-4" />
<Trans>Duplicate</Trans>
</DropdownMenuItem>
{canMutate && (
<DropdownMenuItem onClick={() => setRenameDialogOpen(true)}>
<Pencil className="mr-2 h-4 w-4" />
<Trans>Rename</Trans>
</DropdownMenuItem>
)}
<TemplateDirectLinkDialog
templateId={row.id}
recipients={row.recipients}
directLink={row.directLink}
trigger={
<div
data-testid="template-direct-link"
className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Share2Icon className="mr-2 h-4 w-4" />
<Trans>Direct link</Trans>
</div>
}
/>
{canMutate && (
<EnvelopeDuplicateDialog
envelopeId={row.envelopeId}
envelopeType={EnvelopeType.TEMPLATE}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<Copy className="mr-2 h-4 w-4" />
<Trans>Duplicate</Trans>
</div>
</DropdownMenuItem>
}
/>
)}
<DropdownMenuItem onClick={() => setMoveToFolderDialogOpen(true)}>
{canMutate && (
<TemplateDirectLinkDialog
templateId={row.id}
recipients={row.recipients}
directLink={row.directLink}
trigger={
<div
data-testid="template-direct-link"
className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Share2Icon className="mr-2 h-4 w-4" />
<Trans>Direct link</Trans>
</div>
}
/>
)}
<DropdownMenuItem disabled={!canMutate} onClick={() => setMoveToFolderDialogOpen(true)}>
<FolderIcon className="mr-2 h-4 w-4" />
<Trans>Move to Folder</Trans>
</DropdownMenuItem>
<TemplateBulkSendDialog
templateId={row.id}
recipients={row.recipients}
trigger={
<div className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground">
<Upload className="mr-2 h-4 w-4" />
<Trans>Bulk Send via CSV</Trans>
</div>
}
/>
{canMutate && (
<TemplateBulkSendDialog
templateId={row.id}
recipients={row.recipients}
trigger={
<div className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground">
<Upload className="mr-2 h-4 w-4" />
<Trans>Bulk Send via CSV</Trans>
</div>
}
/>
)}
<DropdownMenuItem
disabled={!isOwner && !isTeamTemplate}
onClick={() => setDeleteDialogOpen(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
</DropdownMenuItem>
{canMutate && (
<EnvelopeDeleteDialog
id={row.envelopeId}
type={EnvelopeType.TEMPLATE}
status={DocumentStatus.DRAFT}
title={row.title}
canManageDocument={canMutate}
onDelete={onDelete}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<Trash2 className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
</div>
</DropdownMenuItem>
}
/>
)}
</DropdownMenuContent>
<TemplateDuplicateDialog
id={row.id}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
/>
<TemplateDeleteDialog
id={row.id}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onDelete={onDelete}
/>
<TemplateMoveToFolderDialog
templateId={row.id}
templateTitle={row.title}
@@ -137,6 +163,17 @@ export const TemplatesTableActionDropdown = ({
onOpenChange={setMoveToFolderDialogOpen}
currentFolderId={row.folderId}
/>
<EnvelopeRenameDialog
id={row.envelopeId}
initialTitle={row.title}
open={isRenameDialogOpen}
onOpenChange={setRenameDialogOpen}
envelopeType="template"
onSuccess={async () => {
await trpcUtils.template.findTemplates.invalidate();
}}
/>
</DropdownMenu>
);
};
@@ -3,7 +3,15 @@ import { useMemo, useTransition } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AlertTriangle, Globe2Icon, InfoIcon, Link2Icon, Loader, LockIcon } from 'lucide-react';
import {
AlertTriangle,
Building2Icon,
Globe2Icon,
InfoIcon,
Link2Icon,
Loader,
LockIcon,
} from 'lucide-react';
import { Link } from 'react-router';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
@@ -166,25 +174,48 @@ export const TemplatesTable = ({
)}
</p>
</li>
<li>
<h2 className="mb-2 flex flex-row items-center font-semibold">
<Building2Icon className="mr-2 h-5 w-5 text-orange-500 dark:text-orange-300" />
<Trans>Organisation</Trans>
</h2>
<p>
<Trans>
Organisation templates are shared across all teams within the same
organisation. Only the owning team can edit them.
</Trans>
</p>
</li>
</ul>
</TooltipContent>
</Tooltip>
</div>
),
accessorKey: 'type',
cell: ({ row }) => (
<div className="flex flex-row items-center">
<TemplateType type={row.original.type} />
cell: ({ row }) => {
const isFromOtherTeam = row.original.teamId !== team?.id;
{row.original.directLink?.token && (
<TemplateDirectLinkBadge
className="ml-2"
token={row.original.directLink.token}
enabled={row.original.directLink.enabled}
/>
)}
</div>
),
return (
<div className="flex flex-row items-center">
<TemplateType type={row.original.type} />
{isFromOtherTeam && row.original.team?.name && (
<span className="ml-2 text-xs text-muted-foreground">
({row.original.team.name})
</span>
)}
{row.original.directLink?.token && (
<TemplateDirectLinkBadge
className="ml-2"
token={row.original.directLink.token}
enabled={row.original.directLink.enabled}
/>
)}
</div>
);
},
},
{
header: _(msg`Actions`),