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`),
+11 -8
View File
@@ -1,3 +1,4 @@
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
import {
Links,
Meta,
@@ -138,15 +139,17 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
</div>
)} */}
<SessionProvider initialSession={session}>
<TooltipProvider>
<TrpcProvider>
{children}
<NuqsAdapter>
<SessionProvider initialSession={session}>
<TooltipProvider>
<TrpcProvider>
{children}
<Toaster />
</TrpcProvider>
</TooltipProvider>
</SessionProvider>
<Toaster />
</TrpcProvider>
</TooltipProvider>
</SessionProvider>
</NuqsAdapter>
<script
dangerouslySetInnerHTML={{
@@ -1,3 +1,4 @@
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import {
AlertTriangleIcon,
@@ -19,9 +20,14 @@ import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { AdminLicenseStatusBanner } from '~/components/general/admin-license-status-banner';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/_layout';
export function meta() {
return appMetaTags(msg`Admin`);
}
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getSession(request);
@@ -54,6 +54,7 @@ export default function OrganisationInsights({ loaderData }: Route.ComponentProp
</Link>
</Button>
</div>
<div className="mt-8">
<OrganisationInsightsTable
insights={insights}
@@ -2,11 +2,12 @@ import { useMemo } from 'react';
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 { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import { ExternalLinkIcon, InfoIcon, Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { Link, useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import type { z } from 'zod';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
@@ -15,9 +16,16 @@ import { AppError } from '@documenso/lib/errors/app-error';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
import { ZUpdateAdminOrganisationRequestSchema } from '@documenso/trpc/server/admin-router/update-admin-organisation.types';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@documenso/ui/primitives/accordion';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
@@ -37,6 +45,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
import { useToast } from '@documenso/ui/primitives/use-toast';
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -56,7 +66,7 @@ export default function OrganisationGroupSettingsPage({
}: Route.ComponentProps) {
const { licenseFlags } = loaderData;
const { t, i18n } = useLingui();
const { i18n, t } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
@@ -92,39 +102,103 @@ export default function OrganisationGroupSettingsPage({
{
header: t`Team`,
accessorKey: 'name',
cell: ({ row }) => (
<Link className="font-medium hover:underline" to={`/admin/teams/${row.original.id}`}>
{row.original.name}
</Link>
),
},
{
header: t`Team ID`,
accessorKey: 'id',
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">{row.original.id}</span>
),
},
{
header: t`Team url`,
accessorKey: 'url',
cell: ({ row }) => <span className="font-mono text-xs">{row.original.url}</span>,
},
{
header: t`Created`,
accessorKey: 'createdAt',
cell: ({ row }) => {
return (
<span className="whitespace-nowrap font-mono text-xs text-muted-foreground">
{i18n.date(row.original.createdAt)}
</span>
);
},
},
] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['teams'][number]>[];
}, [t]);
}, [i18n, t]);
const organisationMembersColumns = useMemo(() => {
return [
{
header: t`Member`,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Link prefetch="intent" to={`/admin/users/${row.original.user.id}`}>
{row.original.user.name}
<div className="space-y-1">
<Link
prefetch="intent"
className="font-medium hover:underline"
to={`/admin/users/${row.original.user.id}`}
>
{row.original.user.name ?? row.original.user.email}
</Link>
{row.original.user.id === organisation?.ownerUserId && (
<Badge>
<Trans>Owner</Trans>
</Badge>
{row.original.user.name && (
<div className="font-mono text-xs text-muted-foreground">
{row.original.user.email}
</div>
)}
</div>
),
},
{
header: t`Email`,
header: t`User ID`,
accessorKey: 'userId',
cell: ({ row }) => (
<Link prefetch="intent" to={`/admin/users/${row.original.user.id}`}>
{row.original.user.email}
</Link>
<span className="font-mono text-xs text-muted-foreground">{row.original.userId}</span>
),
},
{
header: t`Role`,
cell: ({ row }) => {
if (!organisation) {
return null;
}
const isOwner = row.original.userId === organisation.ownerUserId;
if (isOwner) {
return <Badge>{t`Owner`}</Badge>;
}
const highestRole = getHighestOrganisationRoleInGroup(
row.original.organisationGroupMembers.map((ogm) => ogm.group),
);
const roleLabel = match(highestRole)
.with(OrganisationMemberRole.ADMIN, () => t`Admin`)
.with(OrganisationMemberRole.MANAGER, () => t`Manager`)
.with(OrganisationMemberRole.MEMBER, () => t`Member`)
.exhaustive();
return <Badge variant="secondary">{roleLabel}</Badge>;
},
},
{
header: t`Joined`,
accessorKey: 'createdAt',
cell: ({ row }) => {
return (
<span className="whitespace-nowrap font-mono text-xs text-muted-foreground">
{i18n.date(row.original.createdAt)}
</span>
);
},
},
{
header: t`Actions`,
cell: ({ row }) => {
@@ -147,7 +221,7 @@ export default function OrganisationGroupSettingsPage({
},
},
] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['members'][number]>[];
}, [organisation, t]);
}, [organisation, i18n, t]);
if (isLoadingOrganisation) {
return (
@@ -195,6 +269,61 @@ export default function OrganisationGroupSettingsPage({
<GenericOrganisationAdminForm organisation={organisation} />
<div className="mt-6 rounded-lg border p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium">
<Trans>Organisation usage</Trans>
</p>
<p className="mt-1 text-sm text-muted-foreground">
<Trans>Current usage against organisation limits.</Trans>
</p>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<DetailsCard label={<Trans>Members</Trans>}>
<DetailsValue>
{organisation.members.length} /{' '}
{organisation.organisationClaim.memberCount === 0
? t`Unlimited`
: organisation.organisationClaim.memberCount}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Teams</Trans>}>
<DetailsValue>
{organisation.teams.length} /{' '}
{organisation.organisationClaim.teamCount === 0
? t`Unlimited`
: organisation.organisationClaim.teamCount}
</DetailsValue>
</DetailsCard>
</div>
</div>
<div className="mt-6 rounded-lg border p-4">
<Accordion type="single" collapsible>
<AccordionItem value="global-settings" className="border-b-0">
<AccordionTrigger className="py-0">
<div className="text-left">
<p className="text-sm font-medium">
<Trans>Global Settings</Trans>
</p>
<p className="mt-1 text-sm font-normal text-muted-foreground">
<Trans>Default settings applied to this organisation.</Trans>
</p>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="mt-4">
<AdminGlobalSettingsSection settings={organisation.organisationGlobalSettings} />
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
<SettingsHeader
title={t`Manage subscription`}
subtitle={t`Manage the ${organisation.name} organisation subscription`}
@@ -0,0 +1,317 @@
import { useMemo } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { CopyIcon } from 'lucide-react';
import { Link } from 'react-router';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { trpc } from '@documenso/trpc/react';
import type { TGetAdminTeamResponse } from '@documenso/trpc/server/admin-router/get-admin-team.types';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@documenso/ui/primitives/accordion';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { DataTable, type DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { SettingsHeader } from '~/components/general/settings-header';
import type { Route } from './+types/teams.$id';
export default function AdminTeamPage({ params }: Route.ComponentProps) {
const { _, i18n } = useLingui();
const { toast } = useToast();
const teamId = Number(params.id);
const { data: team, isLoading } = trpc.admin.team.get.useQuery(
{
teamId,
},
{
enabled: Number.isFinite(teamId) && teamId > 0,
},
);
const onCopyToClipboard = async (text: string) => {
await navigator.clipboard.writeText(text);
toast({
title: _(msg`Copied to clipboard`),
});
};
const teamMembersColumns = useMemo(() => {
return [
{
header: _(msg`Member`),
cell: ({ row }) => (
<div className="space-y-1">
<Link
className="font-medium hover:underline"
to={`/admin/users/${row.original.user.id}`}
>
{row.original.user.name ?? row.original.user.email}
</Link>
{row.original.user.name && (
<div className="font-mono text-xs text-muted-foreground">
{row.original.user.email}
</div>
)}
</div>
),
},
{
header: _(msg`User ID`),
accessorKey: 'userId',
},
{
header: _(msg`Team role`),
accessorKey: 'teamRole',
cell: ({ row }) => (
<Badge variant="secondary">{_(TEAM_MEMBER_ROLE_MAP[row.original.teamRole])}</Badge>
),
},
{
header: _(msg`Organisation role`),
accessorKey: 'organisationRole',
cell: ({ row }) => {
const isOwner = row.original.userId === team?.organisation.ownerUserId;
if (isOwner) {
return <Badge>{_(msg`Owner`)}</Badge>;
}
return (
<Badge variant="secondary">
{_(ORGANISATION_MEMBER_ROLE_MAP[row.original.organisationRole])}
</Badge>
);
},
},
{
header: _(msg`Joined`),
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt),
},
] satisfies DataTableColumnDef<TGetAdminTeamResponse['teamMembers'][number]>[];
}, [team, _, i18n]);
const pendingInvitesColumns = useMemo(() => {
return [
{
header: _(msg`Email`),
accessorKey: 'email',
},
{
header: _(msg`Role`),
accessorKey: 'organisationRole',
cell: ({ row }) => _(ORGANISATION_MEMBER_ROLE_MAP[row.original.organisationRole]),
},
{
header: _(msg`Invited`),
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt),
},
] satisfies DataTableColumnDef<TGetAdminTeamResponse['pendingInvites'][number]>[];
}, [_, i18n]);
if (!Number.isFinite(teamId) || teamId <= 0) {
return (
<GenericErrorLayout
errorCode={404}
errorCodeMap={{
404: {
heading: msg`Team not found`,
subHeading: msg`404 Team not found`,
message: msg`The team you are looking for may have been removed, renamed or may have never existed.`,
},
}}
primaryButton={
<Button asChild>
<Link to={`/admin/organisations`}>
<Trans>Go back</Trans>
</Link>
</Button>
}
secondaryButton={null}
/>
);
}
if (isLoading) {
return <SpinnerBox className="py-32" />;
}
if (!team) {
return (
<GenericErrorLayout
errorCode={404}
errorCodeMap={{
404: {
heading: msg`Team not found`,
subHeading: msg`404 Team not found`,
message: msg`The team you are looking for may have been removed, renamed or may have never existed.`,
},
}}
primaryButton={
<Button asChild>
<Link to={`/admin/organisations`}>
<Trans>Go back</Trans>
</Link>
</Button>
}
secondaryButton={null}
/>
);
}
return (
<div>
<SettingsHeader title={_(msg`Manage team`)} subtitle={_(msg`Manage the ${team.name} team`)}>
<Button variant="outline" asChild>
<Link to={`/admin/organisations/${team.organisation.id}`}>
<Trans>Manage organisation</Trans>
</Link>
</Button>
</SettingsHeader>
<div className="mt-8 rounded-lg border p-4">
<div>
<p className="text-sm font-medium">
<Trans>Team details</Trans>
</p>
<p className="mt-1 text-sm text-muted-foreground">
<Trans>Key identifiers and relationships for this team.</Trans>
</p>
</div>
<div className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
<DetailsCard
label={<Trans>Team ID</Trans>}
action={
<Button
type="button"
variant="outline"
size="sm"
className="h-8 w-8 shrink-0 p-0"
onClick={() => void onCopyToClipboard(String(team.id))}
title={_(msg`Copy team ID`)}
>
<CopyIcon className="h-4 w-4" />
</Button>
}
>
<DetailsValue isSelectable>{team.id}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Team URL</Trans>}>
<DetailsValue isSelectable>{team.url}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Created</Trans>}>
<DetailsValue>{i18n.date(team.createdAt)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Members</Trans>}>
<DetailsValue>{team.memberCount}</DetailsValue>
</DetailsCard>
<DetailsCard
label={<Trans>Organisation ID</Trans>}
action={
<Button
type="button"
variant="outline"
size="sm"
className="h-8 w-8 shrink-0 p-0"
onClick={() => void onCopyToClipboard(team.organisation.id)}
title={_(msg`Copy organisation ID`)}
>
<CopyIcon className="h-4 w-4" />
</Button>
}
>
<DetailsValue isSelectable>{team.organisation.id}</DetailsValue>
</DetailsCard>
{team.teamEmail && (
<>
<DetailsCard label={<Trans>Team email</Trans>}>
<DetailsValue isSelectable>{team.teamEmail.email}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Team email name</Trans>}>
<DetailsValue>{team.teamEmail.name}</DetailsValue>
</DetailsCard>
</>
)}
</div>
</div>
{team.teamGlobalSettings && (
<div className="mt-8 rounded-lg border p-4">
<Accordion type="single" collapsible>
<AccordionItem value="global-settings" className="border-b-0">
<AccordionTrigger className="py-0">
<div className="text-left">
<p className="text-sm font-medium">
<Trans>Global Settings</Trans>
</p>
<p className="mt-1 text-sm font-normal text-muted-foreground">
<Trans>
Default settings applied to this team. Inherited values come from the
organisation.
</Trans>
</p>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="mt-4">
<AdminGlobalSettingsSection settings={team.teamGlobalSettings} isTeam />
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
)}
<div className="mt-8">
<p className="text-sm font-medium">
<Trans>Team Members</Trans>
</p>
<p className="mt-1 text-sm text-muted-foreground">
<Trans>Members that currently belong to this team.</Trans>
</p>
<div className="mt-4">
<DataTable columns={teamMembersColumns} data={team.teamMembers} />
</div>
</div>
<div className="mt-8">
<p className="text-sm font-medium">
<Trans>Pending Organisation Invites</Trans>
</p>
<p className="mt-1 text-sm text-muted-foreground">
<Trans>Organisation-level pending invites for this team's parent organisation.</Trans>
</p>
<div className="mt-4">
<DataTable columns={pendingInvitesColumns} data={team.pendingInvites} />
</div>
</div>
</div>
);
}
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { msg } from '@lingui/core/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { Building2Icon, InboxIcon, SettingsIcon, UsersIcon } from 'lucide-react';
import { DateTime } from 'luxon';
@@ -25,7 +26,7 @@ export function loader() {
}
export function meta() {
return appMetaTags('Dashboard');
return appMetaTags(msg`Dashboard`);
}
export default function DashboardPage() {
@@ -1,3 +1,4 @@
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { InboxIcon } from 'lucide-react';
@@ -6,7 +7,7 @@ import { InboxTable } from '~/components/tables/inbox-table';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Personal Inbox');
return appMetaTags(msg`Personal Inbox`);
}
export default function InboxPage() {
@@ -14,11 +15,11 @@ export default function InboxPage() {
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<div className="mb-8">
<h1 className="flex flex-row items-center gap-2 text-3xl font-bold">
<InboxIcon className="text-muted-foreground h-8 w-8" />
<InboxIcon className="h-8 w-8 text-muted-foreground" />
<Trans>Personal Inbox</Trans>
</h1>
<p className="text-muted-foreground mt-1">
<p className="mt-1 text-muted-foreground">
<Trans>Any documents that you have been invited to will appear here</Trans>
</p>
@@ -22,7 +22,7 @@ import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Organisation Settings');
return appMetaTags(msg`Organisation Settings`);
}
export default function SettingsLayout() {
@@ -1,9 +1,10 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { SubscriptionStatus } from '@prisma/client';
import { Loader } from 'lucide-react';
import type Stripe from 'stripe';
import { match } from 'ts-pattern';
import { P, match } from 'ts-pattern';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
@@ -15,7 +16,7 @@ import { OrganisationBillingInvoicesTable } from '~/components/tables/organisati
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Billing');
return appMetaTags(msg`Billing`);
}
export default function TeamsSettingBillingPage() {
@@ -31,7 +32,7 @@ export default function TeamsSettingBillingPage() {
if (isLoadingSubscription || !subscriptionQuery) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
@@ -57,7 +58,7 @@ export default function TeamsSettingBillingPage() {
<Trans>Billing</Trans>
</h3>
<div className="text-muted-foreground mt-2 text-sm">
<div className="mt-2 text-sm text-muted-foreground">
{!organisationSubscription && (
<p>
<Trans>
@@ -70,36 +71,51 @@ export default function TeamsSettingBillingPage() {
match(organisationSubscription.status)
.with('ACTIVE', () => (
<p>
{currentProductName ? (
<span>
You are currently subscribed to{' '}
<span className="font-semibold">{currentProductName}</span>
</span>
) : (
<span>You currently have an active plan</span>
)}
{organisationSubscription.periodEnd && (
<span>
{' '}
which is set to{' '}
{organisationSubscription.cancelAtPeriodEnd ? (
<span>
end on{' '}
<span className="font-semibold">
{i18n.date(organisationSubscription.periodEnd)}.
</span>
</span>
{match(organisationSubscription)
.with(
{ cancelAtPeriodEnd: true, periodEnd: P.nonNullable },
({ periodEnd }) =>
currentProductName ? (
<Trans>
You are currently subscribed to{' '}
<span className="font-semibold">{currentProductName}</span> which is
set to end on{' '}
<span className="font-semibold">{i18n.date(periodEnd)}</span>.
</Trans>
) : (
<Trans>
You currently have an active plan which is set to end on{' '}
<span className="font-semibold">{i18n.date(periodEnd)}</span>.
</Trans>
),
)
.with(
{ cancelAtPeriodEnd: false, periodEnd: P.nonNullable },
({ periodEnd }) =>
currentProductName ? (
<Trans>
You are currently subscribed to{' '}
<span className="font-semibold">{currentProductName}</span> which is
set to automatically renew on{' '}
<span className="font-semibold">{i18n.date(periodEnd)}</span>.
</Trans>
) : (
<Trans>
You currently have an active plan which is set to automatically renew
on <span className="font-semibold">{i18n.date(periodEnd)}</span>.
</Trans>
),
)
.otherwise(() =>
currentProductName ? (
<Trans>
You are currently subscribed to{' '}
<span className="font-semibold">{currentProductName}</span>.
</Trans>
) : (
<span>
automatically renew on{' '}
<span className="font-semibold">
{i18n.date(organisationSubscription.periodEnd)}.
</span>
</span>
)}
</span>
)}
<Trans>You currently have an active plan.</Trans>
),
)}
</p>
))
.with('INACTIVE', () => (
@@ -107,7 +123,7 @@ export default function TeamsSettingBillingPage() {
{currentProductName ? (
<Trans>
You currently have an inactive{' '}
<span className="font-semibold">{currentProductName}</span> subscription
<span className="font-semibold">{currentProductName}</span> subscription.
</Trans>
) : (
<Trans>Your current plan is inactive.</Trans>
@@ -1,3 +1,4 @@
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { Link } from 'react-router';
@@ -21,7 +22,7 @@ import { useOptionalCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Branding Preferences');
return appMetaTags(msg`Branding Preferences`);
}
export default function OrganisationSettingsBrandingPage() {
@@ -47,12 +48,17 @@ export default function OrganisationSettingsBrandingPage() {
try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data;
let uploadedBrandingLogo: string | undefined = '';
let uploadedBrandingLogo: string | undefined = undefined;
if (brandingLogo) {
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
// Empty the branding logo if the user unsets it.
if (brandingLogo === null) {
uploadedBrandingLogo = '';
}
await updateOrganisationSettings({
organisationId: organisation.id,
data: {
@@ -1,3 +1,4 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useLoaderData } from 'react-router';
@@ -18,7 +19,7 @@ import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Document Preferences');
return appMetaTags(msg`Document Preferences`);
}
export const loader = () => {
@@ -61,6 +62,7 @@ export default function OrganisationSettingsDocumentPage() {
delegateDocumentOwnership,
aiFeaturesEnabled,
envelopeExpirationPeriod,
reminderSettings,
} = data;
if (
@@ -92,6 +94,7 @@ export default function OrganisationSettingsDocumentPage() {
delegateDocumentOwnership: delegateDocumentOwnership,
aiFeaturesEnabled,
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
reminderSettings: reminderSettings ?? undefined,
},
});
@@ -1,3 +1,4 @@
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { Link } from 'react-router';
@@ -14,7 +15,7 @@ import { OrganisationEmailDomainsDataTable } from '~/components/tables/organisat
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Email Domains');
return appMetaTags(msg`Email Domains`);
}
export default function OrganisationSettingsEmailDomains() {
@@ -1,3 +1,4 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
@@ -13,7 +14,7 @@ import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Email Preferences');
return appMetaTags(msg`Email Preferences`);
}
export default function OrganisationSettingsGeneral() {
@@ -13,7 +13,7 @@ import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Settings');
return appMetaTags(msg`Settings`);
}
export default function OrganisationSettingsGeneral() {
@@ -69,7 +69,7 @@ const ZProviderFormSchema = ZUpdateOrganisationAuthenticationPortalRequestSchema
type TProviderFormSchema = z.infer<typeof ZProviderFormSchema>;
export function meta() {
return appMetaTags('Organisation SSO Portal');
return appMetaTags(msg`Organisation SSO Portal`);
}
export default function OrganisationSettingSSOLoginPage() {
@@ -205,7 +205,7 @@ const SSOProviderForm = ({ authenticationPortal }: SSOProviderFormProps) => {
</div>
</div>
<p className="text-muted-foreground text-xs">
<p className="text-xs text-muted-foreground">
<Trans>This is the URL which users will use to sign in to your organisation.</Trans>
</p>
</div>
@@ -229,7 +229,7 @@ const SSOProviderForm = ({ authenticationPortal }: SSOProviderFormProps) => {
</div>
</div>
<p className="text-muted-foreground text-xs">
<p className="text-xs text-muted-foreground">
<Trans>Add this URL to your provider's allowed redirect URIs</Trans>
</p>
</div>
@@ -241,7 +241,7 @@ const SSOProviderForm = ({ authenticationPortal }: SSOProviderFormProps) => {
<Input className="pr-12" disabled value={`openid profile email`} />
<p className="text-muted-foreground text-xs">
<p className="text-xs text-muted-foreground">
<Trans>This is the required scopes you must set in your provider's settings</Trans>
</p>
</div>
@@ -262,7 +262,7 @@ const SSOProviderForm = ({ authenticationPortal }: SSOProviderFormProps) => {
</FormControl>
{!form.formState.errors.wellKnownUrl && (
<p className="text-muted-foreground text-xs">
<p className="text-xs text-muted-foreground">
<Trans>The OpenID discovery endpoint URL for your provider</Trans>
</p>
)}
@@ -356,7 +356,7 @@ const SSOProviderForm = ({ authenticationPortal }: SSOProviderFormProps) => {
</FormControl>
{!form.formState.errors.allowedDomains && (
<p className="text-muted-foreground text-xs">
<p className="text-xs text-muted-foreground">
<Trans>
Space-separated list of domains. Leave empty to allow all domains.
</Trans>
@@ -399,7 +399,7 @@ const SSOProviderForm = ({ authenticationPortal }: SSOProviderFormProps) => {
<FormLabel>
<Trans>Enable SSO portal</Trans>
</FormLabel>
<p className="text-muted-foreground text-sm">
<p className="text-sm text-muted-foreground">
<Trans>Whether to enable the SSO portal for your organisation</Trans>
</p>
</div>
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { BookIcon, HelpCircleIcon, Link2Icon } from 'lucide-react';
import { Link, useSearchParams } from 'react-router';
@@ -13,7 +14,7 @@ import { SupportTicketForm } from '~/components/forms/support-ticket-form';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Support');
return appMetaTags(msg`Support`);
}
export default function SupportPage() {
@@ -39,18 +40,18 @@ export default function SupportPage() {
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<div className="mb-8">
<h1 className="flex flex-row items-center gap-2 text-3xl font-bold">
<HelpCircleIcon className="text-muted-foreground h-8 w-8" />
<HelpCircleIcon className="h-8 w-8 text-muted-foreground" />
<Trans>Support</Trans>
</h1>
<p className="text-muted-foreground mt-2">
<p className="mt-2 text-muted-foreground">
<Trans>Your current plan includes the following support channels:</Trans>
</p>
<div className="mt-6 flex flex-col gap-4">
<div className="rounded-lg border p-4">
<h2 className="flex items-center gap-2 text-lg font-bold">
<BookIcon className="text-muted-foreground h-5 w-5" />
<BookIcon className="h-5 w-5 text-muted-foreground" />
<Link
to="https://docs.documenso.com"
target="_blank"
@@ -60,13 +61,13 @@ export default function SupportPage() {
<Trans>Documentation</Trans>
</Link>
</h2>
<p className="text-muted-foreground mt-1">
<p className="mt-1 text-muted-foreground">
<Trans>Read our documentation to get started with Documenso.</Trans>
</p>
</div>
<div className="rounded-lg border p-4">
<h2 className="flex items-center gap-2 text-lg font-bold">
<Link2Icon className="text-muted-foreground h-5 w-5" />
<Link2Icon className="h-5 w-5 text-muted-foreground" />
<Link
to="https://documen.so/discord"
target="_blank"
@@ -76,7 +77,7 @@ export default function SupportPage() {
<Trans>Discord</Trans>
</Link>
</h2>
<p className="text-muted-foreground mt-1">
<p className="mt-1 text-muted-foreground">
<Trans>
Join our community on{' '}
<Link
@@ -95,10 +96,10 @@ export default function SupportPage() {
<>
<div className="rounded-lg border p-4">
<h2 className="flex items-center gap-2 text-lg font-bold">
<Link2Icon className="text-muted-foreground h-5 w-5" />
<Link2Icon className="h-5 w-5 text-muted-foreground" />
<Trans>Contact us</Trans>
</h2>
<p className="text-muted-foreground mt-1">
<p className="mt-1 text-muted-foreground">
<Trans>We'll get back to you as soon as possible via email.</Trans>
</p>
<div className="mt-4">
@@ -1,3 +1,4 @@
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { Outlet } from 'react-router';
@@ -6,7 +7,7 @@ import { SettingsMobileNav } from '~/components/general/settings-nav-mobile';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Settings');
return appMetaTags(msg`Settings`);
}
export default function SettingsLayout() {
@@ -1,3 +1,4 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -5,7 +6,7 @@ import { UserBillingOrganisationsTable } from '~/components/tables/user-billing-
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Billing');
return appMetaTags(msg`Billing`);
}
export default function SettingsBilling() {
@@ -15,7 +15,7 @@ import { TeamEmailUsage } from '~/components/general/teams/team-email-usage';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Profile');
return appMetaTags(msg`Profile`);
}
export default function SettingsProfile() {
@@ -19,7 +19,7 @@ import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/security._index';
export function meta() {
return appMetaTags('Security');
return appMetaTags(msg`Security`);
}
export async function loader({ request }: Route.LoaderArgs) {
@@ -6,7 +6,7 @@ import { SettingsSecurityActivityTable } from '~/components/tables/settings-secu
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Security activity');
return appMetaTags(msg`Security activity`);
}
export default function SettingsSecurityActivity() {
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { useQuery } from '@tanstack/react-query';
@@ -26,7 +27,7 @@ import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Linked Accounts');
return appMetaTags(msg`Linked Accounts`);
}
export default function SettingsSecurityLinkedAccounts() {
@@ -7,7 +7,7 @@ import { SettingsSecurityPasskeyTable } from '~/components/tables/settings-secur
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Manage passkeys');
return appMetaTags(msg`Manage passkeys`);
}
export default function SettingsPasskeys() {
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { useQuery } from '@tanstack/react-query';
@@ -21,7 +22,7 @@ import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Active Sessions');
return appMetaTags(msg`Active Sessions`);
}
const parser = new UAParser();
@@ -33,6 +33,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
},
{
retry: false,
gcTime: 0,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { FolderType, OrganisationType } from '@prisma/client';
@@ -38,7 +39,7 @@ import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Documents');
return appMetaTags(msg`Documents`);
}
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
@@ -1,14 +1,14 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { HomeIcon, Loader2, SearchIcon } from 'lucide-react';
import { useNavigate } from 'react-router';
import { FolderIcon, HomeIcon, Loader2, SearchIcon } from 'lucide-react';
import { Link, useSearchParams } from 'react-router';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
import { FolderCreateDialog } from '~/components/dialogs/folder-create-dialog';
@@ -20,14 +20,16 @@ import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Documents');
return appMetaTags(msg`Documents`);
}
export default function DocumentsFoldersPage() {
const { t } = useLingui();
const navigate = useNavigate();
const team = useCurrentTeam();
const [searchParams] = useSearchParams();
const parentId = searchParams.get('parentId');
const [isMovingFolder, setIsMovingFolder] = useState(false);
const [folderToMove, setFolderToMove] = useState<TFolderWithSubfolders | null>(null);
@@ -39,46 +41,59 @@ export default function DocumentsFoldersPage() {
const { data: foldersData, isLoading: isFoldersLoading } = trpc.folder.getFolders.useQuery({
type: FolderType.DOCUMENT,
parentId: null,
parentId: parentId,
});
const navigateToFolder = (folderId?: string | null) => {
const documentsPath = formatDocumentsPath(team.url);
if (folderId) {
void navigate(`${documentsPath}/f/${folderId}`);
} else {
void navigate(documentsPath);
}
};
const normalizedSearchTerm = searchTerm.trim().toLowerCase();
const isFolderMatchingSearch = (folder: TFolderWithSubfolders) =>
folder.name.toLowerCase().includes(searchTerm.toLowerCase());
folder.name.toLowerCase().includes(normalizedSearchTerm);
const filteredFolders = foldersData?.folders.filter(isFolderMatchingSearch) ?? [];
const pinnedFolders = filteredFolders.filter((folder) => folder.pinned);
const unpinnedFolders = filteredFolders.filter((folder) => !folder.pinned);
const hasFolders = (foldersData?.folders.length ?? 0) > 0;
const hasSearchResults = filteredFolders.length > 0;
const formatBreadcrumbPath = (folderId: string) => {
const documentsPath = formatDocumentsPath(team.url);
return `${documentsPath}/f/${folderId}`;
};
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<div className="flex w-full items-center justify-between">
<div className="flex flex-1 items-center">
<Button
variant="ghost"
size="sm"
className="flex items-center space-x-2 pl-0 hover:bg-transparent"
onClick={() => navigateToFolder(null)}
<div className="flex flex-1 items-center text-sm font-medium text-muted-foreground">
<Link
to={formatDocumentsPath(team.url)}
className="flex items-center hover:text-muted-foreground/80"
>
<HomeIcon className="h-4 w-4" />
<span>
<Trans>Home</Trans>
</span>
</Button>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Home</Trans>
</Link>
{foldersData?.breadcrumbs.map((folder) => (
<div key={folder.id} className="flex items-center">
<span className="px-3">/</span>
<Link
to={formatBreadcrumbPath(folder.id)}
className="flex items-center hover:text-muted-foreground/80"
>
<FolderIcon className="mr-2 h-4 w-4" />
<span>{folder.name}</span>
</Link>
</div>
))}
</div>
<div className="flex flex-col gap-y-4 sm:flex-row sm:justify-end sm:gap-x-4">
<FolderCreateDialog type={FolderType.DOCUMENT} />
<FolderCreateDialog type={FolderType.DOCUMENT} parentFolderId={parentId} />
</div>
</div>
<div className="relative w-full max-w-md py-6">
<SearchIcon className="text-muted-foreground absolute left-2 top-9 h-4 w-4" />
<SearchIcon className="absolute left-2 top-9 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t`Search folders...`}
value={searchTerm}
@@ -93,50 +108,14 @@ export default function DocumentsFoldersPage() {
{isFoldersLoading ? (
<div className="mt-6 flex justify-center">
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<>
{foldersData?.folders?.some(
(folder) => folder.pinned && isFolderMatchingSearch(folder),
) && (
{pinnedFolders.length > 0 && (
<div className="mt-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{foldersData.folders
.filter((folder) => folder.pinned && isFolderMatchingSearch(folder))
.map((folder) => (
<FolderCard
key={folder.id}
folder={folder}
onMove={(folder) => {
setFolderToMove(folder);
setIsMovingFolder(true);
}}
onSettings={(folder) => {
setFolderToSettings(folder);
setIsSettingsFolderOpen(true);
}}
onDelete={(folder) => {
setFolderToDelete(folder);
setIsDeletingFolder(true);
}}
/>
))}
</div>
</div>
)}
<div>
{searchTerm && foldersData?.folders.filter(isFolderMatchingSearch).length === 0 && (
<div className="text-muted-foreground mt-6 text-center">
<Trans>No folders found matching "{searchTerm}"</Trans>
</div>
)}
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{foldersData?.folders
.filter((folder) => !folder.pinned)
.map((folder) => (
{pinnedFolders.map((folder) => (
<FolderCard
key={folder.id}
folder={folder}
@@ -154,6 +133,42 @@ export default function DocumentsFoldersPage() {
}}
/>
))}
</div>
</div>
)}
<div>
{searchTerm && !hasSearchResults && (
<div className="mt-6 text-center text-muted-foreground">
<Trans>No folders found matching "{searchTerm}"</Trans>
</div>
)}
{!searchTerm && !hasFolders && (
<div className="mt-6 text-center text-muted-foreground">
<Trans>No folders yet.</Trans>
</div>
)}
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{unpinnedFolders.map((folder) => (
<FolderCard
key={folder.id}
folder={folder}
onMove={(folder) => {
setFolderToMove(folder);
setIsMovingFolder(true);
}}
onSettings={(folder) => {
setFolderToSettings(folder);
setIsSettingsFolderOpen(true);
}}
onDelete={(folder) => {
setFolderToDelete(folder);
setIsDeletingFolder(true);
}}
/>
))}
</div>
</div>
</>

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