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-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 +
+ + } + /> + )} 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/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/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 */} +
+ + +
+
+