feat: add recipient role editing and audit log PDF download in admin (#2594)

- Allow admins to update recipient role from document detail page
- Add download button to export audit logs as PDF
- Display recipient status details in accordion
- Add LocalTime component with hover popover for timestamps
This commit is contained in:
Lucas Smith
2026-03-10 21:41:46 +11:00
committed by GitHub
parent ab69ee627b
commit af346b179c
9 changed files with 380 additions and 8 deletions
@@ -3,7 +3,13 @@ import { useMemo } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type Field, type Recipient, type Signature, SigningStatus } from '@prisma/client';
import {
type Field,
type Recipient,
RecipientRole,
type Signature,
SigningStatus,
} from '@prisma/client';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod';
@@ -21,11 +27,27 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
const RECIPIENT_ROLE_LABELS: Record<RecipientRole, string> = {
[RecipientRole.SIGNER]: 'Signer',
[RecipientRole.APPROVER]: 'Approver',
[RecipientRole.CC]: 'CC',
[RecipientRole.VIEWER]: 'Viewer',
[RecipientRole.ASSISTANT]: 'Assistant',
};
const ZAdminUpdateRecipientFormSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
role: z.nativeEnum(RecipientRole),
});
type TAdminUpdateRecipientFormSchema = z.infer<typeof ZAdminUpdateRecipientFormSchema>;
@@ -49,6 +71,7 @@ export const AdminDocumentRecipientItemTable = ({ recipient }: RecipientItemProp
defaultValues: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
},
});
@@ -98,12 +121,17 @@ export const AdminDocumentRecipientItemTable = ({ recipient }: RecipientItemProp
] satisfies DataTableColumnDef<(typeof recipient)['fields'][number]>[];
}, []);
const onUpdateRecipientFormSubmit = async ({ name, email }: TAdminUpdateRecipientFormSchema) => {
const onUpdateRecipientFormSubmit = async ({
name,
email,
role,
}: TAdminUpdateRecipientFormSchema) => {
try {
await updateRecipient({
id: recipient.id,
name,
email,
role,
});
toast({
@@ -167,6 +195,43 @@ export const AdminDocumentRecipientItemTable = ({ recipient }: RecipientItemProp
)}
/>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>
<Trans>Role</Trans>
</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={
form.formState.isSubmitting ||
recipient.signingStatus === SigningStatus.SIGNED
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(RecipientRole).map((role) => (
<SelectItem key={role} value={role}>
{RECIPIENT_ROLE_LABELS[role]}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update Recipient</Trans>
@@ -2,12 +2,16 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
import { DownloadIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, redirect } from 'react-router';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { unsafeGetEntireEnvelope } from '@documenso/lib/server-only/admin/get-entire-document';
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,
@@ -73,6 +77,31 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
},
});
const { mutateAsync: downloadAuditLogs, isPending: isDownloadAuditLogsLoading } =
trpc.admin.document.downloadAuditLogs.useMutation();
const onDownloadAuditLogsClick = async () => {
try {
const { data, envelopeTitle } = await downloadAuditLogs({
envelopeId: envelope.id,
});
const buffer = new Uint8Array(base64.decode(data));
const blob = new Blob([buffer], { type: 'application/pdf' });
downloadFile({
data: blob,
filename: `${envelopeTitle} - Audit Logs.pdf`,
});
} catch {
toast({
title: _(msg`Something went wrong`),
description: _(msg`Failed to download audit logs. Please try again later.`),
variant: 'destructive',
});
}
};
return (
<div>
<div className="flex items-start justify-between">
@@ -169,6 +198,40 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
</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>
@@ -184,11 +247,26 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
<hr className="my-4" />
<Accordion type="single" collapsible className="w-full">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">
<Trans>Audit Logs</Trans>
</h2>
<Button
variant="outline"
loading={isDownloadAuditLogsLoading}
onClick={() => void onDownloadAuditLogsClick()}
>
{!isDownloadAuditLogsLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
<Trans>Download Audit Logs</Trans>
</Button>
</div>
<Accordion type="single" collapsible className="mt-4 w-full">
<AccordionItem value="audit-logs" className="rounded-lg border">
<AccordionTrigger className="px-4">
<h2 className="text-lg font-semibold">
<Trans>Audit Logs</Trans>
<Trans>View Audit Logs</Trans>
</h2>
</AccordionTrigger>