Compare commits

..

3 Commits

Author SHA1 Message Date
David Nguyen f9eeaf1db8 feat: add document version history UI 2024-02-15 18:20:10 +11:00
David Nguyen 82b87739d0 fix: update document links 2024-02-12 19:00:47 +11:00
David Nguyen 071475769c feat: add document page view 2024-02-12 17:30:23 +11:00
91 changed files with 2136 additions and 1704 deletions
@@ -2,12 +2,9 @@
import React, { useEffect, useState } from 'react';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import backgroundPattern from '@documenso/assets/images/background-lw-2.png';
import { cn } from '@documenso/ui/lib/utils';
import { AnnouncementBar } from '@documenso/ui/primitives/announcement-bar';
import { Footer } from '~/components/(marketing)/footer';
import { Header } from '~/components/(marketing)/header';
@@ -41,14 +38,6 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
'bg-background/50 backdrop-blur-md': scrollY > 5,
})}
>
<div className="absolute -inset-0 -z-[1] opacity-100">
<Image
src={backgroundPattern}
alt="background pattern"
className="h-[2rem] w-full object-cover"
/>
</div>
<AnnouncementBar className="relative" isShown={true} />
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
</div>
+1 -1
View File
@@ -43,7 +43,7 @@ export default function NotFound() {
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
<Button
variant="secondary"
variant="ghost"
className="w-32"
onClick={() => {
void router.back();
@@ -40,9 +40,9 @@ export const Callout = ({ starCount }: CalloutProps) => {
className="rounded-full bg-transparent backdrop-blur-sm"
onClick={onSignUpClick}
>
Claim Community Plan
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs font-medium">
-80%
Get the Early Adopters Plan
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
$30/mo. forever!
</span>
</Button>
@@ -1,4 +1,4 @@
import type { HTMLAttributes } from 'react';
import { HTMLAttributes } from 'react';
import Image from 'next/image';
@@ -74,15 +74,6 @@ export const Header = ({ className, ...props }: HeaderProps) => {
>
Sign in
</Link>
<Link
href="https://app.documenso.com/signin"
target="_blank"
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
>
<span className="bg-primary dark:text-background rounded-full px-3 py-2 text-xs">
Sign up
</span>
</Link>
</div>
<HamburgerMenu
@@ -3,8 +3,7 @@
import Image from 'next/image';
import Link from 'next/link';
import type { Variants } from 'framer-motion';
import { motion } from 'framer-motion';
import { Variants, motion } from 'framer-motion';
import { usePlausible } from 'next-plausible';
import { LuGithub } from 'react-icons/lu';
import { match } from 'ts-pattern';
@@ -114,9 +113,9 @@ export const Hero = ({ className, ...props }: HeroProps) => {
className="rounded-full bg-transparent backdrop-blur-sm"
onClick={onSignUpClick}
>
Claim Community Plan
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs font-medium">
-80%
Get the Early Adopters Plan
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
$30/mo. forever!
</span>
</Button>
@@ -191,32 +190,42 @@ export const Hero = ({ className, ...props }: HeroProps) => {
<Widget className="mt-12">
<strong>Documenso Supporter Pledge</strong>
<p className="w-full max-w-[70ch]">
Our mission is to create an open signing infrastructure that empowers the world. We
believe openness and cooperation are the way every business should be conducted.
Our mission is to create an open signing infrastructure that empowers the world,
enabling businesses to embrace openness, cooperation, and transparency. We believe
that signing, as a fundamental act, should embody these values. By offering an
open-source signing solution, we aim to make document signing accessible, transparent,
and trustworthy.
</p>
<p className="w-full max-w-[70ch]">
By creating an open source signing solution we want to bring these values to
businesses' most fundamental act: signing. Document Signing should be open and
transparent, as should all trust based products.
Through our platform, called Documenso, we strive to earn your trust by allowing
self-hosting and providing complete visibility into its inner workings. We value
inclusivity and foster an environment where diverse perspectives and contributions are
welcomed, even though we may not implement them all.
</p>
<p className="w-full max-w-[70ch]">
We aim to earn this trust by enabling everyone to self-host Documenso and inspect its
inner workings. We openly share our source, knowledge, and progress while creating
Documenso.
At Documenso, we envision a web-enabled future for business and contracts, and we are
committed to being the leading provider of open signing infrastructure. By combining
exceptional product design with open-source principles, we aim to deliver a robust and
well-designed application that exceeds your expectations.
</p>
<p className="w-full max-w-[70ch]">
Exceptional products are the results of exceptional communities and we strive to
create an inclusive, creative environment, open to all who choose to support our
mission. We value the inputs, contributions, and perspectives of everyone in our
community, even though we can't apply them all.
We understand that exceptional products are born from exceptional communities, and we
invite you to join our open-source community. Your contributions, whether technical or
non-technical, will help shape the future of signing. Together, we can create a better
future for everyone.
</p>
<p className="w-full max-w-[70ch]">
We are building the next generation of trust software and community the way its meant
to be: Beautifully designed and open for all to join.
Today we invite you to join us on this journey: By signing this mission statement you
signal your support of Documenso's mission{' '}
<span className="bg-primary text-black">
(in a non-legally binding, but heartfelt way)
</span>{' '}
and lock in the early supporter plan for forever, including everything we build this
year.
</p>
<div className="flex h-24 items-center">
@@ -50,10 +50,6 @@ export const MENU_NAVIGATION_LINKS = [
href: 'https://app.documenso.com/signin',
text: 'Sign in',
},
{
href: 'https://app.documenso.com/signup',
text: 'Sign up',
},
];
export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => {
@@ -108,13 +104,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
onClick={() => handleMenuItemClick()}
target={target}
>
{href === 'https://app.documenso.com/signup' ? (
<span className="bg-primary dark:text-background rounded-full px-3 py-2 text-xl">
{text}
</span>
) : (
text
)}
{text}
</Link>
</motion.div>
))}
@@ -194,7 +194,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
className="bg-foreground/5 col-span-12 flex flex-col rounded-2xl p-6 lg:col-span-5"
onSubmit={handleSubmit(onFormSubmit)}
>
<h3 className="text-xl font-semibold">Sign up to Community Plan</h3>
<h3 className="text-2xl font-semibold">Sign up for the early adopters plan</h3>
<p className="text-muted-foreground mt-2 text-xs">
with Timur Ercan & Lucas Smith from Documenso
</p>
@@ -215,7 +215,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
<Input
id="email"
type="email"
placeholder="your@example.com"
placeholder=""
className="bg-background w-full pr-16"
disabled={isSubmitting}
onKeyDown={(e) =>
+1 -1
View File
@@ -1,6 +1,6 @@
'use client';
import type { HTMLAttributes } from 'react';
import { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
@@ -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<User, 'id' | 'name' | 'email'>;
Recipient: Recipient[];
team: Pick<Team, 'id' | 'url'> | null;
};
team?: Pick<Team, 'id' | 'url'>;
};
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 }, () => (
<Button className="w-full" asChild>
<Link href={`/sign/${recipient?.token}`}>
{match(role)
.with(RecipientRole.SIGNER, () => (
<>
<Pencil className="-ml-1 mr-2 h-4 w-4" />
Sign
</>
))
.with(RecipientRole.APPROVER, () => (
<>
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
Approve
</>
))
.otherwise(() => (
<>
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
View
</>
))}
</Link>
</Button>
))
.with({ isComplete: false }, () => (
<Button className="w-full" asChild>
<Link href={`${documentsPath}/${document.id}/edit`}>Edit</Link>
</Button>
))
.with({ isComplete: true }, () => (
<Button className="w-full" onClick={onDownloadClick}>
<Download className="-ml-1 mr-2 inline h-4 w-4" />
Download
</Button>
))
.otherwise(() => null);
};
@@ -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<User, 'id' | 'name' | 'email'>;
Recipient: Recipient[];
team: Pick<Team, 'id' | 'url'> | null;
};
team?: Pick<Team, 'id' | 'url'>;
};
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 (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="end" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
{(isOwner || isCurrentTeamDocument) && !isComplete && (
<DropdownMenuItem asChild>
<Link href={`${documentsPath}/${document.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
)}
{isComplete && (
<DropdownMenuItem onClick={onDownloadClick}>
<Download className="mr-2 h-4 w-4" />
Download
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
<DropdownMenuLabel>Share</DropdownMenuLabel>
<ResendDocumentActionItem
document={document}
recipients={nonSignedRecipients}
team={team}
/>
<DocumentShareButton
documentId={document.id}
token={isOwner ? undefined : recipient?.token}
trigger={({ loading, disabled }) => (
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
<div className="flex items-center">
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
Share Signing Card
</div>
</DropdownMenuItem>
)}
/>
</DropdownMenuContent>
{isDocumentDeletable && (
<DeleteDocumentDialog
id={document.id}
status={document.status}
documentTitle={document.title}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
/>
)}
{isDuplicateDialogOpen && (
<DuplicateDocumentDialog
id={document.id}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
team={team}
/>
)}
</DropdownMenu>
);
};
@@ -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<User, 'id' | 'name' | 'email'>;
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 (
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">
<h1 className="px-4 py-3 font-medium">Information</h1>
<ul className="divide-y border-t">
{documentInformation.map((item) => (
<li
key={item.description}
className="flex items-center justify-between px-4 py-2.5 text-sm last:border-b"
>
<span className="text-muted-foreground">{item.description}</span>
<span>{item.value}</span>
</li>
))}
</ul>
</section>
);
};
@@ -0,0 +1,146 @@
'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',
},
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
},
);
const documentAuditLogs = useMemo(() => (data?.pages ?? []).flatMap((page) => page.data), [data]);
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<div className="flex flex-row items-center justify-between border-b px-4 py-3">
<h1 className="text-foreground font-medium">Recent activity</h1>
{/* Can add dropdown menu here for additional options. */}
</div>
{isLoading && (
<div className="flex h-full items-center justify-center py-16">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
)}
{isLoadingError && (
<div className="flex h-full flex-col items-center justify-center py-16">
<p className="text-foreground/80 text-sm">Unable to load document history</p>
<button
onClick={async () => refetch()}
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
>
Click here to retry
</button>
</div>
)}
<AnimateGenericFadeInOut>
{data && (
<ul role="list" className="space-y-6 p-4">
{hasNextPage && (
<li className="relative flex gap-x-4">
<div className="absolute -bottom-6 left-0 top-0 flex w-6 justify-center">
<div className="w-px bg-gray-200" />
</div>
<div className="bg-widget relative flex h-6 w-6 flex-none items-center justify-center">
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
</div>
<button
onClick={async () => fetchNextPage()}
className="text-foreground/70 hover:text-muted-foreground text-xs"
>
{isFetchingNextPage ? 'Loading...' : 'Load older activity'}
</button>
</li>
)}
{documentAuditLogs.map((auditLog, auditLogIndex) => (
<li key={auditLog.id} className="relative flex gap-x-4">
<div
className={cn(
auditLogIndex === documentAuditLogs.length - 1 ? 'h-6' : '-bottom-6',
'absolute left-0 top-0 flex w-6 justify-center',
)}
>
<div className="bg-border w-px" />
</div>
<div className="bg-widget text-foreground/40 relative flex h-6 w-6 flex-none items-center justify-center">
{match(auditLog.type)
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED, () => (
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<CheckCheckIcon className="h-3 w-3" aria-hidden="true" />
</div>
))
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => (
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<CheckIcon className="h-3 w-3" aria-hidden="true" />
</div>
))
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, () => (
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<MailOpen className="h-3 w-3" aria-hidden="true" />
</div>
))
.otherwise(() => (
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
))}
</div>
<p className="text-muted-foreground dark:text-muted-foreground/70 flex-auto py-0.5 text-xs leading-5">
<span className="text-foreground font-medium">
{formatDocumentAuditLogAction(auditLog, userId).prefix}
</span>{' '}
{formatDocumentAuditLogAction(auditLog, userId).description}
</p>
<time className="text-muted-foreground dark:text-muted-foreground/70 flex-none py-0.5 text-xs leading-5">
{DateTime.fromJSDate(auditLog.createdAt).toRelative({ style: 'short' })}
</time>
</li>
))}
</ul>
)}
</AnimateGenericFadeInOut>
</section>
);
};
@@ -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 (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<div className="flex flex-row items-center justify-between px-4 py-3">
<h1 className="text-foreground font-medium">Recipients</h1>
{document.status !== DocumentStatus.COMPLETED && (
<Link
href={`${documentRootPath}/${document.id}/edit?step=signers`}
title="Modify recipients"
className="flex flex-row items-center justify-between"
>
{recipients.length === 0 ? (
<PlusIcon className="ml-2 h-4 w-4" />
) : (
<PenIcon className="ml-2 h-3 w-3" />
)}
</Link>
)}
</div>
<ul className="text-muted-foreground divide-y border-t">
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">No recipients</li>
)}
{recipients.map((recipient) => (
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
secondaryText={
<p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
}
/>
{document.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.SIGNED && (
<Badge variant="default">
{match(recipient.role)
.with(RecipientRole.APPROVER, () => (
<>
<CheckIcon className="mr-1 h-3 w-3" />
Approved
</>
))
.with(RecipientRole.CC, () =>
document.status === DocumentStatus.COMPLETED ? (
<>
<MailIcon className="mr-1 h-3 w-3" />
Sent
</>
) : (
<>
<CheckIcon className="mr-1 h-3 w-3" />
Ready
</>
),
)
.with(RecipientRole.SIGNER, () => (
<>
<SignatureIcon className="mr-1 h-3 w-3" />
Signed
</>
))
.with(RecipientRole.VIEWER, () => (
<>
<MailOpenIcon className="mr-1 h-3 w-3" />
Viewed
</>
))
.exhaustive()}
</Badge>
)}
{document.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
<Badge variant="secondary">
<Clock className="mr-1 h-3 w-3" />
Pending
</Badge>
)}
</li>
))}
</ul>
</section>
);
};
@@ -1,22 +1,33 @@
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 { 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: {
@@ -67,65 +78,120 @@ 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 (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
<Link href="/documents" className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Documents
</Link>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title}
</h1>
<div className="flex flex-row justify-between">
<div>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatusComponent
inheritColor
status={document.status}
className="text-muted-foreground"
/>
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
<span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip>
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
<span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip>
</div>
)}
</div>
)}
</div>
<div className="self-end">
<DocumentHistorySheet documentId={document.id} userId={user.id}>
<Button variant="outline">
<Clock9 className="mr-1.5 h-4 w-4" />
Document history
</Button>
</DocumentHistorySheet>
</div>
</div>
{document.status !== InternalDocumentStatus.COMPLETED && (
<EditDocumentForm
className="mt-8"
document={document}
user={user}
documentMeta={documentMeta}
recipients={recipients}
fields={fields}
documentData={documentData}
documentRootPath={documentRootPath}
/>
)}
<div className="mt-6 grid w-full grid-cols-12 gap-8">
<Card
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
gradient
>
<CardContent className="p-2">
<LazyPDFViewer document={document} key={documentData.id} documentData={documentData} />
</CardContent>
</Card>
{document.status === InternalDocumentStatus.COMPLETED && (
<div className="mx-auto mt-12 max-w-2xl">
<LazyPDFViewer
document={document}
key={documentData.id}
documentMeta={documentMeta}
documentData={documentData}
/>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<div className="space-y-6">
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
<div className="flex flex-row items-center justify-between px-4">
<h3 className="text-foreground text-2xl font-semibold">
Document {FRIENDLY_STATUS_MAP[document.status].label.toLowerCase()}
</h3>
<DocumentPageViewDropdown document={documentWithRecipients} team={team} />
</div>
<p className="text-muted-foreground mt-2 px-4 text-sm ">
{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()}
</p>
<div className="mt-4 border-t px-4 pt-4">
<DocumentPageViewButton document={documentWithRecipients} team={team} />
</div>
</section>
{/* Document information section. */}
<DocumentPageViewInformation document={documentWithRecipients} userId={user.id} />
{/* Recipients section. */}
<DocumentPageViewRecipients
document={documentWithRecipients}
documentRootPath={documentRootPath}
/>
{/* Recent activity section. */}
<DocumentPageViewRecentActivity documentId={document.id} userId={user.id} />
</div>
</div>
)}
</div>
</div>
);
};
@@ -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<EditDocumentStep>(
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<EditDocumentStep>(() => {
// 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
@@ -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 (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Documents
</Link>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
<span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip>
</div>
)}
</div>
<EditDocumentForm
className="mt-8"
document={document}
user={user}
documentMeta={documentMeta}
recipients={recipients}
fields={fields}
documentData={documentData}
documentRootPath={documentRootPath}
/>
</div>
);
};
@@ -0,0 +1,11 @@
import { DocumentEditPageView } from './document-edit-page-view';
export type DocumentPageProps = {
params: {
id: string;
};
};
export default function DocumentEditPage({ params }: DocumentPageProps) {
return <DocumentEditPageView params={params} />;
}
@@ -119,7 +119,7 @@ export const ResendDocumentActionItem = ({
<DialogContent className="sm:max-w-sm" hideClose>
<DialogHeader>
<DialogTitle>
<DialogTitle asChild>
<h1 className="text-center text-xl">Who do you want to remind?</h1>
</DialogTitle>
</DialogHeader>
@@ -94,7 +94,7 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
() => (
<Button className="w-32" asChild>
<Link href={`${documentsPath}/${row.id}`}>
<Link href={`${documentsPath}/${row.id}/edit`}>
<Edit className="-ml-1 mr-2 h-4 w-4" />
Edit
</Link>
@@ -142,7 +142,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
)}
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild>
<Link href={`${documentsPath}/${row.id}`}>
<Link href={`${documentsPath}/${row.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
@@ -5,16 +5,19 @@ import Link from 'next/link';
import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern';
import { Document, Recipient, User } from '@documenso/prisma/client';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
export type DataTableTitleProps = {
row: Document & {
User: Pick<User, 'id' | 'name' | 'email'>;
team: Pick<Team, 'url'> | null;
Recipient: Recipient[];
};
teamUrl?: string;
};
export const DataTableTitle = ({ row }: DataTableTitleProps) => {
export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
const { data: session } = useSession();
if (!session) {
@@ -25,14 +28,18 @@ export const DataTableTitle = ({ row }: DataTableTitleProps) => {
const isOwner = row.User.id === session.user.id;
const isRecipient = !!recipient;
const isCurrentTeamDocument = teamUrl && row.team?.url === teamUrl;
const documentsPath = formatDocumentsPath(isCurrentTeamDocument ? teamUrl : undefined);
return match({
isOwner,
isRecipient,
isCurrentTeamDocument,
})
.with({ isOwner: true }, () => (
.with({ isOwner: true }, { isCurrentTeamDocument: true }, () => (
<Link
href={`/documents/${row.id}`}
href={`${documentsPath}/${row.id}`}
title={row.title}
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
>
@@ -66,7 +66,7 @@ export const DocumentsDataTable = ({
},
{
header: 'Title',
cell: ({ row }) => <DataTableTitle row={row.original} />,
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
},
{
id: 'sender',
@@ -47,7 +47,7 @@ export const DuplicateDocumentDialog = ({
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
trpcReact.document.duplicateDocument.useMutation({
onSuccess: (newId) => {
router.push(`${documentsPath}/${newId}`);
router.push(`${documentsPath}/${newId}/edit`);
toast({
title: 'Document Duplicated',
@@ -1,10 +1,7 @@
import type { Metadata } from 'next';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import type { DocumentsPageViewProps } from './documents-page-view';
import { DocumentsPageView } from './documents-page-view';
import { PublicProfileIntro } from './username-claim/public-profile-intro';
export type DocumentsPageProps = {
searchParams?: DocumentsPageViewProps['searchParams'];
@@ -14,12 +11,6 @@ export const metadata: Metadata = {
title: 'Documents',
};
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
const { user } = await getRequiredServerComponentSession();
return (
<>
<PublicProfileIntro user={user} />
<DocumentsPageView searchParams={searchParams} />
</>
);
export default function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
return <DocumentsPageView searchParams={searchParams} />;
}
@@ -83,7 +83,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
timestamp: new Date().toISOString(),
});
router.push(`${formatDocumentsPath(team?.url)}/${id}`);
router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`);
} catch (error) {
console.error(error);
@@ -1,232 +0,0 @@
'use client';
import React, { useRef, useState } from 'react';
import Image from 'next/image';
import { zodResolver } from '@hookform/resolvers/zod';
import { File } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import Check from '@documenso/assets/Check.svg';
import Lucas from '@documenso/assets/images/Lucas.png';
import Timur from '@documenso/assets/images/Timur.png';
import type { User } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardHeader } from '@documenso/ui/primitives/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZPublicProfileFormSchema = z.object({
profileURL: z.string().trim().min(1, { message: 'Please enter a valid URL slug.' }),
});
export type TPublicProfileFormSchema = z.infer<typeof ZPublicProfileFormSchema>;
export type PublicProfileIntroProps = {
user: User;
};
export const PublicProfileIntro = ({ user }: PublicProfileIntroProps) => {
const form = useForm<TPublicProfileFormSchema>({
values: {
profileURL: user.profileURL || '',
},
resolver: zodResolver(ZPublicProfileFormSchema),
});
const textRef = useRef<HTMLSpanElement>(null);
const { toast } = useToast();
const { mutateAsync: updatePublicProfile } = trpc.profile.updatePublicProfile.useMutation();
const isSaving = form.formState.isSubmitting;
const isProfileURLClaimed = user.profileURL ? false : true;
const [showClaimingDialog, setShowClaimingDialog] = useState(isProfileURLClaimed);
const [showClaimedDialog, setShowClaimedDialog] = useState(true);
const onFormSubmit = async ({ profileURL }: TPublicProfileFormSchema) => {
try {
await updatePublicProfile({
profileURL,
});
setShowClaimingDialog(false);
setShowClaimedDialog(true);
} 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 save your details. Please try again later.',
});
}
}
};
return (
<>
<Dialog open={showClaimingDialog} onOpenChange={setShowClaimingDialog}>
<DialogContent position="center" className="pb-4">
<DialogHeader>
<DialogTitle className="font-semi-bold text-center text-2xl">
Introducing public profile!
</DialogTitle>
<DialogDescription className="text-muted-foreground/60 text-center text-sm">
Reserve your Documenso public profile username
</DialogDescription>
</DialogHeader>
<Card className="relative flex flex-col items-center border-none bg-gray-50 px-6 py-6 pb-0 shadow-none">
<code className="rounded-md border-2 border-gray-200 px-1 py-1 text-sm">
<span>documenso.com/u/timur</span>
</code>
<Avatar className="dark:border-border mt-2 h-20 w-20 border-2 border-solid border-white bg-white">
<AvatarImage className="AvatarImage" src={Timur.src} alt="Timur" />
<AvatarFallback className="text-xs text-gray-400">Timur</AvatarFallback>
</Avatar>
<div className="flex flex-row gap-x-2">
Timur Ercan <Image alt="Check" src={Check} />
</div>
<span className="text-muted-foreground/60 text-center">
Hey Im Timur <br /> Pick any of the following agreements below and start signing to
get started
</span>
<Card className="bg mt-2 w-full items-center shadow-none">
<CardHeader className="p-4 text-gray-500">Documents</CardHeader>
<hr className="mb-2" />
<div className="mb-2 flex flex-row items-center justify-between">
<div className="flex flex-row items-center gap-x-2">
<File className="ml-3" />
<div className="flex flex-col">
<span className="text-md">NDA.pdf</span>
<span className="text-muted-foreground mt-0.5 text-xs">
Like to discuss about my work?
</span>
</div>
</div>
<Button className="mr-3" variant="default">
Sign
</Button>
</div>
</Card>
<div
className="fade-overlay bg-black-100 absolute bottom-0 h-1/4 w-full"
style={{
background: `linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, white 75%, white 100%)`,
}}
></div>
</Card>
<Form {...form}>
<form className={cn('flex w-full flex-col')} onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSaving}>
<FormField
control={form.control}
name="profileURL"
render={({ field }) => (
<FormItem>
<FormLabel>Public profile URL</FormLabel>
<FormControl>
<>
<Input type="text" className="mb-2 mt-2" {...field} />
<div className="mt-2">
<code className="bg-muted rounded-md px-1 py-1 text-sm">
<span ref={textRef} id="textToCopy">
documenso.com/u/
</span>
</code>
</div>
</>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<div className="mt-4 text-center">
<Button type="submit" loading={isSaving}>
Claim your username
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
<Dialog open={false} onOpenChange={setShowClaimedDialog}>
<DialogContent position="center" className="pb-4">
<DialogHeader>
<DialogTitle className="font-semi-bold text-center text-xl">All set!</DialogTitle>
<DialogDescription className="text-center">
We will let you know as soon as this feature is launched
</DialogDescription>
</DialogHeader>
<Card className="relative px-6 py-6">
<Card className="flex flex-col items-center px-6 py-6">
<code className="bg-muted rounded-md px-1 py-1 text-sm">
<span>documenso.com/u/lucas</span>
</code>
<Avatar className="dark:border-border mt-2 h-12 w-12 border-2 border-solid border-white">
<AvatarImage className="AvatarImage" src={Lucas.src} alt="Lucas" />
<AvatarFallback className="text-xs text-gray-400">Timur</AvatarFallback>
</Avatar>
<div className="flex flex-row gap-x-2">
Lucas Smith <Image alt="Check" src={Check} />
</div>
<div className="flex inline-flex h-full w-full flex-col items-center justify-center gap-3 py-2">
<Skeleton className="w-75 h-4 animate-none rounded-full" />
<Skeleton className="w-50 h-4 animate-none rounded-full" />
</div>
</Card>
<Card className="mt-2 items-center">
<CardHeader className="p-2">Documents</CardHeader>
<hr className="mb-2" />
<div className="mb-2 flex flex-row items-center justify-between">
<div className="flex flex-row items-center gap-x-2">
<File className="ml-3" />
<div className="flex flex-col">
<span className="text-md">NDA.pdf</span>
<span className="text-muted-foregroun mt-0.5 text-xs">
Like to discuss about my work?
</span>
</div>
</div>
<Button className="mr-3" variant="default">
Sign
</Button>
</div>
</Card>
</Card>
</DialogContent>
</Dialog>
</>
);
};
@@ -1,9 +0,0 @@
import React from 'react';
export type PublicProfileSettingsLayout = {
children: React.ReactNode;
};
export default function PublicProfileSettingsLayout({ children }: PublicProfileSettingsLayout) {
return <div className="col-span-12 md:col-span-9">{children}</div>;
}
@@ -1,30 +0,0 @@
import * as React from 'react';
import type { Metadata } from 'next';
import Link from 'next/link';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
export const metadata: Metadata = {
title: 'Public Profile',
};
export default function PublicProfilePage() {
return (
<>
<SettingsHeader
title="Public profile"
subtitle=""
className="max-w-xl"
titleChildren={
<Link
href="#"
className="bg-primary dark:text-background ml-2 rounded-full px-2 py-1 text-xs font-semibold sm:px-3"
>
Coming soon!
</Link>
}
/>
</>
);
}
@@ -1,8 +1,5 @@
import type { Metadata } from 'next';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import ActivityPageBackButton from '../../../../../components/(dashboard)/settings/layout/activity-back';
import { UserSecurityActivityDataTable } from './user-security-activity-data-table';
export const metadata: Metadata = {
@@ -12,11 +9,11 @@ export const metadata: Metadata = {
export default function SettingsSecurityActivityPage() {
return (
<div>
<SettingsHeader
title="Security activity"
subtitle="View all recent security activity related to your account."
titleChildren={<ActivityPageBackButton />}
/>
<h3 className="text-2xl font-semibold">Security activity</h3>
<p className="text-muted-foreground mt-2 text-sm">
View all recent security activity related to your account.
</p>
<hr className="my-4" />
@@ -76,7 +76,7 @@ export const TemplatesDataTable = ({
duration: 5000,
});
router.push(`${documentRootPath}/${id}`);
router.push(`${documentRootPath}/${id}/edit`);
} catch (err) {
toast({
title: 'Error',
@@ -0,0 +1,21 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentEditPageView } from '~/app/(dashboard)/documents/[id]/edit/document-edit-page-view';
export type DocumentPageProps = {
params: {
id: string;
teamUrl: string;
};
};
export default async function TeamsDocumentEditPage({ params }: DocumentPageProps) {
const { teamUrl } = params;
const { user } = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: user.id, teamUrl });
return <DocumentEditPageView params={params} team={team} />;
}
@@ -34,7 +34,7 @@ export default function ErrorPage({ error }: ErrorProps) {
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
<Button
variant="secondary"
variant="ghost"
className="w-32"
onClick={() => {
void router.back();
@@ -19,7 +19,7 @@ export default function NotFound() {
</p>
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
<Button variant="secondary" asChild className="w-32">
<Button asChild className="w-32">
<Link href="/settings/teams">
<ChevronLeft className="mr-2 h-4 w-4" />
Go Back
@@ -18,7 +18,7 @@ export type TeamTransferStatusProps = {
className?: string;
currentUserTeamRole: TeamMemberRole;
teamId: number;
transferVerification: Pick<TeamTransferVerification, 'email' | 'expiresAt' | 'name'> | null;
transferVerification: TeamTransferVerification | null;
};
export const TeamTransferStatus = ({
@@ -1,34 +0,0 @@
import React from 'react';
import Image from 'next/image';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { Card } from '@documenso/ui/primitives/card';
import { NewHeader } from '../../../components/(dashboard)/layout/new/new-header';
type CheckEmailLayoutProps = {
children: React.ReactNode;
};
export default function CheckEmailLayout({ children }: CheckEmailLayoutProps) {
return (
<>
<NewHeader className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
<main className="bg-sand-100 relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
<div className="relative flex w-full max-w-md items-center gap-x-24">
<div className="absolute -inset-96 -z-[1] flex items-center justify-center opacity-50">
<Image
src={backgroundPattern}
alt="background pattern"
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
/>
</div>
<Card className="px-6 py-6">
<div className="w-full">{children}</div>
</Card>
</div>
</main>
</>
);
}
@@ -1,34 +0,0 @@
import React from 'react';
import Image from 'next/image';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { Card } from '@documenso/ui/primitives/card';
import { NewHeader } from '../../../components/(dashboard)/layout/new/new-header';
type ForgotPasswordLayoutProps = {
children: React.ReactNode;
};
export default function ForgotPasswordLayout({ children }: ForgotPasswordLayoutProps) {
return (
<>
<NewHeader className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
<main className="bg-sand-100 relative flex flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-44">
<div className="absolute -inset-96 -z-[1] flex items-center justify-center bg-contain opacity-50">
<Image
src={backgroundPattern}
alt="background pattern"
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
/>
</div>
<div className="relative flex w-full max-w-md items-center gap-x-24">
<Card className="px-6 py-6">
<div className="w-full">{children}</div>
</Card>
</div>
</main>
</>
);
}
@@ -10,7 +10,7 @@ export const metadata: Metadata = {
export default function ForgotPasswordPage() {
return (
<div>
<h1 className="text-3xl font-semibold">Forgot your password?</h1>
<h1 className="text-4xl font-semibold">Forgot your password?</h1>
<p className="text-muted-foreground mt-2 text-sm">
No worries, it happens! Enter your email and we'll email you a special link to reset your
@@ -0,0 +1,27 @@
import React from 'react';
import Image from 'next/image';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
type UnauthenticatedLayoutProps = {
children: React.ReactNode;
};
export default function UnauthenticatedLayout({ children }: UnauthenticatedLayoutProps) {
return (
<main className="bg-sand-100 relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
<div className="relative flex w-full max-w-md items-center gap-x-24">
<div className="absolute -inset-96 -z-[1] flex items-center justify-center opacity-50">
<Image
src={backgroundPattern}
alt="background pattern"
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
/>
</div>
<div className="w-full">{children}</div>
</div>
</main>
);
}
@@ -1,34 +0,0 @@
import React from 'react';
import Image from 'next/image';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { Card } from '@documenso/ui/primitives/card';
import { NewHeader } from '../../../components/(dashboard)/layout/new/new-header';
type ResetPasswordLayoutProps = {
children: React.ReactNode;
};
export default function ResetPasswordLayout({ children }: ResetPasswordLayoutProps) {
return (
<>
<NewHeader className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
<main className="bg-sand-100 relative flex flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-56">
<div className="relative flex w-full max-w-md items-center gap-x-24">
<div className="absolute -inset-96 -z-[1] flex items-center justify-center opacity-50">
<Image
src={backgroundPattern}
alt="background pattern"
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
/>
</div>
<Card className="px-6 py-6">
<div className="w-full">{children}</div>
</Card>
</div>
</main>
</>
);
}
@@ -10,7 +10,7 @@ export const metadata: Metadata = {
export default function ResetPasswordPage() {
return (
<div>
<h1 className="text-3xl font-semibold">Unable to reset password</h1>
<h1 className="text-4xl font-semibold">Unable to reset password</h1>
<p className="text-muted-foreground mt-2 text-sm">
The token you have used to reset your password is either expired or it never existed. If you
@@ -1,34 +0,0 @@
import React from 'react';
import Image from 'next/image';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { Card } from '@documenso/ui/primitives/card';
import { NewHeader } from '../../../components/(dashboard)/layout/new/new-header';
type SignInLayoutProps = {
children: React.ReactNode;
};
export default function SignInLayout({ children }: SignInLayoutProps) {
return (
<>
<NewHeader className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
<main className="bg-sand-100 relative flex flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-[7.2rem]">
<div className="absolute -inset-96 -z-[1] flex items-center justify-center bg-contain opacity-50">
<Image
src={backgroundPattern}
alt="background pattern"
className="flex min-h-screen flex-col overflow-hidden dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
/>
</div>
<div className="relative flex w-full max-w-md items-center gap-x-24">
<Card className="px-6 py-6">
<div className="w-full">{children}</div>
</Card>
</div>
</main>
</>
);
}
@@ -26,31 +26,36 @@ export default function SignInPage({ searchParams }: SignInPageProps) {
}
return (
<>
<div>
<h1 className="text-3xl font-semibold">Sign in to your account</h1>
<div>
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
<p className="text-muted-foreground/60 mt-2 text-sm">
Welcome back, we are lucky to have you.
<p className="text-muted-foreground/60 mt-2 text-sm">
Welcome back, we are lucky to have you.
</p>
<SignInForm
className="mt-4"
initialEmail={email || undefined}
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
/>
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
<p className="text-muted-foreground mt-6 text-center text-sm">
Don't have an account?{' '}
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
Sign up
</Link>
</p>
)}
<hr className="my-4" />
<SignInForm
className="mt-0"
initialEmail={email || undefined}
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
/>
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
<p className="text-muted-foreground mt-6 text-center text-sm">
Don't have an account?{' '}
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
Sign up
</Link>
</p>
)}
</div>
</>
<p className="mt-2.5 text-center">
<Link
href="/forgot-password"
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
>
Forgot your password?
</Link>
</p>
</div>
);
}
@@ -1,21 +0,0 @@
import React from 'react';
import { NewHeader } from '../../../components/(dashboard)/layout/new/new-header';
type SignUpLayoutProps = {
children: React.ReactNode;
};
export default function SignUpLayout({ children }: SignUpLayoutProps) {
return (
<>
<NewHeader className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
<main
className="bg-sand-100 scale-90 items-center justify-center px-4 md:h-20 lg:mx-28 lg:px-8"
style={{ height: 'calc(100vh - 80px)' }}
>
<div className="grid grid-cols-12 gap-y-8 overflow-hidden p-2 lg:gap-x-8">{children}</div>
</main>
</>
);
}
@@ -1,6 +1,8 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { SignUpForm } from '~/components/forms/signup';
@@ -28,8 +30,26 @@ export default function SignUpPage({ searchParams }: SignUpPageProps) {
}
return (
<>
<SignUpForm className="mt-1" initialEmail={email || undefined} isGoogleSSOEnabled={true} />
</>
<div>
<h1 className="text-4xl font-semibold">Create a new account</h1>
<p className="text-muted-foreground/60 mt-2 text-sm">
Create your account and start using state-of-the-art document signing. Open and beautiful
signing is within your grasp.
</p>
<SignUpForm
className="mt-4"
initialEmail={email || undefined}
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
/>
<p className="text-muted-foreground mt-6 text-center text-sm">
Already have an account?{' '}
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
Sign in instead
</Link>
</p>
</div>
);
}
@@ -1,34 +0,0 @@
import React from 'react';
import Image from 'next/image';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { Card } from '@documenso/ui/primitives/card';
import { NewHeader } from '../../../components/(dashboard)/layout/new/new-header';
type TeamLayoutProps = {
children: React.ReactNode;
};
export default function TeamLayout({ children }: TeamLayoutProps) {
return (
<>
<NewHeader className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
<main className="bg-sand-100 relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
<div className="relative flex w-full max-w-md items-center gap-x-24">
<div className="absolute -inset-96 -z-[1] flex items-center justify-center opacity-50">
<Image
src={backgroundPattern}
alt="background pattern"
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
/>
</div>
<Card className="px-6 py-6">
<div className="w-full">{children}</div>
</Card>
</div>
</main>
</>
);
}
@@ -1,34 +0,0 @@
import React from 'react';
import Image from 'next/image';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { Card } from '@documenso/ui/primitives/card';
import { NewHeader } from '../../../components/(dashboard)/layout/new/new-header';
type VerifyEmailLayoutProps = {
children: React.ReactNode;
};
export default function VerifyEmailLayout({ children }: VerifyEmailLayoutProps) {
return (
<>
<NewHeader className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
<main className="bg-sand-100 relative flex flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-[11.2rem]">
<div className="absolute -inset-96 -z-[1] flex items-center justify-center bg-contain opacity-50">
<Image
src={backgroundPattern}
alt="background pattern"
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
/>
</div>
<div className="relative flex w-full max-w-md items-center gap-x-24">
<Card className="px-6 py-6">
<div className="w-full">{children}</div>
</Card>
</div>
</main>
</>
);
}
@@ -1,97 +0,0 @@
'use client';
import React from 'react';
import Image from 'next/image';
import { File } from 'lucide-react';
import Check from '@documenso/assets/Check.svg';
import Timur from '@documenso/assets/images/Timur.png';
import backgroundPattern from '@documenso/assets/images/background-blog-og.png';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardFooter, CardHeader } from '@documenso/ui/primitives/card';
type ClaimUsernameCardProps = {
className: string;
};
export default function ClaimUsernameCard({ className }: ClaimUsernameCardProps) {
const onSignUpClick = () => {};
return (
<div className={cn('relative', className)}>
<Card className={cn('relative h-full overflow-hidden px-16 py-16 shadow-none')}>
<Image
src={backgroundPattern}
alt="background pattern"
className="absolute left-0 top-0 h-full w-full bg-cover opacity-50 dark:brightness-95 dark:contrast-[100%] dark:invert"
/>
<Card className="mt-28 flex flex-col items-center px-6 py-6 shadow-none">
<code className="rounded-md border-2 border-gray-200 px-1 py-1 text-sm">
<span>documenso.com/u/timur</span>
</code>
<Avatar className="dark:border-border mt-2 h-20 w-20 border-2 border-solid border-white">
<AvatarImage className="AvatarImage" src={Timur.src} alt="Timur" />
<AvatarFallback className="text-xs text-gray-400">Timur</AvatarFallback>
</Avatar>
<div className="mb-2 flex flex-row gap-x-2">
Timur Ercan <Image alt="Check" src={Check} />
</div>
<span className="text-muted-foreground/60 text-center ">
Hey Im Timur <br /> Pick any of the following agreements below and <br /> start signing
to get started
</span>
<Card className="mt-2 w-full items-center shadow-none">
<CardHeader className="p-4 text-gray-500">Documents</CardHeader>
<hr className="mb-2" />
<div className="mb-2 flex flex-row items-center justify-between">
<div className="flex flex-row items-center gap-x-2">
<File className="text-muted-foreground ml-3" />
<div className="flex flex-col">
<span className="text-md">NDA.pdf</span>
<span className="text-muted-foreground mt-0.5 text-xs">
Like to discuss about my work?
</span>
</div>
</div>
<Button className="mr-3 px-6" variant="default">
Sign
</Button>
</div>
<hr className="mb-2" />
<div className="mb-2 flex flex-row items-center justify-between">
<div className="flex flex-row items-center gap-x-2">
<File className="text-muted-foreground ml-3" />
<div className="flex flex-col">
<span className="text-md">NDA.pdf</span>
<span className="text-muted-foreground mt-0.5 text-xs">
Like to discuss about my work?
</span>
</div>
</div>
<Button className="mr-3 px-6" variant="default">
Sign
</Button>
</div>
</Card>
</Card>
<CardFooter className="mt-32 justify-center">
<Button
type="button"
variant="outline"
className="rounded-full bg-transparent backdrop-blur-sm"
onClick={onSignUpClick}
>
Claim Community Plan
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs font-medium">
-80%
</span>
</Button>
</CardFooter>
</Card>
</div>
);
}
@@ -1,87 +0,0 @@
'use client';
import type { HTMLAttributes } from 'react';
import { useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import LogoImage from '@documenso/assets/logo.png';
import { cn } from '@documenso/ui/lib/utils';
import { NewHamburgerMenu } from './new-mobile-hamburger';
import { NewMobileNavigation } from './new-mobile-navigation';
export type HeaderProps = HTMLAttributes<HTMLElement>;
export const NewHeader = ({ className, ...props }: HeaderProps) => {
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
return (
<header className={cn('flex items-center justify-between', className)} {...props}>
<div className="flex items-center space-x-4">
<Link href="/" className="z-10" onClick={() => setIsHamburgerMenuOpen(false)}>
<Image
src={LogoImage}
alt="Documenso Logo"
className="dark:invert"
width={170}
height={25}
/>
</Link>
</div>
<div className="hidden items-center gap-x-6 md:flex">
<Link
href="https://documenso.com/pricing"
target="_blank"
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
>
Pricing
</Link>
<Link
href="https://documenso.com/blog"
target="_blank"
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
>
Blog
</Link>
<Link
href="https://documenso.com/open"
target="_blank"
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
>
Open Startup
</Link>
<Link
href="/signin"
target="_blank"
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
>
Sign in
</Link>
<Link
href="/signup"
target="_blank"
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
>
<span className="bg-primary dark:text-background rounded-full px-3 py-2 text-xs">
Sign up
</span>
</Link>
</div>
<NewHamburgerMenu
onToggleMenuOpen={() => setIsHamburgerMenuOpen((v) => !v)}
isMenuOpen={isHamburgerMenuOpen}
/>
<NewMobileNavigation
isMenuOpen={isHamburgerMenuOpen}
onMenuOpenChange={setIsHamburgerMenuOpen}
/>
</header>
);
};
@@ -1,20 +0,0 @@
'use client';
import { Menu, X } from 'lucide-react';
import { Button } from '@documenso/ui/primitives/button';
export interface HamburgerMenuProps {
isMenuOpen: boolean;
onToggleMenuOpen?: () => void;
}
export const NewHamburgerMenu = ({ isMenuOpen, onToggleMenuOpen }: HamburgerMenuProps) => {
return (
<div className="flex md:hidden">
<Button variant="outline" className="z-20 w-10 p-0" onClick={onToggleMenuOpen}>
{isMenuOpen ? <X /> : <Menu />}
</Button>
</div>
);
};
@@ -1,151 +0,0 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import { motion, useReducedMotion } from 'framer-motion';
import { FaXTwitter } from 'react-icons/fa6';
import { LiaDiscord } from 'react-icons/lia';
import { LuGithub } from 'react-icons/lu';
import LogoImage from '@documenso/assets/logo.png';
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
export type MobileNavigationProps = {
isMenuOpen: boolean;
onMenuOpenChange?: (_value: boolean) => void;
};
export const MENU_NAVIGATION_LINKS = [
{
href: 'https://documenso.com/singleplayer',
text: 'Singleplayer',
},
{
href: 'https://documenso.com/blog',
text: 'Blog',
},
{
href: 'https://documenso.com/pricing',
text: 'Pricing',
},
{
href: 'https://documenso.com/open',
text: 'Open Startup',
},
{
href: 'https://status.documenso.com',
text: 'Status',
},
{
href: 'mailto:support@documenso.com',
text: 'Support',
target: '_blank',
},
{
href: 'https://documenso.com/privacy',
text: 'Privacy',
},
{
href: '/signin',
text: 'Sign in',
},
{
href: '/signup',
text: 'Sign up',
},
];
export const NewMobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => {
const shouldReduceMotion = useReducedMotion();
const handleMenuItemClick = () => {
onMenuOpenChange?.(false);
};
return (
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<SheetContent className="w-full max-w-[400px]">
<Link href="/" className="z-10" onClick={handleMenuItemClick}>
<Image
src={LogoImage}
alt="Documenso Logo"
className="dark:invert"
width={170}
height={25}
/>
</Link>
<motion.div
className="mt-12 flex w-full flex-col items-start gap-y-4"
initial="initial"
animate="animate"
transition={{
staggerChildren: 0.03,
}}
>
{MENU_NAVIGATION_LINKS.map(({ href, text, target }) => (
<motion.div
key={href}
variants={{
initial: {
opacity: 0,
x: shouldReduceMotion ? 0 : 100,
},
animate: {
opacity: 1,
x: 0,
transition: {
duration: 0.5,
ease: 'backInOut',
},
},
}}
>
<Link
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
href={href}
onClick={() => handleMenuItemClick()}
target={target}
>
{href === 'https://app.documenso.com/signup' ? (
<span className="bg-primary dark:text-background rounded-full px-3 py-2 text-xl">
{text}
</span>
) : (
text
)}
</Link>
</motion.div>
))}
</motion.div>
<div className="mx-auto mt-8 flex w-full flex-wrap items-center gap-x-4 gap-y-4 ">
<Link
href="https://twitter.com/documenso"
target="_blank"
className="text-foreground hover:text-foreground/80"
>
<FaXTwitter className="h-6 w-6" />
</Link>
<Link
href="https://github.com/documenso/documenso"
target="_blank"
className="text-foreground hover:text-foreground/80"
>
<LuGithub className="h-6 w-6" />
</Link>
<Link
href="https://documen.so/discord"
target="_blank"
className="text-foreground hover:text-foreground/80"
>
<LiaDiscord className="h-7 w-7" />
</Link>
</div>
</SheetContent>
</Sheet>
);
};
@@ -1,22 +0,0 @@
'use client';
import { useRouter } from 'next/navigation';
import { Button } from '@documenso/ui/primitives/button';
export default function ActivityPageBackButton() {
const router = useRouter();
return (
<div>
<Button
className="flex-shrink-0"
variant="secondary"
onClick={() => {
void router.back();
}}
>
Back
</Button>
</div>
);
}
@@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { CreditCard, Globe2, Lock, User, Users } from 'lucide-react';
import { CreditCard, Lock, User, Users } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
@@ -78,25 +78,6 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button>
</Link>
)}
<Link href="#">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
)}
>
<Globe2 className="mr-2 h-5 w-5" />
Public profile
<Link
href="#"
className="bg-primary dark:text-background ml-2 rounded-full px-2 py-1 text-xs font-semibold sm:px-3"
>
Coming soon!
</Link>
</Button>
</Link>
</div>
);
};
@@ -1,33 +1,21 @@
import React from 'react';
import { cn } from '@documenso/ui/lib/utils';
export type SettingsHeaderProps = {
title: string;
subtitle: string;
children?: React.ReactNode;
titleChildren?: React.ReactNode;
className?: string;
};
export const SettingsHeader = ({
children,
title,
subtitle,
titleChildren,
className,
}: SettingsHeaderProps) => {
export const SettingsHeader = ({ children, title, subtitle }: SettingsHeaderProps) => {
return (
<>
<div className={cn('flex flex-row items-center justify-between', className)}>
<div className="flex flex-row items-center justify-between">
<div>
<h3 className="text-lg font-medium">{title}</h3>
<p className="text-muted-foreground text-sm md:mt-2">{subtitle}</p>
</div>
<div>{titleChildren}</div>
{children}
</div>
@@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { CreditCard, Globe2, Lock, User, Users } from 'lucide-react';
import { CreditCard, Lock, User, Users } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
@@ -81,25 +81,6 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button>
</Link>
)}
<Link href="#">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
)}
>
<Globe2 className="mr-2 h-5 w-5" />
Public profile
<Link
href="#"
className="bg-primary dark:text-background ml-2 rounded-full px-2 py-1 text-xs font-semibold sm:px-3"
>
Coming soon!
</Link>
</Button>
</Link>
</div>
);
};
@@ -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 (
<Badge
className="text-muted-foreground mt-3 block w-full space-y-0.5 text-xs"
variant="neutral"
>
{values.map(({ key, value }, i) => (
<p key={typeof key === 'string' ? key : i}>
<span>{key}: </span>
<span className="font-normal">{value}</span>
</p>
))}
</Badge>
);
};
@@ -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 (
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
{children && <SheetTrigger asChild>{children}</SheetTrigger>}
<SheetContent
sheetClass="backdrop-blur-none"
className="flex w-full max-w-[500px] flex-col overflow-y-auto p-0"
>
<div className="text-foreground px-6 pt-6">
<h1 className="text-lg font-medium">Document history</h1>
<button
className="text-muted-foreground text-sm"
onClick={() => setIsUserDetailsVisible(!isUserDetailsVisible)}
>
{isUserDetailsVisible ? 'Hide' : 'Show'} additional information
</button>
</div>
{isLoading && (
<div className="flex h-full items-center justify-center">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
)}
{isLoadingError && (
<div className="flex h-full flex-col items-center justify-center">
<p className="text-foreground/80 text-sm">Unable to load document history</p>
<button
onClick={async () => refetch()}
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
>
Click here to retry
</button>
</div>
)}
{data && (
<ul
className={cn('divide-y border-t', {
'mb-4 border-b': !hasNextPage,
})}
>
{documentAuditLogs.map((auditLog) => (
<li className="px-4 py-2.5" key={auditLog.id}>
<div className="flex flex-row items-center">
<Avatar className="mr-2 h-9 w-9">
<AvatarFallback className="text-xs text-gray-400">
{(auditLog?.email ?? auditLog?.name ?? '?').slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<p className="text-foreground text-xs font-bold">
{formatDocumentAuditLogActionString(auditLog, userId)}
</p>
<p className="text-foreground/50 text-xs">
<LocaleDate date={auditLog.createdAt} format="d MMM, yyyy HH:MM a" />
</p>
</div>
</div>
{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 <DocumentHistorySheetChanges values={values} />;
},
)
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, ({ data }) => {
if (data.changes.length === 0) {
return null;
}
return (
<DocumentHistorySheetChanges
values={data.changes.map(({ type, from, to }) => ({
key: formatGenericText(type),
value: (
<span className="inline-flex flex-row items-center">
<span>{type === 'ROLE' ? formatGenericText(from) : from}</span>
<ArrowRightIcon className="h-4 w-4" />
<span>{type === 'ROLE' ? formatGenericText(to) : to}</span>
</span>
),
}))}
/>
);
})
.with(
{ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED },
({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Field',
value: formatGenericText(data.fieldType),
},
{
key: 'Recipient',
value: formatGenericText(data.fieldRecipientEmail),
},
]}
/>
),
)
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, ({ data }) => {
if (data.changes.length === 0) {
return null;
}
return (
<DocumentHistorySheetChanges
values={data.changes.map((change) => ({
key: formatGenericText(change.type),
value: change.type === 'PASSWORD' ? '*********' : change.to,
}))}
/>
);
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, ({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Old',
value: data.from,
},
{
key: 'New',
value: data.to,
},
]}
/>
))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, ({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Field inserted',
value: formatGenericText(data.field.type),
},
]}
/>
))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, ({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Field uninserted',
value: formatGenericText(data.field),
},
]}
/>
))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Type',
value: DOCUMENT_AUDIT_LOG_EMAIL_FORMAT[data.emailType].description,
},
{
key: 'Sent to',
value: data.recipientEmail,
},
]}
/>
))
.exhaustive()}
{isUserDetailsVisible && (
<>
<div className="mb-1 mt-2 flex flex-row space-x-2">
<Badge variant="neutral" className="text-muted-foreground">
IP: {auditLog.ipAddress ?? 'Unknown'}
</Badge>
<Badge variant="neutral" className="text-muted-foreground">
Browser: {extractBrowser(auditLog.userAgent)}
</Badge>
</div>
</>
)}
</li>
))}
{hasNextPage && (
<div className="flex items-center justify-center py-4">
<Button
variant="outline"
loading={isFetchingNextPage}
onClick={async () => fetchNextPage()}
>
Show more
</Button>
</div>
)}
</ul>
)}
</SheetContent>
</Sheet>
);
};
@@ -13,7 +13,7 @@ type FriendlyStatus = {
color: string;
};
const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus> = {
export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus> = {
PENDING: {
label: 'Pending',
icon: Clock,
@@ -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<HTMLSpanElement> & {
date: string | number | Date;
format?: DateTimeFormatOptions;
format?: DateTimeFormatOptions | string;
};
/**
@@ -22,13 +22,24 @@ export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & {
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 (
<span className={className} {...props}>
+124 -138
View File
@@ -2,8 +2,6 @@
import { useState } from 'react';
import Link from 'next/link';
import { zodResolver } from '@hookform/resolvers/zod';
import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form';
@@ -169,147 +167,135 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
};
return (
<div className={cn('mt-1')}>
<Form {...form}>
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
<Form {...form}>
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button
type="submit"
size="lg"
loading={isSubmitting}
className="dark:bg-documenso dark:hover:opacity-90"
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="john@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
{isSubmitting ? 'Signing in...' : 'Sign In'}
</Button>
{isGoogleSSOEnabled && (
<>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">Or continue with</span>
<div className="bg-border h-px flex-1" />
</div>
<Button
type="button"
size="lg"
variant="outline"
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignInWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
Google
</Button>
</>
)}
</form>
<Dialog
open={isTwoFactorAuthenticationDialogOpen}
onOpenChange={onCloseTwoFactorAuthenticationDialog}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Two-Factor Authentication</DialogTitle>
</DialogHeader>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isSubmitting}>
{twoFactorAuthenticationMethod === 'totp' && (
<FormField
control={form.control}
name="totpCode"
render={({ field }) => (
<FormItem>
<FormLabel>Authentication Token</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<>
<PasswordInput placeholder="Password" {...field} />
<p className="mt-2.5 text-right">
<Link
href="/forgot-password"
className="text-muted-foreground/60 text-sm duration-200 hover:opacity-70"
>
Forgot your password?
</Link>
</p>
</>
</FormControl>
<FormMessage />
</FormItem>
{twoFactorAuthenticationMethod === 'backup' && (
<FormField
control={form.control}
name="backupCode"
render={({ field }) => (
<FormItem>
<FormLabel> Backup Code</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
/>
</fieldset>
<Button
type="submit"
size="lg"
loading={isSubmitting}
className="dark:bg-documenso dark:hover:opacity-90"
>
{isSubmitting ? 'Signing in...' : 'Sign In'}
</Button>
<DialogFooter className="mt-4">
<Button
type="button"
variant="secondary"
onClick={onToggleTwoFactorAuthenticationMethodClick}
>
{twoFactorAuthenticationMethod === 'totp'
? 'Use Backup Code'
: 'Use Authenticator'}
</Button>
{isGoogleSSOEnabled && (
<>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">Or continue with</span>
<div className="bg-border h-px flex-1" />
</div>
<Button
type="button"
size="lg"
variant="outline"
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignInWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
Google
</Button>
</>
)}
</form>
<Dialog
open={isTwoFactorAuthenticationDialogOpen}
onOpenChange={onCloseTwoFactorAuthenticationDialog}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Two-Factor Authentication</DialogTitle>
</DialogHeader>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isSubmitting}>
{twoFactorAuthenticationMethod === 'totp' && (
<FormField
control={form.control}
name="totpCode"
render={({ field }) => (
<FormItem>
<FormLabel>Authentication Token</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{twoFactorAuthenticationMethod === 'backup' && (
<FormField
control={form.control}
name="backupCode"
render={({ field }) => (
<FormItem>
<FormLabel> Backup Code</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<DialogFooter className="mt-4">
<Button
type="button"
variant="secondary"
onClick={onToggleTwoFactorAuthenticationMethodClick}
>
{twoFactorAuthenticationMethod === 'totp'
? 'Use Backup Code'
: 'Use Authenticator'}
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Signing in...' : 'Sign In'}
</Button>
</DialogFooter>
</fieldset>
</form>
</DialogContent>
</Dialog>
</Form>
</div>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Signing in...' : 'Sign In'}
</Button>
</DialogFooter>
</fieldset>
</form>
</DialogContent>
</Dialog>
</Form>
);
};
+96 -238
View File
@@ -1,10 +1,5 @@
'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form';
@@ -17,7 +12,6 @@ import { trpc } from '@documenso/trpc/react';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Card } from '@documenso/ui/primitives/card';
import {
Form,
FormControl,
@@ -31,16 +25,6 @@ import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
import ClaimUsernameCard from '../(dashboard)/claim-username-card/claim-username-card';
export const STEP = {
SIGNUP: 'SIGNUP',
CLAIM: 'CLAIM',
} as const;
type StepKeys = keyof typeof STEP;
type StepValues = (typeof STEP)[StepKeys];
const SIGN_UP_REDIRECT_PATH = '/documents';
export const ZSignUpFormSchema = z
@@ -49,7 +33,6 @@ export const ZSignUpFormSchema = z
email: z.string().email().min(1),
password: ZPasswordSchema,
signature: z.string().min(1, { message: 'We need your signature to sign documents' }),
profileURL: z.string().trim().min(1, { message: 'Please enter a valid URL slug.' }),
})
.refine(
(data) => {
@@ -72,13 +55,6 @@ export type SignUpFormProps = {
export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignUpFormProps) => {
const { toast } = useToast();
const analytics = useAnalytics();
const searchParams = useSearchParams();
const [step, setStep] = useState<StepValues>(STEP.SIGNUP);
let src: string | null = null;
if (searchParams) {
src = searchParams.get('src');
}
const form = useForm<TSignUpFormSchema>({
values: {
@@ -86,33 +62,18 @@ export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
email: initialEmail ?? '',
password: '',
signature: '',
profileURL: '',
},
resolver: zodResolver(ZSignUpFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const isValid = form.formState.isValid;
const signature = form.watch('signature');
const { mutateAsync: signup } = trpc.auth.signup.useMutation();
const { mutateAsync: updatePublicProfile } = trpc.profile.updatePublicProfile.useMutation();
const onFormSubmit = async ({
name,
email,
password,
signature,
profileURL,
}: TSignUpFormSchema) => {
const onFormSubmit = async ({ name, email, password, signature }: TSignUpFormSchema) => {
try {
await signup({ name, email, password, signature });
await updatePublicProfile({
profileURL,
});
await signIn('credentials', {
email,
password,
@@ -122,7 +83,6 @@ export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
analytics.capture('App: User Sign Up', {
email,
timestamp: new Date().toISOString(),
custom_campaign_params: { src },
});
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
@@ -142,16 +102,6 @@ export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
}
};
const onNextStepClick = () => {
if (step === STEP.SIGNUP) {
setStep(STEP.CLAIM);
setTimeout(() => {
document.querySelector<HTMLElement>('#signature')?.focus();
}, 0);
}
};
const onSignUpWithGoogleClick = async () => {
try {
await signIn('google', { callbackUrl: SIGN_UP_REDIRECT_PATH });
@@ -165,199 +115,107 @@ export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
}
};
const stepsRemaining = useMemo(() => {
if (step === STEP.CLAIM) {
return 2;
}
return 1;
}, [step]);
return (
<>
<ClaimUsernameCard className="col-span-12 gap-y-4 lg:col-span-7" />
<Card className="col-span-12 gap-y-4 bg-gray-50 px-6 py-6 shadow-none lg:col-span-5">
<div className="w-full">
{step === STEP.SIGNUP && (
<>
<h1 className="text-3xl font-semibold">Create a new account</h1>
<Form {...form}>
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<p className="text-muted-foreground/60 mt-2 text-sm">
Create your account and start using state-of-the-art document signing. Open and
beautiful signing is within your grasp.
</p>
</>
)}
{step === STEP.CLAIM && (
<>
<h1 className="text-3xl font-semibold">Claim your username now</h1>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<p className="text-muted-foreground/60 mt-2 text-sm">
You will get notified & be able to set up your documenso public profile when we
launch the feature
</p>
</>
)}
<hr className="mb-6 mt-4" />
<Form {...form}>
<form
className={cn('flex h-full w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<div className={cn(step === STEP.SIGNUP && 'hidden')}>
<FormField
control={form.control}
name="profileURL"
render={({ field }) => (
<FormItem>
<FormLabel>Public profile URL</FormLabel>
<FormControl>
<>
<Input id="username" type="text" className="mb-2 mt-2" {...field} />
<div className="mt-2">
<code className="bg-muted rounded-md px-1 py-1 text-sm">
documenso.com/u/
</code>
</div>
</>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className={cn('space-y-2', step === STEP.CLAIM && 'invisible')}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input type="text" className="bg-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input type="email" className="bg-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput className="bg-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="signature"
render={({ field: { onChange } }) => (
<FormItem>
<FormLabel>Sign Here</FormLabel>
<FormControl>
<SignaturePad
id="signatureText"
className="w-full"
disabled={isSubmitting}
containerClassName="mt-2 rounded-lg border bg-background"
onChange={(v) => onChange(v ?? '')}
height={200}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</fieldset>
{isGoogleSSOEnabled && (
<>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">Or</span>
<div className="bg-border h-px flex-1" />
</div>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
<FormField
control={form.control}
name="signature"
render={({ field: { onChange } }) => (
<FormItem>
<FormLabel>Sign Here</FormLabel>
<FormControl>
<SignaturePad
className="h-36 w-full"
disabled={isSubmitting}
onClick={onSignUpWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
Sign Up with Google
</Button>
</>
)}
<p className="text-muted-foreground text-left text-sm">
Already have an account?{' '}
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
Sign in instead
</Link>
</p>
<div className="mt-6 flex items-center justify-between">
<p className="text-muted-foreground text-xs">
{isValid ? 'Claim username' : `Basic details ${stepsRemaining}/2`}
</p>
containerClassName="mt-2 rounded-lg border bg-background"
onChange={(v) => onChange(v ?? '')}
/>
</FormControl>
<p className="text-muted-foreground block text-xs md:hidden">Minimise contract</p>
</div>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<div className="bg-background relative h-[2px] w-full">
<div
className={cn('bg-primary/60 absolute inset-y-0 left-0 duration-200', {
'w-1/2': stepsRemaining === 1,
'w-full': isValid,
})}
/>
</div>
{!isValid && (
<Button
loading={isSubmitting}
className="dark:bg-documenso ml-auto w-52 dark:hover:opacity-90"
onClick={() => onNextStepClick()}
>
Next
</Button>
)}
{isValid && (
<Button
type="submit"
loading={isSubmitting}
className="dark:bg-documenso ml-auto w-52 dark:hover:opacity-90"
>
Complete
</Button>
)}
</form>
</Form>
</div>
</Card>
</>
<Button
type="submit"
size="lg"
loading={isSubmitting}
className="dark:bg-documenso dark:hover:opacity-90"
>
{isSubmitting ? 'Signing up...' : 'Sign Up'}
</Button>
{isGoogleSSOEnabled && (
<>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">Or</span>
<div className="bg-border h-px flex-1" />
</div>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
Sign Up with Google
</Button>
</>
)}
</form>
</Form>
);
};
@@ -46,7 +46,7 @@ export default function NotFoundPartial({ children }: NotFoundPartialProps) {
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
<Button
variant="secondary"
variant="ghost"
className="w-32"
onClick={() => {
void router.back();
-1
View File
@@ -32,7 +32,6 @@ export function PostHogPageview() {
// Do nothing.
});
},
custom_campaign_params: ['src'],
});
}
-3
View File
@@ -1,3 +0,0 @@
<svg width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.54572 1.10579C9.57786 -0.0821356 11.4223 -0.0821393 12.4545 1.10579L13.2585 2.03112C13.3917 2.18452 13.5947 2.25838 13.7954 2.22654L15.0061 2.03449C16.5603 1.78794 17.9732 2.97353 18.0003 4.54698L18.0214 5.77262C18.0249 5.97581 18.1329 6.16284 18.3071 6.26746L19.358 6.89855C20.7071 7.70873 21.0274 9.52517 20.0368 10.7479L19.2651 11.7004C19.1372 11.8583 19.0997 12.0709 19.1659 12.2631L19.5652 13.422C20.0779 14.9098 19.1557 16.5072 17.6109 16.8071L16.4075 17.0407C16.208 17.0794 16.0426 17.2182 15.9698 17.408L15.5308 18.5525C14.9672 20.0218 13.234 20.6526 11.8578 19.8893L10.7858 19.2948C10.6081 19.1962 10.3921 19.1962 10.2144 19.2948L9.14242 19.8893C7.76623 20.6526 6.033 20.0218 5.4694 18.5525L5.03038 17.408C4.9576 17.2182 4.79216 17.0794 4.59268 17.0407L3.38932 16.8071C1.84448 16.5072 0.922245 14.9098 1.43495 13.422L1.83431 12.2631C1.90052 12.0709 1.86302 11.8583 1.7351 11.7004L0.963432 10.7479C-0.0272148 9.52517 0.293068 7.70873 1.64218 6.89855L2.69306 6.26746C2.86728 6.16284 2.97526 5.97581 2.97875 5.77263L2.99985 4.54699C3.02694 2.97354 4.43987 1.78794 5.99413 2.03449L7.20481 2.22654C7.40551 2.25838 7.60845 2.18452 7.74173 2.03112L8.54572 1.10579ZM13.7072 9.42195C14.0977 9.03143 14.0977 8.39826 13.7072 8.00774C13.3167 7.61721 12.6835 7.61721 12.293 8.00774L9.5001 10.8006L8.7072 10.0077C8.31667 9.61721 7.68351 9.61721 7.29298 10.0077C6.90246 10.3983 6.90246 11.0314 7.29298 11.422L8.43944 12.5684C9.02522 13.1542 9.97497 13.1542 10.5608 12.5684L13.7072 9.42195Z" fill="#7AC455"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

@@ -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<keyof typeof DOCUMENT_EMAIL_TYPE, unknown>;
+7 -5
View File
@@ -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',
roleName: 'Cc',
},
[RecipientRole.SIGNER]: {
actionVerb: 'Sign',
actioned: 'Signed',
progressiveVerb: 'Signing',
roleName: 'Signer',
},
[RecipientRole.VIEWER]: {
actionVerb: 'View',
actioned: 'Viewed',
progressiveVerb: 'Viewing',
roleName: 'Viewer',
},
};
} satisfies Record<keyof typeof RecipientRole, unknown>;
export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
[RecipientRole.SIGNER]: 'SIGNING_REQUEST',
@@ -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;
});
@@ -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(),
},
});
});
};
@@ -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<typeof parsedData> & { nextCursor?: string };
};
@@ -21,6 +21,19 @@ export const getDocumentById = async ({ id, userId, teamId }: GetDocumentByIdOpt
include: {
documentData: true,
documentMeta: true,
User: {
select: {
id: true,
name: true,
email: true,
},
},
team: {
select: {
id: true,
url: true,
},
},
},
});
};
@@ -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;
+2 -14
View File
@@ -72,20 +72,8 @@ export const getTeamByUrl = async ({ userId, teamUrl }: GetTeamByUrlOptions) =>
where: whereFilter,
include: {
teamEmail: true,
emailVerification: {
select: {
expiresAt: true,
name: true,
email: true,
},
},
transferVerification: {
select: {
expiresAt: true,
name: true,
email: true,
},
},
emailVerification: true,
transferVerification: true,
subscription: true,
members: {
where: {
@@ -1,35 +0,0 @@
import { prisma } from '@documenso/prisma';
import type { User, UserProfile } from '@documenso/prisma/client';
import { getUserById } from './get-user-by-id';
export type UpdatePublicProfileOptions = {
id: User['id'];
profileURL: UserProfile['profileURL'];
};
export const updatePublicProfile = async ({ id, profileURL }: UpdatePublicProfileOptions) => {
const user = await getUserById({ id });
// Existence check
await prisma.userProfile.findFirstOrThrow({
where: {
profileURL: user.profileURL ?? undefined,
},
});
return await prisma.$transaction(async (tx) => {
await tx.userProfile.create({
data: {
profileURL,
},
});
await tx.userProfile.update({
where: {
profileURL: user.profileURL ?? undefined,
},
data: {
profileURL: profileURL,
},
});
});
};
+50 -15
View File
@@ -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<T = TDocumentAuditLog['type']> = Extract<
TDocumentAuditLog,
{ type: T }
>;
+123 -1
View File
@@ -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,
};
};
@@ -1,25 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[profileURL]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "User" ADD COLUMN "profileURL" TEXT;
-- CreateTable
CREATE TABLE "UserProfile" (
"profileURL" TEXT NOT NULL,
"profileBio" TEXT,
CONSTRAINT "UserProfile_pkey" PRIMARY KEY ("profileURL")
);
-- CreateIndex
CREATE UNIQUE INDEX "UserProfile_profileURL_key" ON "UserProfile"("profileURL");
-- CreateIndex
CREATE UNIQUE INDEX "User_profileURL_key" ON "User"("profileURL");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_profileURL_fkey" FOREIGN KEY ("profileURL") REFERENCES "UserProfile"("profileURL") ON DELETE CASCADE ON UPDATE CASCADE;
-10
View File
@@ -43,9 +43,6 @@ model User {
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
twoFactorBackupCodes String?
profileURL String? @unique
UserProfile UserProfile? @relation(fields: [profileURL], references: [profileURL], onDelete: Cascade)
VerificationToken VerificationToken[]
Template Template[]
@@ -54,13 +51,6 @@ model User {
@@index([email])
}
model UserProfile {
profileURL String @id @unique
profileBio String?
User User?
}
enum UserSecurityAuditLogType {
ACCOUNT_PROFILE_UPDATE
ACCOUNT_SSO_LINK
+32 -1
View File
@@ -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 }) => {
@@ -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(),
@@ -7,7 +7,6 @@ import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token';
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
import { updateProfile } from '@documenso/lib/server-only/user/update-profile';
import { updatePublicProfile } from '@documenso/lib/server-only/user/update-public-profile';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { adminProcedure, authenticatedProcedure, procedure, router } from '../trpc';
@@ -19,7 +18,6 @@ import {
ZRetrieveUserByIdQuerySchema,
ZUpdatePasswordMutationSchema,
ZUpdateProfileMutationSchema,
ZUpdatePublicProfileMutationSchema,
} from './schema';
export const profileRouter = router({
@@ -75,27 +73,6 @@ export const profileRouter = router({
}
}),
updatePublicProfile: authenticatedProcedure
.input(ZUpdatePublicProfileMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { profileURL } = input;
return await updatePublicProfile({
id: ctx.user.id,
profileURL,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'We were unable to update your public profile. Please review the information you provided and try again.',
});
}
}),
updatePassword: authenticatedProcedure
.input(ZUpdatePasswordMutationSchema)
.mutation(async ({ input, ctx }) => {
@@ -16,10 +16,6 @@ export const ZUpdateProfileMutationSchema = z.object({
signature: z.string(),
});
export const ZUpdatePublicProfileMutationSchema = z.object({
profileURL: z.string().min(1),
});
export const ZUpdatePasswordMutationSchema = z.object({
currentPassword: ZCurrentPasswordSchema,
password: ZPasswordSchema,
+3 -2
View File
@@ -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(),
});
/**
@@ -1,32 +0,0 @@
import Link from 'next/link';
import { cn } from '../lib/utils';
interface AnnouncementBarProps {
isShown: boolean;
className: string;
}
export const AnnouncementBar: React.FC<AnnouncementBarProps> = ({ isShown, className }) => {
return (
isShown && (
<div
className={cn(
'flex h-full w-full items-center justify-center gap-4 border-b-2 p-1',
className,
)}
>
<div className="text-center">
<span className="text-sm text-white">Claim your documenso public profile URL now!</span>{' '}
<span className="text-sm font-medium text-white">documenso.com/u/yourname</span>
</div>
<div className="flex items-center justify-center gap-4 rounded-lg bg-white px-3 py-1">
<div className="text-xs text-gray-900">
<Link href="https://app.documenso.com">Claim now</Link>
</div>
</div>
</div>
)
);
};
+10 -6
View File
@@ -6,16 +6,20 @@ import { cva } from 'class-variance-authority';
import { cn } from '../lib/utils';
const badgeVariants = cva(
'inline-flex items-center border rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'inline-flex items-center rounded-md px-2 py-1.5 text-xs font-medium ring-1 ring-inset w-fit',
{
variants: {
variant: {
default: 'bg-primary hover:bg-primary/80 border-transparent text-primary-foreground',
secondary:
'bg-secondary hover:bg-secondary/80 border-transparent text-secondary-foreground',
neutral:
'bg-gray-50 text-gray-600 ring-gray-500/10 dark:bg-gray-400/10 dark:text-gray-400 dark:ring-gray-400/20',
destructive:
'bg-destructive hover:bg-destructive/80 border-transparent text-destructive-foreground',
outline: 'text-foreground',
'bg-red-50 text-red-700 ring-red-600/10 dark:bg-red-400/10 dark:text-red-400 dark:ring-red-400/20',
warning:
'bg-yellow-50 text-yellow-800 ring-yellow-600/20 dark:bg-yellow-400/10 dark:text-yellow-500 dark:ring-yellow-400/20',
default:
'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/20',
secondary:
'bg-blue-50 text-blue-700 ring-blue-700/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/30',
},
},
defaultVariants: {
+6 -3
View File
@@ -143,14 +143,17 @@ const sheetVariants = cva(
export interface DialogContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
VariantProps<typeof sheetVariants> {
showOverlay?: boolean;
sheetClass?: string;
}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
DialogContentProps
>(({ position, size, className, children, ...props }, ref) => (
>(({ position, size, className, sheetClass, showOverlay = true, children, ...props }, ref) => (
<SheetPortal position={position}>
<SheetOverlay />
{showOverlay && <SheetOverlay className={sheetClass} />}
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ position, size }), className)}
@@ -17,7 +17,6 @@ export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChang
onChange?: (_signatureDataUrl: string | null) => void;
containerClassName?: string;
disabled?: boolean;
height?: number;
};
export const SignaturePad = ({
@@ -26,7 +25,6 @@ export const SignaturePad = ({
defaultValue,
onChange,
disabled = false,
height,
...props
}: SignaturePadProps) => {
const $el = useRef<HTMLCanvasElement>(null);
@@ -232,7 +230,6 @@ export const SignaturePad = ({
onPointerUp={(event) => onMouseUp(event)}
onPointerLeave={(event) => onMouseLeave(event)}
onPointerEnter={(event) => onMouseEnter(event)}
height={height}
{...props}
/>
+1 -2
View File
@@ -3,8 +3,7 @@
import * as React from 'react';
import * as TogglePrimitive from '@radix-ui/react-toggle';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import { VariantProps, cva } from 'class-variance-authority';
import { cn } from '../lib/utils';
+2
View File
@@ -55,6 +55,8 @@
--card-border-tint: 112 205 159;
--card-foreground: 0 0% 95%;
--widget: 0 0% 14.9%;
--border: 0 0% 27.9%;
--input: 0 0% 27.9%;