feat: allow admins to force remove non-completed recipients (#3420)

<img width="1810" height="1408" alt="image"
src="https://github.com/user-attachments/assets/1375ebf6-9aa5-4f02-9b21-8913f71d6634"
/>
This commit is contained in:
Lucas Smith
2026-10-03 17:04:43 +10:00
committed by GitHub
parent 5311457b4b
commit 80823ede73
19 changed files with 1446 additions and 71 deletions
@@ -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<typeof ZAdminRecipientDeleteFormSchema>;
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<TAdminRecipientDeleteFormSchema>({
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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button type="button" variant="ghost" className="pointer-events-none text-destructive" disabled>
<Trans>Remove Recipient</Trans>
</Button>
</span>
</TooltipTrigger>
<TooltipContent className="max-w-[40ch]">{disabledReason}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button type="button" variant="ghost" className="text-destructive hover:text-destructive">
<Trans>Remove Recipient</Trans>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Remove Recipient</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
<span className="font-medium text-foreground">{recipientLabel}</span> and their fields will be removed
from this document. They and the document owner will be emailed.
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={(event) => {
// The trigger may live inside another form, React bubbles submit events through portals.
event.stopPropagation();
void form.handleSubmit(onFormSubmit)(event);
}}
>
<fieldset className="flex flex-col gap-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="reason"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Reason</Trans>
</FormLabel>
<FormControl>
<Textarea {...field} rows={3} placeholder={_(msg`e.g. Removed at the sender's request`)} />
</FormControl>
<FormDescription>
<Trans>Visible to the document owner in the audit log.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" variant="destructive" loading={form.formState.isSubmitting}>
<Trans>Remove</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -1,6 +1,8 @@
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
import { zEmail } from '@documenso/lib/utils/zod';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@documenso/ui/primitives/collapsible';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
@@ -11,11 +13,14 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type Field, type Recipient, RecipientRole, type Signature, SigningStatus } from '@prisma/client';
import { useMemo } from 'react';
import { DateTime } from 'luxon';
import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod';
import { AdminRecipientDeleteDialog } from '~/components/dialogs/admin-recipient-delete-dialog';
const RECIPIENT_ROLE_LABELS: Record<RecipientRole, string> = {
[RecipientRole.SIGNER]: 'Signer',
[RecipientRole.APPROVER]: 'Approver',
@@ -33,6 +38,8 @@ const ZAdminUpdateRecipientFormSchema = z.object({
type TAdminUpdateRecipientFormSchema = z.infer<typeof ZAdminUpdateRecipientFormSchema>;
export type RecipientItemProps = {
envelopeId: string;
isDocumentCompleted: boolean;
recipient: Recipient & {
fields: Array<
Field & {
@@ -42,11 +49,21 @@ export type RecipientItemProps = {
};
};
export const AdminDocumentRecipientItemTable = ({ recipient }: RecipientItemProps) => {
export const AdminDocumentRecipientItemTable = ({ envelopeId, isDocumentCompleted, recipient }: RecipientItemProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const isRecipientSigned = recipient.signingStatus === SigningStatus.SIGNED;
let removeDisabledReason: string | undefined;
if (isDocumentCompleted) {
removeDisabledReason = _(msg`Recipients cannot be removed from a completed document.`);
} else if (isRecipientSigned) {
removeDisabledReason = _(msg`This recipient has already signed and cannot be removed.`);
}
const form = useForm<TAdminUpdateRecipientFormSchema>({
defaultValues: {
name: recipient.name,
@@ -125,6 +142,38 @@ export const AdminDocumentRecipientItemTable = ({ recipient }: RecipientItemProp
return (
<div>
<div className="mb-4 grid grid-cols-4 gap-4 text-sm">
<div>
<span className="text-muted-foreground">
<Trans>Send Status</Trans>
</span>
<p className="font-medium">{recipient.sendStatus}</p>
</div>
<div>
<span className="text-muted-foreground">
<Trans>Read Status</Trans>
</span>
<p className="font-medium">{recipient.readStatus}</p>
</div>
<div>
<span className="text-muted-foreground">
<Trans>Signing Status</Trans>
</span>
<p className="font-medium">{recipient.signingStatus}</p>
</div>
<div>
<span className="text-muted-foreground">
<Trans>Signing Order</Trans>
</span>
<p className="font-medium">{recipient.signingOrder ?? '-'}</p>
</div>
</div>
<hr className="mb-4" />
<Form {...form}>
<form onSubmit={form.handleSubmit(onUpdateRecipientFormSubmit)}>
<fieldset
@@ -182,7 +231,7 @@ export const AdminDocumentRecipientItemTable = ({ recipient }: RecipientItemProp
onValueChange={field.onChange}
disabled={form.formState.isSubmitting || recipient.signingStatus === SigningStatus.SIGNED}
>
<SelectTrigger>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
@@ -200,13 +249,24 @@ export const AdminDocumentRecipientItemTable = ({ recipient }: RecipientItemProp
</FormItem>
)}
/>
<div>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update Recipient</Trans>
</Button>
</div>
</fieldset>
<div className="mt-4 flex gap-x-4">
<Button
type="submit"
loading={form.formState.isSubmitting}
disabled={form.formState.isSubmitting || isRecipientSigned}
>
<Trans>Update Recipient</Trans>
</Button>
<AdminRecipientDeleteDialog
recipientId={recipient.id}
recipientName={recipient.name}
recipientEmail={recipient.email}
disabledReason={removeDisabledReason}
/>
</div>
</form>
</Form>
@@ -216,7 +276,67 @@ export const AdminDocumentRecipientItemTable = ({ recipient }: RecipientItemProp
<Trans>Fields</Trans>
</h2>
<DataTable columns={columns} data={recipient.fields} />
<DataTable className="bg-background" columns={columns} data={recipient.fields} />
<hr className="my-4" />
<RecipientActivity envelopeId={envelopeId} recipientId={recipient.id} />
</div>
);
};
const RecipientActivity = ({ envelopeId, recipientId }: { envelopeId: string; recipientId: number }) => {
const { _, i18n } = useLingui();
const [isOpen, setIsOpen] = useState(false);
const { data, isLoading } = trpc.admin.document.findAuditLogs.useQuery(
{ envelopeId, recipientId, perPage: 50 },
{ enabled: isOpen },
);
const columns = useMemo(() => {
return [
{
header: _(msg`Date`),
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt, DateTime.DATETIME_MED),
},
{
header: _(msg`Action`),
accessorKey: 'type',
cell: ({ row }) => formatDocumentAuditLogAction(i18n, row.original).description,
},
{
header: _(msg`IP Address`),
accessorKey: 'ipAddress',
cell: ({ row }) => row.original.ipAddress ?? '-',
},
] satisfies DataTableColumnDef<NonNullable<typeof data>['data'][number]>[];
}, []);
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold text-lg">
<Trans>Activity</Trans>
</h2>
<CollapsibleTrigger asChild>
<Button variant="outline" size="sm">
{isOpen ? <Trans>Hide</Trans> : <Trans>Show</Trans>}
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<DataTable
className="bg-background"
columns={columns}
data={data?.data ?? []}
skeleton={{ enable: isLoading, rows: 3 }}
/>
</CollapsibleContent>
</Collapsible>
);
};
@@ -0,0 +1,122 @@
import { LocalTime } from '@documenso/ui/components/common/local-time';
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@documenso/ui/primitives/table';
import { Trans } from '@lingui/react/macro';
import { SigningStatus } from '@prisma/client';
import { ChevronDownIcon } from 'lucide-react';
import { Fragment, useState } from 'react';
import { match } from 'ts-pattern';
import { AdminDocumentRecipientItemTable, type RecipientItemProps } from './admin-document-recipient-item-table';
export type AdminDocumentRecipientsTableProps = {
envelopeId: string;
isDocumentCompleted: boolean;
recipients: RecipientItemProps['recipient'][];
};
export const AdminDocumentRecipientsTable = ({
envelopeId,
isDocumentCompleted,
recipients,
}: AdminDocumentRecipientsTableProps) => {
const [expandedRecipientIds, setExpandedRecipientIds] = useState<number[]>([]);
const toggleRecipient = (recipientId: number) => {
setExpandedRecipientIds((ids) =>
ids.includes(recipientId) ? ids.filter((id) => id !== recipientId) : [...ids, recipientId],
);
};
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>
<Trans>Name</Trans>
</TableHead>
<TableHead>
<Trans>Email</Trans>
</TableHead>
<TableHead>
<Trans>Role</Trans>
</TableHead>
<TableHead>
<Trans>Completed</Trans>
</TableHead>
<TableHead>
<Trans>Completed At</Trans>
</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{recipients.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="h-32 text-center">
<Trans>No recipients</Trans>
</TableCell>
</TableRow>
)}
{recipients.map((recipient) => {
const isExpanded = expandedRecipientIds.includes(recipient.id);
return (
<Fragment key={recipient.id}>
<TableRow
className="cursor-pointer"
data-state={isExpanded ? 'open' : 'closed'}
onClick={() => toggleRecipient(recipient.id)}
>
<TableCell className="font-medium">{recipient.name || '-'}</TableCell>
<TableCell>{recipient.email}</TableCell>
<TableCell>
<Badge size="small" variant="secondary">
{recipient.role}
</Badge>
</TableCell>
<TableCell>
{match(recipient.signingStatus)
.with(SigningStatus.SIGNED, () => <Trans>Yes</Trans>)
.with(SigningStatus.REJECTED, () => <Trans>Rejected</Trans>)
.otherwise(() => (
<Trans>No</Trans>
))}
</TableCell>
<TableCell>
{recipient.signingStatus === SigningStatus.SIGNED && recipient.signedAt ? (
<LocalTime date={recipient.signedAt} />
) : (
'-'
)}
</TableCell>
<TableCell>
<ChevronDownIcon
className={cn('h-4 w-4 text-muted-foreground transition-transform', isExpanded && 'rotate-180')}
/>
</TableCell>
</TableRow>
{isExpanded && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={6} className="bg-muted/30 p-4">
<AdminDocumentRecipientItemTable
envelopeId={envelopeId}
isDocumentCompleted={isDocumentCompleted}
recipient={recipient}
/>
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
</TableBody>
</Table>
</div>
);
};
@@ -3,7 +3,6 @@ import { unsafeGetEntireEnvelope } from '@documenso/lib/server-only/admin/get-en
import { base64 } from '@documenso/lib/universal/base64';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { trpc } from '@documenso/trpc/react';
import { LocalTime } from '@documenso/ui/components/common/local-time';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
@@ -21,7 +20,7 @@ import { AdminDocumentDeleteDialog } from '~/components/dialogs/admin-document-d
import { DocumentStatus } from '~/components/general/document/document-status';
import { AdminDocumentJobsTable } from '~/components/tables/admin-document-jobs-table';
import { AdminDocumentLogsTable } from '~/components/tables/admin-document-logs-table';
import { AdminDocumentRecipientItemTable } from '~/components/tables/admin-document-recipient-item-table';
import { AdminDocumentRecipientsTable } from '~/components/tables/admin-document-recipients-table';
import type { Route } from './+types/documents.$id';
@@ -166,59 +165,11 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
</h2>
<div className="mt-4">
<Accordion type="multiple" className="space-y-4">
{envelope.recipients.map((recipient) => (
<AccordionItem key={recipient.id} value={recipient.id.toString()} className="rounded-lg border">
<AccordionTrigger className="px-4">
<div className="flex items-center gap-x-4">
<h4 className="font-semibold">{recipient.name}</h4>
<Badge size="small" variant="neutral">
{recipient.email}
</Badge>
<Badge size="small" variant="secondary">
{recipient.role}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent className="border-t px-4 pt-4">
<div className="mb-4 grid grid-cols-4 gap-4 text-sm">
<div>
<span className="text-muted-foreground">
<Trans>Send Status</Trans>
</span>
<p className="font-medium">{recipient.sendStatus}</p>
</div>
<div>
<span className="text-muted-foreground">
<Trans>Read Status</Trans>
</span>
<p className="font-medium">{recipient.readStatus}</p>
</div>
<div>
<span className="text-muted-foreground">
<Trans>Signing Status</Trans>
</span>
<p className="font-medium">{recipient.signingStatus}</p>
</div>
<div>
<span className="text-muted-foreground">
<Trans>Completed At</Trans>
</span>
<p className="font-medium">{recipient.signedAt ? <LocalTime date={recipient.signedAt} /> : '-'}</p>
</div>
</div>
<hr className="mb-4" />
<AdminDocumentRecipientItemTable recipient={recipient} />
</AccordionContent>
</AccordionItem>
))}
</Accordion>
<AdminDocumentRecipientsTable
envelopeId={envelope.id}
isDocumentCompleted={Boolean(envelope.completedAt)}
recipients={envelope.recipients}
/>
</div>
<hr className="my-4" />
@@ -0,0 +1,380 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentSigningOrder, DocumentStatus, SendStatus, SigningStatus } from '@prisma/client';
import { apiSignin } from '../../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({ mode: 'parallel' });
const callDeleteRecipient = async (page: Page, input: { id: number; reason?: string }) => {
return await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/admin.recipient.delete`, {
headers: { 'content-type': 'application/json' },
data: JSON.stringify({ json: { reason: 'Removed at the request of the sender', ...input } }),
});
};
const findJobByDocumentId = async (jobId: string, documentId: number) => {
return await prisma.backgroundJob.findFirst({
where: {
jobId,
payload: { path: ['documentId'], equals: documentId },
},
});
};
const findRemovalEmailJob = async (envelopeId: string, recipientEmail: string) => {
return await prisma.backgroundJob.findFirst({
where: {
jobId: 'send.admin.recipient.removed.emails',
AND: [
{ payload: { path: ['envelopeId'], equals: envelopeId } },
{ payload: { path: ['recipientEmail'], equals: recipientEmail } },
],
},
});
};
// ─── Access control ──────────────────────────────────────────────────────────
test('[ADMIN][TRPC][DELETE_RECIPIENT]: unauthenticated request is rejected with 401', async ({ page }) => {
const { user: owner, team } = await seedUser();
const document = await seedPendingDocument(owner, team.id, ['recipient@test.documenso.com']);
const recipient = document.recipients[0];
const res = await callDeleteRecipient(page, { id: recipient.id });
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
const stillExists = await prisma.recipient.findUnique({ where: { id: recipient.id } });
expect(stillExists).not.toBeNull();
});
test('[ADMIN][TRPC][DELETE_RECIPIENT]: non-admin authenticated user is rejected with 401', async ({ page }) => {
const { user: nonAdminUser } = await seedUser({ isAdmin: false });
const { user: owner, team } = await seedUser();
const document = await seedPendingDocument(owner, team.id, ['recipient@test.documenso.com']);
const recipient = document.recipients[0];
await apiSignin({ page, email: nonAdminUser.email });
const res = await callDeleteRecipient(page, { id: recipient.id });
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
const stillExists = await prisma.recipient.findUnique({ where: { id: recipient.id } });
expect(stillExists).not.toBeNull();
});
// ─── Behaviour ───────────────────────────────────────────────────────────────
test('[ADMIN][TRPC][DELETE_RECIPIENT]: admin can remove a recipient who has opened the document and inserted fields', async ({
page,
}) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: owner, team } = await seedUser();
const recipientEmail = 'recipient@test.documenso.com';
// Seeded recipients are OPENED with an inserted field, which the non-admin flow refuses to delete.
const document = await seedPendingDocument(owner, team.id, [recipientEmail, 'other@test.documenso.com']);
const recipient = document.recipients.find((r) => r.email === recipientEmail);
if (!recipient) {
throw new Error('Recipient not seeded');
}
await apiSignin({ page, email: adminUser.email });
const res = await callDeleteRecipient(page, { id: recipient.id });
expect(res.ok()).toBeTruthy();
const deletedRecipient = await prisma.recipient.findUnique({ where: { id: recipient.id } });
expect(deletedRecipient).toBeNull();
const remainingFields = await prisma.field.count({ where: { recipientId: recipient.id } });
expect(remainingFields).toBe(0);
const remainingRecipients = await prisma.recipient.findMany({ where: { envelopeId: document.id } });
expect(remainingRecipients).toHaveLength(1);
expect(remainingRecipients[0].email).toBe('other@test.documenso.com');
const auditLog = await prisma.documentAuditLog.findFirst({
where: {
envelopeId: document.id,
type: 'RECIPIENT_DELETED',
},
});
expect(auditLog).not.toBeNull();
expect(auditLog?.userId).toBe(adminUser.id);
expect(auditLog?.email).toBe(adminUser.email);
expect(auditLog?.data).toMatchObject({
recipientId: recipient.id,
recipientEmail,
removedByAdmin: true,
reason: 'Removed at the request of the sender',
});
// Parallel signing with a signer still pending: nothing to seal or advance.
expect(
await findJobByDocumentId('internal.seal-document', mapSecondaryIdToDocumentId(document.secondaryId)),
).toBeNull();
expect(
await findJobByDocumentId('send.signing.requested.email', mapSecondaryIdToDocumentId(document.secondaryId)),
).toBeNull();
const job = await findRemovalEmailJob(document.id, recipientEmail);
expect(job).not.toBeNull();
expect(job?.payload).toMatchObject({
envelopeId: document.id,
recipientEmail,
notifyRecipient: true,
});
});
test('[ADMIN][TRPC][DELETE_RECIPIENT]: admin cannot remove a recipient who has completed signing', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: owner, team } = await seedUser();
const recipientEmail = 'recipient@test.documenso.com';
const document = await seedPendingDocument(owner, team.id, [recipientEmail, 'other@test.documenso.com']);
const recipient = document.recipients.find((r) => r.email === recipientEmail);
if (!recipient) {
throw new Error('Recipient not seeded');
}
await prisma.recipient.update({
where: { id: recipient.id },
data: { signingStatus: SigningStatus.SIGNED, signedAt: new Date() },
});
await apiSignin({ page, email: adminUser.email });
const res = await callDeleteRecipient(page, { id: recipient.id });
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
const stillExists = await prisma.recipient.findUnique({ where: { id: recipient.id } });
expect(stillExists).not.toBeNull();
const job = await findRemovalEmailJob(document.id, recipientEmail);
expect(job).toBeNull();
});
test('[ADMIN][TRPC][DELETE_RECIPIENT]: admin cannot remove a recipient from a completed document', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: owner, team } = await seedUser();
const recipientEmail = 'recipient@test.documenso.com';
const document = await seedPendingDocument(owner, team.id, [recipientEmail], {
createDocumentOptions: {
status: DocumentStatus.COMPLETED,
completedAt: new Date(),
},
});
const recipient = document.recipients[0];
await apiSignin({ page, email: adminUser.email });
const res = await callDeleteRecipient(page, { id: recipient.id });
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
const stillExists = await prisma.recipient.findUnique({ where: { id: recipient.id } });
expect(stillExists).not.toBeNull();
});
test('[ADMIN][TRPC][DELETE_RECIPIENT]: unknown recipient returns 404', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
const res = await callDeleteRecipient(page, { id: 999_999_999 });
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('[ADMIN][TRPC][DELETE_RECIPIENT]: a missing reason is rejected with 400', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: owner, team } = await seedUser();
const document = await seedPendingDocument(owner, team.id, ['recipient@test.documenso.com']);
const recipient = document.recipients[0];
await apiSignin({ page, email: adminUser.email });
const res = await callDeleteRecipient(page, { id: recipient.id, reason: ' ' });
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
const stillExists = await prisma.recipient.findUnique({ where: { id: recipient.id } });
expect(stillExists).not.toBeNull();
});
test('[ADMIN][TRPC][DELETE_RECIPIENT]: document is sealed when every remaining signer has already signed', async ({
page,
}) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: owner, team } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [
'signed@test.documenso.com',
'blocking@test.documenso.com',
]);
const signedRecipient = document.recipients.find((r) => r.email === 'signed@test.documenso.com');
const blockingRecipient = document.recipients.find((r) => r.email === 'blocking@test.documenso.com');
if (!signedRecipient || !blockingRecipient) {
throw new Error('Recipients not seeded');
}
await prisma.recipient.update({
where: { id: signedRecipient.id },
data: { signingStatus: SigningStatus.SIGNED, signedAt: new Date() },
});
await apiSignin({ page, email: adminUser.email });
const res = await callDeleteRecipient(page, { id: blockingRecipient.id });
expect(res.ok()).toBeTruthy();
const sealJob = await findJobByDocumentId('internal.seal-document', mapSecondaryIdToDocumentId(document.secondaryId));
expect(sealJob).not.toBeNull();
await expect
.poll(
async () => {
const envelope = await prisma.envelope.findUniqueOrThrow({ where: { id: document.id } });
return envelope.status;
},
{ timeout: 30_000 },
)
.toBe(DocumentStatus.COMPLETED);
});
test('[ADMIN][TRPC][DELETE_RECIPIENT]: document with no remaining signers is not sealed', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: owner, team } = await seedUser();
const document = await seedPendingDocument(owner, team.id, ['only@test.documenso.com']);
await apiSignin({ page, email: adminUser.email });
const res = await callDeleteRecipient(page, { id: document.recipients[0].id });
expect(res.ok()).toBeTruthy();
expect(
await findJobByDocumentId('internal.seal-document', mapSecondaryIdToDocumentId(document.secondaryId)),
).toBeNull();
const envelope = await prisma.envelope.findUniqueOrThrow({ where: { id: document.id } });
expect(envelope.status).toBe(DocumentStatus.PENDING);
});
test('[ADMIN][TRPC][DELETE_RECIPIENT]: sequential signing advances to the next recipient', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: owner, team } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [
'first@test.documenso.com',
'second@test.documenso.com',
'third@test.documenso.com',
]);
await prisma.documentMeta.update({
where: { id: document.documentMetaId },
data: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
});
const [first, second, third] = ['first', 'second', 'third'].map((prefix) => {
const recipient = document.recipients.find((r) => r.email === `${prefix}@test.documenso.com`);
if (!recipient) {
throw new Error(`Recipient ${prefix} not seeded`);
}
return recipient;
});
await prisma.recipient.update({
where: { id: first.id },
data: { signingOrder: 1, sendStatus: SendStatus.SENT },
});
await prisma.recipient.update({
where: { id: second.id },
data: { signingOrder: 2, sendStatus: SendStatus.NOT_SENT, sentAt: null },
});
await prisma.recipient.update({
where: { id: third.id },
data: { signingOrder: 3, sendStatus: SendStatus.NOT_SENT, sentAt: null },
});
await apiSignin({ page, email: adminUser.email });
const res = await callDeleteRecipient(page, { id: first.id });
expect(res.ok()).toBeTruthy();
const updatedSecond = await prisma.recipient.findUniqueOrThrow({ where: { id: second.id } });
expect(updatedSecond.sendStatus).toBe(SendStatus.SENT);
const updatedThird = await prisma.recipient.findUniqueOrThrow({ where: { id: third.id } });
expect(updatedThird.sendStatus).toBe(SendStatus.NOT_SENT);
const signingJob = await prisma.backgroundJob.findFirst({
where: {
jobId: 'send.signing.requested.email',
AND: [
{ payload: { path: ['documentId'], equals: mapSecondaryIdToDocumentId(document.secondaryId) } },
{ payload: { path: ['recipientId'], equals: second.id } },
],
},
});
expect(signingJob).not.toBeNull();
const thirdSigningJob = await prisma.backgroundJob.findFirst({
where: {
jobId: 'send.signing.requested.email',
AND: [
{ payload: { path: ['documentId'], equals: mapSecondaryIdToDocumentId(document.secondaryId) } },
{ payload: { path: ['recipientId'], equals: third.id } },
],
},
});
expect(thirdSigningJob).toBeNull();
expect(
await findJobByDocumentId('internal.seal-document', mapSecondaryIdToDocumentId(document.secondaryId)),
).toBeNull();
});
@@ -0,0 +1,71 @@
import { Trans } from '@lingui/react/macro';
import { Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
export type TemplateRecipientRemovedByAdminAudience = 'recipient' | 'owner';
export type TemplateRecipientRemovedByAdminProps = {
audience: TemplateRecipientRemovedByAdminAudience;
documentName: string;
recipientName: string;
recipientEmail: string;
assetBaseUrl: string;
};
export const TemplateRecipientRemovedByAdmin = ({
audience,
documentName,
recipientName,
recipientEmail,
assetBaseUrl,
}: TemplateRecipientRemovedByAdminProps) => {
const recipientLabel =
recipientName && recipientName !== recipientEmail ? `${recipientName} (${recipientEmail})` : recipientEmail;
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="mt-6 mb-0 text-left font-semibold text-foreground text-lg">
{audience === 'recipient' ? (
<Trans>You have been removed from a document</Trans>
) : (
<Trans>A recipient has been removed from your document</Trans>
)}
</Text>
<Text className="mx-auto mt-1 mb-6 text-left text-base text-muted-foreground">
{audience === 'recipient' ? (
<Trans>
You have been removed from the document "{documentName}" by an administrator, typically at the request of
the document sender.
</Trans>
) : (
<Trans>
{recipientLabel} has been removed from the document "{documentName}" by an administrator, typically at the
request of the document sender.
</Trans>
)}
</Text>
<Text className="mx-auto mt-1 mb-6 text-left text-base text-muted-foreground">
{audience === 'recipient' ? (
<Trans>
There is no action required on your side. If you believe this was a mistake, please contact the document
sender.
</Trans>
) : (
<Trans>
There is no action required on your side. If you believe this was a mistake, please contact your
administrator.
</Trans>
)}
</Text>
</Section>
</>
);
};
export default TemplateRecipientRemovedByAdmin;
@@ -0,0 +1,61 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Body, Container, Head, Hr, Html, Preview, Section } from '../components';
import { TemplateBrandingLogo } from '../template-components/template-branding-logo';
import { TemplateFooter } from '../template-components/template-footer';
import {
TemplateRecipientRemovedByAdmin,
type TemplateRecipientRemovedByAdminProps,
} from '../template-components/template-recipient-removed-by-admin';
export type RecipientRemovedByAdminEmailTemplateProps = Partial<TemplateRecipientRemovedByAdminProps>;
export const RecipientRemovedByAdminEmailTemplate = ({
audience = 'recipient',
documentName = 'Open Source Pledge.pdf',
recipientName = 'Lucas Smith',
recipientEmail = 'lucas@documenso.com',
assetBaseUrl = 'http://localhost:3002',
}: RecipientRemovedByAdminEmailTemplateProps) => {
const { _ } = useLingui();
const previewText =
audience === 'recipient'
? msg`You have been removed from the document "${documentName}" by an administrator.`
: msg`${recipientName} has been removed from the document "${documentName}" by an administrator.`;
return (
<Html>
<Head />
<Body className="mx-auto my-auto bg-background font-sans">
<Preview>{_(previewText)}</Preview>
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
<TemplateRecipientRemovedByAdmin
audience={audience}
documentName={documentName}
recipientName={recipientName}
recipientEmail={recipientEmail}
assetBaseUrl={assetBaseUrl}
/>
</Section>
</Container>
<Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter />
</Container>
</Section>
</Body>
</Html>
);
};
export default RecipientRemovedByAdminEmailTemplate;
+2
View File
@@ -1,4 +1,5 @@
import { JobClient } from './client/client';
import { SEND_ADMIN_RECIPIENT_REMOVED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-admin-recipient-removed-emails';
import { SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-admin-user-created-email';
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
@@ -52,6 +53,7 @@ export const jobsClient = new JobClient([
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION,
SEND_ADMIN_RECIPIENT_REMOVED_EMAILS_JOB_DEFINITION,
SEND_DOCUMENT_COMPLETED_EMAILS_JOB_DEFINITION,
SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION,
SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION,
@@ -0,0 +1,164 @@
import RecipientRemovedByAdminEmailTemplate from '@documenso/email/templates/recipient-removed-by-admin';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { assertOrganisationRatesAndLimits } from '../../../server-only/rate-limit/assert-organisation-rates-and-limits';
import { isRecipientEmailValidForSending } from '../../../utils/recipients';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendAdminRecipientRemovedEmailsJobDefinition } from './send-admin-recipient-removed-emails';
export const run = async ({
payload,
io,
}: {
payload: TSendAdminRecipientRemovedEmailsJobDefinition;
io: JobRunIO;
}) => {
const { envelopeId, recipientEmail, recipientName, notifyRecipient } = payload;
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
},
include: {
documentMeta: true,
user: {
select: {
email: true,
name: true,
},
},
},
});
// Envelope may have been deleted since enqueueing, don't retry forever.
if (!envelope) {
return;
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const shouldNotifyRecipient = notifyRecipient && isRecipientEmailValidForSending({ email: recipientEmail });
// The document's `recipientRemoved` email preference is intentionally ignored
// for admin removals. Organisation email disabling and limits still apply.
if (shouldNotifyRecipient) {
const {
branding,
emailLanguage,
senderEmail,
replyToEmail,
organisationId,
claims,
emailsDisabled,
emailTransport,
} = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
let isWithinEmailLimits = !emailsDisabled;
if (isWithinEmailLimits) {
try {
await assertOrganisationRatesAndLimits({
organisationId,
organisationClaim: claims,
type: 'email',
count: 1,
});
} catch (_err) {
io.logger.warn({
msg: 'Admin recipient removed email dropped: org email limit exceeded',
organisationId,
envelopeId: envelope.id,
});
isWithinEmailLimits = false;
}
}
if (isWithinEmailLimits) {
const template = createElement(RecipientRemovedByAdminEmailTemplate, {
audience: 'recipient',
documentName: envelope.title,
recipientName,
recipientEmail,
assetBaseUrl,
});
const i18n = await getI18nInstance(emailLanguage);
await io.runTask('send-admin-recipient-removed-email-recipient', async () => {
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
await emailTransport.sendMail({
to: {
address: recipientEmail,
name: recipientName,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`You have been removed from a document`),
html,
text,
});
});
}
}
const ownerEmail = envelope.user.email;
if (!isRecipientEmailValidForSending({ email: ownerEmail })) {
return;
}
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const ownerTemplate = createElement(RecipientRemovedByAdminEmailTemplate, {
audience: 'owner',
documentName: envelope.title,
recipientName,
recipientEmail,
assetBaseUrl,
});
const ownerI18n = await getI18nInstance(emailLanguage);
await io.runTask('send-admin-recipient-removed-email-owner', async () => {
const [html, text] = await Promise.all([
renderEmailWithI18N(ownerTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(ownerTemplate, { lang: emailLanguage, branding, plainText: true }),
]);
await emailTransport.sendMail({
to: {
address: ownerEmail,
name: envelope.user.name || '',
},
from: senderEmail,
subject: ownerI18n._(msg`A recipient has been removed from your document`),
html,
text,
});
});
};
@@ -0,0 +1,37 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_ADMIN_RECIPIENT_REMOVED_EMAILS_JOB_DEFINITION_ID = 'send.admin.recipient.removed.emails';
const SEND_ADMIN_RECIPIENT_REMOVED_EMAILS_JOB_DEFINITION_SCHEMA = z.object({
envelopeId: z.string(),
recipientEmail: z.string(),
recipientName: z.string(),
/**
* False when the recipient was never sent the document or is a CC.
*/
notifyRecipient: z.boolean(),
});
export type TSendAdminRecipientRemovedEmailsJobDefinition = z.infer<
typeof SEND_ADMIN_RECIPIENT_REMOVED_EMAILS_JOB_DEFINITION_SCHEMA
>;
export const SEND_ADMIN_RECIPIENT_REMOVED_EMAILS_JOB_DEFINITION = {
id: SEND_ADMIN_RECIPIENT_REMOVED_EMAILS_JOB_DEFINITION_ID,
name: 'Send Admin Recipient Removed Emails',
version: '1.0.0',
trigger: {
name: SEND_ADMIN_RECIPIENT_REMOVED_EMAILS_JOB_DEFINITION_ID,
schema: SEND_ADMIN_RECIPIENT_REMOVED_EMAILS_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-admin-recipient-removed-emails.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_ADMIN_RECIPIENT_REMOVED_EMAILS_JOB_DEFINITION_ID,
TSendAdminRecipientRemovedEmailsJobDefinition
>;
@@ -0,0 +1,208 @@
import { prisma } from '@documenso/prisma';
import {
DocumentSigningOrder,
DocumentStatus,
EnvelopeType,
RecipientRole,
SendStatus,
SigningStatus,
} from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { isTspEnvelope } from '../../types/signature-level';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { getRecipientsInActiveSigningStep, isRecipientTurnBySigningOrder } from '../../utils/recipient-groups';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
export type AdminDeleteRecipientOptions = {
recipientId: number;
reason: string;
requestMetadata: ApiRequestMetadata;
};
/**
* Unlike `deleteEnvelopeRecipient` this is not team scoped and allows removing
* recipients who have opened the document or inserted fields, as long as they
* have not signed.
*/
export const adminDeleteRecipient = async ({ recipientId, reason, requestMetadata }: AdminDeleteRecipientOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
id: recipientId,
},
include: {
envelope: {
select: {
id: true,
secondaryId: true,
type: true,
status: true,
signatureLevel: true,
completedAt: true,
userId: true,
documentMeta: {
select: {
signingOrder: true,
},
},
},
},
},
});
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
const { envelope } = recipient;
if (envelope.type !== EnvelopeType.DOCUMENT) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Recipient does not belong to a document',
});
}
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
});
}
if (recipient.signingStatus === SigningStatus.SIGNED) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Recipient has already completed signing',
});
}
// The TSP (AES/QES) lock protects WYSIWYS and is not bypassed for admins.
await assertEnvelopeMutable(envelope);
const deletedRecipient = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
removedByAdmin: true,
reason,
},
}),
});
return await tx.recipient.delete({
where: {
id: recipientId,
},
});
});
const shouldNotifyRecipient = recipient.sendStatus === SendStatus.SENT && recipient.role !== RecipientRole.CC;
await jobs.triggerJob({
name: 'send.admin.recipient.removed.emails',
payload: {
envelopeId: envelope.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
notifyRecipient: shouldNotifyRecipient,
},
});
// The removed recipient may have been the only thing blocking completion or
// the next sequential step, so seal or advance as the signing flow would.
if (envelope.status !== DocumentStatus.PENDING) {
return deletedRecipient;
}
const documentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
const remainingRecipients = await prisma.recipient.findMany({
where: {
envelopeId: envelope.id,
},
select: {
id: true,
role: true,
signingOrder: true,
signingStatus: true,
sendStatus: true,
},
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
});
const signableRecipients = remainingRecipients.filter((recipient) => recipient.role !== RecipientRole.CC);
// A document left with no signers stays pending rather than sealing with no signatures.
const haveAllRecipientsSigned =
signableRecipients.length > 0 &&
signableRecipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
if (haveAllRecipientsSigned) {
await jobs.triggerJob({
name: 'internal.seal-document',
payload: {
documentId,
requestMetadata: requestMetadata.requestMetadata,
},
});
return deletedRecipient;
}
if (envelope.documentMeta?.signingOrder !== DocumentSigningOrder.SEQUENTIAL) {
return deletedRecipient;
}
const recipientsToSend = getRecipientsInActiveSigningStep(remainingRecipients, {
strictlySequential: isTspEnvelope(envelope),
}).filter(
(recipient) =>
recipient.sendStatus !== SendStatus.SENT &&
isRecipientTurnBySigningOrder(remainingRecipients, recipient, { strictlySequential: isTspEnvelope(envelope) }),
);
if (recipientsToSend.length === 0) {
return deletedRecipient;
}
await prisma.recipient.updateMany({
where: {
id: {
in: recipientsToSend.map((recipient) => recipient.id),
},
},
data: {
sendStatus: SendStatus.SENT,
sentAt: new Date(),
},
});
await Promise.allSettled(
recipientsToSend.map(async (recipient) =>
jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId: envelope.userId,
documentId,
recipientId: recipient.id,
requestMetadata: requestMetadata.requestMetadata,
},
}),
),
);
return deletedRecipient;
};
+4 -1
View File
@@ -713,7 +713,10 @@ export const ZDocumentAuditLogEventRecipientUpdatedSchema = z.object({
*/
export const ZDocumentAuditLogEventRecipientRemovedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED),
data: ZBaseRecipientDataSchema,
data: ZBaseRecipientDataSchema.extend({
removedByAdmin: z.boolean().optional(),
reason: z.string().optional(),
}),
});
/**
@@ -323,6 +323,14 @@ export const formatDocumentAuditLogAction = (i18n: I18n, auditLog: TDocumentAudi
you: msg`You added a recipient`,
user: msg`${user} added a recipient`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED, data: { removedByAdmin: true } }, () => ({
anonymous: msg({
message: `A recipient was removed by an administrator`,
context: `Audit log format`,
}),
you: msg`You removed a recipient as an administrator`,
user: msg`${user} removed a recipient as an administrator`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED }, () => ({
anonymous: msg({
message: `A recipient was removed`,
@@ -0,0 +1,19 @@
import { adminDeleteRecipient } from '@documenso/lib/server-only/admin/admin-delete-recipient';
import { adminProcedure } from '../trpc';
import { ZDeleteRecipientRequestSchema, ZDeleteRecipientResponseSchema } from './delete-recipient.types';
export const deleteRecipientRoute = adminProcedure
.input(ZDeleteRecipientRequestSchema)
.output(ZDeleteRecipientResponseSchema)
.mutation(async ({ input, ctx }) => {
const { id, reason } = input;
ctx.logger.info({ input: { id } });
await adminDeleteRecipient({
recipientId: id,
reason,
requestMetadata: ctx.metadata,
});
});
@@ -0,0 +1,11 @@
import { z } from 'zod';
export const ZDeleteRecipientRequestSchema = z.object({
id: z.number().min(1),
reason: z.string().trim().min(1).max(500),
});
export const ZDeleteRecipientResponseSchema = z.void();
export type TDeleteRecipientRequest = z.infer<typeof ZDeleteRecipientRequestSchema>;
export type TDeleteRecipientResponse = z.infer<typeof ZDeleteRecipientResponseSchema>;
@@ -3,7 +3,7 @@ import type { FindResultResponse } from '@documenso/lib/types/search-params';
import { parseDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { unsafeBuildEnvelopeIdQuery } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
import { EnvelopeType, type Prisma } from '@prisma/client';
import { adminProcedure } from '../trpc';
import {
@@ -15,7 +15,14 @@ export const findDocumentAuditLogsRoute = adminProcedure
.input(ZFindDocumentAuditLogsRequestSchema)
.output(ZFindDocumentAuditLogsResponseSchema)
.query(async ({ input }) => {
const { envelopeId, page = 1, perPage = 50, orderByColumn = 'createdAt', orderByDirection = 'desc' } = input;
const {
envelopeId,
recipientId,
page = 1,
perPage = 50,
orderByColumn = 'createdAt',
orderByDirection = 'desc',
} = input;
const envelope = await prisma.envelope.findFirst({
where: unsafeBuildEnvelopeIdQuery(
@@ -33,9 +40,20 @@ export const findDocumentAuditLogsRoute = adminProcedure
});
}
const whereClause: Prisma.DocumentAuditLogWhereInput = {
envelopeId: envelope.id,
};
if (recipientId !== undefined) {
whereClause.data = {
path: ['recipientId'],
equals: recipientId,
};
}
const [data, count] = await Promise.all([
prisma.documentAuditLog.findMany({
where: { envelopeId: envelope.id },
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
@@ -43,7 +61,7 @@ export const findDocumentAuditLogsRoute = adminProcedure
},
}),
prisma.documentAuditLog.count({
where: { envelopeId: envelope.id },
where: whereClause,
}),
]);
@@ -4,6 +4,7 @@ import { z } from 'zod';
export const ZFindDocumentAuditLogsRequestSchema = ZFindSearchParamsSchema.extend({
envelopeId: z.string(),
recipientId: z.number().optional(),
orderByColumn: z.enum(['createdAt']).optional(),
orderByDirection: z.enum(['asc', 'desc']).optional(),
});
@@ -7,6 +7,7 @@ import { createUserRoute } from './create-user';
import { deleteDocumentRoute } from './delete-document';
import { deleteOrganisationRoute } from './delete-organisation';
import { deleteAdminOrganisationMemberRoute } from './delete-organisation-member';
import { deleteRecipientRoute } from './delete-recipient';
import { deleteSubscriptionClaimRoute } from './delete-subscription-claim';
import { deleteAdminTeamMemberRoute } from './delete-team-member';
import { deleteUserRoute } from './delete-user';
@@ -100,6 +101,7 @@ export const adminRouter = router({
},
recipient: {
update: updateRecipientRoute,
delete: deleteRecipientRoute,
},
emailDomain: {
find: findEmailDomainsRoute,
+4 -1
View File
@@ -10,6 +10,7 @@ import type {
import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import type React from 'react';
import { useMemo } from 'react';
import { cn } from '../lib/utils';
import { Skeleton } from './skeleton';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './table';
@@ -19,6 +20,7 @@ export type DataTableChildren<TData> = (_table: TTable<TData>) => React.ReactNod
export type { ColumnDef as DataTableColumnDef, RowSelectionState } from '@tanstack/react-table';
export interface DataTableProps<TData, TValue> {
className?: string;
columns: ColumnDef<TData, TValue>[];
columnVisibility?: VisibilityState;
data: TData[];
@@ -48,6 +50,7 @@ export interface DataTableProps<TData, TValue> {
}
export function DataTable<TData, TValue>({
className,
columns,
columnVisibility,
data,
@@ -120,7 +123,7 @@ export function DataTable<TData, TValue>({
return (
<>
<div className="rounded-md border">
<div className={cn('rounded-md border', className)}>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (