diff --git a/apps/remix/app/components/tables/admin-document-recipient-item-table.tsx b/apps/remix/app/components/tables/admin-document-recipient-item-table.tsx index ccd7926f2..55b845794 100644 --- a/apps/remix/app/components/tables/admin-document-recipient-item-table.tsx +++ b/apps/remix/app/components/tables/admin-document-recipient-item-table.tsx @@ -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.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; @@ -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 )} /> + ( + + + Role + + + + + + + + + )} + /> +
+
+ +

- Audit Logs + View Audit Logs

diff --git a/packages/lib/server-only/admin/update-recipient.ts b/packages/lib/server-only/admin/update-recipient.ts index eda226edf..498a59226 100644 --- a/packages/lib/server-only/admin/update-recipient.ts +++ b/packages/lib/server-only/admin/update-recipient.ts @@ -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, }, }); }; diff --git a/packages/trpc/server/admin-router/download-document-audit-logs.ts b/packages/trpc/server/admin-router/download-document-audit-logs.ts new file mode 100644 index 000000000..60ac0b694 --- /dev/null +++ b/packages/trpc/server/admin-router/download-document-audit-logs.ts @@ -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, + }; + }); diff --git a/packages/trpc/server/admin-router/download-document-audit-logs.types.ts b/packages/trpc/server/admin-router/download-document-audit-logs.types.ts new file mode 100644 index 000000000..203d7388c --- /dev/null +++ b/packages/trpc/server/admin-router/download-document-audit-logs.types.ts @@ -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 +>; diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index 33d094d41..7f8c231d2 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -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, diff --git a/packages/trpc/server/admin-router/update-recipient.ts b/packages/trpc/server/admin-router/update-recipient.ts index 1fc286af2..5b9d9aa77 100644 --- a/packages/trpc/server/admin-router/update-recipient.ts +++ b/packages/trpc/server/admin-router/update-recipient.ts @@ -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 }); }); diff --git a/packages/trpc/server/admin-router/update-recipient.types.ts b/packages/trpc/server/admin-router/update-recipient.types.ts index 7b9bc4008..d740f9f14 100644 --- a/packages/trpc/server/admin-router/update-recipient.types.ts +++ b/packages/trpc/server/admin-router/update-recipient.types.ts @@ -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(); diff --git a/packages/ui/components/common/local-time.tsx b/packages/ui/components/common/local-time.tsx new file mode 100644 index 000000000..c43ada0cf --- /dev/null +++ b/packages/ui/components/common/local-time.tsx @@ -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(null); + + const hoverTimeout = useRef | 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 ( + + + {relative} + + + { + isMouseOver.current = true; + }} + onMouseLeave={onMouseLeave} + > +
+ void onCopy('Local', local)} + /> + void onCopy('UTC', utc)} + /> + void onCopy('Unix', unix)} + /> +
+
+
+ ); +}; + +type TimeRowProps = { + label: string; + value: string; + isCopied: boolean; + onCopy: () => void; +}; + +const TimeRow = ({ label, value, isCopied, onCopy }: TimeRowProps) => ( +
+
+ {label}: + {value} +
+ + +
+);