mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
Reference in New Issue
Block a user