diff --git a/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx b/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx deleted file mode 100644 index 694f2d56a..000000000 --- a/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { FolderType } from '@documenso/lib/types/folder-type'; -import { formatDocumentsPath } from '@documenso/lib/utils/teams'; -import { trpc } from '@documenso/trpc/react'; -import { Button } from '@documenso/ui/primitives/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@documenso/ui/primitives/dialog'; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; -import { Input } from '@documenso/ui/primitives/input'; -import { useToast } from '@documenso/ui/primitives/use-toast'; -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 * as DialogPrimitive from '@radix-ui/react-dialog'; -import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react'; -import { useEffect, useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { useNavigate } from 'react-router'; -import { z } from 'zod'; - -import { useCurrentTeam } from '~/providers/team'; - -export type DocumentMoveToFolderDialogProps = { - documentId: number; - open: boolean; - onOpenChange: (open: boolean) => void; - currentFolderId?: string; -} & Omit; - -const ZMoveDocumentFormSchema = z.object({ - folderId: z.string().nullable().optional(), -}); - -type TMoveDocumentFormSchema = z.infer; - -export const DocumentMoveToFolderDialog = ({ - documentId, - open, - onOpenChange, - currentFolderId, - ...props -}: DocumentMoveToFolderDialogProps) => { - const { _ } = useLingui(); - const { toast } = useToast(); - - const navigate = useNavigate(); - const team = useCurrentTeam(); - - const [searchTerm, setSearchTerm] = useState(''); - - const form = useForm({ - resolver: zodResolver(ZMoveDocumentFormSchema), - defaultValues: { - folderId: currentFolderId, - }, - }); - - const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery( - { - parentId: currentFolderId, - type: FolderType.DOCUMENT, - }, - { - enabled: open, - }, - ); - - const { mutateAsync: updateDocument } = trpc.document.update.useMutation(); - - useEffect(() => { - if (!open) { - form.reset(); - setSearchTerm(''); - } else { - form.reset({ folderId: currentFolderId }); - } - }, [open, currentFolderId, form]); - - const onSubmit = async (data: TMoveDocumentFormSchema) => { - try { - await updateDocument({ - documentId, - data: { - folderId: data.folderId ?? null, - }, - }); - - const documentsPath = formatDocumentsPath(team.url); - - if (data.folderId) { - await navigate(`${documentsPath}/f/${data.folderId}`); - } else { - await navigate(documentsPath); - } - - toast({ - title: _(msg`Document moved`), - description: _(msg`The document has been moved successfully.`), - variant: 'default', - }); - - onOpenChange(false); - } catch (err) { - const error = AppError.parseError(err); - - if (error.code === AppErrorCode.NOT_FOUND) { - toast({ - title: _(msg`Error`), - description: _(msg`The folder you are trying to move the document to does not exist.`), - variant: 'destructive', - }); - - return; - } - - if (error.code === AppErrorCode.UNAUTHORIZED) { - toast({ - title: _(msg`Error`), - description: _(msg`You are not allowed to move this document.`), - variant: 'destructive', - }); - - return; - } - - toast({ - title: _(msg`Error`), - description: _(msg`An error occurred while moving the document.`), - variant: 'destructive', - }); - } - }; - - const filteredFolders = folders?.data.filter((folder) => - folder.name.toLowerCase().includes(searchTerm.toLowerCase()), - ); - - return ( - - - - - Move Document to Folder - - - - Select a folder to move this document to. - - - -
- - setSearchTerm(e.target.value)} - className="pl-8" - /> -
- -
- - ( - - - Folder - - - -
- {isFoldersLoading ? ( -
- -
- ) : ( - <> - - - {filteredFolders?.map((folder) => ( - - ))} - - {searchTerm && filteredFolders?.length === 0 && ( -
- No folders found -
- )} - - )} -
-
- -
- )} - /> - - - - - - - - -
-
- ); -}; diff --git a/apps/remix/app/components/dialogs/document-resend-dialog.tsx b/apps/remix/app/components/dialogs/document-resend-dialog.tsx deleted file mode 100644 index 493d07138..000000000 --- a/apps/remix/app/components/dialogs/document-resend-dialog.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { useSession } from '@documenso/lib/client-only/providers/session'; -import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; -import { AppError } from '@documenso/lib/errors/app-error'; -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'; -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; -import { Checkbox } from '@documenso/ui/primitives/checkbox'; -import { - Dialog, - DialogClose, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@documenso/ui/primitives/dialog'; -import { DropdownMenuItem } from '@documenso/ui/primitives/dropdown-menu'; -import { Form, FormControl, FormField, FormItem, FormLabel } from '@documenso/ui/primitives/form/form'; -import { useToast } from '@documenso/ui/primitives/use-toast'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import { SigningStatus, type Team, type User } from '@prisma/client'; -import { History } from 'lucide-react'; -import { useState } from 'react'; -import { useForm, useWatch } from 'react-hook-form'; -import * as z from 'zod'; - -import { useCurrentTeam } from '~/providers/team'; -import { getDistributeErrorMessage } from '~/utils/toast-error-messages'; - -import { StackAvatar } from '../general/stack-avatar'; - -const FORM_ID = 'resend-email'; - -export type DocumentResendDialogProps = { - document: Pick & { - user: Pick; - recipients: TRecipientLite[]; - team: Pick | null; - }; - recipients: TRecipientLite[]; -}; - -export const ZResendDocumentFormSchema = z.object({ - recipients: z.array(z.number()).min(1, { - message: 'You must select at least one item.', - }), -}); - -export type TResendDocumentFormSchema = z.infer; - -export const DocumentResendDialog = ({ document, recipients }: DocumentResendDialogProps) => { - const { user } = useSession(); - const team = useCurrentTeam(); - - const { toast } = useToast(); - const { _ } = useLingui(); - - const [isOpen, setIsOpen] = useState(false); - const isOwner = document.userId === user.id; - const isCurrentTeamDocument = team && document.team?.url === team.url; - - const isDisabled = - (!isOwner && !isCurrentTeamDocument) || - document.status !== 'PENDING' || - !recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED); - - const { mutateAsync: resendDocument } = trpcReact.document.redistribute.useMutation(); - - const form = useForm({ - resolver: zodResolver(ZResendDocumentFormSchema), - defaultValues: { - recipients: [], - }, - }); - - const { - handleSubmit, - formState: { isSubmitting }, - } = form; - - const selectedRecipients = useWatch({ - control: form.control, - name: 'recipients', - }); - - const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => { - try { - await resendDocument({ documentId: document.id, recipients }); - - toast({ - title: _(msg`Document re-sent`), - description: _(msg`Your document has been re-sent successfully.`), - duration: 5000, - }); - - setIsOpen(false); - } catch (err) { - const error = AppError.parseError(err); - const errorMessage = getDistributeErrorMessage(error.code); - - toast({ - title: _(errorMessage.title), - description: _(errorMessage.description), - variant: 'destructive', - duration: 7500, - }); - } - }; - - return ( - - - e.preventDefault()}> - - Resend - - - - - - -

