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);
diff --git a/apps/marketing/src/app/layout.tsx b/apps/marketing/src/app/layout.tsx
index 57da42c3f..99a1a6483 100644
--- a/apps/marketing/src/app/layout.tsx
+++ b/apps/marketing/src/app/layout.tsx
@@ -2,6 +2,8 @@ import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google';
+import { PublicEnvScript } from 'next-runtime-env';
+
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
import { NEXT_PUBLIC_MARKETING_URL } from '@documenso/lib/constants/app';
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
@@ -62,6 +64,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
+
diff --git a/apps/marketing/src/components/(marketing)/pricing-table.tsx b/apps/marketing/src/components/(marketing)/pricing-table.tsx
index 0d9956d86..bdaa9fdf4 100644
--- a/apps/marketing/src/components/(marketing)/pricing-table.tsx
+++ b/apps/marketing/src/components/(marketing)/pricing-table.tsx
@@ -114,7 +114,9 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
diff --git a/apps/web/package.json b/apps/web/package.json
index fd4faa0c1..3f485d9bd 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -42,6 +42,7 @@
"react-hotkeys-hook": "^4.4.1",
"react-icons": "^4.11.0",
"react-rnd": "^10.4.1",
+ "remeda": "^1.27.1",
"sharp": "0.33.1",
"ts-pattern": "^5.0.5",
"typescript": "5.2.2",
diff --git a/apps/web/src/app/(dashboard)/admin/nav.tsx b/apps/web/src/app/(dashboard)/admin/nav.tsx
index 089861069..b0d652283 100644
--- a/apps/web/src/app/(dashboard)/admin/nav.tsx
+++ b/apps/web/src/app/(dashboard)/admin/nav.tsx
@@ -1,11 +1,11 @@
'use client';
-import { HTMLAttributes } from 'react';
+import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
-import { BarChart3, FileStack, User2, Wallet2 } from 'lucide-react';
+import { BarChart3, FileStack, Settings, User2, Wallet2 } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -78,6 +78,20 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
Subscriptions
+
+
);
};
diff --git a/apps/web/src/app/(dashboard)/admin/site-settings/banner-form.tsx b/apps/web/src/app/(dashboard)/admin/site-settings/banner-form.tsx
new file mode 100644
index 000000000..351e146ff
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/admin/site-settings/banner-form.tsx
@@ -0,0 +1,200 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useForm } from 'react-hook-form';
+import type { z } from 'zod';
+
+import type { TSiteSettingsBannerSchema } from '@documenso/lib/server-only/site-settings/schemas/banner';
+import {
+ SITE_SETTINGS_BANNER_ID,
+ ZSiteSettingsBannerSchema,
+} from '@documenso/lib/server-only/site-settings/schemas/banner';
+import { TRPCClientError } from '@documenso/trpc/client';
+import { trpc as trpcReact } from '@documenso/trpc/react';
+import { Button } from '@documenso/ui/primitives/button';
+import { ColorPicker } from '@documenso/ui/primitives/color-picker';
+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 ZBannerFormSchema = ZSiteSettingsBannerSchema;
+
+type TBannerFormSchema = z.infer;
+
+export type BannerFormProps = {
+ banner?: TSiteSettingsBannerSchema;
+};
+
+export function BannerForm({ banner }: BannerFormProps) {
+ const router = useRouter();
+ const { toast } = useToast();
+
+ const form = useForm({
+ resolver: zodResolver(ZBannerFormSchema),
+ defaultValues: {
+ id: SITE_SETTINGS_BANNER_ID,
+ enabled: banner?.enabled ?? false,
+ data: {
+ content: banner?.data?.content ?? '',
+ bgColor: banner?.data?.bgColor ?? '#000000',
+ textColor: banner?.data?.textColor ?? '#FFFFFF',
+ },
+ },
+ });
+
+ const enabled = form.watch('enabled');
+
+ const { mutateAsync: updateSiteSetting, isLoading: isUpdateSiteSettingLoading } =
+ trpcReact.admin.updateSiteSetting.useMutation();
+
+ const onBannerUpdate = async ({ id, enabled, data }: TBannerFormSchema) => {
+ try {
+ await updateSiteSetting({
+ id,
+ enabled,
+ data,
+ });
+
+ 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 update the banner. Please try again later.',
+ });
+ }
+ }
+ };
+
+ return (
+
+
Site Banner
+
+ The site banner is a message that is shown at the top of the site. It can be used to display
+ important information to your users.
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/admin/site-settings/page.tsx b/apps/web/src/app/(dashboard)/admin/site-settings/page.tsx
new file mode 100644
index 000000000..bffb72ff0
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/admin/site-settings/page.tsx
@@ -0,0 +1,24 @@
+import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
+import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
+
+import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
+
+import { BannerForm } from './banner-form';
+
+// import { BannerForm } from './banner-form';
+
+export default async function AdminBannerPage() {
+ const banner = await getSiteSettings().then((settings) =>
+ settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
+ );
+
+ return (
+
+ );
+}
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..24a85bacc
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx
@@ -0,0 +1,72 @@
+'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-recent-activity.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recent-activity.tsx
new file mode 100644
index 000000000..1c632355a
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recent-activity.tsx
@@ -0,0 +1,153 @@
+'use client';
+
+import { useMemo } from 'react';
+
+import { CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react';
+import { DateTime } from 'luxon';
+import { match } from 'ts-pattern';
+
+import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
+import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
+import { trpc } from '@documenso/trpc/react';
+import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
+import { cn } from '@documenso/ui/lib/utils';
+
+export type DocumentPageViewRecentActivityProps = {
+ documentId: number;
+ userId: number;
+};
+
+export const DocumentPageViewRecentActivity = ({
+ documentId,
+ userId,
+}: DocumentPageViewRecentActivityProps) => {
+ const {
+ data,
+ isLoading,
+ isLoadingError,
+ refetch,
+ hasNextPage,
+ fetchNextPage,
+ isFetchingNextPage,
+ } = trpc.document.findDocumentAuditLogs.useInfiniteQuery(
+ {
+ documentId,
+ filterForRecentActivity: true,
+ orderBy: {
+ column: 'createdAt',
+ direction: 'asc',
+ },
+ perPage: 10,
+ },
+ {
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
+ },
+ );
+
+ const documentAuditLogs = useMemo(() => (data?.pages ?? []).flatMap((page) => page.data), [data]);
+
+ return (
+
+
+
Recent activity
+
+ {/* Can add dropdown menu here for additional options. */}
+
+
+ {isLoading && (
+
+
+
+ )}
+
+ {isLoadingError && (
+
+
Unable to load document history
+
+
+ )}
+
+
+ {data && (
+
+ )}
+
+
+ );
+};
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 6759d91ac..e12a745a2 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,34 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
-import { ChevronLeft, 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 { 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 { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
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 } from '@documenso/prisma/client';
import type { Team } from '@documenso/prisma/client';
-import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
+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 { 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 { DocumentHistorySheet } from '~/components/document/document-history-sheet';
+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';
+import { DocumentPageViewRecentActivity } from './document-page-view-recent-activity';
+import { DocumentPageViewRecipients } from './document-page-view-recipients';
export type DocumentPageViewProps = {
params: {
@@ -44,6 +56,10 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
teamId: team?.id,
}).catch(() => null);
+ const isDocumentHistoryEnabled = await getServerComponentFlag(
+ 'app_document_page_view_history_sheet',
+ );
+
if (!document || !document.documentData) {
redirect(documentRootPath);
}
@@ -67,65 +83,122 @@ 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)
+
+
+ )}
+
+
+
+ {isDocumentHistoryEnabled && (
+
+
+
+
)}
- {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. */}
+
+
+ {/* Recent activity section. */}
+
+
- )}
+
);
};
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/[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/(dashboard)/documents/_action-items/resend-document.tsx b/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx
index 4bcb25a6c..5bfd85645 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
@@ -118,7 +118,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 },
() => (
- {teams.map((team) => (
-
-
-
- )
- }
- />
-
-
- ))}
+
+ {teams.map((team) => (
+
+
+
+ )
+ }
+ />
+
+
+ ))}
+
>
) : (
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..0d0c56aa2
--- /dev/null
+++ b/apps/web/src/components/document/document-history-sheet.tsx
@@ -0,0 +1,318 @@
+'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,
+ keepPreviousData: true,
+ },
+ );
+
+ 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 && (
+
+ )}
+
+
+ );
+};
diff --git a/apps/web/src/components/formatter/document-status.tsx b/apps/web/src/components/formatter/document-status.tsx
index 3deaa302a..f8a8ef98b 100644
--- a/apps/web/src/components/formatter/document-status.tsx
+++ b/apps/web/src/components/formatter/document-status.tsx
@@ -13,7 +13,7 @@ type FriendlyStatus = {
color: string;
};
-const FRIENDLY_STATUS_MAP: Record = {
+export const FRIENDLY_STATUS_MAP: Record = {
PENDING: {
label: 'Pending',
icon: Clock,
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/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..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 } from 'react';
-import { useRouter } from 'next/navigation';
-
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,14 +52,16 @@ export const EnableAuthenticatorAppDialog = ({
open,
onOpenChange,
}: EnableAuthenticatorAppDialogProps) => {
- const router = useRouter();
const { toast } = useToast();
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: {
@@ -115,6 +115,19 @@ export const EnableAuthenticatorAppDialog = ({
}
};
+ const downloadRecoveryCodes = () => {
+ if (enableTwoFactorAuthenticationData && enableTwoFactorAuthenticationData.recoveryCodes) {
+ const blob = new Blob([enableTwoFactorAuthenticationData.recoveryCodes.join('\n')], {
+ type: 'text/plain',
+ });
+
+ downloadFile({
+ filename: 'documenso-2FA-recovery-codes.txt',
+ data: blob,
+ });
+ }
+ };
+
const onEnableTwoFactorAuthenticationFormSubmit = async ({
token,
}: TEnableTwoFactorAuthenticationForm) => {
@@ -136,14 +149,6 @@ export const EnableAuthenticatorAppDialog = ({
}
};
- const onCompleteClick = () => {
- flushSync(() => {
- onOpenChange(false);
- });
-
- router.refresh();
- };
-
return (