From 071475769c2c7965a2c0eaeb6a549ab99516350b Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 12 Feb 2024 17:30:23 +1100 Subject: [PATCH 01/24] feat: add document page view --- .../[id]/document-page-view-button.tsx | 110 ++++++++ .../[id]/document-page-view-dropdown.tsx | 160 +++++++++++ .../[id]/document-page-view-information.tsx | 71 +++++ .../documents/[id]/document-page-view.tsx | 251 ++++++++++++++---- .../documents/[id]/edit-document.tsx | 37 ++- .../[id]/edit/document-edit-page-view.tsx | 121 +++++++++ .../(dashboard)/documents/[id]/edit/page.tsx | 11 + .../_action-items/resend-document.tsx | 2 +- .../documents/data-table-action-button.tsx | 2 +- .../documents/data-table-action-dropdown.tsx | 2 +- .../documents/duplicate-document-dialog.tsx | 2 +- .../(dashboard)/documents/upload-document.tsx | 2 +- .../templates/data-table-templates.tsx | 2 +- .../t/[teamUrl]/documents/[id]/edit/page.tsx | 21 ++ .../components/formatter/document-status.tsx | 2 +- packages/lib/constants/recipient-roles.ts | 2 +- .../document/get-document-by-id.ts | 13 + packages/ui/primitives/badge.tsx | 16 +- 18 files changed, 755 insertions(+), 72 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/documents/[id]/document-page-view-button.tsx create mode 100644 apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx create mode 100644 apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx create mode 100644 apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx create mode 100644 apps/web/src/app/(dashboard)/documents/[id]/edit/page.tsx create mode 100644 apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/edit/page.tsx diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-button.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-button.tsx new file mode 100644 index 000000000..334089a5f --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-button.tsx @@ -0,0 +1,110 @@ +'use client'; + +import Link from 'next/link'; + +import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react'; +import { useSession } from 'next-auth/react'; +import { match } from 'ts-pattern'; + +import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; +import type { Document, Recipient, Team, User } from '@documenso/prisma/client'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; +import { trpc as trpcClient } from '@documenso/trpc/client'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type DocumentPageViewButtonProps = { + document: Document & { + User: Pick; + Recipient: Recipient[]; + team: Pick | null; + }; + team?: Pick; +}; + +export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButtonProps) => { + const { data: session } = useSession(); + const { toast } = useToast(); + + if (!session) { + return null; + } + + const recipient = document.Recipient.find((recipient) => recipient.email === session.user.email); + + const isRecipient = !!recipient; + const isPending = document.status === DocumentStatus.PENDING; + const isComplete = document.status === DocumentStatus.COMPLETED; + const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; + const role = recipient?.role; + + const documentsPath = formatDocumentsPath(document.team?.url); + + const onDownloadClick = async () => { + try { + const documentWithData = await trpcClient.document.getDocumentById.query({ + id: document.id, + teamId: team?.id, + }); + + const documentData = documentWithData?.documentData; + + if (!documentData) { + throw new Error('No document available'); + } + + await downloadPDF({ documentData, fileName: documentWithData.title }); + } catch (err) { + toast({ + title: 'Something went wrong', + description: 'An error occurred while downloading your document.', + variant: 'destructive', + }); + } + }; + + return match({ + isRecipient, + isPending, + isComplete, + isSigned, + }) + .with({ isRecipient: true, isPending: true, isSigned: false }, () => ( + + )) + .with({ isComplete: false }, () => ( + + )) + .with({ isComplete: true }, () => ( + + )) + .otherwise(() => null); +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx new file mode 100644 index 000000000..3e108aed5 --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx @@ -0,0 +1,160 @@ +'use client'; + +import { useState } from 'react'; + +import Link from 'next/link'; + +import { Copy, Download, Edit, Loader, MoreHorizontal, Share, Trash2 } from 'lucide-react'; +import { useSession } from 'next-auth/react'; + +import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; +import { DocumentStatus } from '@documenso/prisma/client'; +import type { Document, Recipient, Team, User } from '@documenso/prisma/client'; +import { trpc as trpcClient } from '@documenso/trpc/client'; +import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { ResendDocumentActionItem } from '../_action-items/resend-document'; +import { DeleteDocumentDialog } from '../delete-document-dialog'; +import { DuplicateDocumentDialog } from '../duplicate-document-dialog'; + +export type DocumentPageViewDropdownProps = { + document: Document & { + User: Pick; + Recipient: Recipient[]; + team: Pick | null; + }; + team?: Pick; +}; + +export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => { + const { data: session } = useSession(); + const { toast } = useToast(); + + const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false); + + if (!session) { + return null; + } + + const recipient = document.Recipient.find((recipient) => recipient.email === session.user.email); + + const isOwner = document.User.id === session.user.id; + const isDraft = document.status === DocumentStatus.DRAFT; + const isComplete = document.status === DocumentStatus.COMPLETED; + const isDocumentDeletable = isOwner; + const isCurrentTeamDocument = team && document.team?.url === team.url; + + const documentsPath = formatDocumentsPath(team?.url); + + const onDownloadClick = async () => { + try { + const documentWithData = await trpcClient.document.getDocumentById.query({ + id: document.id, + teamId: team?.id, + }); + + const documentData = documentWithData?.documentData; + + if (!documentData) { + return; + } + + await downloadPDF({ documentData, fileName: document.title }); + } catch (err) { + toast({ + title: 'Something went wrong', + description: 'An error occurred while downloading your document.', + variant: 'destructive', + }); + } + }; + + const nonSignedRecipients = document.Recipient.filter((item) => item.signingStatus !== 'SIGNED'); + + return ( + + + + + + + Action + + {(isOwner || isCurrentTeamDocument) && !isComplete && ( + + + + Edit + + + )} + + {isComplete && ( + + + Download + + )} + + setDuplicateDialogOpen(true)}> + + Duplicate + + + setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}> + + Delete + + + Share + + + + ( + e.preventDefault()}> +
+ {loading ? : } + Share Signing Card +
+
+ )} + /> +
+ + {isDocumentDeletable && ( + + )} + {isDuplicateDialogOpen && ( + + )} +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx new file mode 100644 index 000000000..00bbe0d83 --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useMemo } from 'react'; + +import { DateTime } from 'luxon'; + +import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted'; +import { useLocale } from '@documenso/lib/client-only/providers/locale'; +import type { Document, Recipient, User } from '@documenso/prisma/client'; + +export type DocumentPageViewInformationProps = { + userId: number; + document: Document & { + User: Pick; + Recipient: Recipient[]; + }; +}; + +export const DocumentPageViewInformation = ({ + document, + userId, +}: DocumentPageViewInformationProps) => { + const isMounted = useIsMounted(); + const { locale } = useLocale(); + + const documentInformation = useMemo(() => { + let createdValue = DateTime.fromJSDate(document.createdAt).toFormat('MMMM d, yyyy'); + let lastModifiedValue = DateTime.fromJSDate(document.updatedAt).toRelative(); + + if (!isMounted) { + createdValue = DateTime.fromJSDate(document.createdAt) + .setLocale(locale) + .toFormat('MMMM d, yyyy'); + + lastModifiedValue = DateTime.fromJSDate(document.updatedAt).setLocale(locale).toRelative(); + } + + return [ + { + description: 'Uploaded by', + value: userId === document.userId ? 'You' : document.User.name ?? document.User.email, + }, + { + description: 'Created', + value: createdValue, + }, + { + description: 'Last modified', + value: lastModifiedValue, + }, + ]; + }, [isMounted, document, locale, userId]); + + return ( +
+

Information

+ +
    + {documentInformation.map((item) => ( +
  • + {item.description} + {item.value} +
  • + ))} +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx index 6759d91ac..c821bfac8 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx @@ -1,22 +1,42 @@ import Link from 'next/link'; import { redirect } from 'next/navigation'; -import { ChevronLeft, Users2 } from 'lucide-react'; +import { + CheckIcon, + ChevronLeft, + Clock, + MailIcon, + MailOpenIcon, + PenIcon, + PlusIcon, + Users2, +} from 'lucide-react'; +import { match } from 'ts-pattern'; import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; -import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import type { Team } from '@documenso/prisma/client'; -import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; +import { SignatureIcon } from '@documenso/ui/icons/signature'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; -import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document'; import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; -import { DocumentStatus } from '~/components/formatter/document-status'; +import { + DocumentStatus as DocumentStatusComponent, + FRIENDLY_STATUS_MAP, +} from '~/components/formatter/document-status'; + +import { DocumentPageViewButton } from './document-page-view-button'; +import { DocumentPageViewDropdown } from './document-page-view-dropdown'; +import { DocumentPageViewInformation } from './document-page-view-information'; export type DocumentPageViewProps = { params: { @@ -67,65 +87,196 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) documentMeta.password = securePassword; } - const [recipients, fields] = await Promise.all([ - getRecipientsForDocument({ - documentId, - userId: user.id, - }), - getFieldsForDocument({ - documentId, - userId: user.id, - }), - ]); + const recipients = await getRecipientsForDocument({ + documentId, + userId: user.id, + }); + + const documentWithRecipients = { + ...document, + Recipient: recipients, + }; return (
- + Documents -

- {document.title} -

+
+

+ {document.title} +

-
- +
+ - {recipients.length > 0 && ( -
- + {recipients.length > 0 && ( +
+ - - {recipients.length} Recipient(s) - -
- )} + + {recipients.length} Recipient(s) + +
+ )} +
- {document.status !== InternalDocumentStatus.COMPLETED && ( - - )} +
+ + + + + - {document.status === InternalDocumentStatus.COMPLETED && ( -
- +
+
+
+
+

+ Document {FRIENDLY_STATUS_MAP[document.status].label.toLowerCase()} +

+ + +
+ +

+ {match(document.status) + .with( + DocumentStatus.COMPLETED, + () => 'This document has been signed by all recipients', + ) + .with( + DocumentStatus.DRAFT, + () => 'This document is currently a draft and has not been sent', + ) + .with(DocumentStatus.PENDING, () => { + const pendingRecipients = recipients.filter( + (recipient) => recipient.signingStatus === 'NOT_SIGNED', + ); + + return `Waiting on ${pendingRecipients.length} recipient${ + pendingRecipients.length > 1 ? 's' : '' + }`; + }) + .exhaustive()} +

+ +
+ +
+
+ + {/* Document information section. */} + + + {/* Recipients section. */} +
+
+

Recipients

+ + {document.status !== DocumentStatus.COMPLETED && ( + + {recipients.length === 0 ? ( + + ) : ( + + )} + + )} +
+ +
    + {recipients.length === 0 && ( +
  • + No recipients +
  • + )} + + {recipients.map((recipient) => ( +
  • + {recipient.email}

    + } + secondaryText={ +

    + {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} +

    + } + /> + + {document.status !== DocumentStatus.DRAFT && + recipient.signingStatus === SigningStatus.SIGNED && ( + + {match(recipient.role) + .with(RecipientRole.APPROVER, () => ( + <> + + Approved + + )) + .with(RecipientRole.CC, () => + document.status === DocumentStatus.COMPLETED ? ( + <> + + Sent + + ) : ( + <> + + Ready + + ), + ) + + .with(RecipientRole.SIGNER, () => ( + <> + + Signed + + )) + .with(RecipientRole.VIEWER, () => ( + <> + + Viewed + + )) + .exhaustive()} + + )} + + {document.status !== DocumentStatus.DRAFT && + recipient.signingStatus === SigningStatus.NOT_SIGNED && ( + + + Pending + + )} +
  • + ))} +
+
+
- )} +
); }; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index 813458062..fe278486e 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -2,10 +2,16 @@ import { useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; -import type { DocumentData, DocumentMeta, Field, Recipient, User } from '@documenso/prisma/client'; -import { DocumentStatus } from '@documenso/prisma/client'; +import { + type DocumentData, + type DocumentMeta, + DocumentStatus, + type Field, + type Recipient, + type User, +} from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; @@ -49,12 +55,9 @@ export const EditDocumentForm = ({ documentRootPath, }: EditDocumentFormProps) => { const { toast } = useToast(); - const router = useRouter(); - // controlled stepper state - const [step, setStep] = useState( - document.status === DocumentStatus.DRAFT ? 'title' : 'signers', - ); + const router = useRouter(); + const searchParams = useSearchParams(); const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation(); const { mutateAsync: addFields } = trpc.field.addFields.useMutation(); @@ -86,6 +89,24 @@ export const EditDocumentForm = ({ }, }; + const [step, setStep] = useState(() => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const searchParamStep = searchParams?.get('step') as EditDocumentStep | undefined; + + let initialStep: EditDocumentStep = + document.status === DocumentStatus.DRAFT ? 'title' : 'signers'; + + if ( + searchParamStep && + documentFlow[searchParamStep] !== undefined && + !(recipients.length === 0 && (searchParamStep === 'subject' || searchParamStep === 'fields')) + ) { + initialStep = searchParamStep; + } + + return initialStep; + }); + const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => { try { // Custom invocation server action diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx new file mode 100644 index 000000000..87b3738bb --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx @@ -0,0 +1,121 @@ +import Link from 'next/link'; +import { redirect } from 'next/navigation'; + +import { ChevronLeft, Users2 } from 'lucide-react'; + +import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; +import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document'; +import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; +import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; +import type { Team } from '@documenso/prisma/client'; +import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; + +import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document'; +import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; +import { DocumentStatus } from '~/components/formatter/document-status'; + +export type DocumentEditPageViewProps = { + params: { + id: string; + }; + team?: Team; +}; + +export const DocumentEditPageView = async ({ params, team }: DocumentEditPageViewProps) => { + const { id } = params; + + const documentId = Number(id); + + const documentRootPath = formatDocumentsPath(team?.url); + + if (!documentId || Number.isNaN(documentId)) { + redirect(documentRootPath); + } + + const { user } = await getRequiredServerComponentSession(); + + const document = await getDocumentById({ + id: documentId, + userId: user.id, + teamId: team?.id, + }).catch(() => null); + + if (!document || !document.documentData) { + redirect(documentRootPath); + } + + if (document.status === InternalDocumentStatus.COMPLETED) { + redirect(`${documentRootPath}/${documentId}`); + } + + const { documentData, documentMeta } = document; + + if (documentMeta?.password) { + const key = DOCUMENSO_ENCRYPTION_KEY; + + if (!key) { + throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY'); + } + + const securePassword = Buffer.from( + symmetricDecrypt({ + key, + data: documentMeta.password, + }), + ).toString('utf-8'); + + documentMeta.password = securePassword; + } + + const [recipients, fields] = await Promise.all([ + getRecipientsForDocument({ + documentId, + userId: user.id, + }), + getFieldsForDocument({ + documentId, + userId: user.id, + }), + ]); + + return ( +
+ + + Documents + + +

+ {document.title} +

+ +
+ + + {recipients.length > 0 && ( +
+ + + + {recipients.length} Recipient(s) + +
+ )} +
+ + +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit/page.tsx new file mode 100644 index 000000000..6c613a287 --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit/page.tsx @@ -0,0 +1,11 @@ +import { DocumentEditPageView } from './document-edit-page-view'; + +export type DocumentPageProps = { + params: { + id: string; + }; +}; + +export default function DocumentEditPage({ params }: DocumentPageProps) { + return ; +} diff --git a/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx b/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx index e8e3d6130..ff2291c54 100644 --- a/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx @@ -119,7 +119,7 @@ export const ResendDocumentActionItem = ({ - +

Who do you want to remind?

diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx index 78ffd0b3b..455f50be5 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx @@ -94,7 +94,7 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps) isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true }, () => ( +
+ )} + + + {data && ( +
    + {hasNextPage && ( +
  • +
    +
    +
    + +
    +
    +
    + + +
  • + )} + + {documentAuditLogs.map((auditLog, auditLogIndex) => ( +
  • +
    +
    +
    + +
    + {match(auditLog.type) + .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED, () => ( +
    +
    + )) + .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => ( +
    +
    + )) + .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, () => ( +
    +
    + )) + .otherwise(() => ( +
    + ))} +
    + +

    + + {formatDocumentAuditLogAction(auditLog, userId).prefix} + {' '} + {formatDocumentAuditLogAction(auditLog, userId).description} +

    + + +
  • + ))} +