- Who do you want to remind? -

-
-
- -
- - ( - <> - {recipients.map((recipient) => ( - - - - {recipient.email} - - - - - checked - ? onChange([...value, recipient.id]) - : onChange(value.filter((v) => v !== recipient.id)) - } - /> - - - ))} - - )} - /> - - - - -
- - - - - -
-
-
-
- ); -}; diff --git a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx index 750ab38f2..ed495f83a 100644 --- a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx @@ -3,12 +3,13 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError } from '@documenso/lib/errors/app-error'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; +import { hasOverlappingFields } from '@documenso/lib/utils/fields-overlap'; 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'; -import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, @@ -32,7 +33,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { Trans, useLingui } from '@lingui/react/macro'; import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client'; import { AnimatePresence, motion } from 'framer-motion'; -import { InfoIcon } from 'lucide-react'; +import { AlertTriangleIcon, InfoIcon } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; @@ -138,6 +139,27 @@ export const EnvelopeDistributeDialog = ({ }); }, [recipientsWithIndex, envelope.authOptions]); + /** + * Whether any fields significantly overlap each other. This is surfaced as a + * non-blocking warning since overlapping fields still allow sending, but can + * complicate the signing process or cause fields to behave unexpectedly. + */ + const hasOverlappingEnvelopeFields = useMemo( + () => + hasOverlappingFields( + envelope.fields.map((field) => ({ + id: field.id, + envelopeItemId: field.envelopeItemId, + page: field.page, + positionX: Number(field.positionX), + positionY: Number(field.positionY), + width: Number(field.width), + height: Number(field.height), + })), + ), + [envelope.fields], + ); + const invalidEnvelopeCode = useMemo(() => { if (recipientsMissingSignatureFields.length > 0) { return 'MISSING_SIGNATURES'; @@ -206,6 +228,11 @@ export const EnvelopeDistributeDialog = ({ }; useEffect(() => { + // Default the distribution method tab to the envelope's configured setting. + if (isOpen && envelope.documentMeta) { + setValue('meta.distributionMethod', envelope.documentMeta.distributionMethod); + } + // Resync the whole envelope if the envelope is mid saving. if (isOpen && (isAutosaving || autosaveError)) { void handleSync(); @@ -235,6 +262,24 @@ export const EnvelopeDistributeDialog = ({
+ {hasOverlappingEnvelopeFields && ( + + + +
+ + Overlapping fields detected + + + + Some fields are placed on top of each other. This may complicate the signing process or cause + fields to not work as expected. + + +
+
+ )} + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions diff --git a/apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx b/apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx index 9e33d338c..893fbf6e9 100644 --- a/apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx @@ -1,6 +1,7 @@ import { formatDocumentsPath, 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, @@ -11,10 +12,12 @@ import { DialogTitle, DialogTrigger, } from '@documenso/ui/primitives/dialog'; +import { Label } from '@documenso/ui/primitives/label'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { Trans, useLingui } from '@lingui/react/macro'; import { EnvelopeType } from '@prisma/client'; import { useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import { useCurrentTeam } from '~/providers/team'; @@ -37,6 +40,15 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E const isDocument = envelopeType === EnvelopeType.DOCUMENT; + const form = useForm({ + defaultValues: { + includeRecipients: true, + includeFields: true, + }, + }); + + const includeRecipients = form.watch('includeRecipients'); + const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } = trpc.envelope.duplicate.useMutation({ onSuccess: async ({ id }) => { toast({ @@ -55,8 +67,14 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E }); const onDuplicate = async () => { + const { includeRecipients, includeFields } = form.getValues(); + try { - await duplicateEnvelope({ envelopeId }); + await duplicateEnvelope({ + envelopeId, + includeRecipients, + includeFields: includeRecipients && includeFields, + }); } catch { toast({ title: t`Something went wrong`, @@ -70,7 +88,20 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E }; return ( - !isDuplicating && setOpen(value)}> + { + if (isDuplicating) { + return; + } + + setOpen(value); + + if (!value) { + form.reset(); + } + }} + > {trigger && {trigger}} @@ -87,6 +118,49 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E +
+ ( +
+ { + field.onChange(checked === true); + + if (!checked) { + form.setValue('includeFields', false); + } + }} + /> + +
+ )} + /> + + ( +
+ field.onChange(checked === true)} + /> + +
+ )} + /> +
+ - {!isInheritMemberEnabled && ( + {!disableReason && ( - - {filteredFolders?.map((folder) => ( - - ))} - - {searchTerm && filteredFolders?.length === 0 && ( -
- No folders found -
- )} - - )} - - - - - )} - /> - - - - - - - - -
-
- ); -} diff --git a/apps/remix/app/components/general/document/document-page-view-dropdown.tsx b/apps/remix/app/components/general/document/document-page-view-dropdown.tsx index e18852d95..668a0cd2e 100644 --- a/apps/remix/app/components/general/document/document-page-view-dropdown.tsx +++ b/apps/remix/app/components/general/document/document-page-view-dropdown.tsx @@ -19,6 +19,7 @@ import { Download, Edit, FileOutputIcon, + History, Loader, MoreHorizontal, Pencil, @@ -29,10 +30,10 @@ import { import { useState } from 'react'; import { Link, useNavigate } from 'react-router'; -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 { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-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'; @@ -67,8 +68,6 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP const documentsPath = formatDocumentsPath(team.url); - const nonSignedRecipients = envelope.recipients.filter((item) => item.signingStatus !== 'SIGNED'); - return ( @@ -172,13 +171,20 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP /> )} - + {canManageDocument && ( + e.preventDefault()}> +
+ + Resend +
+ + } + /> + )} { + const formIds = new Set(); + + const pairs = getOverlappingFieldPairs( + debouncedPageFields.map((field) => ({ + id: field.formId, + envelopeItemId: field.envelopeItemId, + page: field.page, + positionX: field.positionX, + positionY: field.positionY, + width: field.width, + height: field.height, + })), + ); + + for (const pair of pairs) { + formIds.add(pair.fieldA.id); + formIds.add(pair.fieldB.id); + } + + return formIds; + }, [debouncedPageFields]); + const handleResizeOrMove = (event: KonvaEventObject) => { const isDragEvent = event.type === 'dragend'; @@ -113,6 +145,62 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR pageLayer.current?.batchDraw(); }; + /** + * Draws (or removes) a dashed warning outline over a field that significantly + * overlaps another field. The highlight is a child of the field group so it moves + * and resizes with the field, and sits on top of the field's own rect (which is + * re-styled on every render and would otherwise clobber a direct stroke change). + */ + const syncOverlapHighlight = (fieldGroup: Konva.Group, isOverlapping: boolean) => { + const existingHighlight = fieldGroup.findOne('.field-overlap-highlight'); + + // Skip while a field is actively being dragged/resized. The highlight is driven + // by debounced field data, so it would lag behind and distort during the gesture. + // It is repainted once the gesture settles (the effect re-runs on isFieldChanging). + if (isFieldChanging) { + existingHighlight?.destroy(); + return; + } + + if (!isOverlapping) { + existingHighlight?.destroy(); + return; + } + + const fieldRect = fieldGroup.findOne('.field-rect'); + + if (!fieldRect) { + return; + } + + const highlightAttrs = { + x: 0, + y: 0, + width: fieldRect.width(), + height: fieldRect.height(), + stroke: '#f59e0b', + strokeWidth: 2, + dash: [6, 4], + cornerRadius: 2, + strokeScaleEnabled: false, + listening: false, + } satisfies Partial; + + if (existingHighlight instanceof Konva.Rect) { + existingHighlight.setAttrs(highlightAttrs); + existingHighlight.moveToTop(); + return; + } + + const highlight = new Konva.Rect({ + name: 'field-overlap-highlight', + ...highlightAttrs, + }); + + fieldGroup.add(highlight); + highlight.moveToTop(); + }; + const unsafeRenderFieldOnLayer = (field: TLocalField) => { if (!pageLayer.current) { return; @@ -139,6 +227,8 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR mode: 'edit', }); + syncOverlapHighlight(fieldGroup, overlappingFieldFormIds.has(field.formId)); + if (!isFieldEditable) { return; } @@ -435,7 +525,7 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR interactiveTransformer.current?.forceUpdate(); pageLayer.current.batchDraw(); - }, [localPageFields, selectedKonvaFieldGroups]); + }, [localPageFields, selectedKonvaFieldGroups, overlappingFieldFormIds, isFieldChanging]); const setSelectedFields = (nodes: Konva.Node[]) => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx index a5ec4ed16..945d3c308 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx @@ -1,3 +1,4 @@ +import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n'; @@ -17,6 +18,7 @@ import { type TTextFieldMeta, } from '@documenso/lib/types/field-meta'; import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope'; +import { getOverlappingFieldPairs } from '@documenso/lib/utils/fields-overlap'; 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'; @@ -28,7 +30,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, PencilIcon, SparklesIcon } from 'lucide-react'; +import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useRevalidator, useSearchParams } from 'react-router'; import { isDeepEqual } from 'remeda'; @@ -78,7 +80,7 @@ export const EnvelopeEditorFieldsPage = () => { const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor(); - const { currentEnvelopeItem } = useCurrentEnvelopeRender(); + const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender(); const { _ } = useLingui(); @@ -93,6 +95,53 @@ export const EnvelopeEditorFieldsPage = () => { const selectedField = useMemo(() => structuredClone(editorFields.selectedField), [editorFields.selectedField]); + /** + * Debounce the fields used for overlap detection so we don't recompute on every + * small drag/resize movement, which is expensive on large field counts and can + * bog down lower-end devices. + */ + const debouncedLocalFields = useDebouncedValue(editorFields.localFields, 300); + + /** + * Fields that significantly overlap each other. Overlapping fields render poorly in + * the editor and can behave unexpectedly during signing, so we warn the author here. + */ + const overlappingFieldPairs = useMemo( + () => + getOverlappingFieldPairs( + debouncedLocalFields.map((field) => ({ + id: field.formId, + envelopeItemId: field.envelopeItemId, + page: field.page, + positionX: field.positionX, + positionY: field.positionY, + width: field.width, + height: field.height, + })), + ), + [debouncedLocalFields], + ); + + const handleReviewOverlappingField = () => { + const firstPair = overlappingFieldPairs[0]; + + if (!firstPair) { + return; + } + + const targetField = editorFields.localFields.find((field) => field.formId === firstPair.fieldA.id); + + if (!targetField) { + return; + } + + if (targetField.envelopeItemId !== currentEnvelopeItem?.id) { + setCurrentEnvelopeItem(targetField.envelopeItemId); + } + + editorFields.setSelectedField(targetField.formId); + }; + const updateSelectedFieldMeta = (fieldMeta: TFieldMetaSchema) => { if (!selectedField) { return; @@ -211,6 +260,29 @@ export const EnvelopeEditorFieldsPage = () => { )} + {overlappingFieldPairs.length > 0 && ( + +
+ + +
+ + Overlapping fields detected + + + + Some fields are placed on top of each other. This may complicate the signing process or cause + fields to not work as expected. + + +
+
+
+ )} + {currentEnvelopeItem !== null ? ( item.signingStatus !== 'SIGNED'); - return ( @@ -244,7 +243,25 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT /> )} - + {canManageDocument && ( + e.preventDefault()}> +
+ + Resend +
+ + } + /> + )} void; + onMoveDocument?: (envelopeId: string) => void; enableSelection?: boolean; rowSelection?: RowSelectionState; onRowSelectionChange?: (selection: RowSelectionState) => void; @@ -117,7 +117,7 @@ export const DocumentsTable = ({ onMoveDocument(row.original.id) : undefined} + onMoveDocument={onMoveDocument ? () => onMoveDocument(row.original.envelopeId) : undefined} /> ), diff --git a/apps/remix/app/components/tables/team-members-table.tsx b/apps/remix/app/components/tables/team-members-table.tsx index c8a9f0faa..612920efd 100644 --- a/apps/remix/app/components/tables/team-members-table.tsx +++ b/apps/remix/app/components/tables/team-members-table.tsx @@ -29,7 +29,7 @@ import { useSearchParams } from 'react-router'; import { useCurrentTeam } from '~/providers/team'; -import { TeamMemberDeleteDialog } from '../dialogs/team-member-delete-dialog'; +import { TeamMemberDeleteDialog, type TeamMemberDeleteDisableReason } from '../dialogs/team-member-delete-dialog'; import { TeamMemberUpdateDialog } from '../dialogs/team-member-update-dialog'; import { TeamInheritMemberAlert } from '../general/teams/team-inherit-member-alert'; @@ -86,6 +86,39 @@ export const TeamMembersTable = () => { ); const columns = useMemo(() => { + // A member is a direct team member when they belong to one of the team's + // INTERNAL_TEAM groups. Otherwise they are inherited from an organisation or + // custom group and cannot be managed directly from this team. + const isMemberPartOfInternalTeamGroup = (memberId: string) => + groups.some( + (group) => + group.organisationGroupType === OrganisationGroupType.INTERNAL_TEAM && + group.members.some((member) => member.id === memberId), + ); + + // Determine why a member can't be removed from the team (if at all). The delete + // dialog uses this to explain the reason instead of attempting a removal that + // would fail. + const getDeleteDisableReason = (member: (typeof results)['data'][number]): TeamMemberDeleteDisableReason | null => { + if (organisation.ownerUserId === member.userId) { + return 'TEAM_OWNER'; + } + + if (!isTeamRoleWithinUserHierarchy(team.currentTeamRole, member.teamRole)) { + return 'HIGHER_ROLE'; + } + + if (memberAccessTeamGroup !== undefined) { + return 'INHERIT_MEMBER_ENABLED'; + } + + if (!isMemberPartOfInternalTeamGroup(member.id)) { + return 'INHERITED_MEMBER'; + } + + return null; + }; + return [ { header: _(msg`Team Member`), @@ -111,15 +144,7 @@ export const TeamMembersTable = () => { }, { header: _(msg`Source`), - cell: ({ row }) => { - const internalTeamGroupFound = groups.find( - (group) => - group.organisationGroupType === OrganisationGroupType.INTERNAL_TEAM && - group.members.some((member) => member.id === row.original.id), - ); - - return internalTeamGroupFound ? _(msg`Member`) : _(msg`Group`); - }, + cell: ({ row }) => (isMemberPartOfInternalTeamGroup(row.original.id) ? _(msg`Member`) : _(msg`Group`)), }, { header: _(msg`Actions`), @@ -161,16 +186,9 @@ export const TeamMembersTable = () => { memberId={row.original.id} memberName={row.original.name ?? ''} memberEmail={row.original.email} - isInheritMemberEnabled={memberAccessTeamGroup !== undefined} + disableReason={getDeleteDisableReason(row.original)} trigger={ - e.preventDefault()} - disabled={ - organisation.ownerUserId === row.original.userId || - !isTeamRoleWithinUserHierarchy(team.currentTeamRole, row.original.teamRole) - } - title={_(msg`Remove team member`)} - > + e.preventDefault()} title={_(msg`Remove team member`)}> Remove diff --git a/apps/remix/app/components/tables/templates-table-action-dropdown.tsx b/apps/remix/app/components/tables/templates-table-action-dropdown.tsx index 8872a1a78..9d8a288f5 100644 --- a/apps/remix/app/components/tables/templates-table-action-dropdown.tsx +++ b/apps/remix/app/components/tables/templates-table-action-dropdown.tsx @@ -11,15 +11,15 @@ import { Trans } from '@lingui/react/macro'; import { DocumentStatus, EnvelopeType, type TemplateDirectLink } from '@prisma/client'; import { Copy, Download, Edit, FolderIcon, MoreHorizontal, Pencil, Share2Icon, Trash2, Upload } from 'lucide-react'; import { useState } from 'react'; -import { Link } from 'react-router'; +import { Link, useNavigate } from 'react-router'; import { EnvelopeDeleteDialog } from '../dialogs/envelope-delete-dialog'; import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog'; import { EnvelopeDuplicateDialog } from '../dialogs/envelope-duplicate-dialog'; import { EnvelopeRenameDialog } from '../dialogs/envelope-rename-dialog'; +import { EnvelopesBulkMoveDialog } from '../dialogs/envelopes-bulk-move-dialog'; import { TemplateBulkSendDialog } from '../dialogs/template-bulk-send-dialog'; import { TemplateDirectLinkDialog } from '../dialogs/template-direct-link-dialog'; -import { TemplateMoveToFolderDialog } from '../dialogs/template-move-to-folder-dialog'; export type TemplatesTableActionDropdownProps = { row: { @@ -44,6 +44,7 @@ export const TemplatesTableActionDropdown = ({ onDelete, }: TemplatesTableActionDropdownProps) => { const trpcUtils = trpcReact.useUtils(); + const navigate = useNavigate(); const [isRenameDialogOpen, setRenameDialogOpen] = useState(false); const [isMoveToFolderDialogOpen, setMoveToFolderDialogOpen] = useState(false); @@ -153,12 +154,13 @@ export const TemplatesTableActionDropdown = ({ )} - navigate(folderId ? `${templateRootPath}/f/${folderId}` : templateRootPath)} /> (null); + const [documentToMove, setDocumentToMove] = useState(null); const [rowSelection, setRowSelection] = useSessionStorage('documents-bulk-selection', {}); const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false); @@ -200,8 +202,8 @@ export default function DocumentsPage() { data={data} isLoading={isLoading} isLoadingError={isLoadingError} - onMoveDocument={(documentId) => { - setDocumentToMove(documentId); + onMoveDocument={(envelopeId) => { + setDocumentToMove(envelopeId); setIsMovingDocument(true); }} enableSelection @@ -213,8 +215,9 @@ export default function DocumentsPage() { {documentToMove && ( - { @@ -224,6 +227,9 @@ export default function DocumentsPage() { setDocumentToMove(null); } }} + onSuccess={(destinationFolderId) => + navigate(destinationFolderId ? `${documentsPath}/f/${destinationFolderId}` : documentsPath) + } /> )} diff --git a/packages/app-tests/e2e/api/v2/redistribute-send-status.spec.ts b/packages/app-tests/e2e/api/v2/redistribute-send-status.spec.ts new file mode 100644 index 000000000..11bbb149b --- /dev/null +++ b/packages/app-tests/e2e/api/v2/redistribute-send-status.spec.ts @@ -0,0 +1,64 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; +import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; +import { prisma } from '@documenso/prisma'; +import { SendStatus, SigningStatus } from '@documenso/prisma/client'; +import { seedPendingDocument } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; +import type { Team, User } from '@prisma/client'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`; + +test.describe.configure({ + mode: 'parallel', +}); + +test.describe('Redistribute updates recipient send status', () => { + let user: User, team: Team, token: string; + + test.beforeEach(async () => { + ({ user, team } = await seedUser()); + ({ token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'test', + expiresIn: null, + })); + }); + + test('marks a NOT_SENT signer as SENT after a successful resend', async ({ request }) => { + const document = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']); + + const [recipient] = document.recipients; + + // Simulate a recipient that is stuck at NOT_SENT on a pending document + // (e.g. the initial send did not dispatch an email for them). + await prisma.recipient.update({ + where: { id: recipient.id }, + data: { + sendStatus: SendStatus.NOT_SENT, + signingStatus: SigningStatus.NOT_SIGNED, + sentAt: null, + }, + }); + + const res = await request.post(`${baseUrl}/document/redistribute`, { + headers: { Authorization: `Bearer ${token}` }, + data: { + documentId: mapSecondaryIdToDocumentId(document.secondaryId), + recipients: [recipient.id], + }, + }); + + expect(res.ok(), `redistribute should succeed: ${await res.text()}`).toBeTruthy(); + + const updatedRecipient = await prisma.recipient.findFirstOrThrow({ + where: { id: recipient.id }, + }); + + expect(updatedRecipient.sendStatus).toBe(SendStatus.SENT); + expect(updatedRecipient.sentAt).not.toBeNull(); + }); +}); diff --git a/packages/app-tests/e2e/api/v2/reject-recipient-on-behalf-of.spec.ts b/packages/app-tests/e2e/api/v2/reject-recipient-on-behalf-of.spec.ts new file mode 100644 index 000000000..afe5a3cd9 --- /dev/null +++ b/packages/app-tests/e2e/api/v2/reject-recipient-on-behalf-of.spec.ts @@ -0,0 +1,260 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; +import { prisma } from '@documenso/prisma'; +import { DocumentVisibility, SigningStatus, TeamMemberRole } from '@documenso/prisma/client'; +import { seedPendingDocument } from '@documenso/prisma/seed/documents'; +import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams'; +import { seedUser } from '@documenso/prisma/seed/users'; +import type { TRejectEnvelopeRecipientOnBehalfOfRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/reject-envelope-recipient-on-behalf-of.types'; +import { type APIRequestContext, expect, test } from '@playwright/test'; +import type { Team, User } from '@prisma/client'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`; + +test.describe.configure({ + mode: 'parallel', +}); + +const rejectRecipient = ( + request: APIRequestContext, + authToken: string, + envelopeId: string, + recipientId: number, + reason: string, + actAsEmail?: string, +) => { + return request.post(`${baseUrl}/envelope/recipient/${recipientId}/reject`, { + headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' }, + data: { + envelopeId, + recipientId, + reason, + actAsEmail, + } satisfies TRejectEnvelopeRecipientOnBehalfOfRequest, + }); +}; + +test.describe('Reject recipient on behalf of', () => { + let user: User; + let team: Team; + let token: string; + + test.beforeEach(async () => { + ({ user, team } = await seedUser()); + ({ token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'test-reject-recipient', + expiresIn: null, + })); + }); + + test('should reject a recipient and record an external rejection audit log', async ({ request }) => { + const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']); + const recipient = envelope.recipients[0]; + + const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Declined out of band'); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const updatedRecipient = await prisma.recipient.findUniqueOrThrow({ + where: { id: recipient.id }, + }); + + expect(updatedRecipient.signingStatus).toBe(SigningStatus.REJECTED); + expect(updatedRecipient.rejectionReason).toBe('Declined out of band'); + + const auditLog = await prisma.documentAuditLog.findFirst({ + where: { + envelopeId: envelope.id, + type: 'DOCUMENT_RECIPIENT_REJECTED', + }, + orderBy: { createdAt: 'desc' }, + }); + + expect(auditLog).not.toBeNull(); + + const auditData = auditLog!.data as Record; + + expect(auditData.recipientId).toBe(recipient.id); + expect(auditData.recipientEmail).toBe(recipient.email); + expect(auditData.reason).toBe('Declined out of band'); + expect(auditData.isExternal).toBe(true); + + // No actAsEmail supplied - the rejection defaults to the API user. + expect(auditLog!.userId).toBe(user.id); + expect(auditLog!.email).toBe(user.email); + expect(auditData.onBehalfOfUserEmail).toBeUndefined(); + }); + + test('should attribute the rejection to the elected team member when actAsEmail is supplied', async ({ request }) => { + const member = await seedTeamMember({ teamId: team.id }); + + const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']); + const recipient = envelope.recipients[0]; + + const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Declined out of band', member.email); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const auditLog = await prisma.documentAuditLog.findFirstOrThrow({ + where: { + envelopeId: envelope.id, + type: 'DOCUMENT_RECIPIENT_REJECTED', + }, + orderBy: { createdAt: 'desc' }, + }); + + // The audit log actor must be the elected member, not the API user. + expect(auditLog.userId).toBe(member.id); + expect(auditLog.email).toBe(member.email); + + const auditData = auditLog.data as Record; + + expect(auditData.isExternal).toBe(true); + expect(auditData.onBehalfOfUserEmail).toBe(member.email); + }); + + test('should reject when actAsEmail is not a member of the team', async ({ request }) => { + // A user that exists but belongs to a different team. + const { user: outsider } = await seedUser(); + + const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']); + const recipient = envelope.recipients[0]; + + const res = await rejectRecipient( + request, + token, + envelope.id, + recipient.id, + 'Declined out of band', + outsider.email, + ); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(401); + + // The recipient must remain untouched. + const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({ + where: { id: recipient.id }, + }); + + expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED); + expect(untouchedRecipient.rejectionReason).toBeNull(); + }); + + test('should deny rejecting a recipient that has already actioned the document', async ({ request }) => { + const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']); + const recipient = envelope.recipients[0]; + + // Reject once - succeeds. + const firstRes = await rejectRecipient(request, token, envelope.id, recipient.id, 'First rejection'); + expect(firstRes.ok()).toBeTruthy(); + + // Reject again - the recipient is no longer NOT_SIGNED. + const secondRes = await rejectRecipient(request, token, envelope.id, recipient.id, 'Second rejection'); + + expect(secondRes.ok()).toBeFalsy(); + expect(secondRes.status()).toBe(400); + + // The original rejection reason must remain unchanged. + const updatedRecipient = await prisma.recipient.findUniqueOrThrow({ + where: { id: recipient.id }, + }); + + expect(updatedRecipient.rejectionReason).toBe('First rejection'); + }); + + test('should not allow rejecting a recipient in another team', async ({ request }) => { + // Seed a separate team/user that owns the document. + const { user: otherUser, team: otherTeam } = await seedUser(); + + const envelope = await seedPendingDocument(otherUser, otherTeam.id, ['recipient@test.documenso.com']); + const recipient = envelope.recipients[0]; + + // Use the original team's token - it must not be able to reject. + const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Should not work'); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + + // The recipient must remain untouched. + const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({ + where: { id: recipient.id }, + }); + + expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED); + expect(untouchedRecipient.rejectionReason).toBeNull(); + }); + + test('should return 404 for a non-existent recipient', async ({ request }) => { + const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']); + + const res = await rejectRecipient(request, token, envelope.id, 999999999, 'No such recipient'); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should return 404 when the recipient does not belong to the supplied envelope', async ({ request }) => { + const targetEnvelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']); + const otherEnvelope = await seedPendingDocument(user, team.id, ['other-recipient@test.documenso.com']); + + const recipient = targetEnvelope.recipients[0]; + + // Valid recipient ID, but paired with the wrong envelope ID. + const res = await rejectRecipient(request, token, otherEnvelope.id, recipient.id, 'Mismatched envelope'); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + + // The recipient must remain untouched. + const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({ + where: { id: recipient.id }, + }); + + expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED); + expect(untouchedRecipient.rejectionReason).toBeNull(); + }); + + test('should enforce document visibility: manager cannot reject on an ADMIN-only document', async ({ request }) => { + // The API token belongs to a MANAGER, who cannot see ADMIN-visibility docs. + const { team: visTeam, owner } = await seedTeam(); + const manager = await seedTeamMember({ teamId: visTeam.id, role: TeamMemberRole.MANAGER }); + + const { token: managerToken } = await createApiToken({ + userId: manager.id, + teamId: visTeam.id, + tokenName: 'manager-reject-token', + expiresIn: null, + }); + + // ADMIN-visibility document owned by the team owner. + const envelope = await seedPendingDocument(owner, visTeam.id, ['recipient@test.documenso.com'], { + createDocumentOptions: { visibility: DocumentVisibility.ADMIN }, + }); + const recipient = envelope.recipients[0]; + + const res = await rejectRecipient( + request, + managerToken, + envelope.id, + recipient.id, + 'Should be hidden by visibility', + ); + + // Visibility failure surfaces as not-found, matching the canonical checks. + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + + const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({ + where: { id: recipient.id }, + }); + + expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED); + expect(untouchedRecipient.rejectionReason).toBeNull(); + }); +}); diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-actions.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-actions.spec.ts index 6283a9235..274b45eee 100644 --- a/packages/app-tests/e2e/envelope-editor-v2/envelope-actions.spec.ts +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-actions.spec.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; import { prisma } from '@documenso/prisma'; +import { seedDraftDocument } from '@documenso/prisma/seed/documents'; import { seedTemplate } from '@documenso/prisma/seed/templates'; import { seedUser } from '@documenso/prisma/seed/users'; import type { @@ -302,6 +303,95 @@ test.describe('document editor', () => { expect(envelopes.length).toBeGreaterThanOrEqual(2); }); + test('duplicate document without recipients excludes recipients and fields', async ({ page }) => { + const { user, team } = await seedUser(); + + // Seed a draft document that has a recipient with a field. + const document = await seedDraftDocument(user, team.id, ['signer@test.documenso.com'], { + key: `dup-exclude-recipients-${Date.now()}`, + internalVersion: 2, + }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents/${document.id}/edit`, + }); + + await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible(); + + // Open the duplicate dialog. + await page.locator('button[title="Duplicate Envelope"]').click(); + await expect(page.getByRole('heading', { name: 'Duplicate Document' })).toBeVisible(); + + // Uncheck "Include Recipients" — this also disables and unchecks "Include Fields". + await page.getByLabel('Include Recipients').click(); + await expect(page.getByLabel('Include Fields')).toBeDisabled(); + + // Duplicate. + await page.getByRole('button', { name: 'Duplicate' }).click(); + await expectToastTextToBeVisible(page, 'Document Duplicated'); + await expect(page).toHaveURL(/\/documents\/.*\/edit/); + + // The duplicate should have neither recipients nor fields. + const duplicate = await prisma.envelope.findFirstOrThrow({ + where: { + teamId: team.id, + type: EnvelopeType.DOCUMENT, + id: { not: document.id }, + }, + include: { recipients: true, fields: true }, + orderBy: { createdAt: 'desc' }, + }); + + expect(duplicate.recipients).toHaveLength(0); + expect(duplicate.fields).toHaveLength(0); + }); + + test('duplicate document without fields keeps recipients but excludes fields', async ({ page }) => { + const { user, team } = await seedUser(); + + // Seed a draft document that has a recipient with a field. + const document = await seedDraftDocument(user, team.id, ['signer@test.documenso.com'], { + key: `dup-exclude-fields-${Date.now()}`, + internalVersion: 2, + }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents/${document.id}/edit`, + }); + + await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible(); + + // Open the duplicate dialog. + await page.locator('button[title="Duplicate Envelope"]').click(); + await expect(page.getByRole('heading', { name: 'Duplicate Document' })).toBeVisible(); + + // Uncheck only "Include Fields" (recipients stay included). + await page.getByLabel('Include Fields').click(); + + // Duplicate. + await page.getByRole('button', { name: 'Duplicate' }).click(); + await expectToastTextToBeVisible(page, 'Document Duplicated'); + await expect(page).toHaveURL(/\/documents\/.*\/edit/); + + // The duplicate should keep the recipient but have no fields. + const duplicate = await prisma.envelope.findFirstOrThrow({ + where: { + teamId: team.id, + type: EnvelopeType.DOCUMENT, + id: { not: document.id }, + }, + include: { recipients: true, fields: true }, + orderBy: { createdAt: 'desc' }, + }); + + expect(duplicate.recipients).toHaveLength(1); + expect(duplicate.fields).toHaveLength(0); + }); + test('download PDF dialog shows envelope items', async ({ page }) => { await openDocumentEnvelopeEditor(page); diff --git a/packages/app-tests/e2e/envelopes/envelope-expiration-send.spec.ts b/packages/app-tests/e2e/envelopes/envelope-expiration-send.spec.ts index 5aaaa3a58..7af9a2310 100644 --- a/packages/app-tests/e2e/envelopes/envelope-expiration-send.spec.ts +++ b/packages/app-tests/e2e/envelopes/envelope-expiration-send.spec.ts @@ -270,7 +270,7 @@ test('[ENVELOPE_EXPIRATION]: resending refreshes expiresAt', async ({ page }) => await page.getByLabel('test.documenso.com').first().click(); await page.getByRole('button', { name: 'Send reminder' }).click(); - await expect(page.getByText('Document re-sent', { exact: true })).toBeVisible({ + await expect(page.getByText('Document resent', { exact: true })).toBeVisible({ timeout: 10_000, }); diff --git a/packages/app-tests/e2e/teams/team-documents.spec.ts b/packages/app-tests/e2e/teams/team-documents.spec.ts index 3e8192b78..6d5f2ca08 100644 --- a/packages/app-tests/e2e/teams/team-documents.spec.ts +++ b/packages/app-tests/e2e/teams/team-documents.spec.ts @@ -238,7 +238,7 @@ test('[TEAMS]: resend pending team document', async ({ page }) => { await page.getByLabel('test.documenso.com').first().click(); await page.getByRole('button', { name: 'Send reminder' }).click(); - await expectToastTextToBeVisible(page, 'Document re-sent'); + await expectToastTextToBeVisible(page, 'Document resent'); }); test('[TEAMS]: delete draft team document', async ({ page }) => { diff --git a/packages/app-tests/e2e/teams/team-members.spec.ts b/packages/app-tests/e2e/teams/team-members.spec.ts new file mode 100644 index 000000000..0064b35dc --- /dev/null +++ b/packages/app-tests/e2e/teams/team-members.spec.ts @@ -0,0 +1,105 @@ +import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations'; +import { seedTeamMember } from '@documenso/prisma/seed/teams'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; +import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client'; + +import { apiSignin } from '../fixtures/authentication'; +import { openDropdownMenu } from '../fixtures/generic'; + +/** + * Reproduces the "Team has no internal team groups" bug. + * + * When a team has member inheritance turned OFF, organisation admins/managers are + * still inherited into the team as team admins (shown with the "Group" source). + * These members are not part of the team's INTERNAL_TEAM group, so they cannot be + * removed via the team members page - attempting to do so threw a 500 ("Team has no + * internal team groups"). + * + * Instead of crashing, the delete dialog must explain why the inherited member can't + * be removed and not offer a confirm button. + */ +test('[TEAMS]: explains why an inherited organisation member cannot be removed', async ({ page }) => { + // Team created with member inheritance OFF. + const { user: owner, organisation, team } = await seedUser({ inheritMembers: false }); + + const inheritedAdminEmail = `inherited-admin-${team.url}@test.documenso.com`; + + // A second organisation admin is inherited into the team as a team admin (source "Group"). + await seedOrganisationMembers({ + organisationId: organisation.id, + members: [ + { + name: 'Inherited Admin', + email: inheritedAdminEmail, + organisationRole: OrganisationMemberRole.ADMIN, + }, + ], + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/settings/members`, + }); + + const inheritedMemberRow = page.getByRole('row').filter({ hasText: inheritedAdminEmail }); + + // Sanity check: the member is inherited from a group, not a direct team member. + await expect(inheritedMemberRow).toBeVisible(); + await expect(inheritedMemberRow).toContainText('Group'); + + await openDropdownMenu(page, inheritedMemberRow.getByRole('button').last()); + + // The action stays enabled - opening it shows a dialog explaining why the inherited + // member can't be removed, rather than triggering the 500. + const removeMenuItem = page.getByRole('menuitem', { name: 'Remove' }); + await expect(removeMenuItem).toBeEnabled(); + await removeMenuItem.click(); + + await expect(page.getByText('inherited from a group').first()).toBeVisible(); + + // No confirm button is offered, so the broken removal can never be triggered. + await expect(page.getByRole('button', { name: 'Remove' })).toHaveCount(0); +}); + +/** + * Guards against over-disabling the remove action: a direct team member (one that + * belongs to the team's INTERNAL_TEAM group) must still be removable. + */ +test('[TEAMS]: can remove a direct team member', async ({ page }) => { + const { user: owner, team } = await seedUser({ inheritMembers: false }); + + const directMember = await seedTeamMember({ + teamId: team.id, + name: 'Direct Member', + role: TeamMemberRole.MEMBER, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/settings/members`, + }); + + const directMemberRow = page.getByRole('row').filter({ hasText: directMember.email }); + + await expect(directMemberRow).toBeVisible(); + + await openDropdownMenu(page, directMemberRow.getByRole('button').last()); + + const removeMenuItem = page.getByRole('menuitem', { name: 'Remove' }); + + // The "Remove" action is enabled for direct members and removing them succeeds. + await expect(removeMenuItem).toBeEnabled(); + await removeMenuItem.click(); + + await page.getByRole('button', { name: 'Remove' }).click(); + + await expect(page.getByText('You have successfully removed this user from the team.').first()).toBeVisible(); + + // The member is actually gone after reloading the members list. + await page.reload(); + await expect(page.getByRole('row').filter({ hasText: owner.email })).toBeVisible(); + await expect(page.getByRole('row').filter({ hasText: directMember.email })).toHaveCount(0); +}); diff --git a/packages/ee/server-only/signing/csc/execute-tsp-sign.ts b/packages/ee/server-only/signing/csc/execute-tsp-sign.ts index 5045b33d8..3c95720f6 100644 --- a/packages/ee/server-only/signing/csc/execute-tsp-sign.ts +++ b/packages/ee/server-only/signing/csc/execute-tsp-sign.ts @@ -1,6 +1,5 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { jobs } from '@documenso/lib/jobs/client'; -import { sendPendingEmail } from '@documenso/lib/server-only/document/send-pending-email'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { triggerWebhook } from '@documenso/lib/server-only/webhooks/trigger/trigger-webhook'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; @@ -474,9 +473,12 @@ export const executeTspSign = async (opts: ExecuteTspSignOptions): Promise 0) { - await sendPendingEmail({ - id: { type: 'envelopeId', id: envelope.id }, - recipientId: recipient.id, + await jobs.triggerJob({ + name: 'send.document.pending.email', + payload: { + envelopeId: envelope.id, + recipientId: recipient.id, + }, }); // TSP envelopes are forced SEQUENTIAL at send-time; this branch always diff --git a/packages/email/package.json b/packages/email/package.json index f7791ef5b..bb549f70b 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -12,12 +12,13 @@ "index.ts" ], "scripts": { - "dev": "email dev --port 3002 --dir templates", + "dev": "react-router dev --config preview/vite.config.ts", + "preview:build": "react-router build --config preview/vite.config.ts", "clean": "rimraf node_modules" }, "dependencies": { - "@documenso/tailwind-config": "*", "@documenso/nodemailer-resend": "4.0.0", + "@documenso/tailwind-config": "*", "@react-email/body": "0.2.0", "@react-email/button": "0.2.0", "@react-email/code-block": "0.2.0", diff --git a/packages/email/preview/.gitignore b/packages/email/preview/.gitignore new file mode 100644 index 000000000..d41162243 --- /dev/null +++ b/packages/email/preview/.gitignore @@ -0,0 +1,2 @@ +/.react-router/ +/build/ diff --git a/packages/email/preview/app/app.css b/packages/email/preview/app/app.css new file mode 100644 index 000000000..32dbdf8f8 --- /dev/null +++ b/packages/email/preview/app/app.css @@ -0,0 +1,9 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body { + margin: 0; + padding: 0; +} diff --git a/packages/email/preview/app/components/playground.tsx b/packages/email/preview/app/components/playground.tsx new file mode 100644 index 000000000..6cdbd53e2 --- /dev/null +++ b/packages/email/preview/app/components/playground.tsx @@ -0,0 +1,337 @@ +import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/locales'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router'; + +import type { FieldConfig } from '../lib/templates'; +import { templates } from '../lib/templates'; +import { viewports } from '../lib/viewports'; +import { PropFields } from './prop-fields'; + +type Theme = 'light' | 'dark'; + +const GROUP_ORDER = ['Documents', 'Recipients', 'Organisations', 'Teams', 'Account', 'Admin'] as const; + +const LANGUAGE_LABELS: Record = { + en: 'English', + de: 'German', + fr: 'French', + es: 'Spanish', + it: 'Italian', + nl: 'Dutch', + pl: 'Polish', + 'pt-BR': 'Portuguese (Brazil)', + ja: 'Japanese', + ko: 'Korean', + zh: 'Chinese', +}; + +const DEFAULT_COLORS = { + primary: '#a2e771', + primaryForeground: '#162c07', + background: '#ffffff', + foreground: '#0f172a', +}; + +type PlaygroundProps = { + slug: string; + fields: Record; + defaultProps: Record; +}; + +export const EmailPlayground = ({ slug, fields, defaultProps }: PlaygroundProps) => { + const navigate = useNavigate(); + + const [props, setProps] = useState(defaultProps); + const [html, setHtml] = useState(''); + const [loading, setLoading] = useState(false); + + const [theme, setTheme] = useState('light'); + const [viewportIndex, setViewportIndex] = useState(2); + const [lang, setLang] = useState('en'); + + const [brandingEnabled, setBrandingEnabled] = useState(false); + const [colors, setColors] = useState(DEFAULT_COLORS); + + const debounceRef = useRef>(undefined); + + const groupedTemplates = useMemo(() => { + const entries = Object.entries(templates); + + return GROUP_ORDER.map((group) => ({ + group, + entries: entries.filter(([, def]) => def.group === group), + })).filter((section) => section.entries.length > 0); + }, []); + + const fetchHtml = useCallback( + async (currentProps: Record, currentLang: string, brandColors: typeof colors | null) => { + setLoading(true); + + try { + const response = await fetch('/api/render', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + slug, + props: currentProps, + lang: currentLang, + colors: brandColors, + assetBaseUrl: window.location.origin, + }), + }); + + if (response.ok) { + setHtml(await response.text()); + } + } finally { + setLoading(false); + } + }, + [slug], + ); + + // Reset props when navigating to a different template. + useEffect(() => { + setProps(defaultProps); + }, [defaultProps]); + + // Re-render on any input change (debounced). + useEffect(() => { + clearTimeout(debounceRef.current); + + debounceRef.current = setTimeout(() => { + void fetchHtml(props, lang, brandingEnabled ? colors : null); + }, 250); + + return () => clearTimeout(debounceRef.current); + }, [props, lang, brandingEnabled, colors, fetchHtml]); + + const handlePropChange = (key: string, value: unknown) => { + setProps((prev) => ({ ...prev, [key]: value })); + }; + + const handleColorChange = (key: keyof typeof colors, value: string) => { + setColors((prev) => ({ ...prev, [key]: value })); + }; + + // Force dark mode inside the iframe by neutralising the prefers-color-scheme + // media query (color-scheme alone doesn't trigger it inside an iframe). + const displayHtml = theme === 'dark' && html ? html.replaceAll(/prefers-color-scheme:\s*dark/g, 'min-width:0') : html; + + const viewport = viewports[viewportIndex]; + + return ( +
+ {/* Sidebar */} + + + {/* Props panel */} +
+

Props

+ +
+ + {/* Main */} +
+ + +
+
+