From 80823ede73a5889753c341f2a5a22d1459cd99b7 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Sat, 3 Oct 2026 17:04:43 +1000 Subject: [PATCH] feat: allow admins to force remove non-completed recipients (#3420) image --- .../dialogs/admin-recipient-delete-dialog.tsx | 194 +++++++++ .../admin-document-recipient-item-table.tsx | 140 ++++++- .../admin-document-recipients-table.tsx | 122 ++++++ .../_authenticated+/admin+/documents.$id.tsx | 61 +-- .../api/trpc/admin/delete-recipient.spec.ts | 380 ++++++++++++++++++ .../template-recipient-removed-by-admin.tsx | 71 ++++ .../templates/recipient-removed-by-admin.tsx | 61 +++ packages/lib/jobs/client.ts | 2 + ...-admin-recipient-removed-emails.handler.ts | 164 ++++++++ .../send-admin-recipient-removed-emails.ts | 37 ++ .../admin/admin-delete-recipient.ts | 208 ++++++++++ packages/lib/types/document-audit-logs.ts | 5 +- packages/lib/utils/document-audit-logs.ts | 8 + .../server/admin-router/delete-recipient.ts | 19 + .../admin-router/delete-recipient.types.ts | 11 + .../admin-router/find-document-audit-logs.ts | 26 +- .../find-document-audit-logs.types.ts | 1 + packages/trpc/server/admin-router/router.ts | 2 + packages/ui/primitives/data-table.tsx | 5 +- 19 files changed, 1446 insertions(+), 71 deletions(-) create mode 100644 apps/remix/app/components/dialogs/admin-recipient-delete-dialog.tsx create mode 100644 apps/remix/app/components/tables/admin-document-recipients-table.tsx create mode 100644 packages/app-tests/e2e/api/trpc/admin/delete-recipient.spec.ts create mode 100644 packages/email/template-components/template-recipient-removed-by-admin.tsx create mode 100644 packages/email/templates/recipient-removed-by-admin.tsx create mode 100644 packages/lib/jobs/definitions/emails/send-admin-recipient-removed-emails.handler.ts create mode 100644 packages/lib/jobs/definitions/emails/send-admin-recipient-removed-emails.ts create mode 100644 packages/lib/server-only/admin/admin-delete-recipient.ts create mode 100644 packages/trpc/server/admin-router/delete-recipient.ts create mode 100644 packages/trpc/server/admin-router/delete-recipient.types.ts diff --git a/apps/remix/app/components/dialogs/admin-recipient-delete-dialog.tsx b/apps/remix/app/components/dialogs/admin-recipient-delete-dialog.tsx new file mode 100644 index 000000000..f06b665da --- /dev/null +++ b/apps/remix/app/components/dialogs/admin-recipient-delete-dialog.tsx @@ -0,0 +1,194 @@ +import { AppError } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Textarea } from '@documenso/ui/primitives/textarea'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useRevalidator } from 'react-router'; +import { z } from 'zod'; + +const ZAdminRecipientDeleteFormSchema = z.object({ + reason: z.string().trim().min(1, { message: 'A reason is required' }).max(500), +}); + +type TAdminRecipientDeleteFormSchema = z.infer; + +export type AdminRecipientDeleteDialogProps = { + recipientId: number; + recipientName: string; + recipientEmail: string; + /** + * When set the trigger is disabled and this is shown as the tooltip. + */ + disabledReason?: string; +}; + +export const AdminRecipientDeleteDialog = ({ + recipientId, + recipientName, + recipientEmail, + disabledReason, +}: AdminRecipientDeleteDialogProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + const { revalidate } = useRevalidator(); + + const [isOpen, setIsOpen] = useState(false); + + const form = useForm({ + resolver: zodResolver(ZAdminRecipientDeleteFormSchema), + defaultValues: { + reason: '', + }, + }); + + const { mutateAsync: deleteRecipient } = trpc.admin.recipient.delete.useMutation(); + + const recipientLabel = + recipientName && recipientName !== recipientEmail ? `${recipientName} (${recipientEmail})` : recipientEmail; + + const onOpenChange = (open: boolean) => { + if (form.formState.isSubmitting) { + return; + } + + form.reset(); + setIsOpen(open); + }; + + const onFormSubmit = async ({ reason }: TAdminRecipientDeleteFormSchema) => { + try { + await deleteRecipient({ id: recipientId, reason }); + + toast({ + title: _(msg`Recipient removed`), + description: _(msg`The recipient has been removed and the document owner has been notified.`), + duration: 5000, + }); + + setIsOpen(false); + + await revalidate(); + } catch (err) { + const error = AppError.parseError(err); + + toast({ + title: _(msg`Failed to remove recipient`), + description: error.message || _(msg`We encountered an unknown error while removing the recipient.`), + variant: 'destructive', + }); + } + }; + + if (disabledReason) { + return ( + + + + + + + + + {disabledReason} + + + ); + } + + return ( + + + + + + + + + Remove Recipient + + + + + {recipientLabel} and their fields will be removed + from this document. They and the document owner will be emailed. + + + + +
+ { + // The trigger may live inside another form, React bubbles submit events through portals. + event.stopPropagation(); + + void form.handleSubmit(onFormSubmit)(event); + }} + > +
+ ( + + + Reason + + + +