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
@@ -1,4 +1,4 @@
import { SigningStatus } from '@prisma/client';
import { type RecipientRole, SigningStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
@@ -6,9 +6,10 @@ export type UpdateRecipientOptions = {
id: number;
name: string | undefined;
email: string | undefined;
role: RecipientRole | undefined;
};
export const updateRecipient = async ({ id, name, email }: UpdateRecipientOptions) => {
export const updateRecipient = async ({ id, name, email, role }: UpdateRecipientOptions) => {
const recipient = await prisma.recipient.findFirstOrThrow({
where: {
id,
@@ -26,6 +27,7 @@ export const updateRecipient = async ({ id, name, email }: UpdateRecipientOption
data: {
name,
email,
role,
},
});
};
@@ -0,0 +1,82 @@
import { EnvelopeType } from '@prisma/client';
import { PDF_SIZE_A4_72PPI } from '@documenso/lib/constants/pdf';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf';
import { unsafeBuildEnvelopeIdQuery } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import {
ZAdminDownloadDocumentAuditLogsRequestSchema,
ZAdminDownloadDocumentAuditLogsResponseSchema,
} from './download-document-audit-logs.types';
export const downloadDocumentAuditLogsRoute = adminProcedure
.input(ZAdminDownloadDocumentAuditLogsRequestSchema)
.output(ZAdminDownloadDocumentAuditLogsResponseSchema)
.mutation(async ({ input, ctx }) => {
const { envelopeId } = input;
ctx.logger.info({
input: {
envelopeId,
},
});
const envelope = await prisma.envelope.findFirst({
where: unsafeBuildEnvelopeIdQuery(
{
type: 'envelopeId',
id: envelopeId,
},
EnvelopeType.DOCUMENT,
),
include: {
documentMeta: true,
envelopeItems: true,
user: {
select: {
id: true,
name: true,
email: true,
},
},
recipients: {
orderBy: {
id: 'asc',
},
},
fields: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
const auditLogPdf = await generateAuditLogPdf({
envelope,
recipients: envelope.recipients,
fields: envelope.fields,
language: envelope.documentMeta.language,
envelopeOwner: {
email: envelope.user.email,
name: envelope.user.name || '',
},
envelopeItems: envelope.envelopeItems.map((item) => item.title),
pageWidth: PDF_SIZE_A4_72PPI.width,
pageHeight: PDF_SIZE_A4_72PPI.height,
});
const result = await auditLogPdf.save();
const base64 = Buffer.from(result).toString('base64');
return {
data: base64,
envelopeTitle: envelope.title,
};
});
@@ -0,0 +1,17 @@
import { z } from 'zod';
export const ZAdminDownloadDocumentAuditLogsRequestSchema = z.object({
envelopeId: z.string(),
});
export const ZAdminDownloadDocumentAuditLogsResponseSchema = z.object({
data: z.string(),
envelopeTitle: z.string(),
});
export type TAdminDownloadDocumentAuditLogsRequest = z.infer<
typeof ZAdminDownloadDocumentAuditLogsRequestSchema
>;
export type TAdminDownloadDocumentAuditLogsResponse = z.infer<
typeof ZAdminDownloadDocumentAuditLogsResponseSchema
>;
@@ -6,6 +6,7 @@ import { deleteDocumentRoute } from './delete-document';
import { deleteSubscriptionClaimRoute } from './delete-subscription-claim';
import { deleteUserRoute } from './delete-user';
import { disableUserRoute } from './disable-user';
import { downloadDocumentAuditLogsRoute } from './download-document-audit-logs';
import { enableUserRoute } from './enable-user';
import { findAdminOrganisationsRoute } from './find-admin-organisations';
import { findDocumentAuditLogsRoute } from './find-document-audit-logs';
@@ -71,6 +72,7 @@ export const adminRouter = router({
reseal: resealDocumentRoute,
findJobs: findDocumentJobsRoute,
findAuditLogs: findDocumentAuditLogsRoute,
downloadAuditLogs: downloadDocumentAuditLogsRoute,
},
recipient: {
update: updateRecipientRoute,
@@ -10,7 +10,7 @@ export const updateRecipientRoute = adminProcedure
.input(ZUpdateRecipientRequestSchema)
.output(ZUpdateRecipientResponseSchema)
.mutation(async ({ input, ctx }) => {
const { id, name, email } = input;
const { id, name, email, role } = input;
ctx.logger.info({
input: {
@@ -18,5 +18,5 @@ export const updateRecipientRoute = adminProcedure
},
});
await updateRecipient({ id, name, email });
await updateRecipient({ id, name, email, role });
});
@@ -4,6 +4,7 @@ export const ZUpdateRecipientRequestSchema = z.object({
id: z.number().min(1),
name: z.string().optional(),
email: z.string().email().optional(),
role: z.enum(['CC', 'SIGNER', 'VIEWER', 'APPROVER', 'ASSISTANT']).optional(),
});
export const ZUpdateRecipientResponseSchema = z.void();
@@ -0,0 +1,125 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { CheckIcon, CopyIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { cn } from '../../lib/utils';
import { Popover, PopoverContent, PopoverTrigger } from '../../primitives/popover';
const HOVER_DELAY_MS = 500;
export type LocalTimeProps = {
date: Date | string;
className?: string;
};
export const LocalTime = ({ date, className }: LocalTimeProps) => {
const [open, setOpen] = useState(false);
const [copiedField, setCopiedField] = useState<string | null>(null);
const hoverTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const isMouseOver = useRef(false);
const dt = useMemo(() => DateTime.fromJSDate(new Date(date)), [date]);
const relative = dt.toRelative() ?? '';
const local = dt.toFormat('yyyy-MM-dd HH:mm:ss ZZZZ');
const utc = dt.toUTC().toFormat('yyyy-MM-dd HH:mm:ss') + ' UTC';
const unix = Math.floor(dt.toSeconds()).toString();
const onMouseEnter = useCallback(() => {
isMouseOver.current = true;
hoverTimeout.current = setTimeout(() => {
if (isMouseOver.current) {
setOpen(true);
}
}, HOVER_DELAY_MS);
}, []);
const onMouseLeave = useCallback(() => {
isMouseOver.current = false;
if (hoverTimeout.current) {
clearTimeout(hoverTimeout.current);
hoverTimeout.current = null;
}
setTimeout(() => {
if (!isMouseOver.current) {
setOpen(false);
}
}, 200);
}, []);
const onCopy = async (label: string, value: string) => {
await navigator.clipboard.writeText(value);
setCopiedField(label);
setTimeout(() => setCopiedField(null), 1500);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
className={cn(
'cursor-pointer underline decoration-muted-foreground/50 decoration-dotted underline-offset-4',
className,
)}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{relative}
</PopoverTrigger>
<PopoverContent
className="w-auto p-3"
align="start"
onMouseEnter={() => {
isMouseOver.current = true;
}}
onMouseLeave={onMouseLeave}
>
<div className="space-y-1.5 text-xs">
<TimeRow
label="Local"
value={local}
isCopied={copiedField === 'Local'}
onCopy={() => void onCopy('Local', local)}
/>
<TimeRow
label="UTC"
value={utc}
isCopied={copiedField === 'UTC'}
onCopy={() => void onCopy('UTC', utc)}
/>
<TimeRow
label="Unix"
value={unix}
isCopied={copiedField === 'Unix'}
onCopy={() => void onCopy('Unix', unix)}
/>
</div>
</PopoverContent>
</Popover>
);
};
type TimeRowProps = {
label: string;
value: string;
isCopied: boolean;
onCopy: () => void;
};
const TimeRow = ({ label, value, isCopied, onCopy }: TimeRowProps) => (
<div className="flex items-center justify-between gap-x-4">
<div>
<span className="text-muted-foreground">{label}: </span>
<span className="font-mono">{value}</span>
</div>
<button type="button" className="text-muted-foreground hover:text-foreground" onClick={onCopy}>
{isCopied ? <CheckIcon className="h-3 w-3" /> : <CopyIcon className="h-3 w-3" />}
</button>
</div>
);