mirror of
https://github.com/documenso/documenso.git
synced 2026-10-04 02:33:54 +10:00
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:
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user