+ )} +
+ + ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recipients.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recipients.tsx new file mode 100644 index 000000000..37d2cd35e --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recipients.tsx @@ -0,0 +1,115 @@ +import Link from 'next/link'; + +import { CheckIcon, Clock, MailIcon, MailOpenIcon, PenIcon, PlusIcon } from 'lucide-react'; +import { match } from 'ts-pattern'; + +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; +import type { Document, Recipient } from '@documenso/prisma/client'; +import { SignatureIcon } from '@documenso/ui/icons/signature'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { Badge } from '@documenso/ui/primitives/badge'; + +export type DocumentPageViewRecipientsProps = { + document: Document & { + Recipient: Recipient[]; + }; + documentRootPath: string; +}; + +export const DocumentPageViewRecipients = ({ + document, + documentRootPath, +}: DocumentPageViewRecipientsProps) => { + const recipients = document.Recipient; + + return ( +
+
+

Recipients

+ + {document.status !== DocumentStatus.COMPLETED && ( + + {recipients.length === 0 ? ( + + ) : ( + + )} + + )} +
+ +
    + {recipients.length === 0 && ( +
  • No recipients
  • + )} + + {recipients.map((recipient) => ( +
  • + {recipient.email}

    } + secondaryText={ +

    + {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} +

    + } + /> + + {document.status !== DocumentStatus.DRAFT && + recipient.signingStatus === SigningStatus.SIGNED && ( + + {match(recipient.role) + .with(RecipientRole.APPROVER, () => ( + <> + + Approved + + )) + .with(RecipientRole.CC, () => + document.status === DocumentStatus.COMPLETED ? ( + <> + + Sent + + ) : ( + <> + + Ready + + ), + ) + + .with(RecipientRole.SIGNER, () => ( + <> + + Signed + + )) + .with(RecipientRole.VIEWER, () => ( + <> + + Viewed + + )) + .exhaustive()} + + )} + + {document.status !== DocumentStatus.DRAFT && + recipient.signingStatus === SigningStatus.NOT_SIGNED && ( + + + Pending + + )} +
  • + ))} +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx index c821bfac8..c64b8650a 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx @@ -1,34 +1,23 @@ import Link from 'next/link'; import { redirect } from 'next/navigation'; -import { - CheckIcon, - ChevronLeft, - Clock, - MailIcon, - MailOpenIcon, - PenIcon, - PlusIcon, - Users2, -} from 'lucide-react'; +import { ChevronLeft, Clock9, Users2 } from 'lucide-react'; import { match } from 'ts-pattern'; import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; -import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; -import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; +import { DocumentStatus } from '@documenso/prisma/client'; import type { Team } from '@documenso/prisma/client'; -import { SignatureIcon } from '@documenso/ui/icons/signature'; -import { AvatarWithText } from '@documenso/ui/primitives/avatar'; -import { Badge } from '@documenso/ui/primitives/badge'; +import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; +import { DocumentHistorySheet } from '~/components/document/document-history-sheet'; import { DocumentStatus as DocumentStatusComponent, FRIENDLY_STATUS_MAP, @@ -37,6 +26,8 @@ import { import { DocumentPageViewButton } from './document-page-view-button'; import { DocumentPageViewDropdown } from './document-page-view-dropdown'; import { DocumentPageViewInformation } from './document-page-view-information'; +import { DocumentPageViewRecentActivity } from './document-page-view-recent-activity'; +import { DocumentPageViewRecipients } from './document-page-view-recipients'; export type DocumentPageViewProps = { params: { @@ -104,27 +95,38 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) Documents -
-

- {document.title} -

+
+
+

+ {document.title} +

-
- +
+ - {recipients.length > 0 && ( -
- + {recipients.length > 0 && ( +
+ - - {recipients.length} Recipient(s) - -
- )} + + {recipients.length} Recipient(s) + +
+ )} +
+
+ +
+ + +
@@ -139,8 +141,8 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
-
-
+
+

Document {FRIENDLY_STATUS_MAP[document.status].label.toLowerCase()} @@ -180,100 +182,13 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) {/* Recipients section. */} -
-
-

Recipients

+ - {document.status !== DocumentStatus.COMPLETED && ( - - {recipients.length === 0 ? ( - - ) : ( - - )} - - )} -
- -
    - {recipients.length === 0 && ( -
  • - No recipients -
  • - )} - - {recipients.map((recipient) => ( -
  • - {recipient.email}

    - } - secondaryText={ -

    - {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} -

    - } - /> - - {document.status !== DocumentStatus.DRAFT && - recipient.signingStatus === SigningStatus.SIGNED && ( - - {match(recipient.role) - .with(RecipientRole.APPROVER, () => ( - <> - - Approved - - )) - .with(RecipientRole.CC, () => - document.status === DocumentStatus.COMPLETED ? ( - <> - - Sent - - ) : ( - <> - - Ready - - ), - ) - - .with(RecipientRole.SIGNER, () => ( - <> - - Signed - - )) - .with(RecipientRole.VIEWER, () => ( - <> - - Viewed - - )) - .exhaustive()} - - )} - - {document.status !== DocumentStatus.DRAFT && - recipient.signingStatus === SigningStatus.NOT_SIGNED && ( - - - Pending - - )} -
  • - ))} -
-
+ {/* Recent activity section. */} +

diff --git a/apps/web/src/components/document/document-history-sheet-changes.tsx b/apps/web/src/components/document/document-history-sheet-changes.tsx new file mode 100644 index 000000000..ef3985a61 --- /dev/null +++ b/apps/web/src/components/document/document-history-sheet-changes.tsx @@ -0,0 +1,28 @@ +'use client'; + +import React from 'react'; + +import { Badge } from '@documenso/ui/primitives/badge'; + +export type DocumentHistorySheetChangesProps = { + values: { + key: string | React.ReactNode; + value: string | React.ReactNode; + }[]; +}; + +export const DocumentHistorySheetChanges = ({ values }: DocumentHistorySheetChangesProps) => { + return ( + + {values.map(({ key, value }, i) => ( +

+ {key}: + {value} +

+ ))} +
+ ); +}; diff --git a/apps/web/src/components/document/document-history-sheet.tsx b/apps/web/src/components/document/document-history-sheet.tsx new file mode 100644 index 000000000..29d9a9c96 --- /dev/null +++ b/apps/web/src/components/document/document-history-sheet.tsx @@ -0,0 +1,316 @@ +'use client'; + +import { useMemo, useState } from 'react'; + +import { ArrowRightIcon, Loader } from 'lucide-react'; +import { match } from 'ts-pattern'; +import { UAParser } from 'ua-parser-js'; + +import { DOCUMENT_AUDIT_LOG_EMAIL_FORMAT } from '@documenso/lib/constants/document-audit-logs'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import { formatDocumentAuditLogActionString } from '@documenso/lib/utils/document-audit-logs'; +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { Button } from '@documenso/ui/primitives/button'; +import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet'; + +import { LocaleDate } from '~/components/formatter/locale-date'; + +import { DocumentHistorySheetChanges } from './document-history-sheet-changes'; + +export type DocumentHistorySheetProps = { + documentId: number; + userId: number; + isMenuOpen?: boolean; + onMenuOpenChange?: (_value: boolean) => void; + children?: React.ReactNode; +}; + +export const DocumentHistorySheet = ({ + documentId, + userId, + isMenuOpen, + onMenuOpenChange, + children, +}: DocumentHistorySheetProps) => { + const [isUserDetailsVisible, setIsUserDetailsVisible] = useState(false); + + const { + data, + isLoading, + isLoadingError, + refetch, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = trpc.document.findDocumentAuditLogs.useInfiniteQuery( + { + documentId, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ); + + const documentAuditLogs = useMemo(() => (data?.pages ?? []).flatMap((page) => page.data), [data]); + + const extractBrowser = (userAgent?: string | null) => { + if (!userAgent) { + return 'Unknown'; + } + + const parser = new UAParser(userAgent); + + parser.setUA(userAgent); + + const result = parser.getResult(); + + return result.browser.name; + }; + + /** + * Applies the following formatting for a given text: + * - Uppercase first lower, lowercase rest + * - Replace _ with spaces + * + * @param text The text to format + * @returns The formatted text + */ + const formatGenericText = (text: string) => { + return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' '); + }; + + return ( + + {children && {children}} + + +
+

Document history

+ +
+ + {isLoading && ( +
+ +
+ )} + + {isLoadingError && ( +
+

Unable to load document history

+ +
+ )} + + {data && ( +
    + {documentAuditLogs.map((auditLog) => ( +
  • +
    + + + {(auditLog?.email ?? auditLog?.name ?? '?').slice(0, 1).toUpperCase()} + + + +
    +

    + {formatDocumentAuditLogActionString(auditLog, userId)} +

    +

    + +

    +
    +
    + + {match(auditLog) + .with( + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT }, + () => null, + ) + .with( + { type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED }, + ({ data }) => { + const values = [ + { + key: 'Email', + value: data.recipientEmail, + }, + { + key: 'Role', + value: formatGenericText(data.recipientRole), + }, + ]; + + if (data.recipientName) { + values.unshift({ + key: 'Name', + value: data.recipientName, + }); + } + + return ; + }, + ) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, ({ data }) => { + if (data.changes.length === 0) { + return null; + } + + return ( + ({ + key: formatGenericText(type), + value: ( + + {type === 'ROLE' ? formatGenericText(from) : from} + + {type === 'ROLE' ? formatGenericText(to) : to} + + ), + }))} + /> + ); + }) + .with( + { type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED }, + ({ data }) => ( + + ), + ) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, ({ data }) => { + if (data.changes.length === 0) { + return null; + } + + return ( + ({ + key: formatGenericText(change.type), + value: change.type === 'PASSWORD' ? '*********' : change.to, + }))} + /> + ); + }) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, ({ data }) => ( + + )) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, ({ data }) => ( + + )) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, ({ data }) => ( + + )) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ( + + )) + .exhaustive()} + + {isUserDetailsVisible && ( + <> +
    + + IP: {auditLog.ipAddress ?? 'Unknown'} + + + + Browser: {extractBrowser(auditLog.userAgent)} + +
    + + )} +
  • + ))} + + {hasNextPage && ( +
    + +
    + )} +
