From 353bdce86b9cdb3447b8098f00c3b2d80cede22c Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Tue, 28 Oct 2025 10:09:38 +0000 Subject: [PATCH 01/84] feat: admin member role updates (#2093) --- ...dmin-organisation-member-update-dialog.tsx | 218 +++++++++++++++++ .../admin+/organisations.$id.tsx | 53 ++--- ...> update-organisation-member-role.spec.ts} | 203 ++++++++++++---- .../admin-router/get-admin-organisation.ts | 5 + .../get-admin-organisation.types.ts | 14 ++ packages/trpc/server/admin-router/router.ts | 2 + .../update-organisation-member-role.ts | 220 ++++++++++++++++++ .../update-organisation-member-role.types.ts | 30 +++ 8 files changed, 664 insertions(+), 81 deletions(-) create mode 100644 apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx rename packages/app-tests/e2e/admin/organisations/{promote-member-to-owner.spec.ts => update-organisation-member-role.spec.ts} (64%) create mode 100644 packages/trpc/server/admin-router/update-organisation-member-role.ts create mode 100644 packages/trpc/server/admin-router/update-organisation-member-role.types.ts diff --git a/apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx b/apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx new file mode 100644 index 000000000..1e6be1e3f --- /dev/null +++ b/apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx @@ -0,0 +1,218 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import { OrganisationMemberRole } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router'; +import { match } from 'ts-pattern'; +import { z } from 'zod'; + +import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations'; +import { trpc } from '@documenso/trpc/react'; +import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type AdminOrganisationMemberUpdateDialogProps = { + trigger?: React.ReactNode; + organisationId: string; + organisationMember: TGetAdminOrganisationResponse['members'][number]; + isOwner: boolean; +} & Omit; + +const ZUpdateOrganisationMemberFormSchema = z.object({ + role: z.enum(['OWNER', 'ADMIN', 'MANAGER', 'MEMBER']), +}); + +type ZUpdateOrganisationMemberSchema = z.infer; + +export const AdminOrganisationMemberUpdateDialog = ({ + trigger, + organisationId, + organisationMember, + isOwner, + ...props +}: AdminOrganisationMemberUpdateDialogProps) => { + const [open, setOpen] = useState(false); + + const { t } = useLingui(); + const { toast } = useToast(); + const navigate = useNavigate(); + + // Determine the current role value for the form + const currentRoleValue = isOwner + ? 'OWNER' + : getHighestOrganisationRoleInGroup( + organisationMember.organisationGroupMembers.map((ogm) => ogm.group), + ); + const organisationMemberName = organisationMember.user.name ?? organisationMember.user.email; + + const form = useForm({ + resolver: zodResolver(ZUpdateOrganisationMemberFormSchema), + defaultValues: { + role: currentRoleValue, + }, + }); + + const { mutateAsync: updateOrganisationMemberRole } = + trpc.admin.organisationMember.updateRole.useMutation(); + + const onFormSubmit = async ({ role }: ZUpdateOrganisationMemberSchema) => { + try { + await updateOrganisationMemberRole({ + organisationId, + userId: organisationMember.userId, + role, + }); + + const roleLabel = match(role) + .with('OWNER', () => t`Owner`) + .with(OrganisationMemberRole.ADMIN, () => t`Admin`) + .with(OrganisationMemberRole.MANAGER, () => t`Manager`) + .with(OrganisationMemberRole.MEMBER, () => t`Member`) + .exhaustive(); + + toast({ + title: t`Success`, + description: + role === 'OWNER' + ? t`Ownership transferred to ${organisationMemberName}.` + : t`Updated ${organisationMemberName} to ${roleLabel}.`, + duration: 5000, + }); + + setOpen(false); + + // Refresh the page to show updated data + await navigate(0); + } catch (err) { + console.error(err); + + toast({ + title: t`An unknown error occurred`, + description: t`We encountered an unknown error while attempting to update this organisation member. Please try again later.`, + variant: 'destructive', + }); + } + }; + + useEffect(() => { + if (!open) { + return; + } + + form.reset({ + role: currentRoleValue, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, currentRoleValue, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger ?? ( + + )} + + + + + + Update organisation member + + + + + You are currently updating{' '} + {organisationMemberName}. + + + + +
+ +
+ ( + + + Role + + + + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx index 799c54a3f..cd5c939a2 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx @@ -34,6 +34,7 @@ import { Input } from '@documenso/ui/primitives/input'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog'; import { GenericErrorLayout } from '~/components/general/generic-error-layout'; import { SettingsHeader } from '~/components/general/settings-header'; @@ -71,23 +72,6 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen }, }); - const { mutateAsync: promoteToOwner, isPending: isPromotingToOwner } = - trpc.admin.organisationMember.promoteToOwner.useMutation({ - onSuccess: () => { - toast({ - title: t`Success`, - description: t`Member promoted to owner successfully`, - }); - }, - onError: () => { - toast({ - title: t`Error`, - description: t`We couldn't promote the member to owner. Please try again.`, - variant: 'destructive', - }); - }, - }); - const teamsColumns = useMemo(() => { return [ { @@ -120,23 +104,24 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen }, { header: t`Actions`, - cell: ({ row }) => ( -
- -
- ), + cell: ({ row }) => { + const isOwner = row.original.userId === organisation?.ownerUserId; + + return ( +
+ + Update role + + } + organisationId={organisationId} + organisationMember={row.original} + isOwner={isOwner} + /> +
+ ); + }, }, ] satisfies DataTableColumnDef[]; }, [organisation]); diff --git a/packages/app-tests/e2e/admin/organisations/promote-member-to-owner.spec.ts b/packages/app-tests/e2e/admin/organisations/update-organisation-member-role.spec.ts similarity index 64% rename from packages/app-tests/e2e/admin/organisations/promote-member-to-owner.spec.ts rename to packages/app-tests/e2e/admin/organisations/update-organisation-member-role.spec.ts index 86edeac2b..7cc3f7c03 100644 --- a/packages/app-tests/e2e/admin/organisations/promote-member-to-owner.spec.ts +++ b/packages/app-tests/e2e/admin/organisations/update-organisation-member-role.spec.ts @@ -68,15 +68,29 @@ test('[ADMIN]: promote member to owner', async ({ page }) => { // Test promoting a MEMBER to owner const memberRow = page.getByRole('row', { name: memberUser.email }); - // Find and click the "Promote to owner" button for the member - const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' }); - await expect(promoteButton).toBeVisible(); - await expect(promoteButton).not.toBeDisabled(); + // Find and click the "Update role" button for the member + const updateRoleButton = memberRow.getByRole('button', { + name: 'Update role', + }); + await expect(updateRoleButton).toBeVisible(); + await expect(updateRoleButton).not.toBeDisabled(); - await promoteButton.click(); + await updateRoleButton.click(); - // Verify success toast appears - await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible(); + // Wait for dialog to open and select Owner role + await expect(page.getByRole('dialog')).toBeVisible(); + + // Find and click the select trigger - it's a button with role="combobox" + await page.getByRole('dialog').locator('button[role="combobox"]').click(); + + // Select "Owner" from the dropdown options + await page.getByRole('option', { name: 'Owner' }).click(); + + // Click Update button + await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click(); + + // Wait for dialog to close (indicates success) + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 }); // Reload the page to see the changes await page.reload(); @@ -89,12 +103,18 @@ test('[ADMIN]: promote member to owner', async ({ page }) => { const previousOwnerRow = page.getByRole('row', { name: ownerUser.email }); await expect(previousOwnerRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible(); - // Verify that the promote button is now disabled for the new owner - const newOwnerPromoteButton = newOwnerRow.getByRole('button', { name: 'Promote to owner' }); - await expect(newOwnerPromoteButton).toBeDisabled(); + // Verify that the Update role button exists for the new owner and shows Owner as current role + const newOwnerUpdateButton = newOwnerRow.getByRole('button', { + name: 'Update role', + }); + await expect(newOwnerUpdateButton).toBeVisible(); - // Test that we can't promote the current owner (button should be disabled) - await expect(newOwnerPromoteButton).toHaveAttribute('disabled'); + // Verify clicking it shows the dialog with Owner already selected + await newOwnerUpdateButton.click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // Close the dialog without making changes + await page.getByRole('button', { name: 'Cancel' }).click(); }); test('[ADMIN]: promote manager to owner', async ({ page }) => { @@ -130,10 +150,26 @@ test('[ADMIN]: promote manager to owner', async ({ page }) => { // Promote the manager to owner const managerRow = page.getByRole('row', { name: managerUser.email }); - const promoteButton = managerRow.getByRole('button', { name: 'Promote to owner' }); + const updateRoleButton = managerRow.getByRole('button', { + name: 'Update role', + }); - await promoteButton.click(); - await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible(); + await updateRoleButton.click(); + + // Wait for dialog to open and select Owner role + await expect(page.getByRole('dialog')).toBeVisible(); + + // Find and click the select trigger - it's a button with role="combobox" + await page.getByRole('dialog').locator('button[role="combobox"]').click(); + + // Select "Owner" from the dropdown options + await page.getByRole('option', { name: 'Owner' }).click(); + + // Click Update button + await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click(); + + // Wait for dialog to close (indicates success) + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 }); // Reload and verify the change await page.reload(); @@ -173,14 +209,27 @@ test('[ADMIN]: promote admin member to owner', async ({ page }) => { // Promote the admin member to owner const adminMemberRow = page.getByRole('row', { name: adminMemberUser.email }); - const promoteButton = adminMemberRow.getByRole('button', { name: 'Promote to owner' }); - - await promoteButton.click(); - - await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({ - timeout: 10_000, + const updateRoleButton = adminMemberRow.getByRole('button', { + name: 'Update role', }); + await updateRoleButton.click(); + + // Wait for dialog to open and select Owner role + await expect(page.getByRole('dialog')).toBeVisible(); + + // Find and click the select trigger - it's a button with role="combobox" + await page.getByRole('dialog').locator('button[role="combobox"]').click(); + + // Select "Owner" from the dropdown options + await page.getByRole('option', { name: 'Owner' }).click(); + + // Click Update button + await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click(); + + // Wait for dialog to close (indicates success) + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 }); + // Reload and verify the change await page.reload(); await expect(adminMemberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible(); @@ -249,11 +298,25 @@ test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => { await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible(); // Promote member to owner - const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' }); - await promoteButton.click(); - await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({ - timeout: 10_000, + const updateRoleButton = memberRow.getByRole('button', { + name: 'Update role', }); + await updateRoleButton.click(); + + // Wait for dialog to open and select Owner role + await expect(page.getByRole('dialog')).toBeVisible(); + + // Find and click the select trigger - it's a button with role="combobox" + await page.getByRole('dialog').locator('button[role="combobox"]').click(); + + // Select "Owner" from the dropdown options + await page.getByRole('option', { name: 'Owner' }).click(); + + // Click Update button + await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click(); + + // Wait for dialog to close (indicates success) + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 }); // Reload page to see updated state await page.reload(); @@ -262,9 +325,11 @@ test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => { memberRow = page.getByRole('row', { name: memberUser.email }); await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible(); - // Verify the promote button is now disabled for the new owner - const newOwnerPromoteButton = memberRow.getByRole('button', { name: 'Promote to owner' }); - await expect(newOwnerPromoteButton).toBeDisabled(); + // Verify the Update role button exists and shows Owner as current role + const newOwnerUpdateButton = memberRow.getByRole('button', { + name: 'Update role', + }); + await expect(newOwnerUpdateButton).toBeVisible(); // Sign in as the newly promoted user to verify they have owner permissions await apiSignin({ @@ -336,28 +401,56 @@ test('[ADMIN]: multiple promotions in sequence', async ({ page }) => { // First promotion: Member 1 becomes owner let member1Row = page.getByRole('row', { name: member1User.email }); - let promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' }); - await promoteButton1.click(); - await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({ - timeout: 10_000, + let updateRoleButton1 = member1Row.getByRole('button', { + name: 'Update role', }); + await updateRoleButton1.click(); + + // Wait for dialog to open and select Owner role + await expect(page.getByRole('dialog')).toBeVisible(); + + // Find and click the select trigger - it's a button with role="combobox" + await page.getByRole('dialog').locator('button[role="combobox"]').click(); + + // Select "Owner" from the dropdown options + await page.getByRole('option', { name: 'Owner' }).click(); + + // Click Update button + await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click(); + + // Wait for dialog to close (indicates success) + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 }); await page.reload(); - // Verify Member 1 is now owner and button is disabled + // Verify Member 1 is now owner member1Row = page.getByRole('row', { name: member1User.email }); await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible(); - promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' }); - await expect(promoteButton1).toBeDisabled(); + updateRoleButton1 = member1Row.getByRole('button', { name: 'Update role' }); + await expect(updateRoleButton1).toBeVisible(); // Second promotion: Member 2 becomes the new owner const member2Row = page.getByRole('row', { name: member2User.email }); - const promoteButton2 = member2Row.getByRole('button', { name: 'Promote to owner' }); - await expect(promoteButton2).not.toBeDisabled(); - await promoteButton2.click(); - await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({ - timeout: 10_000, + const updateRoleButton2 = member2Row.getByRole('button', { + name: 'Update role', }); + await expect(updateRoleButton2).toBeVisible(); + await updateRoleButton2.click(); + + // Wait for dialog to open and select Owner role + await expect(page.getByRole('dialog')).toBeVisible(); + + // Find and click the select trigger - it's a button with role="combobox" + await page.getByRole('dialog').locator('button[role="combobox"]').click(); + + // Select "Owner" from the dropdown options + await page.getByRole('option', { name: 'Owner' }).click(); + + // Click Update button + await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click(); + + // Wait for dialog to close (indicates success) + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 }); await page.reload(); @@ -365,9 +458,11 @@ test('[ADMIN]: multiple promotions in sequence', async ({ page }) => { await expect(member2Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible(); await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible(); - // Verify Member 1's promote button is now enabled again - const newPromoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' }); - await expect(newPromoteButton1).not.toBeDisabled(); + // Verify Member 1's Update role button is still visible + const newUpdateButton1 = member1Row.getByRole('button', { + name: 'Update role', + }); + await expect(newUpdateButton1).toBeVisible(); }); test('[ADMIN]: verify organisation access after ownership change', async ({ page }) => { @@ -402,11 +497,25 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page }); const memberRow = page.getByRole('row', { name: memberUser.email }); - const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' }); - await promoteButton.click(); - await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({ - timeout: 10_000, + const updateRoleButton = memberRow.getByRole('button', { + name: 'Update role', }); + await updateRoleButton.click(); + + // Wait for dialog to open and select Owner role + await expect(page.getByRole('dialog')).toBeVisible(); + + // Find and click the select trigger - it's a button with role="combobox" + await page.getByRole('dialog').locator('button[role="combobox"]').click(); + + // Select "Owner" from the dropdown options + await page.getByRole('option', { name: 'Owner' }).click(); + + // Click Update button + await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click(); + + // Wait for dialog to close (indicates success) + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 }); // Test that the new owner can access organisation settings await apiSignin({ diff --git a/packages/trpc/server/admin-router/get-admin-organisation.ts b/packages/trpc/server/admin-router/get-admin-organisation.ts index 8990e0521..31fc09022 100644 --- a/packages/trpc/server/admin-router/get-admin-organisation.ts +++ b/packages/trpc/server/admin-router/get-admin-organisation.ts @@ -39,6 +39,11 @@ export const getAdminOrganisation = async ({ organisationId }: GetOrganisationOp teams: true, members: { include: { + organisationGroupMembers: { + include: { + group: true, + }, + }, user: { select: { id: true, diff --git a/packages/trpc/server/admin-router/get-admin-organisation.types.ts b/packages/trpc/server/admin-router/get-admin-organisation.types.ts index 8491003f9..442999c83 100644 --- a/packages/trpc/server/admin-router/get-admin-organisation.types.ts +++ b/packages/trpc/server/admin-router/get-admin-organisation.types.ts @@ -3,6 +3,8 @@ import { z } from 'zod'; import { ZOrganisationSchema } from '@documenso/lib/types/organisation'; import OrganisationClaimSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationClaimSchema'; import OrganisationGlobalSettingsSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGlobalSettingsSchema'; +import OrganisationGroupMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGroupMemberSchema'; +import OrganisationGroupSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGroupSchema'; import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema'; import SubscriptionSchema from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema'; import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema'; @@ -30,6 +32,18 @@ export const ZGetAdminOrganisationResponseSchema = ZOrganisationSchema.extend({ email: true, name: true, }), + organisationGroupMembers: z.array( + OrganisationGroupMemberSchema.pick({ + id: true, + groupId: true, + }).extend({ + group: OrganisationGroupSchema.pick({ + id: true, + type: true, + organisationRole: true, + }), + }), + ), }).array(), subscription: SubscriptionSchema.nullable(), organisationClaim: OrganisationClaimSchema, diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index c3d2c9b81..526f04980 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -17,6 +17,7 @@ import { promoteMemberToOwnerRoute } from './promote-member-to-owner'; import { resealDocumentRoute } from './reseal-document'; import { resetTwoFactorRoute } from './reset-two-factor-authentication'; import { updateAdminOrganisationRoute } from './update-admin-organisation'; +import { updateOrganisationMemberRoleRoute } from './update-organisation-member-role'; import { updateRecipientRoute } from './update-recipient'; import { updateSiteSettingRoute } from './update-site-setting'; import { updateSubscriptionClaimRoute } from './update-subscription-claim'; @@ -31,6 +32,7 @@ export const adminRouter = router({ }, organisationMember: { promoteToOwner: promoteMemberToOwnerRoute, + updateRole: updateOrganisationMemberRoleRoute, }, claims: { find: findSubscriptionClaimsRoute, diff --git a/packages/trpc/server/admin-router/update-organisation-member-role.ts b/packages/trpc/server/admin-router/update-organisation-member-role.ts new file mode 100644 index 000000000..bfb12256b --- /dev/null +++ b/packages/trpc/server/admin-router/update-organisation-member-role.ts @@ -0,0 +1,220 @@ +import { OrganisationGroupType, OrganisationMemberRole } from '@prisma/client'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { generateDatabaseId } from '@documenso/lib/universal/id'; +import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations'; +import { prisma } from '@documenso/prisma'; + +import { adminProcedure } from '../trpc'; +import { + ZUpdateOrganisationMemberRoleRequestSchema, + ZUpdateOrganisationMemberRoleResponseSchema, +} from './update-organisation-member-role.types'; + +/** + * Admin mutation to update organisation member role or transfer ownership. + * + * This mutation handles two scenarios: + * 1. When role='OWNER': Transfers organisation ownership and promotes to ADMIN + * 2. When role=ADMIN/MANAGER/MEMBER: Updates group membership + * + * Admin privileges bypass normal hierarchy restrictions. + */ +export const updateOrganisationMemberRoleRoute = adminProcedure + .input(ZUpdateOrganisationMemberRoleRequestSchema) + .output(ZUpdateOrganisationMemberRoleResponseSchema) + .mutation(async ({ input, ctx }) => { + const { organisationId, userId, role } = input; + + ctx.logger.info({ + input: { + organisationId, + userId, + role, + }, + }); + + const organisation = await prisma.organisation.findUnique({ + where: { + id: organisationId, + }, + include: { + groups: { + where: { + type: OrganisationGroupType.INTERNAL_ORGANISATION, + }, + }, + members: { + where: { + userId, + }, + include: { + organisationGroupMembers: { + include: { + group: true, + }, + }, + }, + }, + }, + }); + + if (!organisation) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Organisation not found', + }); + } + + const [member] = organisation.members; + + if (!member) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'User is not a member of this organisation', + }); + } + + const currentOrganisationRole = getHighestOrganisationRoleInGroup( + member.organisationGroupMembers.flatMap((member) => member.group), + ); + + if (role === 'OWNER') { + if (organisation.ownerUserId === userId) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'User is already the owner of this organisation', + }); + } + + const currentMemberGroup = organisation.groups.find( + (group) => group.organisationRole === currentOrganisationRole, + ); + + const adminGroup = organisation.groups.find( + (group) => group.organisationRole === OrganisationMemberRole.ADMIN, + ); + + if (!currentMemberGroup) { + ctx.logger.error({ + message: '[CRITICAL]: Missing internal group', + organisationId, + userId, + role: currentOrganisationRole, + }); + + throw new AppError(AppErrorCode.UNKNOWN_ERROR, { + message: 'Current member group not found', + }); + } + + if (!adminGroup) { + ctx.logger.error({ + message: '[CRITICAL]: Missing internal group', + organisationId, + userId, + targetRole: 'ADMIN', + }); + + throw new AppError(AppErrorCode.UNKNOWN_ERROR, { + message: 'Admin group not found', + }); + } + + await prisma.$transaction(async (tx) => { + await tx.organisation.update({ + where: { + id: organisationId, + }, + data: { + ownerUserId: userId, + }, + }); + + if (currentOrganisationRole !== OrganisationMemberRole.ADMIN) { + await tx.organisationGroupMember.delete({ + where: { + organisationMemberId_groupId: { + organisationMemberId: member.id, + groupId: currentMemberGroup.id, + }, + }, + }); + + await tx.organisationGroupMember.create({ + data: { + id: generateDatabaseId('group_member'), + organisationMemberId: member.id, + groupId: adminGroup.id, + }, + }); + } + }); + + return; + } + + const targetRole = role as OrganisationMemberRole; + + if (currentOrganisationRole === targetRole) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'User already has this role', + }); + } + + if (userId === organisation.ownerUserId && targetRole !== OrganisationMemberRole.ADMIN) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Organisation owner must be an admin. Transfer ownership first.', + }); + } + + const currentMemberGroup = organisation.groups.find( + (group) => group.organisationRole === currentOrganisationRole, + ); + + const newMemberGroup = organisation.groups.find( + (group) => group.organisationRole === targetRole, + ); + + if (!currentMemberGroup) { + ctx.logger.error({ + message: '[CRITICAL]: Missing internal group', + organisationId, + userId, + role: currentOrganisationRole, + }); + + throw new AppError(AppErrorCode.UNKNOWN_ERROR, { + message: 'Current member group not found', + }); + } + + if (!newMemberGroup) { + ctx.logger.error({ + message: '[CRITICAL]: Missing internal group', + organisationId, + userId, + targetRole, + }); + + throw new AppError(AppErrorCode.UNKNOWN_ERROR, { + message: 'New member group not found', + }); + } + + await prisma.$transaction(async (tx) => { + await tx.organisationGroupMember.delete({ + where: { + organisationMemberId_groupId: { + organisationMemberId: member.id, + groupId: currentMemberGroup.id, + }, + }, + }); + + await tx.organisationGroupMember.create({ + data: { + id: generateDatabaseId('group_member'), + organisationMemberId: member.id, + groupId: newMemberGroup.id, + }, + }); + }); + }); diff --git a/packages/trpc/server/admin-router/update-organisation-member-role.types.ts b/packages/trpc/server/admin-router/update-organisation-member-role.types.ts new file mode 100644 index 000000000..9e8adf7db --- /dev/null +++ b/packages/trpc/server/admin-router/update-organisation-member-role.types.ts @@ -0,0 +1,30 @@ +import { OrganisationMemberRole } from '@prisma/client'; +import { z } from 'zod'; + +/** + * Admin-only role selection that includes OWNER as a special case. + * OWNER is not a database role but triggers ownership transfer. + */ +export const ZAdminRoleSelection = z.enum([ + 'OWNER', + OrganisationMemberRole.ADMIN, + OrganisationMemberRole.MANAGER, + OrganisationMemberRole.MEMBER, +]); + +export type TAdminRoleSelection = z.infer; + +export const ZUpdateOrganisationMemberRoleRequestSchema = z.object({ + organisationId: z.string().min(1), + userId: z.number().min(1), + role: ZAdminRoleSelection, +}); + +export const ZUpdateOrganisationMemberRoleResponseSchema = z.void(); + +export type TUpdateOrganisationMemberRoleRequest = z.infer< + typeof ZUpdateOrganisationMemberRoleRequestSchema +>; +export type TUpdateOrganisationMemberRoleResponse = z.infer< + typeof ZUpdateOrganisationMemberRoleResponseSchema +>; From ffce7a2c8186c2173928c9ff7d27e38b22c72443 Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Tue, 28 Oct 2025 10:16:12 +0000 Subject: [PATCH 02/84] fix: filter document stats by folder (#2083) This pull request refactors the filtering logic in the `getTeamCounts` function within `get-stats.ts` to improve consistency and maintainability. The main change is the consolidation of multiple filter conditions into a single `AND` clause, which now includes search filters, folder filters, and visibility filters. This ensures that all relevant filters are applied in a unified way for document count queries. --- .../lib/server-only/document/get-stats.ts | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts index 58612cf7f..4d47b4e48 100644 --- a/packages/lib/server-only/document/get-stats.ts +++ b/packages/lib/server-only/document/get-stats.ts @@ -1,7 +1,5 @@ -import { EnvelopeType, TeamMemberRole } from '@prisma/client'; import type { Prisma, User } from '@prisma/client'; -import { SigningStatus } from '@prisma/client'; -import { DocumentVisibility } from '@prisma/client'; +import { DocumentVisibility, EnvelopeType, SigningStatus, TeamMemberRole } from '@prisma/client'; import { DateTime } from 'luxon'; import { match } from 'ts-pattern'; @@ -215,13 +213,14 @@ const getTeamCounts = async (options: GetTeamCountsOption) => { ], }; + const rootPageFilter = folderId === undefined ? { folderId: null } : {}; + let ownerCountsWhereInput: Prisma.EnvelopeWhereInput = { type: EnvelopeType.DOCUMENT, userId: userIdWhereClause, createdAt, teamId, deletedAt: null, - folderId, }; let notSignedCountsGroupByArgs = null; @@ -265,8 +264,16 @@ const getTeamCounts = async (options: GetTeamCountsOption) => { ownerCountsWhereInput = { ...ownerCountsWhereInput, - ...visibilityFiltersWhereInput, - ...searchFilter, + AND: [ + ...(Array.isArray(visibilityFiltersWhereInput.AND) + ? visibilityFiltersWhereInput.AND + : visibilityFiltersWhereInput.AND + ? [visibilityFiltersWhereInput.AND] + : []), + searchFilter, + rootPageFilter, + folderId ? { folderId } : {}, + ], }; if (teamEmail) { @@ -285,6 +292,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => { }, ], deletedAt: null, + AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}], }; notSignedCountsGroupByArgs = { @@ -296,7 +304,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => { type: EnvelopeType.DOCUMENT, userId: userIdWhereClause, createdAt, - folderId, status: ExtendedDocumentStatus.PENDING, recipients: { some: { @@ -306,6 +313,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => { }, }, deletedAt: null, + AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}], }, } satisfies Prisma.EnvelopeGroupByArgs; @@ -318,7 +326,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => { type: EnvelopeType.DOCUMENT, userId: userIdWhereClause, createdAt, - folderId, OR: [ { status: ExtendedDocumentStatus.PENDING, @@ -342,6 +349,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => { }, }, ], + AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}], }, } satisfies Prisma.EnvelopeGroupByArgs; } From 9350c53c7dc46d05d569ab68241c68ec4a3919d8 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 28 Oct 2025 22:25:27 +1100 Subject: [PATCH 03/84] chore: add code styleguide (#2089) Co-authored-by: Ephraim Atta-Duncan --- CODE_STYLE.md | 692 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 692 insertions(+) create mode 100644 CODE_STYLE.md diff --git a/CODE_STYLE.md b/CODE_STYLE.md new file mode 100644 index 000000000..c58c7255f --- /dev/null +++ b/CODE_STYLE.md @@ -0,0 +1,692 @@ +# Documenso Code Style Guide + +This document captures the code style, patterns, and conventions used in the Documenso codebase. It covers both enforceable rules and subjective "taste" elements that make our code consistent and maintainable. + +## Table of Contents + +1. [General Principles](#general-principles) +2. [TypeScript Conventions](#typescript-conventions) +3. [Imports & Dependencies](#imports--dependencies) +4. [Functions & Methods](#functions--methods) +5. [React & Components](#react--components) +6. [Error Handling](#error-handling) +7. [Async/Await Patterns](#asyncawait-patterns) +8. [Whitespace & Formatting](#whitespace--formatting) +9. [Naming Conventions](#naming-conventions) +10. [Pattern Matching](#pattern-matching) +11. [Database & Prisma](#database--prisma) +12. [TRPC Patterns](#trpc-patterns) + +--- + +## General Principles + +- **Functional over Object-Oriented**: Prefer functional programming patterns over classes +- **Explicit over Implicit**: Be explicit about types, return values, and error cases +- **Early Returns**: Use guard clauses and early returns to reduce nesting +- **Immutability**: Favor `const` over `let`; avoid mutation where possible + +--- + +## TypeScript Conventions + +### Type Definitions + +```typescript +// ✅ Prefer `type` over `interface` +type CreateDocumentOptions = { + templateId: number; + userId: number; + recipients: Recipient[]; +}; + +// ❌ Avoid interfaces unless absolutely necessary +interface CreateDocumentOptions { + templateId: number; +} +``` + +### Type Imports + +```typescript +// ✅ Use `type` keyword for type-only imports +import type { Document, Recipient } from '@prisma/client'; +import { DocumentStatus } from '@prisma/client'; + +// Types in function signatures +export const findDocuments = async ({ userId, teamId }: FindDocumentsOptions) => { + // ... +}; +``` + +### Inline Types for Function Parameters + +```typescript +// ✅ Extract inline types to named types +type FinalRecipient = Pick & { + templateRecipientId: number; + fields: Field[]; +}; + +const finalRecipients: FinalRecipient[] = []; +``` + +--- + +## Imports & Dependencies + +### Import Organization + +Imports should be organized in the following order with blank lines between groups: + +```typescript +// 1. React imports +import { useCallback, useEffect, useMemo } from 'react'; + +// 2. Third-party library imports (alphabetically) +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans } from '@lingui/react/macro'; +import type { Document, Recipient } from '@prisma/client'; +import { DocumentStatus, RecipientRole } from '@prisma/client'; +import { match } from 'ts-pattern'; + +// 3. Internal package imports (from @documenso/*) +import { AppError } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; +import { Button } from '@documenso/ui/primitives/button'; + +// 4. Relative imports +import { getTeamById } from '../team/get-team'; +import type { FindResultResponse } from './types'; +``` + +### Destructuring Imports + +```typescript +// ✅ Destructure specific exports +// ✅ Use type imports for types +import type { Document } from '@prisma/client'; + +import { Button } from '@documenso/ui/primitives/button'; +import { Input } from '@documenso/ui/primitives/input'; +``` + +--- + +## Functions & Methods + +### Arrow Functions + +```typescript +// ✅ Always use arrow functions for functions +export const createDocument = async ({ + userId, + title, +}: CreateDocumentOptions) => { + // ... +}; + +// ✅ Callbacks and handlers +const onSubmit = useCallback(async () => { + // ... +}, [dependencies]); + +// ❌ Avoid regular function declarations +function createDocument() { + // ... +} +``` + +### Function Parameters + +```typescript +// ✅ Use destructured object parameters for multiple params +export const findDocuments = async ({ + userId, + teamId, + status = ExtendedDocumentStatus.ALL, + page = 1, + perPage = 10, +}: FindDocumentsOptions) => { + // ... +}; + +// ✅ Destructure on separate line when needed +const onFormSubmit = form.handleSubmit(onSubmit); + +// ✅ Deconstruct nested properties explicitly +const { user } = ctx; +const { templateId } = input; +``` + +--- + +## React & Components + +### Component Definition + +```typescript +// ✅ Use const with arrow function +export const AddSignersFormPartial = ({ + documentFlow, + recipients, + fields, + onSubmit, +}: AddSignersFormProps) => { + // ... +}; + +// ❌ Never use classes +class MyComponent extends React.Component { + // ... +} +``` + +### Hooks + +```typescript +// ✅ Group related hooks together with blank line separation +const { _ } = useLingui(); +const { toast } = useToast(); + +const { currentStep, totalSteps, previousStep } = useStep(); + +const form = useForm({ + resolver: zodResolver(ZFormSchema), + defaultValues: { + // ... + }, +}); +``` + +### Event Handlers + +```typescript +// ✅ Use arrow functions with descriptive names +const onFormSubmit = async () => { + await form.trigger(); + // ... +}; + +const onFieldCopy = useCallback( + (event?: KeyboardEvent | null) => { + event?.preventDefault(); + // ... + }, + [dependencies], +); + +// ✅ Inline handlers for simple operations + +``` + +### State Management + +```typescript +// ✅ Descriptive state names with auxiliary verbs +const [isLoading, setIsLoading] = useState(false); +const [hasError, setHasError] = useState(false); +const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); + +// ✅ Complex state in single useState when related +const [coords, setCoords] = useState({ + x: 0, + y: 0, +}); +``` + +--- + +## Error Handling + +### Try-Catch Blocks + +```typescript +// ✅ Use try-catch for operations that might fail +try { + const document = await getDocumentById({ + documentId: Number(documentId), + userId: user.id, + }); + + return { + status: 200, + body: document, + }; +} catch (err) { + return { + status: 404, + body: { + message: 'Document not found', + }, + }; +} +``` + +### Throwing Errors + +```typescript +// ✅ Use AppError for application errors +throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Template not found', +}); + +// ✅ Use descriptive error messages +if (!template) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: `Template with ID ${templateId} not found`, + }); +} +``` + +### Error Parsing on Frontend + +```typescript +// ✅ Parse errors on the frontend +try { + await updateOrganisation({ organisationId, data }); +} catch (err) { + const error = AppError.parseError(err); + console.error(error); + + toast({ + title: t`An error occurred`, + description: error.message, + variant: 'destructive', + }); +} +``` + +--- + +## Async/Await Patterns + +### Async Function Definitions + +```typescript +// ✅ Mark async functions clearly +export const createDocument = async ({ + userId, + title, +}: Options): Promise => { + // ... +}; + +// ✅ Use await for promises +const document = await prisma.document.create({ data }); + +// ✅ Use Promise.all for parallel operations +const [document, recipients] = await Promise.all([ + getDocumentById({ documentId }), + getRecipientsForDocument({ documentId }), +]); +``` + +### Void for Fire-and-Forget + +```typescript +// ✅ Use void for intentionally unwaited promises +void handleAutoSave(); + +// ✅ Or in event handlers +onClick={() => void onFormSubmit()} +``` + +--- + +## Whitespace & Formatting + +### Blank Lines Between Concepts + +```typescript +// ✅ Blank line after imports +import { prisma } from '@documenso/prisma'; + +export const findDocuments = async () => { + // ... +}; + +// ✅ Blank line between logical sections +const user = await prisma.user.findFirst({ where: { id: userId } }); + +let team = null; + +if (teamId !== undefined) { + team = await getTeamById({ userId, teamId }); +} + +// ✅ Blank line before return statements +const result = await someOperation(); + +return result; +``` + +### Function/Method Spacing + +```typescript +// ✅ No blank lines between chained methods in same operation +const documents = await prisma.document + .findMany({ where: { userId } }) + .then((docs) => docs.map(maskTokens)); + +// ✅ Blank line between different operations +const document = await createDocument({ userId }); + +await sendDocument({ documentId: document.id }); + +return document; +``` + +### Object and Array Formatting + +```typescript +// ✅ Multi-line when complex +const options = { + userId, + teamId, + status: ExtendedDocumentStatus.ALL, + page: 1, +}; + +// ✅ Single line when simple +const coords = { x: 0, y: 0 }; + +// ✅ Array items on separate lines when objects +const recipients = [ + { + name: 'John', + email: 'john@example.com', + }, + { + name: 'Jane', + email: 'jane@example.com', + }, +]; +``` + +--- + +## Naming Conventions + +### Variables + +```typescript +// ✅ camelCase for variables and functions +const documentId = 123; +const onSubmit = () => {}; + +// ✅ Descriptive names with auxiliary verbs for booleans +const isLoading = false; +const hasError = false; +const canEdit = true; +const shouldRender = true; + +// ✅ Prefix with $ for DOM elements +const $page = document.querySelector('.page'); +const $inputRef = useRef(null); +``` + +### Types and Schemas + +```typescript +// ✅ PascalCase for types +type CreateDocumentOptions = { + userId: number; +}; + +// ✅ Prefix Zod schemas with Z +const ZCreateDocumentSchema = z.object({ + title: z.string(), +}); + +// ✅ Prefix type from Zod schema with T +type TCreateDocumentSchema = z.infer; +``` + +### Constants + +```typescript +// ✅ UPPER_SNAKE_CASE for true constants +const DEFAULT_DOCUMENT_DATE_FORMAT = 'dd/MM/yyyy'; +const MAX_FILE_SIZE = 1024 * 1024 * 5; + +// ✅ camelCase for const variables that aren't "constants" +const userId = await getUserId(); +``` + +### Functions + +```typescript +// ✅ Verb-based names for functions +const createDocument = async () => {}; +const findDocuments = async () => {}; +const updateDocument = async () => {}; +const deleteDocument = async () => {}; + +// ✅ On prefix for event handlers +const onSubmit = () => {}; +const onClick = () => {}; +const onFieldCopy = () => {}; // 'on' is also acceptable +``` + +### Clarity Over Brevity + +```typescript +// ✅ Prefer descriptive names over abbreviations +const superLongMethodThatIsCorrect = () => {}; +const recipientAuthenticationOptions = {}; +const documentMetadata = {}; + +// ❌ Avoid abbreviations that sacrifice clarity +const supLongMethThatIsCorrect = () => {}; +const recipAuthOpts = {}; +const docMeta = {}; + +// ✅ Common abbreviations that are widely understood are acceptable +const userId = 123; +const htmlElement = document.querySelector('div'); +const apiResponse = await fetch('/api'); +``` + +--- + +## Pattern Matching + +### Using ts-pattern + +```typescript +import { match } from 'ts-pattern'; + +// ✅ Use match for complex conditionals +const result = match(status) + .with(ExtendedDocumentStatus.DRAFT, () => ({ + status: 'draft', + })) + .with(ExtendedDocumentStatus.PENDING, () => ({ + status: 'pending', + })) + .with(ExtendedDocumentStatus.COMPLETED, () => ({ + status: 'completed', + })) + .exhaustive(); + +// ✅ Use .otherwise() for default case when not exhaustive +const value = match(type) + .with('text', () => 'Text field') + .with('number', () => 'Number field') + .otherwise(() => 'Unknown field'); +``` + +--- + +## Database & Prisma + +### Query Structure + +```typescript +// ✅ Destructure commonly used fields +const { id, email, name } = user; + +// ✅ Use select to limit returned fields +const user = await prisma.user.findFirst({ + where: { id: userId }, + select: { + id: true, + email: true, + name: true, + }, +}); + +// ✅ Use include for relations +const document = await prisma.document.findFirst({ + where: { id: documentId }, + include: { + recipients: true, + fields: true, + }, +}); +``` + +### Transactions + +```typescript +// ✅ Use transactions for related operations +return await prisma.$transaction(async (tx) => { + const document = await tx.document.create({ data }); + + await tx.field.createMany({ data: fieldsData }); + + await tx.documentAuditLog.create({ data: auditData }); + + return document; +}); +``` + +### Where Clauses + +```typescript +// ✅ Build complex where clauses separately +const whereClause: Prisma.DocumentWhereInput = { + AND: [ + { userId: user.id }, + { deletedAt: null }, + { status: { in: [DocumentStatus.DRAFT, DocumentStatus.PENDING] } }, + ], +}; + +const documents = await prisma.document.findMany({ + where: whereClause, +}); +``` + +--- + +## TRPC Patterns + +### Router Structure + +```typescript +// ✅ Destructure context and input at start +.query(async ({ input, ctx }) => { + const { teamId } = ctx; + const { templateId } = input; + + ctx.logger.info({ + input: { templateId }, + }); + + return await getTemplateById({ + id: templateId, + userId: ctx.user.id, + teamId, + }); +}); +``` + +### Request/Response Schemas + +```typescript +// ✅ Name schemas clearly +const ZCreateDocumentRequestSchema = z.object({ + title: z.string(), + recipients: z.array(ZRecipientSchema), +}); + +const ZCreateDocumentResponseSchema = z.object({ + documentId: z.number(), + status: z.string(), +}); +``` + +### Error Handling in TRPC + +```typescript +// ✅ Catch and transform errors appropriately +try { + const result = await createDocument({ userId, data }); + + return result; +} catch (err) { + return AppError.toRestAPIError(err); +} + +// ✅ Or throw AppError directly +if (!template) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Template not found', + }); +} +``` + +--- + +## Additional Patterns + +### Optional Chaining + +```typescript +// ✅ Use optional chaining for potentially undefined values +const email = user?.email; +const recipientToken = recipient?.token ?? ''; + +// ✅ Use nullish coalescing for defaults +const pageSize = perPage ?? 10; +const status = documentStatus ?? DocumentStatus.DRAFT; +``` + +### Array Operations + +```typescript +// ✅ Use functional array methods +const activeRecipients = recipients.filter((r) => r.signingStatus === 'SIGNED'); +const recipientEmails = recipients.map((r) => r.email); +const hasSignedRecipients = recipients.some((r) => r.signingStatus === 'SIGNED'); + +// ✅ Use find instead of filter + [0] +const recipient = recipients.find((r) => r.id === recipientId); +``` + +### Conditional Rendering + +```typescript +// ✅ Use && for conditional rendering +{isLoading && } + +// ✅ Use ternary for either/or +{isLoading ? : } + +// ✅ Extract complex conditions to variables +const shouldShowAdvanced = isAdmin && hasPermission && !isDisabled; +{shouldShowAdvanced && } +``` + +--- + +## When in Doubt + +- **Consistency**: Follow the patterns you see in similar files +- **Readability**: Favor code that's easy to read over clever one-liners +- **Explicitness**: Be explicit rather than implicit +- **Whitespace**: Use blank lines to separate logical sections +- **Early Returns**: Use guard clauses to reduce nesting +- **Functional**: Prefer functional patterns over imperative ones From d2a009d52e2ff81f984f9ab0e348f8f5c7c7a17a Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Sat, 1 Nov 2025 12:44:34 +1100 Subject: [PATCH 04/84] fix: allow direct template recipient dictation (#2108) --- .../direct-template/direct-template-page.tsx | 6 +- .../direct-template-signing-form.tsx | 41 +++- .../document-signing-auth-page.tsx | 22 +- .../document-signing-auth-provider.tsx | 5 +- .../document-signing-complete-dialog.tsx | 1 - .../envelope-signing-provider.tsx | 2 - .../envelope-signing-complete-dialog.tsx | 1 + .../routes/_recipient+/d.$token+/_index.tsx | 21 +- .../e2e/templates/direct-templates.spec.ts | 225 +++++++++++++++++- ...et-envelope-for-direct-template-signing.ts | 31 ++- .../get-envelope-required-access-data.ts | 51 ---- .../create-document-from-direct-template.ts | 90 ++++++- packages/prisma/seed/templates.ts | 4 +- .../trpc/server/template-router/router.ts | 2 + .../trpc/server/template-router/schema.ts | 6 + 15 files changed, 418 insertions(+), 90 deletions(-) diff --git a/apps/remix/app/components/general/direct-template/direct-template-page.tsx b/apps/remix/app/components/general/direct-template/direct-template-page.tsx index 686074a27..9bddf9c0d 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-page.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-page.tsx @@ -89,7 +89,10 @@ export const DirectTemplatePageView = ({ setStep('sign'); }; - const onSignDirectTemplateSubmit = async (fields: DirectTemplateLocalField[]) => { + const onSignDirectTemplateSubmit = async ( + fields: DirectTemplateLocalField[], + nextSigner?: { name: string; email: string }, + ) => { try { let directTemplateExternalId = searchParams?.get('externalId') || undefined; @@ -98,6 +101,7 @@ export const DirectTemplatePageView = ({ } const { token } = await createDocumentFromDirectTemplate({ + nextSigner, directTemplateToken, directTemplateExternalId, directRecipientName: fullName, diff --git a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx index 3ab3046ae..932a693fd 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx @@ -55,10 +55,13 @@ import { DocumentSigningRecipientProvider } from '../document-signing/document-s export type DirectTemplateSigningFormProps = { flowStep: DocumentFlowStep; - directRecipient: Pick; + directRecipient: Pick; directRecipientFields: Field[]; template: Omit; - onSubmit: (_data: DirectTemplateLocalField[]) => Promise; + onSubmit: ( + _data: DirectTemplateLocalField[], + _nextSigner?: { name: string; email: string }, + ) => Promise; }; export type DirectTemplateLocalField = Field & { @@ -149,7 +152,7 @@ export const DirectTemplateSigningForm = ({ validateFieldsInserted(fieldsRequiringValidation); }; - const handleSubmit = async () => { + const handleSubmit = async (nextSigner?: { name: string; email: string }) => { setValidateUninsertedFields(true); const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation); @@ -161,7 +164,7 @@ export const DirectTemplateSigningForm = ({ setIsSubmitting(true); try { - await onSubmit(localFields); + await onSubmit(localFields, nextSigner); } catch { setIsSubmitting(false); } @@ -218,6 +221,30 @@ export const DirectTemplateSigningForm = ({ setLocalFields(updatedFields); }, []); + const nextRecipient = useMemo(() => { + if ( + !template.templateMeta?.signingOrder || + template.templateMeta.signingOrder !== 'SEQUENTIAL' || + !template.templateMeta.allowDictateNextSigner + ) { + return undefined; + } + + const sortedRecipients = template.recipients.sort((a, b) => { + // Sort by signingOrder first (nulls last), then by id + if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id; + if (a.signingOrder === null) return 1; + if (b.signingOrder === null) return -1; + if (a.signingOrder === b.signingOrder) return a.id - b.id; + return a.signingOrder - b.signingOrder; + }); + + const currentIndex = sortedRecipients.findIndex((r) => r.id === directRecipient.id); + return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1 + ? sortedRecipients[currentIndex + 1] + : undefined; + }, [template.templateMeta?.signingOrder, template.recipients, directRecipient.id]); + return ( @@ -417,11 +444,15 @@ export const DirectTemplateSigningForm = ({ handleSubmit()} + onSignatureComplete={async (nextSigner) => handleSubmit(nextSigner)} documentTitle={template.title} fields={localFields} fieldsValidated={fieldsValidated} recipient={directRecipient} + allowDictateNextSigner={nextRecipient && template.templateMeta?.allowDictateNextSigner} + defaultNextSigner={ + nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined + } /> diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx index 21b1be6ca..123baafa5 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx @@ -9,7 +9,7 @@ import { Button } from '@documenso/ui/primitives/button'; import { useToast } from '@documenso/ui/primitives/use-toast'; export type DocumentSigningAuthPageViewProps = { - email: string; + email?: string; emailHasAccount?: boolean; }; @@ -22,12 +22,18 @@ export const DocumentSigningAuthPageView = ({ const [isSigningOut, setIsSigningOut] = useState(false); - const handleChangeAccount = async (email: string) => { + const handleChangeAccount = async (email?: string) => { try { setIsSigningOut(true); + let redirectPath = '/signin'; + + if (email) { + redirectPath = emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`; + } + await authClient.signOut({ - redirectPath: emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`, + redirectPath, }); } catch { toast({ @@ -49,9 +55,13 @@ export const DocumentSigningAuthPageView = ({

- - You need to be logged in as {email} to view this page. - + {email ? ( + + You need to be logged in as {email} to view this page. + + ) : ( + You need to be logged in to view this page. + )}

+
+ {/* Footer of left sidebar. */} + {!isEmbed && ( +
+ +
+ )}
-
+
{/* Horizontal envelope item selector */} {envelopeItems.length > 1 && ( @@ -202,11 +226,11 @@ export const DocumentSigningPageViewV2 = () => { )} {/* Document View */} -
+
{currentEnvelopeItem ? ( ) : ( @@ -218,9 +242,20 @@ export const DocumentSigningPageViewV2 = () => { )} {/* Mobile widget - Additional padding to allow users to scroll */} -
+
+ + {!hidePoweredBy && ( + + Powered by + + + )}
diff --git a/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx b/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx index aaecd3ee9..ddaf3c355 100644 --- a/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx +++ b/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx @@ -13,6 +13,7 @@ import { prop, sortBy } from 'remeda'; import { isBase64Image } from '@documenso/lib/constants/signatures'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing'; +import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { isFieldUnsignedAndRequired, isRequiredField, @@ -51,7 +52,11 @@ export type EnvelopeSigningContextValue = { setSelectedAssistantRecipientId: (_value: number | null) => void; selectedAssistantRecipient: EnvelopeForSigningResponse['envelope']['recipients'][number] | null; - signField: (_fieldId: number, _value: TSignEnvelopeFieldValue) => Promise; + signField: ( + _fieldId: number, + _value: TSignEnvelopeFieldValue, + authOptions?: TRecipientActionAuth, + ) => Promise>; }; const EnvelopeSigningContext = createContext(null); @@ -284,19 +289,26 @@ export const EnvelopeSigningProvider = ({ : null; }, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]); - const signField = async (fieldId: number, fieldValue: TSignEnvelopeFieldValue) => { + const signField = async ( + fieldId: number, + fieldValue: TSignEnvelopeFieldValue, + authOptions?: TRecipientActionAuth, + ) => { // Set the field locally for direct templates. if (isDirectTemplate) { - handleDirectTemplateFieldInsertion(fieldId, fieldValue); - return; + const signedField = handleDirectTemplateFieldInsertion(fieldId, fieldValue); + + return signedField; } - await signEnvelopeField({ + const { signedField } = await signEnvelopeField({ token: envelopeData.recipient.token, fieldId, fieldValue, - authOptions: undefined, + authOptions, }); + + return signedField; }; const handleDirectTemplateFieldInsertion = ( @@ -354,6 +366,8 @@ export const EnvelopeSigningProvider = ({ fields: prev.recipient.fields.map((field) => (field.id === fieldId ? updatedField : field)), }, })); + + return updatedField; }; return ( diff --git a/apps/remix/app/components/general/document/document-certificate-qr-view.tsx b/apps/remix/app/components/general/document/document-certificate-qr-view.tsx index b4360d6bb..d5ff98f75 100644 --- a/apps/remix/app/components/general/document/document-certificate-qr-view.tsx +++ b/apps/remix/app/components/general/document/document-certificate-qr-view.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from 'react'; import { Trans } from '@lingui/react/macro'; -import type { DocumentData, EnvelopeItem } from '@prisma/client'; +import { type DocumentData, DocumentStatus, type EnvelopeItem } from '@prisma/client'; +import { DownloadIcon } from 'lucide-react'; import { DateTime } from 'luxon'; import { @@ -22,9 +23,10 @@ import { } from '@documenso/ui/primitives/dialog'; import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; +import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog'; + import { EnvelopeRendererFileSelector } from '../envelope-editor/envelope-file-selector'; import EnvelopeGenericPageRenderer from '../envelope-editor/envelope-generic-page-renderer'; -import { ShareDocumentDownloadButton } from '../share-document-download-button'; export type DocumentCertificateQRViewProps = { documentId: number; @@ -34,6 +36,7 @@ export type DocumentCertificateQRViewProps = { documentTeamUrl: string; recipientCount?: number; completedDate?: Date; + token: string; }; export const DocumentCertificateQRView = ({ @@ -44,6 +47,7 @@ export const DocumentCertificateQRView = ({ documentTeamUrl, recipientCount = 0, completedDate, + token, }: DocumentCertificateQRViewProps) => { const { data: documentViaUser } = trpc.document.get.useQuery({ documentId, @@ -96,11 +100,12 @@ export const DocumentCertificateQRView = ({ )} {internalVersion === 2 ? ( - + ) : ( @@ -119,14 +124,27 @@ export const DocumentCertificateQRView = ({
- + + Download + + } />
- +
)} @@ -138,14 +156,16 @@ type DocumentCertificateQrV2Props = { title: string; recipientCount: number; formattedDate: string; + token: string; }; const DocumentCertificateQrV2 = ({ title, recipientCount, formattedDate, + token, }: DocumentCertificateQrV2Props) => { - const { currentEnvelopeItem } = useCurrentEnvelopeRender(); + const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender(); return (
@@ -163,18 +183,24 @@ const DocumentCertificateQrV2 = ({
- {currentEnvelopeItem && ( - - )} + + + Download + + } + />
- +
); diff --git a/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx b/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx index 01cee5ad3..2dbe7e59c 100644 --- a/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx +++ b/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx @@ -16,9 +16,9 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/l import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; -import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; +import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types'; import { cn } from '@documenso/ui/lib/utils'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -62,14 +62,18 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon try { setIsLoading(true); - const response = await putPdfFile(file); - - const { legacyDocumentId: id } = await createDocument({ + const payload = { title: file.name, - documentDataId: response.id, - timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field. + timezone: userTimezone, folderId: folderId ?? undefined, - }); + } satisfies TCreateDocumentPayloadSchema; + + const formData = new FormData(); + + formData.append('payload', JSON.stringify(payload)); + formData.append('file', file); + + const { envelopeId: id } = await createDocument(formData); void refreshLimits(); diff --git a/apps/remix/app/components/general/document/document-edit-form.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx index 0b731fa35..e31224f70 100644 --- a/apps/remix/app/components/general/document/document-edit-form.tsx +++ b/apps/remix/app/components/general/document/document-edit-form.tsx @@ -441,9 +441,10 @@ export const DocumentEditForm = ({ > setIsDocumentPdfLoaded(true)} /> diff --git a/apps/remix/app/components/general/document/document-page-view-button.tsx b/apps/remix/app/components/general/document/document-page-view-button.tsx index 361109580..e3e7e0afc 100644 --- a/apps/remix/app/components/general/document/document-page-view-button.tsx +++ b/apps/remix/app/components/general/document/document-page-view-button.tsx @@ -1,18 +1,14 @@ -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client'; import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react'; import { Link } from 'react-router'; import { match } from 'ts-pattern'; -import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { useSession } from '@documenso/lib/client-only/providers/session'; import type { TEnvelope } from '@documenso/lib/types/envelope'; import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { Button } from '@documenso/ui/primitives/button'; -import { useToast } from '@documenso/ui/primitives/use-toast'; import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog'; @@ -23,9 +19,6 @@ export type DocumentPageViewButtonProps = { export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps) => { const { user } = useSession(); - const { toast } = useToast(); - const { _ } = useLingui(); - const recipient = envelope.recipients.find((recipient) => recipient.email === user.email); const isRecipient = !!recipient; @@ -37,25 +30,6 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps const documentsPath = formatDocumentsPath(envelope.team.url); const formatPath = `${documentsPath}/${envelope.id}/edit`; - const onDownloadClick = async () => { - try { - // Todo; Envelopes - Support multiple items - const envelopeItem = envelope.envelopeItems[0]; - - if (!envelopeItem.documentData) { - throw new Error('No document available'); - } - - await downloadPDF({ documentData: envelopeItem.documentData, fileName: envelopeItem.title }); - } catch (err) { - toast({ - title: _(msg`Something went wrong`), - description: _(msg`An error occurred while downloading your document.`), - variant: 'destructive', - }); - } - }; - return match({ isRecipient, isPending, @@ -95,7 +69,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps )) - .with({ isComplete: true, internalVersion: 2 }, () => ( + .with({ isComplete: true }, () => ( )) - .with({ isComplete: true }, () => ( - - )) .otherwise(() => null); }; 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 c5b6c9371..282740369 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 @@ -1,6 +1,5 @@ import { useState } from 'react'; -import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { DocumentStatus } from '@prisma/client'; @@ -16,13 +15,11 @@ import { } from 'lucide-react'; import { Link, useNavigate } from 'react-router'; -import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { useSession } from '@documenso/lib/client-only/providers/session'; import type { TEnvelope } from '@documenso/lib/types/envelope'; import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; -import { trpc as trpcClient } from '@documenso/trpc/client'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DropdownMenu, @@ -67,64 +64,6 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP const documentsPath = formatDocumentsPath(team.url); - const onDownloadClick = async () => { - try { - const documentWithData = await trpcClient.document.get.query( - { - documentId: mapSecondaryIdToDocumentId(envelope.secondaryId), - }, - { - context: { - teamId: team?.id?.toString(), - }, - }, - ); - - const documentData = documentWithData?.documentData; - - if (!documentData) { - return; - } - - await downloadPDF({ documentData, fileName: envelope.title }); - } catch (err) { - toast({ - title: _(msg`Something went wrong`), - description: _(msg`An error occurred while downloading your document.`), - variant: 'destructive', - }); - } - }; - - const onDownloadOriginalClick = async () => { - try { - const documentWithData = await trpcClient.document.get.query( - { - documentId: mapSecondaryIdToDocumentId(envelope.secondaryId), - }, - { - context: { - teamId: team?.id?.toString(), - }, - }, - ); - - const documentData = documentWithData?.documentData; - - if (!documentData) { - return; - } - - await downloadPDF({ documentData, fileName: envelope.title, version: 'original' }); - } catch (err) { - toast({ - title: _(msg`Something went wrong`), - description: _(msg`An error occurred while downloading your document.`), - variant: 'destructive', - }); - } - }; - const nonSignedRecipients = envelope.recipients.filter((item) => item.signingStatus !== 'SIGNED'); return ( @@ -147,36 +86,20 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP )} - {envelope.internalVersion === 2 ? ( - e.preventDefault()}> -
- - Download -
- - } - /> - ) : ( - <> - {isComplete && ( - + e.preventDefault()}> +
Download - - )} - - - - Download Original +
- - )} + } + /> @@ -250,7 +173,8 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP {isDuplicateDialogOpen && ( diff --git a/apps/remix/app/components/general/document/document-upload-button.tsx b/apps/remix/app/components/general/document/document-upload-button.tsx index 7e092363e..ea95bae23 100644 --- a/apps/remix/app/components/general/document/document-upload-button.tsx +++ b/apps/remix/app/components/general/document/document-upload-button.tsx @@ -13,9 +13,9 @@ import { useSession } from '@documenso/lib/client-only/providers/session'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; +import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types'; import { cn } from '@documenso/ui/lib/utils'; import { DocumentDropzone } from '@documenso/ui/primitives/document-upload'; import { @@ -73,14 +73,18 @@ export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) = try { setIsLoading(true); - const response = await putPdfFile(file); - - const { legacyDocumentId: id } = await createDocument({ + const payload = { title: file.name, - documentDataId: response.id, timezone: userTimezone, folderId: folderId ?? undefined, - }); + } satisfies TCreateDocumentPayloadSchema; + + const formData = new FormData(); + + formData.append('payload', JSON.stringify(payload)); + formData.append('file', file); + + const { envelopeId: id } = await createDocument(formData); void refreshLimits(); diff --git a/apps/remix/app/components/general/document/envelope-upload-button.tsx b/apps/remix/app/components/general/document/envelope-upload-button.tsx index 645c3845f..218235706 100644 --- a/apps/remix/app/components/general/document/envelope-upload-button.tsx +++ b/apps/remix/app/components/general/document/envelope-upload-button.tsx @@ -14,9 +14,9 @@ import { useSession } from '@documenso/lib/client-only/providers/session'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; +import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types'; import { cn } from '@documenso/ui/lib/utils'; import { DocumentDropzone } from '@documenso/ui/primitives/document-upload'; import { @@ -78,35 +78,24 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo try { setIsLoading(true); - const result = await Promise.all( - files.map(async (file) => { - try { - const response = await putPdfFile(file); - - return { - title: file.name, - documentDataId: response.id, - }; - } catch (err) { - console.error(err); - throw new Error('Failed to upload document'); - } - }), - ); - - const envelopeItemsToCreate = result.filter( - (item): item is { title: string; documentDataId: string } => item !== undefined, - ); - - const { id } = await createEnvelope({ + const payload = { folderId, type, title: files[0].name, - items: envelopeItemsToCreate, meta: { timezone: userTimezone, }, - }).catch((error) => { + } satisfies TCreateEnvelopePayload; + + const formData = new FormData(); + + formData.append('payload', JSON.stringify(payload)); + + for (const file of files) { + formData.append('files', file); + } + + const { id } = await createEnvelope(formData).catch((error) => { console.error(error); throw error; diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx index c75fb52a5..5c26e5a5d 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx @@ -26,7 +26,7 @@ import { fieldButtonList } from './envelope-editor-fields-drag-drop'; export default function EnvelopeEditorFieldsPageRenderer() { const { t, i18n } = useLingui(); const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor(); - const { currentEnvelopeItem } = useCurrentEnvelopeRender(); + const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender(); const interactiveTransformer = useRef(null); @@ -103,7 +103,6 @@ export default function EnvelopeEditorFieldsPageRenderer() { fieldUpdates.height = fieldPageHeight; } - // Todo: envelopes Use id editorFields.updateFieldByFormId(fieldFormId, fieldUpdates); // Select the field if it is not already selected. @@ -114,7 +113,7 @@ export default function EnvelopeEditorFieldsPageRenderer() { pageLayer.current?.batchDraw(); }; - const renderFieldOnLayer = (field: TLocalField) => { + const unsafeRenderFieldOnLayer = (field: TLocalField) => { if (!pageLayer.current) { return; } @@ -160,6 +159,15 @@ export default function EnvelopeEditorFieldsPageRenderer() { fieldGroup.on('dragend', handleResizeOrMove); }; + const renderFieldOnLayer = (field: TLocalField) => { + try { + unsafeRenderFieldOnLayer(field); + } catch (err) { + console.error(err); + setRenderError(true); + } + }; + /** * Initialize the Konva page canvas and all fields and interactions. */ 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 4bd0915da..ceb8e072a 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 @@ -27,7 +27,8 @@ import type { import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; -import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector'; import { Separator } from '@documenso/ui/primitives/separator'; @@ -112,9 +113,34 @@ export const EnvelopeEditorFieldsPage = () => { {/* Document View */} -
+
+ {envelope.recipients.length === 0 && ( + +
+ + Missing Recipients + + + You need at least one recipient to add fields + +
+ + +
+ )} + {currentEnvelopeItem !== null ? ( - + ) : (
@@ -130,7 +156,7 @@ export const EnvelopeEditorFieldsPage = () => {
{/* Right Section - Form Fields Panel */} - {currentEnvelopeItem && ( + {currentEnvelopeItem && envelope.recipients.length > 0 && (
{/* Recipient selector section. */}
@@ -138,29 +164,15 @@ export const EnvelopeEditorFieldsPage = () => { Selected Recipient - {envelope.recipients.length === 0 ? ( - - - You need at least one recipient to add fields - - -

- Click here to add a recipient -

- -
-
- ) : ( - - editorFields.setSelectedRecipient(recipient.id) - } - recipients={envelope.recipients} - className="w-full" - align="end" - /> - )} + + editorFields.setSelectedRecipient(recipient.id) + } + recipients={envelope.recipients} + className="w-full" + align="end" + /> {editorFields.selectedRecipient && !canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && ( diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx index e57740cd1..1232573ac 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx @@ -1,10 +1,20 @@ -import { lazy, useEffect, useState } from 'react'; +import { lazy, useEffect, useMemo, useState } from 'react'; +import { faker } from '@faker-js/faker/locale/en'; import { Trans } from '@lingui/react/macro'; -import { ConstructionIcon, FileTextIcon } from 'lucide-react'; +import { FieldType } from '@prisma/client'; +import { FileTextIcon } from 'lucide-react'; +import { match } from 'ts-pattern'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; -import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; +import { + EnvelopeRenderProvider, + useCurrentEnvelopeRender, +} from '@documenso/lib/client-only/providers/envelope-render-provider'; +import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; +import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing'; +import { toCheckboxCustomText } from '@documenso/lib/utils/fields'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; @@ -15,15 +25,169 @@ import { EnvelopeRendererFileSelector } from './envelope-file-selector'; const EnvelopeGenericPageRenderer = lazy(async () => import('./envelope-generic-page-renderer')); +// Todo: Envelopes - Dynamically import faker export const EnvelopeEditorPreviewPage = () => { const { envelope, editorFields } = useCurrentEnvelopeEditor(); - const { currentEnvelopeItem } = useCurrentEnvelopeRender(); + const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender(); const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>( 'recipient', ); + const fieldsWithPlaceholders = useMemo(() => { + return fields.map((field) => { + const fieldMeta = ZFieldAndMetaSchema.parse(field); + + const recipient = envelope.recipients.find((recipient) => recipient.id === field.recipientId); + + if (!recipient) { + throw new Error('Recipient not found'); + } + + faker.seed(recipient.id); + + const recipientName = recipient.name || faker.person.fullName(); + const recipientEmail = recipient.email || faker.internet.email(); + + faker.seed(recipient.id + field.id); + + return { + ...field, + inserted: true, + ...match(fieldMeta) + .with({ type: FieldType.TEXT }, ({ fieldMeta }) => { + let text = fieldMeta?.text || faker.lorem.words(5); + + if (fieldMeta?.characterLimit) { + text = text.slice(0, fieldMeta?.characterLimit); + } + + return { + customText: text, + }; + }) + .with({ type: FieldType.NUMBER }, ({ fieldMeta }) => { + let number = fieldMeta?.value ?? ''; + + if (number === '') { + number = faker.number + .int({ + min: fieldMeta?.minValue ?? 0, + max: fieldMeta?.maxValue ?? 1000, + }) + .toString(); + } + + return { + customText: number, + }; + }) + .with({ type: FieldType.DATE }, () => { + const date = extractFieldInsertionValues({ + fieldValue: { + type: FieldType.DATE, + value: true, + }, + field, + documentMeta: envelope.documentMeta, + }); + + return { + customText: date.customText, + }; + }) + .with({ type: FieldType.EMAIL }, () => { + return { + customText: recipientEmail, + }; + }) + .with({ type: FieldType.NAME }, () => { + return { + customText: recipientName, + }; + }) + .with({ type: FieldType.INITIALS }, () => { + return { + customText: extractInitials(recipientName), + }; + }) + .with({ type: FieldType.RADIO }, ({ fieldMeta }) => { + const values = fieldMeta?.values ?? []; + + if (values.length === 0) { + return ''; + } + + let customText = ''; + + const preselectedValue = values.findIndex((value) => value.checked); + + if (preselectedValue !== -1) { + customText = preselectedValue.toString(); + } else { + const randomIndex = faker.number.int({ min: 0, max: values.length - 1 }); + customText = randomIndex.toString(); + } + + return { + customText, + }; + }) + .with({ type: FieldType.CHECKBOX }, ({ fieldMeta }) => { + let checkedValues: number[] = []; + + const values = fieldMeta?.values ?? []; + + values.forEach((value, index) => { + if (value.checked) { + checkedValues.push(index); + } + }); + + if (checkedValues.length === 0 && values.length > 0) { + const numberOfValues = fieldMeta?.validationLength || 1; + + checkedValues = Array.from({ length: numberOfValues }, (_, index) => index); + } + + return { + customText: toCheckboxCustomText(checkedValues), + }; + }) + .with({ type: FieldType.DROPDOWN }, ({ fieldMeta }) => { + const values = fieldMeta?.values ?? []; + + let customText = fieldMeta?.defaultValue || ''; + + if (!customText && values.length > 0) { + const randomIndex = faker.number.int({ min: 0, max: values.length - 1 }); + customText = values[randomIndex].value; + } + + return { + customText, + }; + }) + .with({ type: FieldType.SIGNATURE }, () => { + return { + customText: '', + signature: { + signatureImageAsBase64: '', + typedSignature: recipientName, + }, + }; + }) + .with({ type: FieldType.FREE_SIGNATURE }, () => { + return { + customText: '', + }; + }) + .exhaustive(), + }; + }); + }, [fields, envelope, envelope.recipients, envelope.documentMeta]); + /** * Set the selected recipient to the first recipient in the envelope. */ @@ -31,40 +195,38 @@ export const EnvelopeEditorPreviewPage = () => { editorFields.setSelectedRecipient(envelope.recipients[0]?.id ?? null); }, []); + // Override the parent renderer provider so we can inject custom fields. return ( -
-
- {/* Horizontal envelope item selector */} - + +
+
+ {/* Horizontal envelope item selector */} + - {/* Document View */} -
- - - Preview Mode - - - Preview what the signed document will look like with placeholder data - - + {/* Document View */} +
+ + + Preview Mode + + + Preview what the signed document will look like with placeholder data + + - {/* Coming soon section */} -
-
- -

- Coming soon -

-

- This feature is coming soon -

-
-
- - {/* Todo: Envelopes - Remove div after preview mode is implemented */} -
{currentEnvelopeItem !== null ? ( - + ) : (
@@ -78,27 +240,28 @@ export const EnvelopeEditorPreviewPage = () => { )}
-
- {/* Right Section - Form Fields Panel */} - {currentEnvelopeItem && false && ( -
- {/* Add fields section. */} -
- {/*

+ {/* Right Section - Form Fields Panel */} + {currentEnvelopeItem && false && ( +
+ {/* Add fields section. */} +
+ {/*

Preivew Mode

*/} - - - Preview Mode - - - Preview what the signed document will look like with placeholder data - - + + + Preview Mode + + + + Preview what the signed document will look like with placeholder data + + + - {/* + {/* {
Preview what a recipient will see
Preview the signed document
*/} -
+

- {false && ( - - {selectedPreviewMode === 'recipient' && ( - <> - + {false && ( + + {selectedPreviewMode === 'recipient' && ( + <> + - {/* Recipient selector section. */} -
-

- Selected Recipient -

+ {/* Recipient selector section. */} +
+

+ Selected Recipient +

- - editorFields.setSelectedRecipient(recipient.id) - } - recipients={envelope.recipients} - className="w-full" - align="end" - /> -
- - )} - - )} -
- )} -
+ + editorFields.setSelectedRecipient(recipient.id) + } + recipients={envelope.recipients} + className="w-full" + align="end" + /> +
+ + )} + + )} +
+ )} +
+ ); }; diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx index ef8a1288c..375524e68 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx @@ -482,30 +482,46 @@ export const EnvelopeEditorRecipientForm = () => { const { data } = validatedFormValues; + // Weird edge case where the whole envelope is created via API + // with no signing order. If they come to this page it will show an error + // since they aren't equal and the recipient is no longer editable. + const envelopeRecipients = data.signers.map((recipient) => { + if (!canRecipientBeModified(recipient.id)) { + return { + ...recipient, + signingOrder: recipient.signingOrder, + }; + } + return recipient; + }); + const hasSigningOrderChanged = envelope.documentMeta.signingOrder !== data.signingOrder; const hasAllowDictateNextSignerChanged = envelope.documentMeta.allowDictateNextSigner !== data.allowDictateNextSigner; const hasSignersChanged = - data.signers.length !== recipients.length || - data.signers.some((signer) => { + envelopeRecipients.length !== recipients.length || + envelopeRecipients.some((signer) => { const recipient = recipients.find((recipient) => recipient.id === signer.id); if (!recipient) { return true; } + const signerActionAuth = signer.actionAuth; + const recipientActionAuth = recipient.authOptions?.actionAuth || []; + return ( signer.email !== recipient.email || signer.name !== recipient.name || signer.role !== recipient.role || signer.signingOrder !== recipient.signingOrder || - !isDeepEqual(signer.actionAuth, recipient.authOptions?.actionAuth) + !isDeepEqual(signerActionAuth, recipientActionAuth) ); }); if (hasSignersChanged) { - setRecipientsDebounced(validatedFormValues.data.signers); + setRecipientsDebounced(envelopeRecipients); } if (hasSigningOrderChanged || hasAllowDictateNextSignerChanged) { diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx index 0c505d55c..6593609f5 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx @@ -174,7 +174,7 @@ export const EnvelopeEditorSettingsDialog = ({ const { t, i18n } = useLingui(); const { toast } = useToast(); - const { envelope } = useCurrentEnvelopeEditor(); + const { envelope, updateEnvelopeAsync } = useCurrentEnvelopeEditor(); const team = useCurrentTeam(); const organisation = useCurrentOrganisation(); @@ -186,14 +186,12 @@ export const EnvelopeEditorSettingsDialog = ({ documentAuth: envelope.authOptions, }); - const form = useForm({ - resolver: zodResolver(ZAddSettingsFormSchema), - defaultValues: { - externalId: envelope.externalId || '', // Todo: String or undefined? + const createDefaultValues = () => { + return { + externalId: envelope.externalId || '', visibility: envelope.visibility || '', globalAccessAuth: documentAuthOption?.globalAccessAuth || [], globalActionAuth: documentAuthOption?.globalActionAuth || [], - meta: { subject: envelope.documentMeta.subject ?? '', message: envelope.documentMeta.message ?? '', @@ -210,10 +208,13 @@ export const EnvelopeEditorSettingsDialog = ({ emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings), signatureTypes: extractTeamSignatureSettings(envelope.documentMeta), }, - }, - }); + }; + }; - const { mutateAsync: updateEnvelope } = trpc.envelope.update.useMutation(); + const form = useForm({ + resolver: zodResolver(ZAddSettingsFormSchema), + defaultValues: createDefaultValues(), + }); const envelopeHasBeenSent = envelope.type === EnvelopeType.DOCUMENT && @@ -229,7 +230,6 @@ export const EnvelopeEditorSettingsDialog = ({ const emails = emailData?.data || []; - // Todo: Envelopes this doesn't make sense (look at previous) const canUpdateVisibility = canAccessTeamDocument(team.currentTeamRole, envelope.visibility); const onFormSubmit = async (data: TAddSettingsFormSchema) => { @@ -240,9 +240,7 @@ export const EnvelopeEditorSettingsDialog = ({ .safeParse(data.globalAccessAuth); try { - await updateEnvelope({ - envelopeId: envelope.id, - envelopeType: envelope.type, + await updateEnvelopeAsync({ data: { externalId: data.externalId || null, visibility: data.visibility, @@ -297,7 +295,7 @@ export const EnvelopeEditorSettingsDialog = ({ ]); useEffect(() => { - form.reset(); + form.reset(createDefaultValues()); setActiveTab('general'); }, [open, form]); @@ -323,7 +321,7 @@ export const EnvelopeEditorSettingsDialog = ({ {/* Sidebar. */} -
+
Document Settings diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx index fa19bb6a1..c13ca0d66 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx @@ -18,9 +18,9 @@ import { import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { nanoid } from '@documenso/lib/universal/id'; -import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope'; import { trpc } from '@documenso/trpc/react'; +import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types'; import { Button } from '@documenso/ui/primitives/button'; import { Card, @@ -67,8 +67,8 @@ export const EnvelopeEditorUploadPage = () => { const { mutateAsync: createEnvelopeItems, isPending: isCreatingEnvelopeItems } = trpc.envelope.item.createMany.useMutation({ - onSuccess: (data) => { - const createdEnvelopes = data.createdEnvelopeItems.filter( + onSuccess: ({ data }) => { + const createdEnvelopes = data.filter( (item) => !envelope.envelopeItems.find((envelopeItem) => envelopeItem.id === item.id), ); @@ -79,10 +79,10 @@ export const EnvelopeEditorUploadPage = () => { }); const { mutateAsync: updateEnvelopeItems } = trpc.envelope.item.updateMany.useMutation({ - onSuccess: (data) => { + onSuccess: ({ data }) => { setLocalEnvelope({ envelopeItems: envelope.envelopeItems.map((originalItem) => { - const updatedItem = data.updatedEnvelopeItems.find((item) => item.id === originalItem.id); + const updatedItem = data.find((item) => item.id === originalItem.id); if (updatedItem) { return { @@ -114,36 +114,19 @@ export const EnvelopeEditorUploadPage = () => { setLocalFiles((prev) => [...prev, ...newUploadingFiles]); - const result = await Promise.all( - files.map(async (file, index) => { - try { - const response = await putPdfFile(file); - - // Mark as uploaded (remove from uploading state) - return { - title: file.name, - documentDataId: response.id, - }; - } catch (_error) { - setLocalFiles((prev) => - prev.map((uploadingFile) => - uploadingFile.id === newUploadingFiles[index].id - ? { ...uploadingFile, isError: true, isUploading: false } - : uploadingFile, - ), - ); - } - }), - ); - - const envelopeItemsToCreate = result.filter( - (item): item is { title: string; documentDataId: string } => item !== undefined, - ); - - const { createdEnvelopeItems } = await createEnvelopeItems({ + const payload = { envelopeId: envelope.id, - items: envelopeItemsToCreate, - }).catch((error) => { + } satisfies TCreateEnvelopeItemsPayload; + + const formData = new FormData(); + + formData.append('payload', JSON.stringify(payload)); + + for (const file of files) { + formData.append('files', file); + } + + const { data } = await createEnvelopeItems(formData).catch((error) => { console.error(error); // Set error state on files in batch upload. @@ -165,7 +148,7 @@ export const EnvelopeEditorUploadPage = () => { ); return filteredFiles.concat( - createdEnvelopeItems.map((item) => ({ + data.map((item) => ({ id: item.id, envelopeItemId: item.id, title: item.title, @@ -203,7 +186,6 @@ export const EnvelopeEditorUploadPage = () => { debouncedUpdateEnvelopeItems(items); }; - // Todo: Envelopes - Sync into envelopes data const debouncedUpdateEnvelopeItems = useDebounceFunction((files: LocalFile[]) => { void updateEnvelopeItems({ envelopeId: envelope.id, diff --git a/apps/remix/app/components/general/envelope-editor/envelope-file-selector.tsx b/apps/remix/app/components/general/envelope-editor/envelope-file-selector.tsx index 1d570bed7..144a2bc41 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-file-selector.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-file-selector.tsx @@ -29,7 +29,7 @@ export const EnvelopeItemSelector = ({ {...buttonProps} >
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx index f6ae5c5d7..48f13127e 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo } from 'react'; import { useLingui } from '@lingui/react/macro'; +import { type Recipient, SigningStatus } from '@prisma/client'; import type Konva from 'konva'; import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer'; @@ -8,11 +9,23 @@ import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/e import type { TEnvelope } from '@documenso/lib/types/envelope'; import { renderField } from '@documenso/lib/universal/field-renderer/render-field'; import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields'; +import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip'; + +type GenericLocalField = TEnvelope['fields'][number] & { + recipient: Pick; +}; export default function EnvelopeGenericPageRenderer() { const { i18n } = useLingui(); - const { currentEnvelopeItem, fields, getRecipientColorKey } = useCurrentEnvelopeRender(); + const { + currentEnvelopeItem, + fields, + recipients, + getRecipientColorKey, + setRenderError, + overrideSettings, + } = useCurrentEnvelopeRender(); const { stage, @@ -28,44 +41,73 @@ export default function EnvelopeGenericPageRenderer() { const { _className, scale } = pageContext; - const localPageFields = useMemo( - () => - fields.filter( + const localPageFields = useMemo((): GenericLocalField[] => { + return fields + .filter( (field) => field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id, - ), - [fields, pageContext.pageNumber], - ); + ) + .map((field) => { + const recipient = recipients.find((recipient) => recipient.id === field.recipientId); - const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => { + if (!recipient) { + throw new Error(`Recipient not found for field ${field.id}`); + } + + return { + ...field, + recipient, + }; + }); + }, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]); + + const unsafeRenderFieldOnLayer = (field: GenericLocalField) => { if (!pageLayer.current) { console.error('Layer not loaded yet'); return; } + const { recipient } = field; + + const fieldTranslations = getClientSideFieldTranslations(i18n); + + const isInserted = recipient.signingStatus === SigningStatus.SIGNED && field.inserted; + renderField({ scale, pageLayer: pageLayer.current, field: { renderId: field.id.toString(), ...field, - customText: '', width: Number(field.width), height: Number(field.height), positionX: Number(field.positionX), positionY: Number(field.positionY), - inserted: false, + customText: isInserted ? field.customText : '', fieldMeta: field.fieldMeta, + signature: { + signatureImageAsBase64: '', + typedSignature: fieldTranslations.SIGNATURE, + }, }, - translations: getClientSideFieldTranslations(i18n), + translations: fieldTranslations, pageWidth: unscaledViewport.width, pageHeight: unscaledViewport.height, color: getRecipientColorKey(field.recipientId), editable: false, - mode: 'sign', + mode: overrideSettings?.mode ?? 'sign', }); }; + const renderFieldOnLayer = (field: GenericLocalField) => { + try { + unsafeRenderFieldOnLayer(field); + } catch (err) { + console.error(err); + setRenderError(true); + } + }; + /** * Initialize the Konva page canvas and all fields and interactions. */ @@ -113,6 +155,16 @@ export default function EnvelopeGenericPageRenderer() { className="relative w-full" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`} > + {overrideSettings?.showRecipientTooltip && + localPageFields.map((field) => ( + + ))} + {/* The element Konva will inject it's canvas into. */}
diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx index 98a9173ee..e010e5821 100644 --- a/apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx +++ b/apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx @@ -8,6 +8,8 @@ import { Label } from '@documenso/ui/primitives/label'; import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; +import { useEmbedSigningContext } from '~/components/embed/embed-signing-context'; + import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider'; export default function EnvelopeSignerForm() { @@ -25,6 +27,8 @@ export default function EnvelopeSignerForm() { setSelectedAssistantRecipientId, } = useRequiredEnvelopeSigningContext(); + const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {}; + const hasSignatureField = useMemo(() => { return recipientFields.some((field) => field.type === FieldType.SIGNATURE); }, [recipientFields]); @@ -37,7 +41,7 @@ export default function EnvelopeSignerForm() { if (recipient.role === RecipientRole.ASSISTANT) { return ( -
+
setFullName(e.target.value.trimStart())} + disabled={isNameLocked} + onChange={(e) => !isNameLocked && setFullName(e.target.value.trimStart())} />
diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx index b48ee0c55..bf7b2a971 100644 --- a/apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx +++ b/apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx @@ -16,6 +16,7 @@ import { import { Separator } from '@documenso/ui/primitives/separator'; import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog'; +import { useEmbedSigningContext } from '~/components/embed/embed-signing-context'; import { BrandingLogo } from '~/components/general/branding-logo'; import { BrandingLogoIcon } from '../branding-logo-icon'; @@ -28,7 +29,7 @@ export const EnvelopeSignerHeader = () => { useRequiredEnvelopeSigningContext(); return ( -