+ )} +
+
+ ); +}; diff --git a/apps/web/src/components/formatter/locale-date.tsx b/apps/web/src/components/formatter/locale-date.tsx index 7262a9a57..98a115f60 100644 --- a/apps/web/src/components/formatter/locale-date.tsx +++ b/apps/web/src/components/formatter/locale-date.tsx @@ -1,7 +1,7 @@ 'use client'; import type { HTMLAttributes } from 'react'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import type { DateTimeFormatOptions } from 'luxon'; import { DateTime } from 'luxon'; @@ -10,7 +10,7 @@ import { useLocale } from '@documenso/lib/client-only/providers/locale'; export type LocaleDateProps = HTMLAttributes & { date: string | number | Date; - format?: DateTimeFormatOptions; + format?: DateTimeFormatOptions | string; }; /** @@ -22,13 +22,24 @@ export type LocaleDateProps = HTMLAttributes & { export const LocaleDate = ({ className, date, format, ...props }: LocaleDateProps) => { const { locale } = useLocale(); + const formatDateTime = useCallback( + (date: DateTime) => { + if (typeof format === 'string') { + return date.toFormat(format); + } + + return date.toLocaleString(format); + }, + [format], + ); + const [localeDate, setLocaleDate] = useState(() => - DateTime.fromJSDate(new Date(date)).setLocale(locale).toLocaleString(format), + formatDateTime(DateTime.fromJSDate(new Date(date)).setLocale(locale)), ); useEffect(() => { - setLocaleDate(DateTime.fromJSDate(new Date(date)).toLocaleString(format)); - }, [date, format]); + setLocaleDate(formatDateTime(DateTime.fromJSDate(new Date(date)))); + }, [date, format, formatDateTime]); return ( diff --git a/packages/lib/constants/document-audit-logs.ts b/packages/lib/constants/document-audit-logs.ts new file mode 100644 index 000000000..8ae654977 --- /dev/null +++ b/packages/lib/constants/document-audit-logs.ts @@ -0,0 +1,19 @@ +import { DOCUMENT_EMAIL_TYPE } from '../types/document-audit-logs'; + +export const DOCUMENT_AUDIT_LOG_EMAIL_FORMAT = { + [DOCUMENT_EMAIL_TYPE.SIGNING_REQUEST]: { + description: 'Signing request', + }, + [DOCUMENT_EMAIL_TYPE.VIEW_REQUEST]: { + description: 'Viewing request', + }, + [DOCUMENT_EMAIL_TYPE.APPROVE_REQUEST]: { + description: 'Approval request', + }, + [DOCUMENT_EMAIL_TYPE.CC]: { + description: 'CC', + }, + [DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED]: { + description: 'Document completed', + }, +} satisfies Record; diff --git a/packages/lib/constants/recipient-roles.ts b/packages/lib/constants/recipient-roles.ts index d86026782..44e4c34da 100644 --- a/packages/lib/constants/recipient-roles.ts +++ b/packages/lib/constants/recipient-roles.ts @@ -1,29 +1,31 @@ import { RecipientRole } from '@documenso/prisma/client'; -export const RECIPIENT_ROLES_DESCRIPTION: { - [key in RecipientRole]: { actionVerb: string; progressiveVerb: string; roleName: string }; -} = { +export const RECIPIENT_ROLES_DESCRIPTION = { [RecipientRole.APPROVER]: { actionVerb: 'Approve', + actioned: 'Approved', progressiveVerb: 'Approving', roleName: 'Approver', }, [RecipientRole.CC]: { actionVerb: 'CC', + actioned: 'CCed', progressiveVerb: 'CC', roleName: 'Cc', }, [RecipientRole.SIGNER]: { actionVerb: 'Sign', + actioned: 'Signed', progressiveVerb: 'Signing', roleName: 'Signer', }, [RecipientRole.VIEWER]: { actionVerb: 'View', + actioned: 'Viewed', progressiveVerb: 'Viewing', roleName: 'Viewer', }, -}; +} satisfies Record; export const RECIPIENT_ROLE_TO_EMAIL_TYPE = { [RecipientRole.SIGNER]: 'SIGNING_REQUEST', diff --git a/packages/lib/server-only/document-meta/upsert-document-meta.ts b/packages/lib/server-only/document-meta/upsert-document-meta.ts index 5a1c1594e..d4781f280 100644 --- a/packages/lib/server-only/document-meta/upsert-document-meta.ts +++ b/packages/lib/server-only/document-meta/upsert-document-meta.ts @@ -89,17 +89,21 @@ export const upsertDocumentMeta = async ({ }, }); - await tx.documentAuditLog.create({ - data: createDocumentAuditLogData({ - type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED, - documentId, - user, - requestMetadata, - data: { - changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta), - }, - }), - }); + const changes = diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta); + + if (changes.length > 0) { + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED, + documentId, + user, + requestMetadata, + data: { + changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta), + }, + }), + }); + } return upsertedDocumentMeta; }); diff --git a/packages/lib/server-only/document/delete-document.ts b/packages/lib/server-only/document/delete-document.ts index 22365a727..473177b9b 100644 --- a/packages/lib/server-only/document/delete-document.ts +++ b/packages/lib/server-only/document/delete-document.ts @@ -9,27 +9,72 @@ import { prisma } from '@documenso/prisma'; import { DocumentStatus } from '@documenso/prisma/client'; import { FROM_ADDRESS, FROM_NAME } from '../../constants/email'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; +import type { RequestMetadata } from '../../universal/extract-request-metadata'; +import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; export type DeleteDocumentOptions = { id: number; userId: number; status: DocumentStatus; + requestMetadata?: RequestMetadata; }; -export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptions) => { +export const deleteDocument = async ({ + id, + userId, + status, + requestMetadata, +}: DeleteDocumentOptions) => { + await prisma.document.findFirstOrThrow({ + where: { + id, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], + }, + }); + + const user = await prisma.user.findFirstOrThrow({ + where: { + id: userId, + }, + }); + // if the document is a draft, hard-delete if (status === DocumentStatus.DRAFT) { - return await prisma.document.delete({ where: { id, userId, status: DocumentStatus.DRAFT } }); + return await prisma.$transaction(async (tx) => { + // Currently redundant since deleting a document will delete the audit logs. + // However may be useful if we disassociate audit lgos and documents if required. + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + documentId: id, + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, + user, + requestMetadata, + data: { + type: 'HARD', + }, + }), + }); + + return await tx.document.delete({ where: { id, status: DocumentStatus.DRAFT } }); + }); } // if the document is pending, send cancellation emails to all recipients if (status === DocumentStatus.PENDING) { - const user = await prisma.user.findFirstOrThrow({ - where: { - id: userId, - }, - }); - const document = await prisma.document.findUnique({ where: { id, @@ -77,12 +122,26 @@ export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptio } // If the document is not a draft, only soft-delete. - return await prisma.document.update({ - where: { - id, - }, - data: { - deletedAt: new Date().toISOString(), - }, + return await prisma.$transaction(async (tx) => { + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + documentId: id, + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, + user, + requestMetadata, + data: { + type: 'SOFT', + }, + }), + }); + + return await tx.document.update({ + where: { + id, + }, + data: { + deletedAt: new Date().toISOString(), + }, + }); }); }; diff --git a/packages/lib/server-only/document/find-document-audit-logs.ts b/packages/lib/server-only/document/find-document-audit-logs.ts new file mode 100644 index 000000000..4f423ce8c --- /dev/null +++ b/packages/lib/server-only/document/find-document-audit-logs.ts @@ -0,0 +1,115 @@ +import type { FindResultSet } from '@documenso/lib/types/find-result-set'; +import { prisma } from '@documenso/prisma'; +import type { DocumentAuditLog } from '@documenso/prisma/client'; +import type { Prisma } from '@documenso/prisma/client'; + +import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; +import { parseDocumentAuditLogData } from '../../utils/document-audit-logs'; + +export interface FindDocumentAuditLogsOptions { + userId: number; + documentId: number; + page?: number; + perPage?: number; + orderBy?: { + column: keyof DocumentAuditLog; + direction: 'asc' | 'desc'; + }; + cursor?: string; + filterForRecentActivity?: boolean; +} + +export const findDocumentAuditLogs = async ({ + userId, + documentId, + page = 1, + perPage = 30, + orderBy, + cursor, + filterForRecentActivity, +}: FindDocumentAuditLogsOptions) => { + const orderByColumn = orderBy?.column ?? 'createdAt'; + const orderByDirection = orderBy?.direction ?? 'desc'; + + await prisma.document.findFirstOrThrow({ + where: { + id: documentId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], + }, + }); + + const whereClause: Prisma.DocumentAuditLogWhereInput = { + documentId, + }; + + // Filter events down to what we consider recent activity. + if (filterForRecentActivity) { + whereClause.OR = [ + { + type: { + in: [ + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED, + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED, + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, + ], + }, + }, + { + type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, + data: { + path: ['isResending'], + equals: true, + }, + }, + ]; + } + + const [data, count] = await Promise.all([ + prisma.documentAuditLog.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage + 1, + orderBy: { + [orderByColumn]: orderByDirection, + }, + cursor: cursor ? { id: cursor } : undefined, + }), + prisma.documentAuditLog.count({ + where: whereClause, + }), + ]); + + let nextCursor: string | undefined = undefined; + + const parsedData = data.map((auditLog) => parseDocumentAuditLogData(auditLog)); + + if (parsedData.length > perPage) { + const nextItem = parsedData.pop(); + nextCursor = nextItem!.id; + } + + return { + data: parsedData, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + nextCursor, + } satisfies FindResultSet & { nextCursor?: string }; +}; diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index fc174c084..aa44ccedf 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -152,13 +152,27 @@ export const sendDocument = async ({ }), ); - const updatedDocument = await prisma.document.update({ - where: { - id: documentId, - }, - data: { - status: DocumentStatus.PENDING, - }, + const updatedDocument = await prisma.$transaction(async (tx) => { + if (document.status === DocumentStatus.DRAFT) { + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, + documentId: document.id, + requestMetadata, + user, + data: {}, + }), + }); + } + + return await tx.document.update({ + where: { + id: documentId, + }, + data: { + status: DocumentStatus.PENDING, + }, + }); }); return updatedDocument; diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts index e6a954603..14d594786 100644 --- a/packages/lib/types/document-audit-logs.ts +++ b/packages/lib/types/document-audit-logs.ts @@ -21,15 +21,24 @@ export const ZDocumentAuditLogTypeSchema = z.enum([ 'RECIPIENT_UPDATED', // Document events. + 'DOCUMENT_COMPLETED', // When the document is sealed and fully completed. + 'DOCUMENT_CREATED', // When the document is created. + 'DOCUMENT_DELETED', // When the document is soft deleted. + 'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient. + 'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient. + 'DOCUMENT_META_UPDATED', // When the document meta data is updated. + 'DOCUMENT_OPENED', // When the document is opened by a recipient. + 'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document. + 'DOCUMENT_SENT', // When the document transitions from DRAFT to PENDING. + 'DOCUMENT_TITLE_UPDATED', // When the document title is updated. +]); + +export const ZDocumentAuditLogEmailTypeSchema = z.enum([ + 'SIGNING_REQUEST', + 'VIEW_REQUEST', + 'APPROVE_REQUEST', + 'CC', 'DOCUMENT_COMPLETED', - 'DOCUMENT_CREATED', - 'DOCUMENT_DELETED', - 'DOCUMENT_FIELD_INSERTED', - 'DOCUMENT_FIELD_UNINSERTED', - 'DOCUMENT_META_UPDATED', - 'DOCUMENT_OPENED', - 'DOCUMENT_TITLE_UPDATED', - 'DOCUMENT_RECIPIENT_COMPLETED', ]); export const ZDocumentMetaDiffTypeSchema = z.enum([ @@ -40,10 +49,12 @@ export const ZDocumentMetaDiffTypeSchema = z.enum([ 'SUBJECT', 'TIMEZONE', ]); + export const ZFieldDiffTypeSchema = z.enum(['DIMENSION', 'POSITION']); export const ZRecipientDiffTypeSchema = z.enum(['NAME', 'ROLE', 'EMAIL']); export const DOCUMENT_AUDIT_LOG_TYPE = ZDocumentAuditLogTypeSchema.Enum; +export const DOCUMENT_EMAIL_TYPE = ZDocumentAuditLogEmailTypeSchema.Enum; export const DOCUMENT_META_DIFF_TYPE = ZDocumentMetaDiffTypeSchema.Enum; export const FIELD_DIFF_TYPE = ZFieldDiffTypeSchema.Enum; export const RECIPIENT_DIFF_TYPE = ZRecipientDiffTypeSchema.Enum; @@ -140,13 +151,7 @@ const ZBaseRecipientDataSchema = z.object({ export const ZDocumentAuditLogEventEmailSentSchema = z.object({ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT), data: ZBaseRecipientDataSchema.extend({ - emailType: z.enum([ - 'SIGNING_REQUEST', - 'VIEW_REQUEST', - 'APPROVE_REQUEST', - 'CC', - 'DOCUMENT_COMPLETED', - ]), + emailType: ZDocumentAuditLogEmailTypeSchema, isResending: z.boolean(), }), }); @@ -171,6 +176,16 @@ export const ZDocumentAuditLogEventDocumentCreatedSchema = z.object({ }), }); +/** + * Event: Document deleted. + */ +export const ZDocumentAuditLogEventDocumentDeletedSchema = z.object({ + type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED), + data: z.object({ + type: z.enum(['SOFT', 'HARD']), + }), +}); + /** * Event: Document field inserted. */ @@ -247,6 +262,14 @@ export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({ data: ZBaseRecipientDataSchema, }); +/** + * Event: Document sent. + */ +export const ZDocumentAuditLogEventDocumentSentSchema = z.object({ + type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT), + data: z.object({}), +}); + /** * Event: Document title updated. */ @@ -314,6 +337,11 @@ export const ZDocumentAuditLogBaseSchema = z.object({ id: z.string(), createdAt: z.date(), documentId: z.number(), + name: z.string().optional().nullable(), + email: z.string().optional().nullable(), + userId: z.number().optional().nullable(), + userAgent: z.string().optional().nullable(), + ipAddress: z.string().optional().nullable(), }); export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and( @@ -321,11 +349,13 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and( ZDocumentAuditLogEventEmailSentSchema, ZDocumentAuditLogEventDocumentCompletedSchema, ZDocumentAuditLogEventDocumentCreatedSchema, + ZDocumentAuditLogEventDocumentDeletedSchema, ZDocumentAuditLogEventDocumentFieldInsertedSchema, ZDocumentAuditLogEventDocumentFieldUninsertedSchema, ZDocumentAuditLogEventDocumentMetaUpdatedSchema, ZDocumentAuditLogEventDocumentOpenedSchema, ZDocumentAuditLogEventDocumentRecipientCompleteSchema, + ZDocumentAuditLogEventDocumentSentSchema, ZDocumentAuditLogEventDocumentTitleUpdatedSchema, ZDocumentAuditLogEventFieldCreatedSchema, ZDocumentAuditLogEventFieldRemovedSchema, @@ -348,3 +378,8 @@ export type TDocumentAuditLogDocumentMetaDiffSchema = z.infer< export type TDocumentAuditLogRecipientDiffSchema = z.infer< typeof ZDocumentAuditLogRecipientDiffSchema >; + +export type DocumentAuditLogByType = Extract< + TDocumentAuditLog, + { type: T } +>; diff --git a/packages/lib/utils/document-audit-logs.ts b/packages/lib/utils/document-audit-logs.ts index dcc3932e9..65ffb2817 100644 --- a/packages/lib/utils/document-audit-logs.ts +++ b/packages/lib/utils/document-audit-logs.ts @@ -1,5 +1,14 @@ -import type { DocumentAuditLog, DocumentMeta, Field, Recipient } from '@documenso/prisma/client'; +import { match } from 'ts-pattern'; +import type { + DocumentAuditLog, + DocumentMeta, + Field, + Recipient, + RecipientRole, +} from '@documenso/prisma/client'; + +import { RECIPIENT_ROLES_DESCRIPTION } from '../constants/recipient-roles'; import type { TDocumentAuditLog, TDocumentAuditLogDocumentMetaDiffSchema, @@ -7,6 +16,7 @@ import type { TDocumentAuditLogRecipientDiffSchema, } from '../types/document-audit-logs'; import { + DOCUMENT_AUDIT_LOG_TYPE, DOCUMENT_META_DIFF_TYPE, FIELD_DIFF_TYPE, RECIPIENT_DIFF_TYPE, @@ -58,6 +68,7 @@ export const parseDocumentAuditLogData = (auditLog: DocumentAuditLog): TDocument // Handle any required migrations here. if (!data.success) { + console.error(data.error); throw new Error('Migration required'); } @@ -203,3 +214,114 @@ export const diffDocumentMetaChanges = ( return diffs; }; + +/** + * Formats the audit log into a description of the action. + * + * Provide a userId to prefix the action with the user, example 'X did Y'. + */ +export const formatDocumentAuditLogActionString = ( + auditLog: TDocumentAuditLog, + userId?: number, +) => { + const { prefix, description } = formatDocumentAuditLogAction(auditLog, userId); + + return prefix ? `${prefix} ${description}` : description; +}; + +/** + * Formats the audit log into a description of the action. + * + * Provide a userId to prefix the action with the user, example 'X did Y'. + */ +export const formatDocumentAuditLogAction = (auditLog: TDocumentAuditLog, userId?: number) => { + let prefix = userId === auditLog.userId ? 'You' : auditLog.name || auditLog.email || ''; + + const description = match(auditLog) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED }, () => ({ + anonymous: 'A field was added', + identified: 'added a field', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED }, () => ({ + anonymous: 'A field was removed', + identified: 'removed a field', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED }, () => ({ + anonymous: 'A field was updated', + identified: 'updated a field', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED }, () => ({ + anonymous: 'A recipient was added', + identified: 'added a recipient', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED }, () => ({ + anonymous: 'A recipient was removed', + identified: 'removed a recipient', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, () => ({ + anonymous: 'A recipient was updated', + identified: 'updated a recipient', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED }, () => ({ + anonymous: 'Document created', + identified: 'created the document', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED }, () => ({ + anonymous: 'Document deleted', + identified: 'deleted the document', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, () => ({ + anonymous: 'Field signed', + identified: 'signed a field', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, () => ({ + anonymous: 'Field unsigned', + identified: 'unsigned a field', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, () => ({ + anonymous: 'Document updated', + identified: 'updated the document', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED }, () => ({ + anonymous: 'Document opened', + identified: 'opened the document', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, () => ({ + anonymous: 'Document title updated', + identified: 'updated the document title', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT }, () => ({ + anonymous: 'Document sent', + identified: 'sent the document', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, ({ data }) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const action = RECIPIENT_ROLES_DESCRIPTION[data.recipientRole as RecipientRole]?.actioned; + + const value = action ? `${action.toLowerCase()} the document` : 'completed their task'; + + return { + anonymous: `Recipient ${value}`, + identified: value, + }; + }) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({ + anonymous: `Email ${data.isResending ? 'resent' : 'sent'}`, + identified: `${data.isResending ? 'resent' : 'sent'} an email`, + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED }, () => { + // Clear the prefix since this should be considered an 'anonymous' event. + prefix = ''; + + return { + anonymous: 'Document completed', + identified: 'Document completed', + }; + }) + .exhaustive(); + + return { + prefix, + description: prefix ? description.identified : description.anonymous, + }; +}; diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index aebc6e505..cd9491fd6 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -6,6 +6,7 @@ import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/ups import { createDocument } from '@documenso/lib/server-only/document/create-document'; import { deleteDocument } from '@documenso/lib/server-only/document/delete-document'; import { duplicateDocumentById } from '@documenso/lib/server-only/document/duplicate-document-by-id'; +import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { resendDocument } from '@documenso/lib/server-only/document/resend-document'; @@ -21,6 +22,7 @@ import { authenticatedProcedure, procedure, router } from '../trpc'; import { ZCreateDocumentMutationSchema, ZDeleteDraftDocumentMutationSchema, + ZFindDocumentAuditLogsQuerySchema, ZGetDocumentByIdQuerySchema, ZGetDocumentByTokenQuerySchema, ZResendDocumentMutationSchema, @@ -111,7 +113,12 @@ export const documentRouter = router({ const userId = ctx.user.id; - return await deleteDocument({ id, userId, status }); + return await deleteDocument({ + id, + userId, + status, + requestMetadata: extractNextApiRequestMetadata(ctx.req), + }); } catch (err) { console.error(err); @@ -122,6 +129,30 @@ export const documentRouter = router({ } }), + findDocumentAuditLogs: authenticatedProcedure + .input(ZFindDocumentAuditLogsQuerySchema) + .query(async ({ input, ctx }) => { + try { + const { perPage, documentId, cursor, filterForRecentActivity, orderBy } = input; + + return await findDocumentAuditLogs({ + perPage, + documentId, + cursor, + filterForRecentActivity, + orderBy, + userId: ctx.user.id, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to find audit logs for this document. Please try again later.', + }); + } + }), + setTitleForDocument: authenticatedProcedure .input(ZSetTitleForDocumentMutationSchema) .mutation(async ({ input, ctx }) => { diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index 899baa41f..83c05b3b3 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -1,8 +1,21 @@ import { z } from 'zod'; import { URL_REGEX } from '@documenso/lib/constants/url-regex'; +import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params'; import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client'; +export const ZFindDocumentAuditLogsQuerySchema = ZBaseTableSearchParamsSchema.extend({ + documentId: z.number().min(1), + cursor: z.string().optional(), + filterForRecentActivity: z.boolean().optional(), + orderBy: z + .object({ + column: z.enum(['createdAt', 'type']), + direction: z.enum(['asc', 'desc']), + }) + .optional(), +}); + export const ZGetDocumentByIdQuerySchema = z.object({ id: z.number().min(1), teamId: z.number().min(1).optional(), diff --git a/packages/trpc/server/team-router/schema.ts b/packages/trpc/server/team-router/schema.ts index 953b12490..75c307e35 100644 --- a/packages/trpc/server/team-router/schema.ts +++ b/packages/trpc/server/team-router/schema.ts @@ -3,10 +3,11 @@ import { z } from 'zod'; import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams'; import { TeamMemberRole } from '@documenso/prisma/client'; +// Consider refactoring to use ZBaseTableSearchParamsSchema. const GenericFindQuerySchema = z.object({ term: z.string().optional(), - page: z.number().optional(), - perPage: z.number().optional(), + page: z.number().min(1).optional(), + perPage: z.number().min(1).optional(), }); /** diff --git a/packages/ui/primitives/sheet.tsx b/packages/ui/primitives/sheet.tsx index a6326de0f..ef5348e59 100644 --- a/packages/ui/primitives/sheet.tsx +++ b/packages/ui/primitives/sheet.tsx @@ -143,14 +143,17 @@ const sheetVariants = cva( export interface DialogContentProps extends React.ComponentPropsWithoutRef, - VariantProps {} + VariantProps { + showOverlay?: boolean; + sheetClass?: string; +} const SheetContent = React.forwardRef< React.ElementRef, DialogContentProps ->(({ position, size, className, children, ...props }, ref) => ( +>(({ position, size, className, sheetClass, showOverlay = true, children, ...props }, ref) => ( - + {showOverlay && } Date: Thu, 15 Feb 2024 20:42:17 +1100 Subject: [PATCH 05/24] fix: styling --- .../documents/[id]/document-page-view-recent-activity.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recent-activity.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recent-activity.tsx index ef7d2e498..5890f8aa2 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recent-activity.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recent-activity.tsx @@ -37,6 +37,7 @@ export const DocumentPageViewRecentActivity = ({ column: 'createdAt', direction: 'asc', }, + perPage: 10, }, { getNextPageParam: (lastPage) => lastPage.nextCursor, @@ -77,7 +78,7 @@ export const DocumentPageViewRecentActivity = ({ {hasNextPage && (
  • -
    +
    From 5d6cdbef891b558900a0017aabe4c5090ee9b264 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu Date: Fri, 16 Feb 2024 20:46:27 +0000 Subject: [PATCH 06/24] feat: ability to download all the 2FA recovery codes --- .../forms/2fa/view-recovery-codes-dialog.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx index 18714332a..323bc7198 100644 --- a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx +++ b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; @@ -41,6 +41,7 @@ export type ViewRecoveryCodesDialogProps = { export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => { const { toast } = useToast(); + const [recoveryCodesUrl, setRecoveryCodesUrl] = useState(''); const { mutateAsync: viewRecoveryCodes, data: viewRecoveryCodesData } = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation(); @@ -62,6 +63,16 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode return 'view'; }, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]); + useEffect(() => { + if (viewRecoveryCodesData && viewRecoveryCodesData.recoveryCodes) { + const textBlob = new Blob([viewRecoveryCodesData.recoveryCodes.join('\n')], { + type: 'text/plain', + }); + if (recoveryCodesUrl) URL.revokeObjectURL(recoveryCodesUrl); + setRecoveryCodesUrl(URL.createObjectURL(textBlob)); + } + }, [viewRecoveryCodesData]); + const onViewRecoveryCodesFormSubmit = async ({ password }: TViewRecoveryCodesForm) => { try { await viewRecoveryCodes({ password }); @@ -139,8 +150,11 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode )} -
    +
    + + +
    )) From 0186f2dfeda8240be2bbf9e46e158f81d70e3321 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Sat, 17 Feb 2024 13:19:03 +0530 Subject: [PATCH 07/24] feat: ability to download 2FA recovery codes --- .../web/src/components/forms/2fa/view-recovery-codes-dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx index 323bc7198..cfdae7015 100644 --- a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx +++ b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx @@ -153,7 +153,7 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
    - +
    From 791a22cb5f833bd0578c09f4d1e8d629e27845f6 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 19 Feb 2024 13:22:48 +1100 Subject: [PATCH 08/24] refactor: add comments --- apps/web/src/components/document/document-history-sheet.tsx | 2 ++ packages/lib/constants/recipient-roles.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/document/document-history-sheet.tsx b/apps/web/src/components/document/document-history-sheet.tsx index 29d9a9c96..0d0c56aa2 100644 --- a/apps/web/src/components/document/document-history-sheet.tsx +++ b/apps/web/src/components/document/document-history-sheet.tsx @@ -51,6 +51,7 @@ export const DocumentHistorySheet = ({ }, { getNextPageParam: (lastPage) => lastPage.nextCursor, + keepPreviousData: true, }, ); @@ -168,6 +169,7 @@ export const DocumentHistorySheet = ({ }, ]; + // Insert the name to the start of the array if available. if (data.recipientName) { values.unshift({ key: 'Name', diff --git a/packages/lib/constants/recipient-roles.ts b/packages/lib/constants/recipient-roles.ts index 44e4c34da..ce1037dd9 100644 --- a/packages/lib/constants/recipient-roles.ts +++ b/packages/lib/constants/recipient-roles.ts @@ -9,7 +9,7 @@ export const RECIPIENT_ROLES_DESCRIPTION = { }, [RecipientRole.CC]: { actionVerb: 'CC', - actioned: 'CCed', + actioned: `CC'd`, progressiveVerb: 'CC', roleName: 'Cc', }, From ac6da9ab4569291c19d59cba1a80a202492bf261 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 19 Feb 2024 14:31:26 +1100 Subject: [PATCH 09/24] feat: add feature flag --- .../document-page-view-recent-activity.tsx | 6 +++++ .../documents/[id]/document-page-view.tsx | 23 ++++++++++++------- packages/lib/constants/feature-flags.ts | 1 + 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recent-activity.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recent-activity.tsx index 5890f8aa2..1c632355a 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recent-activity.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recent-activity.tsx @@ -94,6 +94,12 @@ export const DocumentPageViewRecentActivity = ({
  • )} + {documentAuditLogs.length === 0 && ( +
    +

    No recent activity

    +
    + )} + {documentAuditLogs.map((auditLog, auditLogIndex) => (
  • null); + const isDocumentHistoryEnabled = await getServerComponentFlag( + 'app_document_page_view_history_sheet', + ); + if (!document || !document.documentData) { redirect(documentRootPath); } @@ -120,14 +125,16 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
  • -
    - - - -
    + {isDocumentHistoryEnabled && ( +
    + + + +
    + )}
    diff --git a/packages/lib/constants/feature-flags.ts b/packages/lib/constants/feature-flags.ts index 947409be1..6815b73b0 100644 --- a/packages/lib/constants/feature-flags.ts +++ b/packages/lib/constants/feature-flags.ts @@ -18,6 +18,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000; export const LOCAL_FEATURE_FLAGS: Record = { app_billing: process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true', app_teams: true, + app_document_page_view_history_sheet: false, marketing_header_single_player_mode: false, } as const; From 39c6cbf66a9cb29f711e1af3b96243c31fe143ae Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu Date: Mon, 19 Feb 2024 11:25:15 +0000 Subject: [PATCH 10/24] feat: ability to download 2FA recovery codes --- .../2fa/enable-authenticator-app-dialog.tsx | 24 +++++++++++++++---- .../forms/2fa/view-recovery-codes-dialog.tsx | 10 ++++---- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx index 7a181c4cc..671292bde 100644 --- a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx +++ b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; @@ -56,6 +56,7 @@ export const EnableAuthenticatorAppDialog = ({ }: EnableAuthenticatorAppDialogProps) => { const router = useRouter(); const { toast } = useToast(); + const [recoveryCodesUrl, setRecoveryCodesUrl] = useState(''); const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } = trpc.twoFactorAuthentication.setup.useMutation(); @@ -115,6 +116,16 @@ export const EnableAuthenticatorAppDialog = ({ } }; + const downloadRecoveryCodes = () => { + if (enableTwoFactorAuthenticationData && enableTwoFactorAuthenticationData.recoveryCodes) { + const textBlob = new Blob([enableTwoFactorAuthenticationData.recoveryCodes.join('\n')], { + type: 'text/plain', + }); + if (recoveryCodesUrl) URL.revokeObjectURL(recoveryCodesUrl); + setRecoveryCodesUrl(URL.createObjectURL(textBlob)); + } + }; + const onEnableTwoFactorAuthenticationFormSubmit = async ({ token, }: TEnableTwoFactorAuthenticationForm) => { @@ -270,10 +281,13 @@ export const EnableAuthenticatorAppDialog = ({ )} -
    - +
    + + + +
    )) diff --git a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx index cfdae7015..797b61b84 100644 --- a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx +++ b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; @@ -63,7 +63,7 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode return 'view'; }, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]); - useEffect(() => { + const downloadRecoveryCodes = () => { if (viewRecoveryCodesData && viewRecoveryCodesData.recoveryCodes) { const textBlob = new Blob([viewRecoveryCodesData.recoveryCodes.join('\n')], { type: 'text/plain', @@ -71,7 +71,7 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode if (recoveryCodesUrl) URL.revokeObjectURL(recoveryCodesUrl); setRecoveryCodesUrl(URL.createObjectURL(textBlob)); } - }, [viewRecoveryCodesData]); + }; const onViewRecoveryCodesFormSubmit = async ({ password }: TViewRecoveryCodesForm) => { try { @@ -153,7 +153,9 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
    - +
    From b6c9213b66f00ece9074b3d301188b76546dd9d1 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Wed, 21 Feb 2024 00:58:57 +0000 Subject: [PATCH 11/24] fix: disable static generation of marketing site pages Disables the generation of the blog and content pages using generateStaticPaths to deal with a regression with routing introduced with next-runtime-env. --- apps/marketing/src/app/(marketing)/[content]/page.tsx | 5 ++--- apps/marketing/src/app/(marketing)/blog/[post]/page.tsx | 3 --- apps/marketing/src/app/(marketing)/blog/page.tsx | 1 + 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/marketing/src/app/(marketing)/[content]/page.tsx b/apps/marketing/src/app/(marketing)/[content]/page.tsx index ba23e6b81..72941fbc5 100644 --- a/apps/marketing/src/app/(marketing)/[content]/page.tsx +++ b/apps/marketing/src/app/(marketing)/[content]/page.tsx @@ -5,11 +5,10 @@ import { allDocuments } from 'contentlayer/generated'; import type { MDXComponents } from 'mdx/types'; import { useMDXComponent } from 'next-contentlayer/hooks'; -export const generateStaticParams = () => - allDocuments.map((post) => ({ post: post._raw.flattenedPath })); +export const dynamic = 'force-dynamic'; export const generateMetadata = ({ params }: { params: { content: string } }) => { - const document = allDocuments.find((post) => post._raw.flattenedPath === params.content); + const document = allDocuments.find((doc) => doc._raw.flattenedPath === params.content); if (!document) { return { title: 'Not Found' }; diff --git a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx index 495b8946e..14b8b2d8f 100644 --- a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx +++ b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx @@ -9,9 +9,6 @@ import { useMDXComponent } from 'next-contentlayer/hooks'; export const dynamic = 'force-dynamic'; -export const generateStaticParams = () => - allBlogPosts.map((post) => ({ post: post._raw.flattenedPath })); - export const generateMetadata = ({ params }: { params: { post: string } }) => { const blogPost = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`); diff --git a/apps/marketing/src/app/(marketing)/blog/page.tsx b/apps/marketing/src/app/(marketing)/blog/page.tsx index 2eac963d1..4be1ab694 100644 --- a/apps/marketing/src/app/(marketing)/blog/page.tsx +++ b/apps/marketing/src/app/(marketing)/blog/page.tsx @@ -5,6 +5,7 @@ import { allBlogPosts } from 'contentlayer/generated'; export const metadata: Metadata = { title: 'Blog', }; + export default function BlogPage() { const blogPosts = allBlogPosts.sort((a, b) => { const dateA = new Date(a.date); From aba6b58c14581d302649adb9967da1d3dec73f76 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Wed, 21 Feb 2024 02:19:35 +0000 Subject: [PATCH 12/24] fix: simplify download api --- .../2fa/enable-authenticator-app-dialog.tsx | 46 +++++++++---------- packages/lib/client-only/download-file.ts | 19 ++++++++ packages/lib/client-only/download-pdf.ts | 13 ++---- 3 files changed, 46 insertions(+), 32 deletions(-) create mode 100644 packages/lib/client-only/download-file.ts diff --git a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx index 671292bde..27560c073 100644 --- a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx +++ b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx @@ -1,14 +1,12 @@ -import { useMemo, useState } from 'react'; - -import { useRouter } from 'next/navigation'; +import { useMemo } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { flushSync } from 'react-dom'; import { useForm } from 'react-hook-form'; import { match } from 'ts-pattern'; import { renderSVG } from 'uqr'; import { z } from 'zod'; +import { downloadFile } from '@documenso/lib/client-only/download-file'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -54,15 +52,16 @@ export const EnableAuthenticatorAppDialog = ({ open, onOpenChange, }: EnableAuthenticatorAppDialogProps) => { - const router = useRouter(); const { toast } = useToast(); - const [recoveryCodesUrl, setRecoveryCodesUrl] = useState(''); const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } = trpc.twoFactorAuthentication.setup.useMutation(); - const { mutateAsync: enableTwoFactorAuthentication, data: enableTwoFactorAuthenticationData } = - trpc.twoFactorAuthentication.enable.useMutation(); + const { + mutateAsync: enableTwoFactorAuthentication, + data: enableTwoFactorAuthenticationData, + isLoading: isEnableTwoFactorAuthenticationDataLoading, + } = trpc.twoFactorAuthentication.enable.useMutation(); const setupTwoFactorAuthenticationForm = useForm({ defaultValues: { @@ -118,11 +117,14 @@ export const EnableAuthenticatorAppDialog = ({ const downloadRecoveryCodes = () => { if (enableTwoFactorAuthenticationData && enableTwoFactorAuthenticationData.recoveryCodes) { - const textBlob = new Blob([enableTwoFactorAuthenticationData.recoveryCodes.join('\n')], { + const blob = new Blob([enableTwoFactorAuthenticationData.recoveryCodes.join('\n')], { type: 'text/plain', }); - if (recoveryCodesUrl) URL.revokeObjectURL(recoveryCodesUrl); - setRecoveryCodesUrl(URL.createObjectURL(textBlob)); + + downloadFile({ + filename: 'documenso-2FA-recovery-codes.txt', + data: blob, + }); } }; @@ -147,14 +149,6 @@ export const EnableAuthenticatorAppDialog = ({ } }; - const onCompleteClick = () => { - flushSync(() => { - onOpenChange(false); - }); - - router.refresh(); - }; - return ( @@ -283,11 +277,15 @@ export const EnableAuthenticatorAppDialog = ({
    - - - + +
    )) diff --git a/packages/lib/client-only/download-file.ts b/packages/lib/client-only/download-file.ts new file mode 100644 index 000000000..36351bedc --- /dev/null +++ b/packages/lib/client-only/download-file.ts @@ -0,0 +1,19 @@ +export type DownloadFileOptions = { + filename: string; + data: Blob; +}; + +export const downloadFile = ({ filename, data }: DownloadFileOptions) => { + if (typeof window === 'undefined') { + throw new Error('downloadFile can only be called in browser environments'); + } + + const link = window.document.createElement('a'); + + link.href = window.URL.createObjectURL(data); + link.download = filename; + + link.click(); + + window.URL.revokeObjectURL(link.href); +}; diff --git a/packages/lib/client-only/download-pdf.ts b/packages/lib/client-only/download-pdf.ts index ec7d0c252..0f757c98d 100644 --- a/packages/lib/client-only/download-pdf.ts +++ b/packages/lib/client-only/download-pdf.ts @@ -1,6 +1,7 @@ import type { DocumentData } from '@documenso/prisma/client'; import { getFile } from '../universal/upload/get-file'; +import { downloadFile } from './download-file'; type DownloadPDFProps = { documentData: DocumentData; @@ -14,16 +15,12 @@ export const downloadPDF = async ({ documentData, fileName }: DownloadPDFProps) type: 'application/pdf', }); - const link = window.document.createElement('a'); - const [baseTitle] = fileName?.includes('.pdf') ? fileName.split('.pdf') : [fileName ?? 'document']; - link.href = window.URL.createObjectURL(blob); - link.download = `${baseTitle}_signed.pdf`; - - link.click(); - - window.URL.revokeObjectURL(link.href); + downloadFile({ + filename: baseTitle, + data: blob, + }); }; From 8287722f59f9b9f128045ce358db80139db9ea81 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Wed, 21 Feb 2024 02:29:19 +0000 Subject: [PATCH 13/24] fix: update view dialog to use new download api --- .../forms/2fa/view-recovery-codes-dialog.tsx | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx index 797b61b84..376a8939c 100644 --- a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx +++ b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx @@ -1,10 +1,11 @@ -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { match } from 'ts-pattern'; import { z } from 'zod'; +import { downloadFile } from '@documenso/lib/client-only/download-file'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -41,10 +42,12 @@ export type ViewRecoveryCodesDialogProps = { export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => { const { toast } = useToast(); - const [recoveryCodesUrl, setRecoveryCodesUrl] = useState(''); - const { mutateAsync: viewRecoveryCodes, data: viewRecoveryCodesData } = - trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation(); + const { + mutateAsync: viewRecoveryCodes, + data: viewRecoveryCodesData, + isLoading: isViewRecoveryCodesDataLoading, + } = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation(); const viewRecoveryCodesForm = useForm({ defaultValues: { @@ -65,11 +68,14 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode const downloadRecoveryCodes = () => { if (viewRecoveryCodesData && viewRecoveryCodesData.recoveryCodes) { - const textBlob = new Blob([viewRecoveryCodesData.recoveryCodes.join('\n')], { + const blob = new Blob([viewRecoveryCodesData.recoveryCodes.join('\n')], { type: 'text/plain', }); - if (recoveryCodesUrl) URL.revokeObjectURL(recoveryCodesUrl); - setRecoveryCodesUrl(URL.createObjectURL(textBlob)); + + downloadFile({ + filename: 'documenso-2FA-recovery-codes.txt', + data: blob, + }); } }; @@ -152,11 +158,15 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
    - - - + +
    )) From a48bda0d273acf22a81d7000a79d7e11d363797e Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+dephraiim@users.noreply.github.com> Date: Thu, 22 Feb 2024 05:36:34 +0000 Subject: [PATCH 14/24] feat: add page numbers to documents (#946) --- packages/ui/primitives/pdf-viewer.tsx | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/ui/primitives/pdf-viewer.tsx b/packages/ui/primitives/pdf-viewer.tsx index b4e5c10ba..1069290e6 100644 --- a/packages/ui/primitives/pdf-viewer.tsx +++ b/packages/ui/primitives/pdf-viewer.tsx @@ -233,18 +233,20 @@ export const PDFViewer = ({ {Array(numPages) .fill(null) .map((_, i) => ( -
    - ''} - onClick={(e) => onDocumentPageClick(e, i + 1)} - /> +
    +
    + ''} + onClick={(e) => onDocumentPageClick(e, i + 1)} + /> +
    +

    + Page {i + 1} of {numPages} +

    ))} From dd29845934985a046ec1ab8255d604ef2e2deef8 Mon Sep 17 00:00:00 2001 From: Sumit Bisht <75713174+sumitbishti@users.noreply.github.com> Date: Thu, 22 Feb 2024 11:19:54 +0530 Subject: [PATCH 15/24] fix: put max height in teams section in profile dropdown (#947) fixes: #942 --- .../(dashboard)/layout/menu-switcher.tsx | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx index 195716d64..765343d27 100644 --- a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx +++ b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx @@ -166,22 +166,24 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
    - {teams.map((team) => ( - - - - ) - } - /> - - - ))} +
    + {teams.map((team) => ( + + + + ) + } + /> + + + ))} +
    ) : ( From 34825aaf3a031f1d01a41b4e7b7b0082b3b00ff2 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 22 Feb 2024 19:13:35 +1100 Subject: [PATCH 16/24] feat: add separate document audit page --- .../[id]/document-page-view-information.tsx | 1 + .../[id]/logs/document-logs-data-table.tsx | 165 ++++++++++++++++++ .../[id]/logs/document-logs-page-view.tsx | 150 ++++++++++++++++ .../(dashboard)/documents/[id]/logs/page.tsx | 11 ++ .../t/[teamUrl]/documents/[id]/logs/page.tsx | 20 +++ .../trpc/server/document-router/router.ts | 3 +- 6 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-data-table.tsx create mode 100644 apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx create mode 100644 apps/web/src/app/(dashboard)/documents/[id]/logs/page.tsx create mode 100644 apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/logs/page.tsx diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx index af0bc8644..24a85bacc 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx @@ -21,6 +21,7 @@ export const DocumentPageViewInformation = ({ userId, }: DocumentPageViewInformationProps) => { const isMounted = useIsMounted(); + const { locale } = useLocale(); const documentInformation = useMemo(() => { diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-data-table.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-data-table.tsx new file mode 100644 index 000000000..bdfdc8658 --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-data-table.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; + +import { DateTime } from 'luxon'; +import type { DateTimeFormatOptions } from 'luxon'; +import { UAParser } from 'ua-parser-js'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs'; +import { trpc } from '@documenso/trpc/react'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; + +import { LocaleDate } from '~/components/formatter/locale-date'; + +export type DocumentLogsDataTableProps = { + documentId: number; +}; + +const dateFormat: DateTimeFormatOptions = { + ...DateTime.DATETIME_SHORT, + hourCycle: 'h12', +}; + +export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps) => { + const parser = new UAParser(); + + const searchParams = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const parsedSearchParams = ZBaseTableSearchParamsSchema.parse( + Object.fromEntries(searchParams ?? []), + ); + + const { data, isLoading, isInitialLoading, isLoadingError } = + trpc.document.findDocumentAuditLogs.useQuery( + { + documentId, + page: parsedSearchParams.page, + perPage: parsedSearchParams.perPage, + }, + { + keepPreviousData: true, + }, + ); + + const onPaginationChange = (page: number, perPage: number) => { + updateSearchParams({ + page, + perPage, + }); + }; + + const uppercaseFistLetter = (text: string) => { + return text.charAt(0).toUpperCase() + text.slice(1); + }; + + const results = data ?? { + data: [], + perPage: 10, + currentPage: 1, + totalPages: 1, + }; + + return ( + , + }, + { + header: 'User', + accessorKey: 'name', + cell: ({ row }) => + row.original.name || row.original.email ? ( +
    + {row.original.name && ( +

    + {row.original.name} +

    + )} + + {row.original.email && ( +

    + {row.original.email} +

    + )} +
    + ) : ( +

    N/A

    + ), + }, + { + header: 'Action', + accessorKey: 'type', + cell: ({ row }) => ( + + {uppercaseFistLetter(formatDocumentAuditLogAction(row.original).description)} + + ), + }, + { + header: 'IP Address', + accessorKey: 'ipAddress', + }, + { + header: 'Browser', + cell: ({ row }) => { + if (!row.original.userAgent) { + return 'N/A'; + } + + parser.setUA(row.original.userAgent); + + const result = parser.getResult(); + + return result.browser.name ?? 'N/A'; + }, + }, + ]} + data={results.data} + perPage={results.perPage} + currentPage={results.currentPage} + totalPages={results.totalPages} + onPaginationChange={onPaginationChange} + error={{ + enable: isLoadingError, + }} + skeleton={{ + enable: isLoading && isInitialLoading, + rows: 3, + component: ( + <> + + + + +
    + + +
    +
    + + + + + + + + + + + ), + }} + > + {(table) => } +
    + ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx new file mode 100644 index 000000000..e9627d2c7 --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx @@ -0,0 +1,150 @@ +import Link from 'next/link'; +import { redirect } from 'next/navigation'; + +import { ChevronLeft, DownloadIcon } from 'lucide-react'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; +import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; +import type { Recipient, Team } from '@documenso/prisma/client'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card } from '@documenso/ui/primitives/card'; + +import { FRIENDLY_STATUS_MAP } from '~/components/formatter/document-status'; + +import { DocumentLogsDataTable } from './document-logs-data-table'; + +export type DocumentLogsPageViewProps = { + params: { + id: string; + }; + team?: Team; +}; + +export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageViewProps) => { + const { id } = params; + + const documentId = Number(id); + + const documentRootPath = formatDocumentsPath(team?.url); + + if (!documentId || Number.isNaN(documentId)) { + redirect(documentRootPath); + } + + const { user } = await getRequiredServerComponentSession(); + + const [document, recipients] = await Promise.all([ + getDocumentById({ + id: documentId, + userId: user.id, + teamId: team?.id, + }).catch(() => null), + getRecipientsForDocument({ + documentId, + userId: user.id, + }), + ]); + + if (!document || !document.documentData) { + redirect(documentRootPath); + } + + const documentInformation: { description: string; value: string }[] = [ + { + description: 'Document title', + value: document.title, + }, + { + description: 'Document ID', + value: document.id.toString(), + }, + { + description: 'Document status', + value: FRIENDLY_STATUS_MAP[document.status].label, + }, + { + description: 'Created by', + value: document.User.name ?? document.User.email, + }, + { + description: 'Date created', + value: document.createdAt.toISOString(), + }, + { + description: 'Last updated', + value: document.updatedAt.toISOString(), + }, + { + description: 'Time zone', + value: document.documentMeta?.timezone ?? 'N/A', + }, + ]; + + const formatRecipientText = (recipient: Recipient) => { + let text = recipient.email; + + if (recipient.name) { + text = `${recipient.name} (${recipient.email})`; + } + + return `${text} - ${recipient.role}`; + }; + + return ( +
    + + + Document + + +
    +

    + {document.title} +

    + +
    + + + +
    +
    + +
    + + {documentInformation.map((info, i) => ( +
    +

    {info.description}

    +

    {info.value}

    +
    + ))} + +
    +

    Recipients

    +
      + {recipients.map((recipient) => ( +
    • + {formatRecipientText(recipient)} +
    • + ))} +
    +
    +
    +
    + +
    + +
    +
    + ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/page.tsx new file mode 100644 index 000000000..e21f8459b --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/page.tsx @@ -0,0 +1,11 @@ +import { DocumentLogsPageView } from './document-logs-page-view'; + +export type DocumentsLogsPageProps = { + params: { + id: string; + }; +}; + +export default function DocumentsLogsPage({ params }: DocumentsLogsPageProps) { + return ; +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/logs/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/logs/page.tsx new file mode 100644 index 000000000..4f514dd56 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/logs/page.tsx @@ -0,0 +1,20 @@ +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; + +import { DocumentLogsPageView } from '~/app/(dashboard)/documents/[id]/logs/document-logs-page-view'; + +export type TeamDocumentsLogsPageProps = { + params: { + id: string; + teamUrl: string; + }; +}; + +export default async function TeamsDocumentsLogsPage({ params }: TeamDocumentsLogsPageProps) { + const { teamUrl } = params; + + const { user } = await getRequiredServerComponentSession(); + const team = await getTeamByUrl({ userId: user.id, teamUrl }); + + return ; +} diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index cd9491fd6..eb833684a 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -133,9 +133,10 @@ export const documentRouter = router({ .input(ZFindDocumentAuditLogsQuerySchema) .query(async ({ input, ctx }) => { try { - const { perPage, documentId, cursor, filterForRecentActivity, orderBy } = input; + const { page, perPage, documentId, cursor, filterForRecentActivity, orderBy } = input; return await findDocumentAuditLogs({ + page, perPage, documentId, cursor, From 306e5ff31f01cd2b168550e159b84e505163ac34 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 22 Feb 2024 11:05:49 +0000 Subject: [PATCH 17/24] fix: add timeouts to longer transactions --- .../server-only/document/resend-document.tsx | 72 +++++----- .../document/send-completed-email.ts | 75 +++++----- .../server-only/document/send-document.tsx | 87 +++++------ .../lib/server-only/document/update-title.ts | 44 +++--- .../team/accept-team-invitation.ts | 85 +++++------ .../team/create-team-email-verification.ts | 85 +++++------ .../server-only/team/delete-team-members.ts | 135 +++++++++--------- packages/lib/server-only/team/delete-team.ts | 59 ++++---- packages/lib/server-only/team/leave-team.ts | 69 ++++----- .../team/request-team-ownership-transfer.ts | 105 +++++++------- .../team/resend-team-email-verification.ts | 75 +++++----- .../team/resend-team-member-invitation.ts | 65 +++++---- .../team/transfer-team-ownership.ts | 133 ++++++++--------- packages/lib/server-only/user/create-user.ts | 81 ++++++----- 14 files changed, 607 insertions(+), 563 deletions(-) diff --git a/packages/lib/server-only/document/resend-document.tsx b/packages/lib/server-only/document/resend-document.tsx index 0dbda803e..ebf140007 100644 --- a/packages/lib/server-only/document/resend-document.tsx +++ b/packages/lib/server-only/document/resend-document.tsx @@ -16,9 +16,8 @@ import { prisma } from '@documenso/prisma'; import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import type { Prisma } from '@documenso/prisma/client'; -import { getDocumentWhereInput } from './get-document-by-id'; - import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; +import { getDocumentWhereInput } from './get-document-by-id'; export type ResendDocumentOptions = { documentId: number; @@ -111,40 +110,43 @@ export const resendDocument = async ({ const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; - await prisma.$transaction(async (tx) => { - await mailer.sendMail({ - to: { - address: email, - name, - }, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, - subject: customEmail?.subject - ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) - : `Please ${actionVerb.toLowerCase()} this document`, - html: render(template), - text: render(template, { plainText: true }), - }); - - await tx.documentAuditLog.create({ - data: createDocumentAuditLogData({ - type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, - documentId: document.id, - user, - requestMetadata, - data: { - emailType: recipientEmailType, - recipientEmail: recipient.email, - recipientName: recipient.name, - recipientRole: recipient.role, - recipientId: recipient.id, - isResending: true, + await prisma.$transaction( + async (tx) => { + await mailer.sendMail({ + to: { + address: email, + name, }, - }), - }); - }); + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: customEmail?.subject + ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) + : `Please ${actionVerb.toLowerCase()} this document`, + html: render(template), + text: render(template, { plainText: true }), + }); + + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, + documentId: document.id, + user, + requestMetadata, + data: { + emailType: recipientEmailType, + recipientEmail: recipient.email, + recipientName: recipient.name, + recipientRole: recipient.role, + recipientId: recipient.id, + isResending: true, + }, + }), + }); + }, + { timeout: 30_000 }, + ); }), ); }; diff --git a/packages/lib/server-only/document/send-completed-email.ts b/packages/lib/server-only/document/send-completed-email.ts index ad8207303..812e54ba3 100644 --- a/packages/lib/server-only/document/send-completed-email.ts +++ b/packages/lib/server-only/document/send-completed-email.ts @@ -49,44 +49,47 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo downloadLink: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${token}/complete`, }); - await prisma.$transaction(async (tx) => { - await mailer.sendMail({ - to: { - address: email, - name, - }, - from: { - name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso', - address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com', - }, - subject: 'Signing Complete!', - html: render(template), - text: render(template, { plainText: true }), - attachments: [ - { - filename: document.title, - content: Buffer.from(buffer), + await prisma.$transaction( + async (tx) => { + await mailer.sendMail({ + to: { + address: email, + name, }, - ], - }); + from: { + name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso', + address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com', + }, + subject: 'Signing Complete!', + html: render(template), + text: render(template, { plainText: true }), + attachments: [ + { + filename: document.title, + content: Buffer.from(buffer), + }, + ], + }); - await tx.documentAuditLog.create({ - data: createDocumentAuditLogData({ - type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, - documentId: document.id, - user: null, - requestMetadata, - data: { - emailType: 'DOCUMENT_COMPLETED', - recipientEmail: recipient.email, - recipientName: recipient.name, - recipientId: recipient.id, - recipientRole: recipient.role, - isResending: false, - }, - }), - }); - }); + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, + documentId: document.id, + user: null, + requestMetadata, + data: { + emailType: 'DOCUMENT_COMPLETED', + recipientEmail: recipient.email, + recipientName: recipient.name, + recipientId: recipient.id, + recipientRole: recipient.role, + isResending: false, + }, + }), + }); + }, + { timeout: 30_000 }, + ); }), ); }; diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index be26ffcaf..4d85a8f32 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -108,49 +108,52 @@ export const sendDocument = async ({ const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; - await prisma.$transaction(async (tx) => { - await mailer.sendMail({ - to: { - address: email, - name, - }, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, - subject: customEmail?.subject - ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) - : `Please ${actionVerb.toLowerCase()} this document`, - html: render(template), - text: render(template, { plainText: true }), - }); - - await tx.recipient.update({ - where: { - id: recipient.id, - }, - data: { - sendStatus: SendStatus.SENT, - }, - }); - - await tx.documentAuditLog.create({ - data: createDocumentAuditLogData({ - type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, - documentId: document.id, - user, - requestMetadata, - data: { - emailType: recipientEmailType, - recipientEmail: recipient.email, - recipientName: recipient.name, - recipientRole: recipient.role, - recipientId: recipient.id, - isResending: false, + await prisma.$transaction( + async (tx) => { + await mailer.sendMail({ + to: { + address: email, + name, }, - }), - }); - }); + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: customEmail?.subject + ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) + : `Please ${actionVerb.toLowerCase()} this document`, + html: render(template), + text: render(template, { plainText: true }), + }); + + await tx.recipient.update({ + where: { + id: recipient.id, + }, + data: { + sendStatus: SendStatus.SENT, + }, + }); + + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, + documentId: document.id, + user, + requestMetadata, + data: { + emailType: recipientEmailType, + recipientEmail: recipient.email, + recipientName: recipient.name, + recipientRole: recipient.role, + recipientId: recipient.id, + isResending: false, + }, + }), + }); + }, + { timeout: 30_000 }, + ); }), ); diff --git a/packages/lib/server-only/document/update-title.ts b/packages/lib/server-only/document/update-title.ts index 3e934e7be..f7f7a6b88 100644 --- a/packages/lib/server-only/document/update-title.ts +++ b/packages/lib/server-only/document/update-title.ts @@ -24,34 +24,38 @@ export const updateTitle = async ({ }, }); - return await prisma.$transaction(async (tx) => { - const document = await tx.document.findFirstOrThrow({ - where: { - id: documentId, - OR: [ - { - userId, - }, - { - team: { - members: { - some: { - userId, - }, + const document = await prisma.document.findFirstOrThrow({ + where: { + id: documentId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, }, }, }, - ], - }, - }); + }, + ], + }, + }); - if (document.title === title) { - return document; - } + if (document.title === title) { + return document; + } + return await prisma.$transaction(async (tx) => { + // Instead of doing everything in a transaction we can use our knowledge + // of the current document title to ensure we aren't performing a conflicting + // update. const updatedDocument = await tx.document.update({ where: { id: documentId, + title: document.title, }, data: { title, diff --git a/packages/lib/server-only/team/accept-team-invitation.ts b/packages/lib/server-only/team/accept-team-invitation.ts index 4bfd31dfa..31fef5967 100644 --- a/packages/lib/server-only/team/accept-team-invitation.ts +++ b/packages/lib/server-only/team/accept-team-invitation.ts @@ -9,55 +9,58 @@ export type AcceptTeamInvitationOptions = { }; export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitationOptions) => { - await prisma.$transaction(async (tx) => { - const user = await tx.user.findFirstOrThrow({ - where: { - id: userId, - }, - }); + await prisma.$transaction( + async (tx) => { + const user = await tx.user.findFirstOrThrow({ + where: { + id: userId, + }, + }); - const teamMemberInvite = await tx.teamMemberInvite.findFirstOrThrow({ - where: { - teamId, - email: user.email, - }, - include: { - team: { - include: { - subscription: true, + const teamMemberInvite = await tx.teamMemberInvite.findFirstOrThrow({ + where: { + teamId, + email: user.email, + }, + include: { + team: { + include: { + subscription: true, + }, }, }, - }, - }); + }); - const { team } = teamMemberInvite; + const { team } = teamMemberInvite; - await tx.teamMember.create({ - data: { - teamId: teamMemberInvite.teamId, - userId: user.id, - role: teamMemberInvite.role, - }, - }); - - await tx.teamMemberInvite.delete({ - where: { - id: teamMemberInvite.id, - }, - }); - - if (IS_BILLING_ENABLED() && team.subscription) { - const numberOfSeats = await tx.teamMember.count({ - where: { + await tx.teamMember.create({ + data: { teamId: teamMemberInvite.teamId, + userId: user.id, + role: teamMemberInvite.role, }, }); - await updateSubscriptionItemQuantity({ - priceId: team.subscription.priceId, - subscriptionId: team.subscription.planId, - quantity: numberOfSeats, + await tx.teamMemberInvite.delete({ + where: { + id: teamMemberInvite.id, + }, }); - } - }); + + if (IS_BILLING_ENABLED() && team.subscription) { + const numberOfSeats = await tx.teamMember.count({ + where: { + teamId: teamMemberInvite.teamId, + }, + }); + + await updateSubscriptionItemQuantity({ + priceId: team.subscription.priceId, + subscriptionId: team.subscription.planId, + quantity: numberOfSeats, + }); + } + }, + { timeout: 30_000 }, + ); }; diff --git a/packages/lib/server-only/team/create-team-email-verification.ts b/packages/lib/server-only/team/create-team-email-verification.ts index 28e1538d0..86cded7a9 100644 --- a/packages/lib/server-only/team/create-team-email-verification.ts +++ b/packages/lib/server-only/team/create-team-email-verification.ts @@ -28,56 +28,59 @@ export const createTeamEmailVerification = async ({ data, }: CreateTeamEmailVerificationOptions) => { try { - await prisma.$transaction(async (tx) => { - const team = await tx.team.findFirstOrThrow({ - where: { - id: teamId, - members: { - some: { - userId, - role: { - in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + await prisma.$transaction( + async (tx) => { + const team = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, }, }, }, - }, - include: { - teamEmail: true, - emailVerification: true, - }, - }); + include: { + teamEmail: true, + emailVerification: true, + }, + }); - if (team.teamEmail || team.emailVerification) { - throw new AppError( - AppErrorCode.INVALID_REQUEST, - 'Team already has an email or existing email verification.', - ); - } + if (team.teamEmail || team.emailVerification) { + throw new AppError( + AppErrorCode.INVALID_REQUEST, + 'Team already has an email or existing email verification.', + ); + } - const existingTeamEmail = await tx.teamEmail.findFirst({ - where: { - email: data.email, - }, - }); + const existingTeamEmail = await tx.teamEmail.findFirst({ + where: { + email: data.email, + }, + }); - if (existingTeamEmail) { - throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.'); - } + if (existingTeamEmail) { + throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.'); + } - const { token, expiresAt } = createTokenVerification({ hours: 1 }); + const { token, expiresAt } = createTokenVerification({ hours: 1 }); - await tx.teamEmailVerification.create({ - data: { - token, - expiresAt, - email: data.email, - name: data.name, - teamId, - }, - }); + await tx.teamEmailVerification.create({ + data: { + token, + expiresAt, + email: data.email, + name: data.name, + teamId, + }, + }); - await sendTeamEmailVerificationEmail(data.email, token, team.name, team.url); - }); + await sendTeamEmailVerificationEmail(data.email, token, team.name, team.url); + }, + { timeout: 30_000 }, + ); } catch (err) { console.error(err); diff --git a/packages/lib/server-only/team/delete-team-members.ts b/packages/lib/server-only/team/delete-team-members.ts index 162d7de53..14b75a473 100644 --- a/packages/lib/server-only/team/delete-team-members.ts +++ b/packages/lib/server-only/team/delete-team-members.ts @@ -27,76 +27,81 @@ export const deleteTeamMembers = async ({ teamId, teamMemberIds, }: DeleteTeamMembersOptions) => { - await prisma.$transaction(async (tx) => { - // Find the team and validate that the user is allowed to remove members. - const team = await tx.team.findFirstOrThrow({ - where: { - id: teamId, - members: { - some: { - userId, - role: { - in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + await prisma.$transaction( + async (tx) => { + // Find the team and validate that the user is allowed to remove members. + const team = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, }, }, }, - }, - include: { - members: { - select: { - id: true, - userId: true, - role: true, + include: { + members: { + select: { + id: true, + userId: true, + role: true, + }, + }, + subscription: true, + }, + }); + + const currentTeamMember = team.members.find((member) => member.userId === userId); + const teamMembersToRemove = team.members.filter((member) => + teamMemberIds.includes(member.id), + ); + + if (!currentTeamMember) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Team member record does not exist'); + } + + if (teamMembersToRemove.find((member) => member.userId === team.ownerUserId)) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove the team owner'); + } + + const isMemberToRemoveHigherRole = teamMembersToRemove.some( + (member) => !isTeamRoleWithinUserHierarchy(currentTeamMember.role, member.role), + ); + + if (isMemberToRemoveHigherRole) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove a member with a higher role'); + } + + // Remove the team members. + await tx.teamMember.deleteMany({ + where: { + id: { + in: teamMemberIds, + }, + teamId, + userId: { + not: team.ownerUserId, }, }, - subscription: true, - }, - }); - - const currentTeamMember = team.members.find((member) => member.userId === userId); - const teamMembersToRemove = team.members.filter((member) => teamMemberIds.includes(member.id)); - - if (!currentTeamMember) { - throw new AppError(AppErrorCode.NOT_FOUND, 'Team member record does not exist'); - } - - if (teamMembersToRemove.find((member) => member.userId === team.ownerUserId)) { - throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove the team owner'); - } - - const isMemberToRemoveHigherRole = teamMembersToRemove.some( - (member) => !isTeamRoleWithinUserHierarchy(currentTeamMember.role, member.role), - ); - - if (isMemberToRemoveHigherRole) { - throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove a member with a higher role'); - } - - // Remove the team members. - await tx.teamMember.deleteMany({ - where: { - id: { - in: teamMemberIds, - }, - teamId, - userId: { - not: team.ownerUserId, - }, - }, - }); - - if (IS_BILLING_ENABLED() && team.subscription) { - const numberOfSeats = await tx.teamMember.count({ - where: { - teamId, - }, }); - await updateSubscriptionItemQuantity({ - priceId: team.subscription.priceId, - subscriptionId: team.subscription.planId, - quantity: numberOfSeats, - }); - } - }); + if (IS_BILLING_ENABLED() && team.subscription) { + const numberOfSeats = await tx.teamMember.count({ + where: { + teamId, + }, + }); + + await updateSubscriptionItemQuantity({ + priceId: team.subscription.priceId, + subscriptionId: team.subscription.planId, + quantity: numberOfSeats, + }); + } + }, + { timeout: 30_000 }, + ); }; diff --git a/packages/lib/server-only/team/delete-team.ts b/packages/lib/server-only/team/delete-team.ts index dffc044d8..667d2f448 100644 --- a/packages/lib/server-only/team/delete-team.ts +++ b/packages/lib/server-only/team/delete-team.ts @@ -9,34 +9,37 @@ export type DeleteTeamOptions = { }; export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => { - await prisma.$transaction(async (tx) => { - const team = await tx.team.findFirstOrThrow({ - where: { - id: teamId, - ownerUserId: userId, - }, - include: { - subscription: true, - }, - }); + await prisma.$transaction( + async (tx) => { + const team = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + ownerUserId: userId, + }, + include: { + subscription: true, + }, + }); - if (team.subscription) { - await stripe.subscriptions - .cancel(team.subscription.planId, { - prorate: false, - invoice_now: true, - }) - .catch((err) => { - console.error(err); - throw AppError.parseError(err); - }); - } + if (team.subscription) { + await stripe.subscriptions + .cancel(team.subscription.planId, { + prorate: false, + invoice_now: true, + }) + .catch((err) => { + console.error(err); + throw AppError.parseError(err); + }); + } - await tx.team.delete({ - where: { - id: teamId, - ownerUserId: userId, - }, - }); - }); + await tx.team.delete({ + where: { + id: teamId, + ownerUserId: userId, + }, + }); + }, + { timeout: 30_000 }, + ); }; diff --git a/packages/lib/server-only/team/leave-team.ts b/packages/lib/server-only/team/leave-team.ts index 509c38950..410d0707c 100644 --- a/packages/lib/server-only/team/leave-team.ts +++ b/packages/lib/server-only/team/leave-team.ts @@ -15,45 +15,48 @@ export type LeaveTeamOptions = { }; export const leaveTeam = async ({ userId, teamId }: LeaveTeamOptions) => { - await prisma.$transaction(async (tx) => { - const team = await tx.team.findFirstOrThrow({ - where: { - id: teamId, - ownerUserId: { - not: userId, - }, - }, - include: { - subscription: true, - }, - }); - - await tx.teamMember.delete({ - where: { - userId_teamId: { - userId, - teamId, - }, - team: { + await prisma.$transaction( + async (tx) => { + const team = await tx.team.findFirstOrThrow({ + where: { + id: teamId, ownerUserId: { not: userId, }, }, - }, - }); - - if (IS_BILLING_ENABLED() && team.subscription) { - const numberOfSeats = await tx.teamMember.count({ - where: { - teamId, + include: { + subscription: true, }, }); - await updateSubscriptionItemQuantity({ - priceId: team.subscription.priceId, - subscriptionId: team.subscription.planId, - quantity: numberOfSeats, + await tx.teamMember.delete({ + where: { + userId_teamId: { + userId, + teamId, + }, + team: { + ownerUserId: { + not: userId, + }, + }, + }, }); - } - }); + + if (IS_BILLING_ENABLED() && team.subscription) { + const numberOfSeats = await tx.teamMember.count({ + where: { + teamId, + }, + }); + + await updateSubscriptionItemQuantity({ + priceId: team.subscription.priceId, + subscriptionId: team.subscription.planId, + quantity: numberOfSeats, + }); + } + }, + { timeout: 30_000 }, + ); }; diff --git a/packages/lib/server-only/team/request-team-ownership-transfer.ts b/packages/lib/server-only/team/request-team-ownership-transfer.ts index 7da976ee1..92fd5b61e 100644 --- a/packages/lib/server-only/team/request-team-ownership-transfer.ts +++ b/packages/lib/server-only/team/request-team-ownership-transfer.ts @@ -44,63 +44,66 @@ export const requestTeamOwnershipTransfer = async ({ // Todo: Clear payment methods disabled for now. const clearPaymentMethods = false; - await prisma.$transaction(async (tx) => { - const team = await tx.team.findFirstOrThrow({ - where: { - id: teamId, - ownerUserId: userId, - members: { - some: { - userId: newOwnerUserId, + await prisma.$transaction( + async (tx) => { + const team = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + ownerUserId: userId, + members: { + some: { + userId: newOwnerUserId, + }, }, }, - }, - }); + }); - const newOwnerUser = await tx.user.findFirstOrThrow({ - where: { - id: newOwnerUserId, - }, - }); + const newOwnerUser = await tx.user.findFirstOrThrow({ + where: { + id: newOwnerUserId, + }, + }); - const { token, expiresAt } = createTokenVerification({ minute: 10 }); + const { token, expiresAt } = createTokenVerification({ minute: 10 }); - const teamVerificationPayload = { - teamId, - token, - expiresAt, - userId: newOwnerUserId, - name: newOwnerUser.name ?? '', - email: newOwnerUser.email, - clearPaymentMethods, - }; - - await tx.teamTransferVerification.upsert({ - where: { + const teamVerificationPayload = { teamId, - }, - create: teamVerificationPayload, - update: teamVerificationPayload, - }); + token, + expiresAt, + userId: newOwnerUserId, + name: newOwnerUser.name ?? '', + email: newOwnerUser.email, + clearPaymentMethods, + }; - const template = createElement(TeamTransferRequestTemplate, { - assetBaseUrl: WEBAPP_BASE_URL, - baseUrl: WEBAPP_BASE_URL, - senderName: userName, - teamName: team.name, - teamUrl: team.url, - token, - }); + await tx.teamTransferVerification.upsert({ + where: { + teamId, + }, + create: teamVerificationPayload, + update: teamVerificationPayload, + }); - await mailer.sendMail({ - to: newOwnerUser.email, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, - subject: `You have been requested to take ownership of team ${team.name} on Documenso`, - html: render(template), - text: render(template, { plainText: true }), - }); - }); + const template = createElement(TeamTransferRequestTemplate, { + assetBaseUrl: WEBAPP_BASE_URL, + baseUrl: WEBAPP_BASE_URL, + senderName: userName, + teamName: team.name, + teamUrl: team.url, + token, + }); + + await mailer.sendMail({ + to: newOwnerUser.email, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: `You have been requested to take ownership of team ${team.name} on Documenso`, + html: render(template), + text: render(template, { plainText: true }), + }); + }, + { timeout: 30_000 }, + ); }; diff --git a/packages/lib/server-only/team/resend-team-email-verification.ts b/packages/lib/server-only/team/resend-team-email-verification.ts index 55afe61ce..b1d6ffe99 100644 --- a/packages/lib/server-only/team/resend-team-email-verification.ts +++ b/packages/lib/server-only/team/resend-team-email-verification.ts @@ -17,49 +17,52 @@ export const resendTeamEmailVerification = async ({ userId, teamId, }: ResendTeamMemberInvitationOptions) => { - await prisma.$transaction(async (tx) => { - const team = await tx.team.findUniqueOrThrow({ - where: { - id: teamId, - members: { - some: { - userId, - role: { - in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + await prisma.$transaction( + async (tx) => { + const team = await tx.team.findUniqueOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, }, }, }, - }, - include: { - emailVerification: true, - }, - }); + include: { + emailVerification: true, + }, + }); - if (!team) { - throw new AppError('TeamNotFound', 'User is not a member of the team.'); - } + if (!team) { + throw new AppError('TeamNotFound', 'User is not a member of the team.'); + } - const { emailVerification } = team; + const { emailVerification } = team; - if (!emailVerification) { - throw new AppError( - 'VerificationNotFound', - 'No team email verification exists for this team.', - ); - } + if (!emailVerification) { + throw new AppError( + 'VerificationNotFound', + 'No team email verification exists for this team.', + ); + } - const { token, expiresAt } = createTokenVerification({ hours: 1 }); + const { token, expiresAt } = createTokenVerification({ hours: 1 }); - await tx.teamEmailVerification.update({ - where: { - teamId, - }, - data: { - token, - expiresAt, - }, - }); + await tx.teamEmailVerification.update({ + where: { + teamId, + }, + data: { + token, + expiresAt, + }, + }); - await sendTeamEmailVerificationEmail(emailVerification.email, token, team.name, team.url); - }); + await sendTeamEmailVerificationEmail(emailVerification.email, token, team.name, team.url); + }, + { timeout: 30_000 }, + ); }; diff --git a/packages/lib/server-only/team/resend-team-member-invitation.ts b/packages/lib/server-only/team/resend-team-member-invitation.ts index fb860ccc0..897154a2a 100644 --- a/packages/lib/server-only/team/resend-team-member-invitation.ts +++ b/packages/lib/server-only/team/resend-team-member-invitation.ts @@ -35,42 +35,45 @@ export const resendTeamMemberInvitation = async ({ teamId, invitationId, }: ResendTeamMemberInvitationOptions) => { - await prisma.$transaction(async (tx) => { - const team = await tx.team.findUniqueOrThrow({ - where: { - id: teamId, - members: { - some: { - userId, - role: { - in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + await prisma.$transaction( + async (tx) => { + const team = await tx.team.findUniqueOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, }, }, }, - }, - }); + }); - if (!team) { - throw new AppError('TeamNotFound', 'User is not a valid member of the team.'); - } + if (!team) { + throw new AppError('TeamNotFound', 'User is not a valid member of the team.'); + } - const teamMemberInvite = await tx.teamMemberInvite.findUniqueOrThrow({ - where: { - id: invitationId, - teamId, - }, - }); + const teamMemberInvite = await tx.teamMemberInvite.findUniqueOrThrow({ + where: { + id: invitationId, + teamId, + }, + }); - if (!teamMemberInvite) { - throw new AppError('InviteNotFound', 'No invite exists for this user.'); - } + if (!teamMemberInvite) { + throw new AppError('InviteNotFound', 'No invite exists for this user.'); + } - await sendTeamMemberInviteEmail({ - email: teamMemberInvite.email, - token: teamMemberInvite.token, - teamName: team.name, - teamUrl: team.url, - senderName: userName, - }); - }); + await sendTeamMemberInviteEmail({ + email: teamMemberInvite.email, + token: teamMemberInvite.token, + teamName: team.name, + teamUrl: team.url, + senderName: userName, + }); + }, + { timeout: 30_000 }, + ); }; diff --git a/packages/lib/server-only/team/transfer-team-ownership.ts b/packages/lib/server-only/team/transfer-team-ownership.ts index 7b886d323..b87c04b91 100644 --- a/packages/lib/server-only/team/transfer-team-ownership.ts +++ b/packages/lib/server-only/team/transfer-team-ownership.ts @@ -11,78 +11,81 @@ export type TransferTeamOwnershipOptions = { }; export const transferTeamOwnership = async ({ token }: TransferTeamOwnershipOptions) => { - await prisma.$transaction(async (tx) => { - const teamTransferVerification = await tx.teamTransferVerification.findFirstOrThrow({ - where: { - token, - }, - include: { - team: { - include: { - subscription: true, + await prisma.$transaction( + async (tx) => { + const teamTransferVerification = await tx.teamTransferVerification.findFirstOrThrow({ + where: { + token, + }, + include: { + team: { + include: { + subscription: true, + }, }, }, - }, - }); - - const { team, userId: newOwnerUserId } = teamTransferVerification; - - await tx.teamTransferVerification.delete({ - where: { - teamId: team.id, - }, - }); - - const newOwnerUser = await tx.user.findFirstOrThrow({ - where: { - id: newOwnerUserId, - teamMembers: { - some: { - teamId: team.id, - }, - }, - }, - include: { - Subscription: true, - }, - }); - - let teamSubscription: Stripe.Subscription | null = null; - - if (IS_BILLING_ENABLED()) { - teamSubscription = await transferTeamSubscription({ - user: newOwnerUser, - team, - clearPaymentMethods: teamTransferVerification.clearPaymentMethods, }); - } - if (teamSubscription) { - await tx.subscription.upsert( - mapStripeSubscriptionToPrismaUpsertAction(teamSubscription, undefined, team.id), - ); - } + const { team, userId: newOwnerUserId } = teamTransferVerification; - await tx.team.update({ - where: { - id: team.id, - }, - data: { - ownerUserId: newOwnerUserId, - members: { - update: { - where: { - userId_teamId: { - teamId: team.id, - userId: newOwnerUserId, + await tx.teamTransferVerification.delete({ + where: { + teamId: team.id, + }, + }); + + const newOwnerUser = await tx.user.findFirstOrThrow({ + where: { + id: newOwnerUserId, + teamMembers: { + some: { + teamId: team.id, + }, + }, + }, + include: { + Subscription: true, + }, + }); + + let teamSubscription: Stripe.Subscription | null = null; + + if (IS_BILLING_ENABLED()) { + teamSubscription = await transferTeamSubscription({ + user: newOwnerUser, + team, + clearPaymentMethods: teamTransferVerification.clearPaymentMethods, + }); + } + + if (teamSubscription) { + await tx.subscription.upsert( + mapStripeSubscriptionToPrismaUpsertAction(teamSubscription, undefined, team.id), + ); + } + + await tx.team.update({ + where: { + id: team.id, + }, + data: { + ownerUserId: newOwnerUserId, + members: { + update: { + where: { + userId_teamId: { + teamId: team.id, + userId: newOwnerUserId, + }, + }, + data: { + role: TeamMemberRole.ADMIN, }, }, - data: { - role: TeamMemberRole.ADMIN, - }, }, }, - }, - }); - }); + }); + }, + { timeout: 30_000 }, + ); }; diff --git a/packages/lib/server-only/user/create-user.ts b/packages/lib/server-only/user/create-user.ts index 9a40696db..1852dc12e 100644 --- a/packages/lib/server-only/user/create-user.ts +++ b/packages/lib/server-only/user/create-user.ts @@ -53,47 +53,50 @@ export const createUser = async ({ name, email, password, signature }: CreateUse await Promise.allSettled( acceptedTeamInvites.map(async (invite) => prisma - .$transaction(async (tx) => { - await tx.teamMember.create({ - data: { - teamId: invite.teamId, - userId: user.id, - role: invite.role, - }, - }); - - await tx.teamMemberInvite.delete({ - where: { - id: invite.id, - }, - }); - - if (!IS_BILLING_ENABLED()) { - return; - } - - const team = await tx.team.findFirstOrThrow({ - where: { - id: invite.teamId, - }, - include: { - members: { - select: { - id: true, - }, + .$transaction( + async (tx) => { + await tx.teamMember.create({ + data: { + teamId: invite.teamId, + userId: user.id, + role: invite.role, }, - subscription: true, - }, - }); - - if (team.subscription) { - await updateSubscriptionItemQuantity({ - priceId: team.subscription.priceId, - subscriptionId: team.subscription.planId, - quantity: team.members.length, }); - } - }) + + await tx.teamMemberInvite.delete({ + where: { + id: invite.id, + }, + }); + + if (!IS_BILLING_ENABLED()) { + return; + } + + const team = await tx.team.findFirstOrThrow({ + where: { + id: invite.teamId, + }, + include: { + members: { + select: { + id: true, + }, + }, + subscription: true, + }, + }); + + if (team.subscription) { + await updateSubscriptionItemQuantity({ + priceId: team.subscription.priceId, + subscriptionId: team.subscription.planId, + quantity: team.members.length, + }); + } + }, + { timeout: 30_000 }, + ) .catch(async () => { await prisma.teamMemberInvite.update({ where: { From a58fee2da65cfe300c605788f56ffe347bdaa6d7 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 22 Feb 2024 22:58:51 +1100 Subject: [PATCH 18/24] fix: e2e tests --- ...dd-document-search-to-command-menu.spec.ts | 6 +-- packages/app-tests/e2e/test-auth-flow.spec.ts | 47 +++++++++++-------- packages/prisma/seed/users.ts | 19 ++++++++ 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts b/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts index 160113f95..44cfe1e37 100644 --- a/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts +++ b/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts @@ -15,7 +15,7 @@ test('[PR-713]: should see sent documents', async ({ page }) => { await page.keyboard.press('Meta+K'); - await page.getByPlaceholder('Type a command or search...').fill('sent'); + await page.getByPlaceholder('Type a command or search...').first().fill('sent'); await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible(); }); @@ -32,7 +32,7 @@ test('[PR-713]: should see received documents', async ({ page }) => { await page.keyboard.press('Meta+K'); - await page.getByPlaceholder('Type a command or search...').fill('received'); + await page.getByPlaceholder('Type a command or search...').first().fill('received'); await expect(page.getByRole('option', { name: '[713] Document - Received' })).toBeVisible(); }); @@ -49,6 +49,6 @@ test('[PR-713]: should be able to search by recipient', async ({ page }) => { await page.keyboard.press('Meta+K'); - await page.getByPlaceholder('Type a command or search...').fill(recipient.email); + await page.getByPlaceholder('Type a command or search...').first().fill(recipient.email); await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible(); }); diff --git a/packages/app-tests/e2e/test-auth-flow.spec.ts b/packages/app-tests/e2e/test-auth-flow.spec.ts index 40ee5e768..57c25bb26 100644 --- a/packages/app-tests/e2e/test-auth-flow.spec.ts +++ b/packages/app-tests/e2e/test-auth-flow.spec.ts @@ -1,20 +1,19 @@ import { type Page, expect, test } from '@playwright/test'; -import { deleteUser } from '@documenso/lib/server-only/user/delete-user'; +import { + extractUserVerificationToken, + seedUser, + unseedUser, + unseedUserByEmail, +} from '@documenso/prisma/seed/users'; test.use({ storageState: { cookies: [], origins: [] } }); -/* - Using them sequentially so the 2nd test - uses the details from the 1st (registration) test -*/ -test.describe.configure({ mode: 'serial' }); - -const username = 'Test User'; -const email = 'test-user@auth-flow.documenso.com'; -const password = 'Password123#'; - test('user can sign up with email and password', async ({ page }: { page: Page }) => { + const username = 'Test User'; + const email = `test-user-${Date.now()}@auth-flow.documenso.com`; + const password = 'Password123#'; + await page.goto('/signup'); await page.getByLabel('Name').fill(username); await page.getByLabel('Email').fill(email); @@ -31,25 +30,33 @@ test('user can sign up with email and password', async ({ page }: { page: Page } } await page.getByRole('button', { name: 'Sign Up', exact: true }).click(); + + await page.waitForURL('/unverified-account'); + + const { token } = await extractUserVerificationToken(email); + + await page.goto(`/verify-email/${token}`); + + await expect(page.getByRole('heading')).toContainText('Email Confirmed!'); + + await page.getByRole('link', { name: 'Go back home' }).click(); + await page.waitForURL('/documents'); await expect(page).toHaveURL('/documents'); + await unseedUserByEmail(email); }); test('user can login with user and password', async ({ page }: { page: Page }) => { + const user = await seedUser(); + await page.goto('/signin'); - await page.getByLabel('Email').fill(email); - await page.getByLabel('Password', { exact: true }).fill(password); + await page.getByLabel('Email').fill(user.email); + await page.getByLabel('Password', { exact: true }).fill('password'); await page.getByRole('button', { name: 'Sign In' }).click(); await page.waitForURL('/documents'); await expect(page).toHaveURL('/documents'); -}); -test.afterAll('Teardown', async () => { - try { - await deleteUser({ email }); - } catch (e) { - throw new Error(`Error deleting user: ${e}`); - } + await unseedUser(user.id); }); diff --git a/packages/prisma/seed/users.ts b/packages/prisma/seed/users.ts index ce3858bc6..f4dd714ed 100644 --- a/packages/prisma/seed/users.ts +++ b/packages/prisma/seed/users.ts @@ -32,3 +32,22 @@ export const unseedUser = async (userId: number) => { }, }); }; + +export const unseedUserByEmail = async (email: string) => { + await prisma.user.delete({ + where: { + email, + }, + }); +}; + +export const extractUserVerificationToken = async (email: string) => { + return await prisma.verificationToken.findFirstOrThrow({ + where: { + identifier: 'confirmation-email', + user: { + email, + }, + }, + }); +}; From c43655978755103a5155081168875b96b8ac943d Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Thu, 22 Feb 2024 20:13:17 +0000 Subject: [PATCH 19/24] feat: create a banner with custom text by admin --- .../(dashboard)/admin/banner/banner-form.tsx | 119 ++++++++++++++++++ .../src/app/(dashboard)/admin/banner/page.tsx | 15 +++ apps/web/src/app/(dashboard)/admin/nav.tsx | 18 ++- apps/web/src/app/(dashboard)/layout.tsx | 3 + .../components/(dashboard)/layout/banner.tsx | 21 ++++ packages/lib/server-only/admin/update-user.ts | 2 +- packages/lib/server-only/banner/get-banner.ts | 11 ++ .../lib/server-only/banner/upsert-banner.ts | 28 +++++ .../migration.sql | 12 ++ .../20240222183231_banner_show/migration.sql | 2 + .../migration.sql | 8 ++ packages/prisma/schema.prisma | 27 ++-- packages/trpc/server/banner-router/router.ts | 27 ++++ packages/trpc/server/banner-router/schema.ts | 8 ++ packages/trpc/server/router.ts | 2 + 15 files changed, 291 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/admin/banner/banner-form.tsx create mode 100644 apps/web/src/app/(dashboard)/admin/banner/page.tsx create mode 100644 apps/web/src/components/(dashboard)/layout/banner.tsx create mode 100644 packages/lib/server-only/banner/get-banner.ts create mode 100644 packages/lib/server-only/banner/upsert-banner.ts create mode 100644 packages/prisma/migrations/20240222183156_display_banner/migration.sql create mode 100644 packages/prisma/migrations/20240222183231_banner_show/migration.sql create mode 100644 packages/prisma/migrations/20240222185936_remove_custom/migration.sql create mode 100644 packages/trpc/server/banner-router/router.ts create mode 100644 packages/trpc/server/banner-router/schema.ts diff --git a/apps/web/src/app/(dashboard)/admin/banner/banner-form.tsx b/apps/web/src/app/(dashboard)/admin/banner/banner-form.tsx new file mode 100644 index 000000000..ce7e3e181 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/banner/banner-form.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { TRPCClientError } from '@documenso/trpc/client'; +import { trpc as trpcReact } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Switch } from '@documenso/ui/primitives/switch'; +import { Textarea } from '@documenso/ui/primitives/textarea'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +const ZBannerSchema = z.object({ + text: z.string().optional(), + show: z.boolean().optional(), +}); + +type TBannerSchema = z.infer; + +export function BannerForm({ show, text }: TBannerSchema) { + const router = useRouter(); + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(ZBannerSchema), + defaultValues: { + show, + text, + }, + }); + + const { mutateAsync: updateBanner, isLoading: isUpdatingBanner } = + trpcReact.banner.updateBanner.useMutation(); + + const onBannerUpdate = async ({ show, text }: TBannerSchema) => { + try { + await updateBanner({ + show, + text, + }); + + toast({ + title: 'Banner Updated', + description: 'Your banner has been updated successfully.', + duration: 5000, + }); + + router.refresh(); + } catch (err) { + if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') { + toast({ + title: 'An error occurred', + description: err.message, + variant: 'destructive', + }); + } else { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + description: + 'We encountered an unknown error while attempting to reset your password. Please try again later.', + }); + } + } + }; + + return ( +
    + + ( + +
    + Show Banner + Show a banner to the users by the admin +
    + + + +
    + )} + /> + + ( + + Banner Text + +