Compare commits

..

2 Commits

Author SHA1 Message Date
dc035ed08c feat: update marketing banner 2024-04-10 15:52:00 +03:00
fb2fd17ad8 feat: add myself as signer 2024-04-10 15:38:31 +03:00
98 changed files with 643 additions and 2606 deletions

View File

@ -17,8 +17,7 @@ For the digital signature of your documents you need a signing certificate in .p
`openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt` `openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt`
4. You will be prompted to enter a password for the p12 file. Choose a strong password and remember it, as you will need it to use the certificate (**can be empty for dev certificates**) 4. You will be prompted to enter a password for the p12 file. Choose a strong password and remember it, as you will need it to use the certificate (**can be empty for dev certificates**)
5. Place the certificate `/apps/web/resources/certificate.p12`
5. Place the certificate `/apps/web/resources/certificate.p12` (If the path does not exist, it needs to be created)
## Docker ## Docker

View File

@ -22,7 +22,7 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
const config = { const config = {
experimental: { experimental: {
outputFileTracingRoot: path.join(__dirname, '../../'), outputFileTracingRoot: path.join(__dirname, '../../'),
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign', 'playwright'], serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'],
serverActions: { serverActions: {
bodySizeLimit: '50mb', bodySizeLimit: '50mb',
}, },

View File

@ -1,5 +1,4 @@
import type { TClaimPlanRequestSchema } from './types'; import { TClaimPlanRequestSchema, ZClaimPlanResponseSchema } from './types';
import { ZClaimPlanResponseSchema } from './types';
export const claimPlan = async ({ export const claimPlan = async ({
name, name,

View File

@ -47,7 +47,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
> >
{showProfilesAnnouncementBar && ( {showProfilesAnnouncementBar && (
<div className="relative inline-flex w-full items-center justify-center overflow-hidden bg-[#e7f3df] px-4 py-2.5"> <div className="relative inline-flex w-full items-center justify-center overflow-hidden bg-[#e7f3df] px-4 py-2.5">
<div className="text-black text-center text-sm font-medium"> <div className="text-foreground text-center text-sm font-medium">
Claim your documenso public profile username now!{' '} Claim your documenso public profile username now!{' '}
<span className="hidden font-semibold md:inline">documenso.com/u/yourname</span> <span className="hidden font-semibold md:inline">documenso.com/u/yourname</span>
<div className="mt-1.5 block md:ml-4 md:mt-0 md:inline-block"> <div className="mt-1.5 block md:ml-4 md:mt-0 md:inline-block">

View File

@ -55,7 +55,6 @@ export const BarMetric = <T extends Record<string, Record<keyof T[string], unkno
cursor={{ fill: 'hsl(var(--primary) / 10%)' }} cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
/> />
<Bar <Bar
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
dataKey={metricKey as string} dataKey={metricKey as string}
maxBarSize={60} maxBarSize={60}
fill="hsl(var(--primary))" fill="hsl(var(--primary))"

View File

@ -13,7 +13,6 @@ export type FundingRaisedProps = HTMLAttributes<HTMLDivElement> & {
export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps) => { export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps) => {
const formattedData = data.map((item) => ({ const formattedData = data.map((item) => ({
amount: Number(item.amount), amount: Number(item.amount),
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
date: formatMonth(item.date as string), date: formatMonth(item.date as string),
})); }));

View File

@ -1,4 +1,4 @@
import type { HTMLAttributes } from 'react'; import { HTMLAttributes } from 'react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';

View File

@ -1,4 +1,4 @@
import type { HTMLAttributes } from 'react'; import { HTMLAttributes } from 'react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { import {

View File

@ -2,14 +2,13 @@
import Link from 'next/link'; import Link from 'next/link';
import type { Variants } from 'framer-motion'; import { Variants, motion } from 'framer-motion';
import { motion } from 'framer-motion';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card'; import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
import type { TOSSFriendsSchema } from './schema'; import { TOSSFriendsSchema } from './schema';
const ContainerVariants: Variants = { const ContainerVariants: Variants = {
initial: { initial: {

View File

@ -158,7 +158,6 @@ export const SinglePlayerClient = () => {
expired: null, expired: null,
signedAt: null, signedAt: null,
readStatus: 'OPENED', readStatus: 'OPENED',
documentDeletedAt: null,
signingStatus: 'NOT_SIGNED', signingStatus: 'NOT_SIGNED',
sendStatus: 'NOT_SENT', sendStatus: 'NOT_SENT',
role: 'SIGNER', role: 'SIGNER',

View File

@ -1,4 +1,4 @@
import type { MetadataRoute } from 'next'; import { MetadataRoute } from 'next';
import { getBaseUrl } from '@documenso/lib/universal/get-base-url'; import { getBaseUrl } from '@documenso/lib/universal/get-base-url';

View File

@ -1,4 +1,4 @@
import type { MetadataRoute } from 'next'; import { MetadataRoute } from 'next';
import { allBlogPosts, allGenericPages } from 'contentlayer/generated'; import { allBlogPosts, allGenericPages } from 'contentlayer/generated';

View File

@ -13,7 +13,7 @@ import LogoImage from '@documenso/assets/logo.png';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher'; import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
// import { StatusWidgetContainer } from './status-widget-container'; import { StatusWidgetContainer } from './status-widget-container';
export type FooterProps = HTMLAttributes<HTMLDivElement>; export type FooterProps = HTMLAttributes<HTMLDivElement>;
@ -65,9 +65,9 @@ export const Footer = ({ className, ...props }: FooterProps) => {
))} ))}
</div> </div>
{/* <div className="mt-6"> <div className="mt-6">
<StatusWidgetContainer /> <StatusWidgetContainer />
</div> */} </div>
</div> </div>
<div className="grid w-full max-w-sm grid-cols-2 gap-x-4 gap-y-2 md:w-auto md:gap-x-8"> <div className="grid w-full max-w-sm grid-cols-2 gap-x-4 gap-y-2 md:w-auto md:gap-x-8">

View File

@ -96,7 +96,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
variants={HeroTitleVariants} variants={HeroTitleVariants}
initial="initial" initial="initial"
animate="animate" animate="animate"
className="text-center text-4xl font-bold leading-tight tracking-tight md:text-[48px] lg:text-[64px]" className="text-center text-4xl font-bold leading-tight tracking-tight lg:text-[64px]"
> >
Document signing, Document signing,
<span className="block" /> finally open source. <span className="block" /> finally open source.

View File

@ -1,4 +1,4 @@
import type { HTMLAttributes } from 'react'; import { HTMLAttributes } from 'react';
import Image from 'next/image'; import Image from 'next/image';

View File

@ -6,7 +6,7 @@ import { StatusWidget } from './status-widget';
export function StatusWidgetContainer() { export function StatusWidgetContainer() {
return ( return (
<Suspense fallback={<StatusWidgetFallback />}> <Suspense fallback={<StatusWidgetFallback />}>
<StatusWidget slug="documenso-status" /> <StatusWidget />
</Suspense> </Suspense>
); );
} }

View File

@ -1,6 +1,7 @@
import { memo, use } from 'react'; import { use, useMemo } from 'react';
import { type Status, getStatus } from '@openstatus/react'; import type { Status } from '@openstatus/react';
import { getStatus } from '@openstatus/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@ -44,8 +45,9 @@ const getStatusLevel = (level: Status) => {
}[level]; }[level];
}; };
export const StatusWidget = memo(function StatusWidget({ slug }: { slug: string }) { export function StatusWidget() {
const { status } = use(getStatus(slug)); const getStatusMemoized = useMemo(async () => getStatus('documenso-status'), []);
const { status } = use(getStatusMemoized);
const level = getStatusLevel(status); const level = getStatusLevel(status);
return ( return (
@ -70,4 +72,4 @@ export const StatusWidget = memo(function StatusWidget({ slug }: { slug: string
</span> </span>
</a> </a>
); );
}); }

View File

@ -346,7 +346,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
{signatureText && ( {signatureText && (
<p <p
className={cn( className={cn(
'text-foreground truncate text-4xl font-semibold [font-family:var(--font-caveat)]', 'text-foreground text-4xl font-semibold [font-family:var(--font-caveat)]',
)} )}
> >
{signatureText} {signatureText}
@ -360,7 +360,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
> >
<Input <Input
id="signatureText" id="signatureText"
className="text-foreground placeholder:text-muted-foreground truncate border-none p-0 text-sm focus-visible:ring-0" className="text-foreground placeholder:text-muted-foreground border-none p-0 text-sm focus-visible:ring-0"
placeholder="Draw or type name here" placeholder="Draw or type name here"
disabled={isSubmitting} disabled={isSubmitting}
{...register('signatureText', { {...register('signatureText', {

View File

@ -1,5 +1,5 @@
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import type { FieldError } from 'react-hook-form'; import { FieldError } from 'react-hook-form';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';

View File

@ -1,4 +1,4 @@
import type { SVGAttributes } from 'react'; import { SVGAttributes } from 'react';
export type BackgroundProps = Omit<SVGAttributes<SVGElement>, 'viewBox'>; export type BackgroundProps = Omit<SVGAttributes<SVGElement>, 'viewBox'>;

View File

@ -3,7 +3,7 @@
import * as React from 'react'; import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes'; import { ThemeProvider as NextThemesProvider } from 'next-themes';
import type { ThemeProviderProps } from 'next-themes/dist/types'; import { ThemeProviderProps } from 'next-themes/dist/types';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) { export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>; return <NextThemesProvider {...props}>{children}</NextThemesProvider>;

View File

@ -23,7 +23,7 @@ const config = {
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined, output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
experimental: { experimental: {
outputFileTracingRoot: path.join(__dirname, '../../'), outputFileTracingRoot: path.join(__dirname, '../../'),
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign', 'playwright'], serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'],
serverActions: { serverActions: {
bodySizeLimit: '50mb', bodySizeLimit: '50mb',
}, },

View File

@ -4,22 +4,13 @@ import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { import { Copy, Download, Edit, Loader, MoreHorizontal, Share, Trash2 } from 'lucide-react';
Copy,
Download,
Edit,
Loader,
MoreHorizontal,
ScrollTextIcon,
Share,
Trash2,
} from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import type { Document, Recipient, Team, TeamEmail, User } from '@documenso/prisma/client'; import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { import {
@ -41,7 +32,7 @@ export type DocumentPageViewDropdownProps = {
Recipient: Recipient[]; Recipient: Recipient[];
team: Pick<Team, 'id' | 'url'> | null; team: Pick<Team, 'id' | 'url'> | null;
}; };
team?: Pick<Team, 'id' | 'url'> & { teamEmail: TeamEmail | null }; team?: Pick<Team, 'id' | 'url'>;
}; };
export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => { export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => {
@ -59,10 +50,9 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
const isOwner = document.User.id === session.user.id; const isOwner = document.User.id === session.user.id;
const isDraft = document.status === DocumentStatus.DRAFT; const isDraft = document.status === DocumentStatus.DRAFT;
const isDeleted = document.deletedAt !== null;
const isComplete = document.status === DocumentStatus.COMPLETED; const isComplete = document.status === DocumentStatus.COMPLETED;
const isDocumentDeletable = isOwner;
const isCurrentTeamDocument = team && document.team?.url === team.url; const isCurrentTeamDocument = team && document.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
const documentsPath = formatDocumentsPath(team?.url); const documentsPath = formatDocumentsPath(team?.url);
@ -116,22 +106,12 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem asChild>
<Link href={`${documentsPath}/${document.id}/logs`}>
<ScrollTextIcon className="mr-2 h-4 w-4" />
Audit Log
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}> <DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" /> <Copy className="mr-2 h-4 w-4" />
Duplicate Duplicate
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}>
onClick={() => setDeleteDialogOpen(true)}
disabled={Boolean(!canManageDocument && team?.teamEmail) || isDeleted}
>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
@ -158,15 +138,15 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
/> />
</DropdownMenuContent> </DropdownMenuContent>
{isDocumentDeletable && (
<DeleteDocumentDialog <DeleteDocumentDialog
id={document.id} id={document.id}
status={document.status} status={document.status}
documentTitle={document.title} documentTitle={document.title}
open={isDeleteDialogOpen} open={isDeleteDialogOpen}
canManageDocument={canManageDocument}
onOpenChange={setDeleteDialogOpen} onOpenChange={setDeleteDialogOpen}
/> />
)}
{isDuplicateDialogOpen && ( {isDuplicateDialogOpen && (
<DuplicateDocumentDialog <DuplicateDocumentDialog
id={document.id} id={document.id}

View File

@ -12,8 +12,7 @@ import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/g
import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import type { Team, TeamEmail } from '@documenso/prisma/client'; import type { Team } from '@documenso/prisma/client';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
@ -35,7 +34,7 @@ export type DocumentPageViewProps = {
params: { params: {
id: string; id: string;
}; };
team?: Team & { teamEmail: TeamEmail | null }; team?: Team;
}; };
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => { export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
@ -119,17 +118,11 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
<div className="text-muted-foreground flex items-center"> <div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" /> <Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip <StackAvatarsWithTooltip recipients={recipients} position="bottom">
recipients={recipients}
documentStatus={document.status}
position="bottom"
>
<span>{recipients.length} Recipient(s)</span> <span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip> </StackAvatarsWithTooltip>
</div> </div>
)} )}
{document.deletedAt && <Badge variant="destructive">Document deleted</Badge>}
</div> </div>
</div> </div>

View File

@ -224,10 +224,6 @@ export const EditDocumentForm = ({
} }
}; };
const setSubjectFormFields = (subject?: string, message?: string) => {
// Add functionality here
};
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => { const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
try { try {
await addFields({ await addFields({
@ -363,7 +359,6 @@ export const EditDocumentForm = ({
fields={fields} fields={fields}
onSubmit={onAddSubjectFormSubmit} onSubmit={onAddSubjectFormSubmit}
isDocumentPdfLoaded={isDocumentPdfLoaded} isDocumentPdfLoaded={isDocumentPdfLoaded}
setSubjectFormFields={setSubjectFormFields}
/> />
</Stepper> </Stepper>
</DocumentFlowFormContainer> </DocumentFlowFormContainer>

View File

@ -92,11 +92,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
<div className="text-muted-foreground flex items-center"> <div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" /> <Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip <StackAvatarsWithTooltip recipients={recipients} position="bottom">
recipients={recipients}
documentStatus={document.status}
position="bottom"
>
<span>{recipients.length} Recipient(s)</span> <span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip> </StackAvatarsWithTooltip>
</div> </div>

View File

@ -1,7 +1,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { ChevronLeft } from 'lucide-react'; import { ChevronLeft, DownloadIcon } from 'lucide-react';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
@ -10,6 +10,7 @@ import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Recipient, Team } from '@documenso/prisma/client'; import type { Recipient, Team } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { Card } from '@documenso/ui/primitives/card'; import { Card } from '@documenso/ui/primitives/card';
import { import {
@ -18,8 +19,6 @@ import {
} from '~/components/formatter/document-status'; } from '~/components/formatter/document-status';
import { DocumentLogsDataTable } from './document-logs-data-table'; import { DocumentLogsDataTable } from './document-logs-data-table';
import { DownloadAuditLogButton } from './download-audit-log-button';
import { DownloadCertificateButton } from './download-certificate-button';
export type DocumentLogsPageViewProps = { export type DocumentLogsPageViewProps = {
params: { params: {
@ -133,9 +132,15 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
</div> </div>
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end"> <div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
<DownloadCertificateButton className="mr-2" documentId={document.id} /> <Button variant="outline" className="mr-2 w-full sm:w-auto">
<DownloadIcon className="mr-1.5 h-4 w-4" />
Download certificate
</Button>
<DownloadAuditLogButton documentId={document.id} /> <Button className="w-full sm:w-auto">
<DownloadIcon className="mr-1.5 h-4 w-4" />
Download PDF
</Button>
</div> </div>
</div> </div>

View File

@ -1,74 +0,0 @@
'use client';
import { DownloadIcon } from 'lucide-react';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DownloadAuditLogButtonProps = {
className?: string;
documentId: number;
};
export const DownloadAuditLogButton = ({ className, documentId }: DownloadAuditLogButtonProps) => {
const { toast } = useToast();
const { mutateAsync: downloadAuditLogs, isLoading } =
trpc.document.downloadAuditLogs.useMutation();
const onDownloadAuditLogsClick = async () => {
try {
const { url } = await downloadAuditLogs({ documentId });
const iframe = Object.assign(document.createElement('iframe'), {
src: url,
});
Object.assign(iframe.style, {
position: 'fixed',
top: '0',
left: '0',
width: '0',
height: '0',
});
const onLoaded = () => {
if (iframe.contentDocument?.readyState === 'complete') {
iframe.contentWindow?.print();
iframe.contentWindow?.addEventListener('afterprint', () => {
document.body.removeChild(iframe);
});
}
};
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
document.body.appendChild(iframe);
onLoaded();
} catch (error) {
console.error(error);
toast({
title: 'Something went wrong',
description: 'Sorry, we were unable to download the audit logs. Please try again later.',
variant: 'destructive',
});
}
};
return (
<Button
className={cn('w-full sm:w-auto', className)}
loading={isLoading}
onClick={() => void onDownloadAuditLogsClick()}
>
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
Download Audit Logs
</Button>
);
};

View File

@ -1,78 +0,0 @@
'use client';
import { DownloadIcon } from 'lucide-react';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DownloadCertificateButtonProps = {
className?: string;
documentId: number;
};
export const DownloadCertificateButton = ({
className,
documentId,
}: DownloadCertificateButtonProps) => {
const { toast } = useToast();
const { mutateAsync: downloadCertificate, isLoading } =
trpc.document.downloadCertificate.useMutation();
const onDownloadCertificatesClick = async () => {
try {
const { url } = await downloadCertificate({ documentId });
const iframe = Object.assign(document.createElement('iframe'), {
src: url,
});
Object.assign(iframe.style, {
position: 'fixed',
top: '0',
left: '0',
width: '0',
height: '0',
});
const onLoaded = () => {
if (iframe.contentDocument?.readyState === 'complete') {
iframe.contentWindow?.print();
iframe.contentWindow?.addEventListener('afterprint', () => {
document.body.removeChild(iframe);
});
}
};
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
document.body.appendChild(iframe);
onLoaded();
} catch (error) {
console.error(error);
toast({
title: 'Something went wrong',
description: 'Sorry, we were unable to download the certificate. Please try again later.',
variant: 'destructive',
});
}
};
return (
<Button
className={cn('w-full sm:w-auto', className)}
loading={isLoading}
variant="outline"
onClick={() => void onDownloadCertificatesClick()}
>
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
Download Certificate
</Button>
);
};

View File

@ -15,6 +15,7 @@ import {
Pencil, Pencil,
Share, Share,
Trash2, Trash2,
XCircle,
} from 'lucide-react'; } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
@ -44,7 +45,7 @@ export type DataTableActionDropdownProps = {
Recipient: Recipient[]; Recipient: Recipient[];
team: Pick<Team, 'id' | 'url'> | null; team: Pick<Team, 'id' | 'url'> | null;
}; };
team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string }; team?: Pick<Team, 'id' | 'url'>;
}; };
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => { export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
@ -66,8 +67,8 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
// const isPending = row.status === DocumentStatus.PENDING; // const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED; const isComplete = row.status === DocumentStatus.COMPLETED;
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isDocumentDeletable = isOwner;
const isCurrentTeamDocument = team && row.team?.url === team.url; const isCurrentTeamDocument = team && row.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
const documentsPath = formatDocumentsPath(team?.url); const documentsPath = formatDocumentsPath(team?.url);
@ -106,14 +107,14 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger data-testid="document-table-action-btn"> <DropdownMenuTrigger>
<MoreHorizontal className="text-muted-foreground h-5 w-5" /> <MoreHorizontal className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount> <DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel> <DropdownMenuLabel>Action</DropdownMenuLabel>
{!isDraft && recipient && recipient?.role !== RecipientRole.CC && ( {recipient && recipient?.role !== RecipientRole.CC && (
<DropdownMenuItem disabled={!recipient || isComplete} asChild> <DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Link href={`/sign/${recipient?.token}`}> <Link href={`/sign/${recipient?.token}`}>
{recipient?.role === RecipientRole.VIEWER && ( {recipient?.role === RecipientRole.VIEWER && (
@ -140,7 +141,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem disabled={!canManageDocument || isComplete} asChild> <DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild>
<Link href={`${documentsPath}/${row.id}/edit`}> <Link href={`${documentsPath}/${row.id}/edit`}>
<Edit className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
Edit Edit
@ -157,18 +158,14 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
Duplicate Duplicate
</DropdownMenuItem> </DropdownMenuItem>
{/* No point displaying this if there's no functionality. */} <DropdownMenuItem disabled>
{/* <DropdownMenuItem disabled>
<XCircle className="mr-2 h-4 w-4" /> <XCircle className="mr-2 h-4 w-4" />
Void Void
</DropdownMenuItem> */} </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}>
onClick={() => setDeleteDialogOpen(true)}
disabled={Boolean(!canManageDocument && team?.teamEmail)}
>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
{canManageDocument ? 'Delete' : 'Hide'} Delete
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuLabel>Share</DropdownMenuLabel> <DropdownMenuLabel>Share</DropdownMenuLabel>
@ -189,6 +186,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
/> />
</DropdownMenuContent> </DropdownMenuContent>
{isDocumentDeletable && (
<DeleteDocumentDialog <DeleteDocumentDialog
id={row.id} id={row.id}
status={row.status} status={row.status}
@ -196,9 +194,8 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
open={isDeleteDialogOpen} open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen} onOpenChange={setDeleteDialogOpen}
teamId={team?.id} teamId={team?.id}
canManageDocument={canManageDocument}
/> />
)}
{isDuplicateDialogOpen && ( {isDuplicateDialogOpen && (
<DuplicateDocumentDialog <DuplicateDocumentDialog
id={row.id} id={row.id}

View File

@ -29,7 +29,7 @@ export type DocumentsDataTableProps = {
} }
>; >;
showSenderColumn?: boolean; showSenderColumn?: boolean;
team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string }; team?: Pick<Team, 'id' | 'url'>;
}; };
export const DocumentsDataTable = ({ export const DocumentsDataTable = ({
@ -76,12 +76,7 @@ export const DocumentsDataTable = ({
{ {
header: 'Recipient', header: 'Recipient',
accessorKey: 'recipient', accessorKey: 'recipient',
cell: ({ row }) => ( cell: ({ row }) => <StackAvatarsWithTooltip recipients={row.original.Recipient} />,
<StackAvatarsWithTooltip
recipients={row.original.Recipient}
documentStatus={row.original.status}
/>
),
}, },
{ {
header: 'Status', header: 'Status',

View File

@ -2,11 +2,8 @@ import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { match } from 'ts-pattern';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react'; import { trpc as trpcReact } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@ -26,7 +23,6 @@ type DeleteDocumentDialogProps = {
status: DocumentStatus; status: DocumentStatus;
documentTitle: string; documentTitle: string;
teamId?: number; teamId?: number;
canManageDocument: boolean;
}; };
export const DeleteDocumentDialog = ({ export const DeleteDocumentDialog = ({
@ -36,7 +32,6 @@ export const DeleteDocumentDialog = ({
status, status,
documentTitle, documentTitle,
teamId, teamId,
canManageDocument,
}: DeleteDocumentDialogProps) => { }: DeleteDocumentDialogProps) => {
const router = useRouter(); const router = useRouter();
@ -88,70 +83,33 @@ export const DeleteDocumentDialog = ({
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}> <Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Are you sure?</DialogTitle> <DialogTitle>Are you sure you want to delete "{documentTitle}"?</DialogTitle>
<DialogDescription> <DialogDescription>
You are about to {canManageDocument ? 'delete' : 'hide'}{' '} Please note that this action is irreversible. Once confirmed, your document will be
<strong>"{documentTitle}"</strong> permanently deleted.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{canManageDocument ? ( {status !== DocumentStatus.DRAFT && (
<Alert variant="warning" className="-mt-1"> <div className="mt-4">
{match(status)
.with(DocumentStatus.DRAFT, () => (
<AlertDescription>
Please note that this action is <strong>irreversible</strong>. Once confirmed,
this document will be permanently deleted.
</AlertDescription>
))
.with(DocumentStatus.PENDING, () => (
<AlertDescription>
<p>
Please note that this action is <strong>irreversible</strong>.
</p>
<p className="mt-1">Once confirmed, the following will occur:</p>
<ul className="mt-0.5 list-inside list-disc">
<li>Document will be permanently deleted</li>
<li>Document signing process will be cancelled</li>
<li>All inserted signatures will be voided</li>
<li>All recipients will be notified</li>
</ul>
</AlertDescription>
))
.with(DocumentStatus.COMPLETED, () => (
<AlertDescription>
<p>By deleting this document, the following will occur:</p>
<ul className="mt-0.5 list-inside list-disc">
<li>The document will be hidden from your account</li>
<li>Recipients will still retain their copy of the document</li>
</ul>
</AlertDescription>
))
.exhaustive()}
</Alert>
) : (
<Alert variant="warning" className="-mt-1">
<AlertDescription>
Please contact support if you would like to revert this action.
</AlertDescription>
</Alert>
)}
{status !== DocumentStatus.DRAFT && canManageDocument && (
<Input <Input
type="text" type="text"
value={inputValue} value={inputValue}
onChange={onInputChange} onChange={onInputChange}
placeholder="Type 'delete' to confirm" placeholder="Type 'delete' to confirm"
/> />
</div>
)} )}
<DialogFooter> <DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}> <div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
className="flex-1"
>
Cancel Cancel
</Button> </Button>
@ -159,11 +117,13 @@ export const DeleteDocumentDialog = ({
type="button" type="button"
loading={isLoading} loading={isLoading}
onClick={onDelete} onClick={onDelete}
disabled={!isDeleteEnabled && canManageDocument} disabled={!isDeleteEnabled}
variant="destructive" variant="destructive"
className="flex-1"
> >
{canManageDocument ? 'Delete' : 'Hide'} Delete
</Button> </Button>
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -41,9 +41,7 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
const page = Number(searchParams.page) || 1; const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 20; const perPage = Number(searchParams.perPage) || 20;
const senderIds = parseToIntegerArray(searchParams.senderIds ?? ''); const senderIds = parseToIntegerArray(searchParams.senderIds ?? '');
const currentTeam = team const currentTeam = team ? { id: team.id, url: team.url } : undefined;
? { id: team.id, url: team.url, teamEmail: team.teamEmail?.email }
: undefined;
const getStatOptions: GetStatsInput = { const getStatOptions: GetStatsInput = {
user, user,

View File

@ -37,10 +37,7 @@ export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
})); }));
return ( return (
<div <div className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4">
className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4"
data-testid="empty-document-state"
>
<Icon className="h-12 w-12" strokeWidth={1.5} /> <Icon className="h-12 w-12" strokeWidth={1.5} />
<div className="text-center"> <div className="text-center">

View File

@ -18,10 +18,7 @@ import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { import {
Dialog, Dialog,
DialogClose,
DialogContent, DialogContent,
DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
@ -37,6 +34,7 @@ import {
FormMessage, FormMessage,
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
const ZCreateTemplateFormSchema = z.object({ const ZCreateTemplateFormSchema = z.object({
@ -63,7 +61,8 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
resolver: zodResolver(ZCreateTemplateFormSchema), resolver: zodResolver(ZCreateTemplateFormSchema),
}); });
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation(); const { mutateAsync: createTemplate, isLoading: isCreatingTemplate } =
trpc.template.createTemplate.useMutation();
const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false); const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false);
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>(); const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
@ -141,7 +140,6 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
useEffect(() => { useEffect(() => {
if (!showNewTemplateDialog) { if (!showNewTemplateDialog) {
form.reset(); form.reset();
setUploadedFile(null);
} }
}, [form, showNewTemplateDialog]); }, [form, showNewTemplateDialog]);
@ -156,23 +154,20 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
<DialogContent className="w-full max-w-xl"> <DialogContent className="w-full max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle>New Template</DialogTitle> <DialogTitle className="mb-4">New Template</DialogTitle>
<DialogDescription>
Templates allow you to quickly generate documents with pre-filled recipients and fields.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}> <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
<fieldset disabled={form.formState.isSubmitting} className="flex flex-col gap-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Template name</FormLabel> <FormLabel>Name your template</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input id="email" type="text" className="bg-background mt-1.5" {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
@ -185,7 +180,10 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
)} )}
/> />
<div className="mt-1.5"> <div>
<Label htmlFor="template">Upload a Document</Label>
<div className="my-3">
{uploadedFile ? ( {uploadedFile ? (
<Card gradient className="h-[40vh]"> <Card gradient className="h-[40vh]">
<CardContent className="flex h-full flex-col items-center justify-center p-2"> <CardContent className="flex h-full flex-col items-center justify-center p-2">
@ -214,28 +212,23 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<DocumentDropzone className="h-[40vh]" onDrop={onFileDrop} type="template" /> <DocumentDropzone
className="mt-1.5 h-[40vh]"
onDrop={onFileDrop}
type="template"
/>
)} )}
</div> </div>
</div>
<DialogFooter> <div className="flex w-full justify-end">
<DialogClose asChild> <Button loading={isCreatingTemplate} type="submit">
<Button type="button" variant="secondary"> Create Template
Cancel
</Button> </Button>
</DialogClose> </div>
<Button
loading={form.formState.isSubmitting}
disabled={!uploadedFile}
type="submit"
>
Create template
</Button>
</DialogFooter>
</fieldset>
</form> </form>
</Form> </Form>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -1,89 +0,0 @@
'use client';
import { DateTime } from 'luxon';
import type { DateTimeFormatOptions } from 'luxon';
import { UAParser } from 'ua-parser-js';
import type { TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs';
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@documenso/ui/primitives/table';
import { LocaleDate } from '~/components/formatter/locale-date';
export type AuditLogDataTableProps = {
logs: TDocumentAuditLog[];
};
const dateFormat: DateTimeFormatOptions = {
...DateTime.DATETIME_SHORT,
hourCycle: 'h12',
};
export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => {
const parser = new UAParser();
const uppercaseFistLetter = (text: string) => {
return text.charAt(0).toUpperCase() + text.slice(1);
};
return (
<Table overflowHidden>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>User</TableHead>
<TableHead>Action</TableHead>
<TableHead>IP Address</TableHead>
<TableHead>Browser</TableHead>
</TableRow>
</TableHeader>
<TableBody className="print:text-xs">
{logs.map((log, i) => (
<TableRow className="break-inside-avoid" key={i}>
<TableCell>
<LocaleDate format={dateFormat} date={log.createdAt} />
</TableCell>
<TableCell>
{log.name || log.email ? (
<div>
{log.name && (
<p className="break-all" title={log.name}>
{log.name}
</p>
)}
{log.email && (
<p className="text-muted-foreground break-all" title={log.email}>
{log.email}
</p>
)}
</div>
) : (
<p>N/A</p>
)}
</TableCell>
<TableCell>
{uppercaseFistLetter(formatDocumentAuditLogAction(log).description)}
</TableCell>
<TableCell>{log.ipAddress}</TableCell>
<TableCell>
{log.userAgent ? parser.setUA(log.userAgent).getBrowser().name : 'N/A'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
};

View File

@ -1,139 +0,0 @@
import React from 'react';
import { redirect } from 'next/navigation';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { Logo } from '~/components/branding/logo';
import { LocaleDate } from '~/components/formatter/locale-date';
import { AuditLogDataTable } from './data-table';
type AuditLogProps = {
searchParams: {
d: string;
};
};
export default async function AuditLog({ searchParams }: AuditLogProps) {
const { d } = searchParams;
if (typeof d !== 'string' || !d) {
return redirect('/');
}
const rawDocumentId = decryptSecondaryData(d);
if (!rawDocumentId || isNaN(Number(rawDocumentId))) {
return redirect('/');
}
const documentId = Number(rawDocumentId);
const document = await getEntireDocument({
id: documentId,
}).catch(() => null);
if (!document) {
return redirect('/');
}
const { data: auditLogs } = await findDocumentAuditLogs({
documentId: documentId,
userId: document.userId,
perPage: 100_000,
});
return (
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
<div className="flex items-center">
<h1 className="my-8 text-2xl font-bold">Version History</h1>
</div>
<Card>
<CardContent className="grid grid-cols-2 gap-4 p-6 text-sm print:text-xs">
<p>
<span className="font-medium">Document ID</span>
<span className="mt-1 block break-words">{document.id}</span>
</p>
<p>
<span className="font-medium">Enclosed Document</span>
<span className="mt-1 block break-words">{document.title}</span>
</p>
<p>
<span className="font-medium">Status</span>
<span className="mt-1 block">{document.deletedAt ? 'DELETED' : document.status}</span>
</p>
<p>
<span className="font-medium">Owner</span>
<span className="mt-1 block break-words">
{document.User.name} ({document.User.email})
</span>
</p>
<p>
<span className="font-medium">Created At</span>
<span className="mt-1 block">
<LocaleDate date={document.createdAt} format="yyyy-mm-dd hh:mm:ss a (ZZZZ)" />
</span>
</p>
<p>
<span className="font-medium">Last Updated</span>
<span className="mt-1 block">
<LocaleDate date={document.updatedAt} format="yyyy-mm-dd hh:mm:ss a (ZZZZ)" />
</span>
</p>
<p>
<span className="font-medium">Time Zone</span>
<span className="mt-1 block break-words">
{document.documentMeta?.timezone ?? 'N/A'}
</span>
</p>
<div>
<p className="font-medium">Recipients</p>
<ul className="mt-1 list-inside list-disc">
{document.Recipient.map((recipient) => (
<li key={recipient.id}>
<span className="text-muted-foreground">
[{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}]
</span>{' '}
{recipient.name} ({recipient.email})
</li>
))}
</ul>
</div>
</CardContent>
</Card>
<Card className="mt-8">
<CardContent className="p-0">
<AuditLogDataTable logs={auditLogs} />
</CardContent>
</Card>
<div className="my-8 flex-row-reverse">
<div className="flex items-end justify-end gap-x-4">
<Logo className="max-h-6 print:max-h-4" />
</div>
</div>
</div>
);
}

View File

@ -1,299 +0,0 @@
import React from 'react';
import { redirect } from 'next/navigation';
import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_SIGNING_REASONS,
} from '@documenso/lib/constants/recipient-roles';
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { getDocumentCertificateAuditLogs } from '@documenso/lib/server-only/document/get-document-certificate-audit-logs';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { FieldType } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@documenso/ui/primitives/table';
import { Logo } from '~/components/branding/logo';
import { LocaleDate } from '~/components/formatter/locale-date';
type SigningCertificateProps = {
searchParams: {
d: string;
};
};
const FRIENDLY_SIGNING_REASONS = {
['__OWNER__']: 'I am the owner of this document',
...RECIPIENT_ROLE_SIGNING_REASONS,
};
export default async function SigningCertificate({ searchParams }: SigningCertificateProps) {
const { d } = searchParams;
if (typeof d !== 'string' || !d) {
return redirect('/');
}
const rawDocumentId = decryptSecondaryData(d);
if (!rawDocumentId || isNaN(Number(rawDocumentId))) {
return redirect('/');
}
const documentId = Number(rawDocumentId);
const document = await getEntireDocument({
id: documentId,
}).catch(() => null);
if (!document) {
return redirect('/');
}
const auditLogs = await getDocumentCertificateAuditLogs({
id: documentId,
});
const isOwner = (email: string) => {
return email.toLowerCase() === document.User.email.toLowerCase();
};
const getDevice = (userAgent?: string | null) => {
if (!userAgent) {
return 'Unknown';
}
const parser = new UAParser(userAgent);
parser.setUA(userAgent);
const result = parser.getResult();
return `${result.os.name} - ${result.browser.name} ${result.browser.version}`;
};
const getAuthenticationLevel = (recipientId: number) => {
const recipient = document.Recipient.find((recipient) => recipient.id === recipientId);
if (!recipient) {
return 'Unknown';
}
const extractedAuthMethods = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
let authLevel = match(extractedAuthMethods.derivedRecipientActionAuth)
.with('ACCOUNT', () => 'Account Re-Authentication')
.with('TWO_FACTOR_AUTH', () => 'Two-Factor Re-Authentication')
.with('PASSKEY', () => 'Passkey Re-Authentication')
.with('EXPLICIT_NONE', () => 'Email')
.with(null, () => null)
.exhaustive();
if (!authLevel) {
authLevel = match(extractedAuthMethods.derivedRecipientAccessAuth)
.with('ACCOUNT', () => 'Account Authentication')
.with(null, () => 'Email')
.exhaustive();
}
return authLevel;
};
const getRecipientAuditLogs = (recipientId: number) => {
return {
[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT].filter(
(log) =>
log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT && log.data.recipientId === recipientId,
),
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs[
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED
].filter(
(log) =>
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED &&
log.data.recipientId === recipientId,
),
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs[
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED
].filter(
(log) =>
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED &&
log.data.recipientId === recipientId,
),
};
};
const getRecipientSignatureField = (recipientId: number) => {
return document.Recipient.find((recipient) => recipient.id === recipientId)?.Field.find(
(field) => field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE,
);
};
return (
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
<div className="flex items-center">
<h1 className="my-8 text-2xl font-bold">Signing Certificate</h1>
</div>
<Card>
<CardContent className="p-0">
<Table overflowHidden>
<TableHeader>
<TableRow>
<TableHead>Signer Events</TableHead>
<TableHead>Signature</TableHead>
<TableHead>Details</TableHead>
{/* <TableHead>Security</TableHead> */}
</TableRow>
</TableHeader>
<TableBody className="print:text-xs">
{document.Recipient.map((recipient, i) => {
const logs = getRecipientAuditLogs(recipient.id);
const signature = getRecipientSignatureField(recipient.id);
return (
<TableRow key={i} className="print:break-inside-avoid">
<TableCell truncate={false} className="w-[min-content] max-w-[220px] align-top">
<div className="hyphens-auto break-words font-medium">{recipient.name}</div>
<div className="break-all">{recipient.email}</div>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">Authentication Level:</span>{' '}
<span className="block">{getAuthenticationLevel(recipient.id)}</span>
</p>
</TableCell>
<TableCell truncate={false} className="w-[min-content] align-top">
{signature ? (
<>
<div
className="inline-block rounded-lg p-1"
style={{
boxShadow: `0px 0px 0px 4.88px rgba(122, 196, 85, 0.1), 0px 0px 0px 1.22px rgba(122, 196, 85, 0.6), 0px 0px 0px 0.61px rgba(122, 196, 85, 1)`,
}}
>
<img
src={`${signature.Signature?.signatureImageAsBase64}`}
alt="Signature"
className="max-h-12 max-w-full"
/>
</div>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">Signature ID:</span>{' '}
<span className="block font-mono uppercase">
{signature.secondaryId}
</span>
</p>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">IP Address:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? 'Unknown'}
</span>
</p>
<p className="text-muted-foreground mt-1 text-sm print:text-xs">
<span className="font-medium">Device:</span>{' '}
<span className="inline-block">
{getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
</span>
</p>
</>
) : (
<p className="text-muted-foreground">N/A</p>
)}
</TableCell>
<TableCell truncate={false} className="w-[min-content] align-top">
<div className="space-y-1">
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">Sent:</span>{' '}
<span className="inline-block">
{logs.EMAIL_SENT[0] ? (
<LocaleDate
date={logs.EMAIL_SENT[0].createdAt}
format="yyyy-MM-dd hh:mm:ss a (ZZZZ)"
/>
) : (
'Unknown'
)}
</span>
</p>
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">Viewed:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_OPENED[0] ? (
<LocaleDate
date={logs.DOCUMENT_OPENED[0].createdAt}
format="yyyy-MM-dd hh:mm:ss a (ZZZZ)"
/>
) : (
'Unknown'
)}
</span>
</p>
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">Signed:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0] ? (
<LocaleDate
date={logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt}
format="yyyy-MM-dd hh:mm:ss a (ZZZZ)"
/>
) : (
'Unknown'
)}
</span>
</p>
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">Reason:</span>{' '}
<span className="inline-block">
{isOwner(recipient.email)
? FRIENDLY_SIGNING_REASONS['__OWNER__']
: FRIENDLY_SIGNING_REASONS[recipient.role]}
</span>
</p>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
<div className="my-8 flex-row-reverse">
<div className="flex items-end justify-end gap-x-4">
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
Signing certificate provided by:
</p>
<Logo className="max-h-6 print:max-h-4" />
</div>
</div>
</div>
);
}

View File

@ -1,155 +0,0 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type ClaimAccountProps = {
defaultName: string;
defaultEmail: string;
trigger?: React.ReactNode;
};
export const ZClaimAccountFormSchema = z
.object({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
email: z.string().email().min(1),
password: ZPasswordSchema,
})
.refine(
(data) => {
const { name, email, password } = data;
return !password.includes(name) && !password.includes(email.split('@')[0]);
},
{
message: 'Password should not be common or based on personal information',
path: ['password'],
},
);
export type TClaimAccountFormSchema = z.infer<typeof ZClaimAccountFormSchema>;
export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) => {
const analytics = useAnalytics();
const { toast } = useToast();
const router = useRouter();
const { mutateAsync: signup } = trpc.auth.signup.useMutation();
const form = useForm<TClaimAccountFormSchema>({
values: {
name: defaultName ?? '',
email: defaultEmail,
password: '',
},
resolver: zodResolver(ZClaimAccountFormSchema),
});
const onFormSubmit = async ({ name, email, password }: TClaimAccountFormSchema) => {
try {
await signup({ name, email, password });
router.push(`/unverified-account`);
toast({
title: 'Registration Successful',
description:
'You have successfully registered. Please verify your account by clicking on the link you received in the email.',
duration: 5000,
});
analytics.capture('App: User Claim Account', {
email,
timestamp: new Date().toISOString(),
});
} catch (error) {
if (error instanceof TRPCClientError && error.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
description: error.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
description:
'We encountered an unknown error while attempting to sign you up. Please try again later.',
variant: 'destructive',
});
}
}
};
return (
<div className="mt-2 w-full">
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="mt-4">
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="Enter your name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="email"
control={form.control}
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel>Email address</FormLabel>
<FormControl>
<Input {...field} placeholder="Enter your email" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="password"
control={form.control}
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel>Set a password</FormLabel>
<FormControl>
<PasswordInput {...field} placeholder="Pick a password" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="mt-6 w-full" loading={form.formState.isSubmitting}>
Claim account
</Button>
</fieldset>
</form>
</Form>
</div>
);
};

View File

@ -3,7 +3,6 @@ import { notFound } from 'next/navigation';
import { CheckCircle2, Clock8 } from 'lucide-react'; import { CheckCircle2, Clock8 } from 'lucide-react';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { env } from 'next-runtime-env';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import signingCelebration from '@documenso/assets/images/signing-celebration.png'; import signingCelebration from '@documenso/assets/images/signing-celebration.png';
@ -17,13 +16,10 @@ import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/clie
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button'; import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { SigningCard3D } from '@documenso/ui/components/signing-card'; import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { truncateTitle } from '~/helpers/truncate-title'; import { truncateTitle } from '~/helpers/truncate-title';
import { SigningAuthPageView } from '../signing-auth-page'; import { SigningAuthPageView } from '../signing-auth-page';
import { ClaimAccount } from './claim-account';
import { DocumentPreviewButton } from './document-preview-button'; import { DocumentPreviewButton } from './document-preview-button';
export type CompletedSigningPageProps = { export type CompletedSigningPageProps = {
@ -35,8 +31,6 @@ export type CompletedSigningPageProps = {
export default async function CompletedSigningPage({ export default async function CompletedSigningPage({
params: { token }, params: { token },
}: CompletedSigningPageProps) { }: CompletedSigningPageProps) {
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
if (!token) { if (!token) {
return notFound(); return notFound();
} }
@ -85,30 +79,9 @@ export default async function CompletedSigningPage({
const sessionData = await getServerSession(); const sessionData = await getServerSession();
const isLoggedIn = !!sessionData?.user; const isLoggedIn = !!sessionData?.user;
const canSignUp = !isLoggedIn && NEXT_PUBLIC_DISABLE_SIGNUP !== 'true';
return ( return (
<div <div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44">
className={cn(
'-mx-4 flex flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44',
{ 'pt-0 lg:pt-0 xl:pt-0': canSignUp },
)}
>
<div
className={cn('relative mt-6 flex w-full flex-col items-center justify-center', {
'mt-0 flex-col divide-y overflow-hidden pt-6 md:pt-16 lg:flex-row lg:divide-x lg:divide-y-0 lg:pt-20 xl:pt-24':
canSignUp,
})}
>
<div
className={cn('flex flex-col items-center', {
'mb-8 p-4 md:mb-0 md:p-12': canSignUp,
})}
>
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
{truncatedTitle}
</Badge>
{/* Card with recipient */} {/* Card with recipient */}
<SigningCard3D <SigningCard3D
name={recipientName} name={recipientName}
@ -116,22 +89,16 @@ export default async function CompletedSigningPage({
signingCelebrationImage={signingCelebration} signingCelebrationImage={signingCelebration}
/> />
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl"> <div className="relative mt-6 flex w-full flex-col items-center">
Document
{recipient.role === RecipientRole.SIGNER && ' Signed '}
{recipient.role === RecipientRole.VIEWER && ' Viewed '}
{recipient.role === RecipientRole.APPROVER && ' Approved '}
</h2>
{match({ status: document.status, deletedAt: document.deletedAt }) {match({ status: document.status, deletedAt: document.deletedAt })
.with({ status: DocumentStatus.COMPLETED }, () => ( .with({ status: DocumentStatus.COMPLETED }, () => (
<div className="text-documenso-700 mt-4 flex items-center text-center"> <div className="text-documenso-700 flex items-center text-center">
<CheckCircle2 className="mr-2 h-5 w-5" /> <CheckCircle2 className="mr-2 h-5 w-5" />
<span className="text-sm">Everyone has signed</span> <span className="text-sm">Everyone has signed</span>
</div> </div>
)) ))
.with({ deletedAt: null }, () => ( .with({ deletedAt: null }, () => (
<div className="flex items-center mt-4 text-center text-blue-600"> <div className="flex items-center text-center text-blue-600">
<Clock8 className="mr-2 h-5 w-5" /> <Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">Waiting for others to sign</span> <span className="text-sm">Waiting for others to sign</span>
</div> </div>
@ -143,6 +110,14 @@ export default async function CompletedSigningPage({
</div> </div>
))} ))}
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
You have
{recipient.role === RecipientRole.SIGNER && ' signed '}
{recipient.role === RecipientRole.VIEWER && ' viewed '}
{recipient.role === RecipientRole.APPROVER && ' approved '}
<span className="mt-1.5 block">"{truncatedTitle}"</span>
</h2>
{match({ status: document.status, deletedAt: document.deletedAt }) {match({ status: document.status, deletedAt: document.deletedAt })
.with({ status: DocumentStatus.COMPLETED }, () => ( .with({ status: DocumentStatus.COMPLETED }, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base"> <p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
@ -156,8 +131,8 @@ export default async function CompletedSigningPage({
)) ))
.otherwise(() => ( .otherwise(() => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base"> <p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
This document has been cancelled by the owner and is no longer available for others This document has been cancelled by the owner and is no longer available for others to
to sign. sign.
</p> </p>
))} ))}
@ -179,26 +154,21 @@ export default async function CompletedSigningPage({
/> />
)} )}
</div> </div>
</div>
{canSignUp && ( {isLoggedIn ? (
<div className={`flex max-w-xl flex-col items-center justify-center p-4 md:p-12`}>
<h2 className="mt-8 text-center text-xl font-semibold md:mt-0">
Need to sign documents?
</h2>
<p className="text-muted-foreground/60 mt-4 max-w-[55ch] text-center leading-normal">
Create your account and start using state-of-the-art document signing.
</p>
<ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} />
</div>
)}
{isLoggedIn && (
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36"> <Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
Go Back Home Go Back Home
</Link> </Link>
) : (
<p className="text-muted-foreground/60 mt-36 text-sm">
Want to send slick signing links like this one?{' '}
<Link
href="https://documenso.com"
className="text-documenso-700 hover:text-documenso-600"
>
Check out Documenso.
</Link>
</p>
)} )}
</div> </div>
</div> </div>

View File

@ -47,12 +47,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
getRecipientByToken({ token }).catch(() => null), getRecipientByToken({ token }).catch(() => null),
]); ]);
if ( if (!document || !document.documentData || !recipient) {
!document ||
!document.documentData ||
!recipient ||
document.status === DocumentStatus.DRAFT
) {
return notFound(); return notFound();
} }

View File

@ -8,7 +8,6 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@ -16,21 +15,18 @@ import { StackAvatar } from './stack-avatar';
export type AvatarWithRecipientProps = { export type AvatarWithRecipientProps = {
recipient: Recipient; recipient: Recipient;
documentStatus: DocumentStatus;
}; };
export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRecipientProps) { export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
const [, copy] = useCopyToClipboard(); const [, copy] = useCopyToClipboard();
const { toast } = useToast(); const { toast } = useToast();
const signingToken = documentStatus === DocumentStatus.PENDING ? recipient.token : null;
const onRecipientClick = () => { const onRecipientClick = () => {
if (!signingToken) { if (!recipient.token) {
return; return;
} }
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => { void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`).then(() => {
toast({ toast({
title: 'Copied to clipboard', title: 'Copied to clipboard',
description: 'The signing link has been copied to your clipboard.', description: 'The signing link has been copied to your clipboard.',
@ -41,10 +37,10 @@ export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRec
return ( return (
<div <div
className={cn('my-1 flex items-center gap-2', { className={cn('my-1 flex items-center gap-2', {
'cursor-pointer hover:underline': signingToken, 'cursor-pointer hover:underline': recipient.token,
})} })}
role={signingToken ? 'button' : undefined} role={recipient.token ? 'button' : undefined}
title={signingToken ? 'Click to copy signing link for sending to recipient' : undefined} title={recipient.token && 'Click to copy signing link for sending to recipient'}
onClick={onRecipientClick} onClick={onRecipientClick}
> >
<StackAvatar <StackAvatar
@ -53,16 +49,17 @@ export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRec
type={getRecipientType(recipient)} type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)} fallbackText={recipientAbbreviation(recipient)}
/> />
<div>
<div <div
className="text-muted-foreground text-sm" className="text-muted-foreground text-sm"
title={signingToken ? 'Click to copy signing link for sending to recipient' : undefined} title="Click to copy signing link for sending to recipient"
> >
<p>{recipient.email}</p> <p>{recipient.email} </p>
<p className="text-muted-foreground/70 text-xs"> <p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p> </p>
</div> </div>
</div> </div>
</div>
); );
} }

View File

@ -5,7 +5,7 @@ import { useRef, useState } from 'react';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { DocumentStatus, Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { AvatarWithRecipient } from './avatar-with-recipient'; import { AvatarWithRecipient } from './avatar-with-recipient';
@ -13,14 +13,12 @@ import { StackAvatar } from './stack-avatar';
import { StackAvatars } from './stack-avatars'; import { StackAvatars } from './stack-avatars';
export type StackAvatarsWithTooltipProps = { export type StackAvatarsWithTooltipProps = {
documentStatus: DocumentStatus;
recipients: Recipient[]; recipients: Recipient[];
position?: 'top' | 'bottom'; position?: 'top' | 'bottom';
children?: React.ReactNode; children?: React.ReactNode;
}; };
export const StackAvatarsWithTooltip = ({ export const StackAvatarsWithTooltip = ({
documentStatus,
recipients, recipients,
position, position,
children, children,
@ -122,11 +120,7 @@ export const StackAvatarsWithTooltip = ({
<div> <div>
<h1 className="text-base font-medium">Waiting</h1> <h1 className="text-base font-medium">Waiting</h1>
{waitingRecipients.map((recipient: Recipient) => ( {waitingRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient <AvatarWithRecipient key={recipient.id} recipient={recipient} />
key={recipient.id}
recipient={recipient}
documentStatus={documentStatus}
/>
))} ))}
</div> </div>
)} )}
@ -135,11 +129,7 @@ export const StackAvatarsWithTooltip = ({
<div> <div>
<h1 className="text-base font-medium">Opened</h1> <h1 className="text-base font-medium">Opened</h1>
{openedRecipients.map((recipient: Recipient) => ( {openedRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient <AvatarWithRecipient key={recipient.id} recipient={recipient} />
key={recipient.id}
recipient={recipient}
documentStatus={documentStatus}
/>
))} ))}
</div> </div>
)} )}
@ -148,11 +138,7 @@ export const StackAvatarsWithTooltip = ({
<div> <div>
<h1 className="text-base font-medium">Uncompleted</h1> <h1 className="text-base font-medium">Uncompleted</h1>
{uncompletedRecipients.map((recipient: Recipient) => ( {uncompletedRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient <AvatarWithRecipient key={recipient.id} recipient={recipient} />
key={recipient.id}
recipient={recipient}
documentStatus={documentStatus}
/>
))} ))}
</div> </div>
)} )}

View File

@ -5,6 +5,7 @@ import { useCallback, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Loader, Monitor, Moon, Sun } from 'lucide-react'; import { Loader, Monitor, Moon, Sun } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
@ -17,6 +18,7 @@ import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION, DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META, SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc'; } from '@documenso/lib/constants/trpc';
import type { Document, Recipient } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react'; import { trpc as trpcReact } from '@documenso/trpc/react';
import { import {
CommandDialog, CommandDialog,
@ -69,6 +71,7 @@ export type CommandMenuProps = {
export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
const { setTheme } = useTheme(); const { setTheme } = useTheme();
const { data: session } = useSession();
const router = useRouter(); const router = useRouter();
@ -90,6 +93,17 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
}, },
); );
const isOwner = useCallback(
(document: Document) => document.userId === session?.user.id,
[session?.user.id],
);
const getSigningLink = useCallback(
(recipients: Recipient[]) =>
`/sign/${recipients.find((r) => r.email === session?.user.email)?.token}`,
[session?.user.email],
);
const searchResults = useMemo(() => { const searchResults = useMemo(() => {
if (!searchDocumentsData) { if (!searchDocumentsData) {
return []; return [];
@ -97,10 +111,10 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
return searchDocumentsData.map((document) => ({ return searchDocumentsData.map((document) => ({
label: document.title, label: document.title,
path: document.path, path: isOwner(document) ? `/documents/${document.id}` : getSigningLink(document.Recipient),
value: document.value, value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
})); }));
}, [searchDocumentsData]); }, [searchDocumentsData, isOwner, getSigningLink]);
const currentPage = pages[pages.length - 1]; const currentPage = pages[pages.length - 1];

View File

@ -1,3 +1,5 @@
'use client';
import type { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@ -10,6 +12,8 @@ import { getRootHref } from '@documenso/lib/utils/params';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { CommandMenu } from '../common/command-menu';
const navigationLinks = [ const navigationLinks = [
{ {
href: '/documents', href: '/documents',
@ -21,14 +25,13 @@ const navigationLinks = [
}, },
]; ];
export type DesktopNavProps = HTMLAttributes<HTMLDivElement> & { export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
setIsCommandMenuOpen: (value: boolean) => void;
};
export const DesktopNav = ({ className, setIsCommandMenuOpen, ...props }: DesktopNavProps) => { export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
const pathname = usePathname(); const pathname = usePathname();
const params = useParams(); const params = useParams();
const [open, setOpen] = useState(false);
const [modifierKey, setModifierKey] = useState(() => 'Ctrl'); const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
const rootHref = getRootHref(params, { returnEmptyRootString: true }); const rootHref = getRootHref(params, { returnEmptyRootString: true });
@ -67,10 +70,12 @@ export const DesktopNav = ({ className, setIsCommandMenuOpen, ...props }: Deskto
))} ))}
</div> </div>
<CommandMenu open={open} onOpenChange={setOpen} />
<Button <Button
variant="outline" variant="outline"
className="text-muted-foreground flex w-96 items-center justify-between rounded-lg" className="text-muted-foreground flex w-96 items-center justify-between rounded-lg"
onClick={() => setIsCommandMenuOpen(true)} onClick={() => setOpen((open) => !open)}
> >
<div className="flex items-center"> <div className="flex items-center">
<Search className="mr-2 h-5 w-5" /> <Search className="mr-2 h-5 w-5" />

View File

@ -58,7 +58,7 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
<Logo className="h-6 w-auto" /> <Logo className="h-6 w-auto" />
</Link> </Link>
<DesktopNav setIsCommandMenuOpen={setIsCommandMenuOpen} /> <DesktopNav />
<div className="flex gap-x-4 md:ml-8"> <div className="flex gap-x-4 md:ml-8">
<MenuSwitcher user={user} teams={teams} /> <MenuSwitcher user={user} teams={teams} />

View File

@ -93,7 +93,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
<Button <Button
data-testid="menu-switcher" data-testid="menu-switcher"
variant="none" variant="none"
className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2" className="relative flex h-12 flex-row items-center px-2 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent"
> >
<AvatarWithText <AvatarWithText
avatarFallback={formatAvatarFallback(selectedTeam?.name)} avatarFallback={formatAvatarFallback(selectedTeam?.name)}
@ -102,13 +102,12 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
rightSideComponent={ rightSideComponent={
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" /> <ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
} }
textSectionClassName="hidden lg:flex"
/> />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
className={cn('z-[60] ml-6 w-full md:ml-0', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')} className={cn('z-[60] ml-2 w-full md:ml-0', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')}
align="end" align="end"
forceMount forceMount
> >

View File

@ -46,7 +46,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
return ( return (
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}> <Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<SheetContent className="flex w-full max-w-[350px] flex-col"> <SheetContent className="flex w-full max-w-[400px] flex-col">
<Link href="/" onClick={handleMenuItemClick}> <Link href="/" onClick={handleMenuItemClick}>
<Image <Image
src={LogoImage} src={LogoImage}
@ -87,7 +87,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
</div> </div>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
© {new Date().getFullYear()} Documenso, Inc. <br /> All rights reserved. © {new Date().getFullYear()} Documenso, Inc. All rights reserved.
</p> </p>
</div> </div>
</SheetContent> </SheetContent>

105
package-lock.json generated
View File

@ -22,7 +22,6 @@
"eslint-config-custom": "*", "eslint-config-custom": "*",
"husky": "^9.0.11", "husky": "^9.0.11",
"lint-staged": "^15.2.2", "lint-staged": "^15.2.2",
"playwright": "1.41.0",
"prettier": "^2.5.1", "prettier": "^2.5.1",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"turbo": "^1.9.3" "turbo": "^1.9.3"
@ -4717,50 +4716,6 @@
"node": ">=16" "node": ">=16"
} }
}, },
"node_modules/@playwright/test/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/@playwright/test/node_modules/playwright": {
"version": "1.40.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz",
"integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==",
"dev": true,
"dependencies": {
"playwright-core": "1.40.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=16"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/@playwright/test/node_modules/playwright-core": {
"version": "1.40.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz",
"integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "5.4.2", "version": "5.4.2",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.4.2.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.4.2.tgz",
@ -17660,11 +17615,12 @@
} }
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.41.0", "version": "1.40.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.0.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz",
"integrity": "sha512-XOsfl5ZtAik/T9oek4V0jAypNlaCNzuKOwVhqhgYT3os6kH34PzbRb74F0VWcLYa5WFdnmxl7qyAHBXvPv7lqQ==", "integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==",
"dev": true,
"dependencies": { "dependencies": {
"playwright-core": "1.41.0" "playwright-core": "1.40.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -17676,10 +17632,23 @@
"fsevents": "2.3.2" "fsevents": "2.3.2"
} }
}, },
"node_modules/playwright-core": {
"version": "1.40.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz",
"integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/playwright/node_modules/fsevents": { "node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"optional": true, "optional": true,
"os": [ "os": [
@ -17689,17 +17658,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/playwright/node_modules/playwright-core": {
"version": "1.41.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.0.tgz",
"integrity": "sha512-UGKASUhXmvqm2Lxa1fNr8sFwAtqjpgBRr9jQ7XBI8Rn5uFiEowGUGwrruUQsVPIom4bk7Lt+oLGpXobnXzrBIw==",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@ -24968,7 +24926,6 @@
"next-auth": "4.24.5", "next-auth": "4.24.5",
"oslo": "^0.17.0", "oslo": "^0.17.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"playwright": "1.41.0",
"react": "18.2.0", "react": "18.2.0",
"remeda": "^1.27.1", "remeda": "^1.27.1",
"stripe": "^12.7.0", "stripe": "^12.7.0",
@ -24976,23 +24933,9 @@
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@playwright/browser-chromium": "1.41.0",
"@types/luxon": "^3.3.1" "@types/luxon": "^3.3.1"
} }
}, },
"packages/lib/node_modules/@playwright/browser-chromium": {
"version": "1.41.0",
"resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.41.0.tgz",
"integrity": "sha512-TaHfh3rDsz4+tVKdMMo4kdFOk8/4U6cPyMXHhoiJVmhOhjHXjR0qPMoa5gz5jDGl478cn5SoXmtgKPgTDFuS0g==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"playwright-core": "1.41.0"
},
"engines": {
"node": ">=16"
}
},
"packages/lib/node_modules/nanoid": { "packages/lib/node_modules/nanoid": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz",
@ -25010,18 +24953,6 @@
"node": "^14 || ^16 || >=18" "node": "^14 || ^16 || >=18"
} }
}, },
"packages/lib/node_modules/playwright-core": {
"version": "1.41.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.0.tgz",
"integrity": "sha512-UGKASUhXmvqm2Lxa1fNr8sFwAtqjpgBRr9jQ7XBI8Rn5uFiEowGUGwrruUQsVPIom4bk7Lt+oLGpXobnXzrBIw==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"packages/prettier-config": { "packages/prettier-config": {
"name": "@documenso/prettier-config", "name": "@documenso/prettier-config",
"version": "0.0.0", "version": "0.0.0",

View File

@ -38,7 +38,6 @@
"eslint-config-custom": "*", "eslint-config-custom": "*",
"husky": "^9.0.11", "husky": "^9.0.11",
"lint-staged": "^15.2.2", "lint-staged": "^15.2.2",
"playwright": "1.41.0",
"prettier": "^2.5.1", "prettier": "^2.5.1",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"turbo": "^1.9.3" "turbo": "^1.9.3"

View File

@ -11,7 +11,6 @@ import {
ZDeleteDocumentMutationSchema, ZDeleteDocumentMutationSchema,
ZDeleteFieldMutationSchema, ZDeleteFieldMutationSchema,
ZDeleteRecipientMutationSchema, ZDeleteRecipientMutationSchema,
ZDownloadDocumentSuccessfulSchema,
ZGetDocumentsQuerySchema, ZGetDocumentsQuerySchema,
ZSendDocumentForSigningMutationSchema, ZSendDocumentForSigningMutationSchema,
ZSuccessfulDocumentResponseSchema, ZSuccessfulDocumentResponseSchema,
@ -52,17 +51,6 @@ export const ApiContractV1 = c.router(
summary: 'Get a single document', summary: 'Get a single document',
}, },
downloadSignedDocument: {
method: 'GET',
path: '/api/v1/documents/:id/download',
responses: {
200: ZDownloadDocumentSuccessfulSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Download a signed document when the storage transport is S3',
},
createDocument: { createDocument: {
method: 'POST', method: 'POST',
path: '/api/v1/documents', path: '/api/v1/documents',

View File

@ -23,10 +23,7 @@ import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { getFile } from '@documenso/lib/universal/upload/get-file'; import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putFile } from '@documenso/lib/universal/upload/put-file'; import { putFile } from '@documenso/lib/universal/upload/put-file';
import { import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
getPresignGetUrl,
getPresignPostUrl,
} from '@documenso/lib/universal/upload/server-actions';
import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { ApiContractV1 } from './contract'; import { ApiContractV1 } from './contract';
@ -86,68 +83,6 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
} }
}), }),
downloadSignedDocument: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId } = args.params;
try {
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
return {
status: 500,
body: {
message: 'Please make sure the storage transport is set to S3.',
},
};
}
const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
teamId: team?.id,
});
if (!document || !document.documentDataId) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
if (DocumentDataType.S3_PATH !== document.documentData.type) {
return {
status: 400,
body: {
message: 'Invalid document data type',
},
};
}
if (document.status !== DocumentStatus.COMPLETED) {
return {
status: 400,
body: {
message: 'Document is not completed yet.',
},
};
}
const { url } = await getPresignGetUrl(document.documentData.data);
return {
status: 200,
body: { downloadUrl: url },
};
} catch (err) {
return {
status: 500,
body: {
message: 'Error downloading the document. Please try again.',
},
};
}
}),
deleteDocument: authenticatedMiddleware(async (args, user, team) => { deleteDocument: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId } = args.params; const { id: documentId } = args.params;

View File

@ -2,8 +2,7 @@ import { generateOpenApi } from '@ts-rest/open-api';
import { ApiContractV1 } from './contract'; import { ApiContractV1 } from './contract';
export const OpenAPIV1 = Object.assign( export const OpenAPIV1 = generateOpenApi(
generateOpenApi(
ApiContractV1, ApiContractV1,
{ {
info: { info: {
@ -15,21 +14,4 @@ export const OpenAPIV1 = Object.assign(
{ {
setOperationId: true, setOperationId: true,
}, },
),
{
components: {
securitySchemes: {
authorization: {
type: 'apiKey',
in: 'header',
name: 'Authorization',
},
},
},
security: [
{
authorization: [],
},
],
},
); );

View File

@ -53,10 +53,6 @@ export const ZUploadDocumentSuccessfulSchema = z.object({
key: z.string(), key: z.string(),
}); });
export const ZDownloadDocumentSuccessfulSchema = z.object({
downloadUrl: z.string(),
});
export type TUploadDocumentSuccessfulSchema = z.infer<typeof ZUploadDocumentSuccessfulSchema>; export type TUploadDocumentSuccessfulSchema = z.infer<typeof ZUploadDocumentSuccessfulSchema>;
export const ZCreateDocumentMutationSchema = z.object({ export const ZCreateDocumentMutationSchema = z.object({

View File

@ -45,7 +45,7 @@ test.describe('[EE_ONLY]', () => {
await page await page
.getByRole('textbox', { name: 'Email', exact: true }) .getByRole('textbox', { name: 'Email', exact: true })
.fill('recipient2@documenso.com'); .fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2'); await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2');
// Display advanced settings. // Display advanced settings.
await page.getByLabel('Show advanced settings').click(); await page.getByLabel('Show advanced settings').click();
@ -82,7 +82,7 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
await page.getByPlaceholder('Name').fill('Recipient 1'); await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Signer' }).click(); await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com'); await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2'); await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2');
// Advanced settings should not be visible for non EE users. // Advanced settings should not be visible for non EE users.
await expect(page.getByLabel('Show advanced settings')).toBeHidden(); await expect(page.getByLabel('Show advanced settings')).toBeHidden();

View File

@ -136,7 +136,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.getByPlaceholder('Name').fill('User 1'); await page.getByPlaceholder('Name').fill('User 1');
await page.getByRole('button', { name: 'Add Signer' }).click(); await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('user2@example.com'); await page.getByRole('textbox', { name: 'Email', exact: true }).fill('user2@example.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('User 2'); await page.getByRole('textbox', { name: 'Name', exact: true }).fill('User 2');
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
@ -254,7 +254,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', asyn
await page.getByRole('button', { name: 'Sign' }).click(); await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`/sign/${token}/complete`); await page.waitForURL(`/sign/${token}/complete`);
await expect(page.getByText('Document Signed')).toBeVisible(); await expect(page.getByText('You have signed')).toBeVisible();
// Check if document has been signed // Check if document has been signed
const { status: completedStatus } = await getDocumentByToken(token); const { status: completedStatus } = await getDocumentByToken(token);

View File

@ -8,7 +8,6 @@ import {
import { seedUser } from '@documenso/prisma/seed/users'; import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication'; import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
test.describe.configure({ mode: 'serial' }); test.describe.configure({ mode: 'serial' });
@ -75,7 +74,7 @@ test('[DOCUMENTS]: deleting a completed document should not remove it from recip
email: sender.email, email: sender.email,
}); });
// Open document action menu. // open actions menu
await page await page
.locator('tr', { hasText: 'Document 1 - Completed' }) .locator('tr', { hasText: 'Document 1 - Completed' })
.getByRole('cell', { name: 'Download' }) .getByRole('cell', { name: 'Download' })
@ -116,7 +115,7 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients'
email: sender.email, email: sender.email,
}); });
// Open document action menu. // open actions menu
await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click(); await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click();
// delete document // delete document
@ -136,11 +135,20 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients'
}); });
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible(); await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible();
await page.goto(`/sign/${recipient.token}`);
await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible();
await page.goto('/documents');
await page.waitForURL('/documents');
await apiSignout({ page }); await apiSignout({ page });
} }
}); });
test('[DOCUMENTS]: deleting draft documents should permanently remove it', async ({ page }) => { test('[DOCUMENTS]: deleting a draft document should remove it without additional prompting', async ({
page,
}) => {
const { sender } = await seedDeleteDocumentsTestRequirements(); const { sender } = await seedDeleteDocumentsTestRequirements();
await apiSignin({ await apiSignin({
@ -148,10 +156,11 @@ test('[DOCUMENTS]: deleting draft documents should permanently remove it', async
email: sender.email, email: sender.email,
}); });
// Open document action menu. // open actions menu
await page await page
.locator('tr', { hasText: 'Document 1 - Draft' }) .locator('tr', { hasText: 'Document 1 - Draft' })
.getByTestId('document-table-action-btn') .getByRole('cell', { name: 'Edit' })
.getByRole('button')
.click(); .click();
// delete document // delete document
@ -160,155 +169,4 @@ test('[DOCUMENTS]: deleting draft documents should permanently remove it', async
await page.getByRole('button', { name: 'Delete' }).click(); await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible(); await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 0);
await checkDocumentTabCount(page, 'All', 2);
});
test('[DOCUMENTS]: deleting pending documents should permanently remove it', async ({ page }) => {
const { sender } = await seedDeleteDocumentsTestRequirements();
await apiSignin({
page,
email: sender.email,
});
// Open document action menu.
await page
.locator('tr', { hasText: 'Document 1 - Pending' })
.getByTestId('document-table-action-btn')
.click();
// Delete document.
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 2);
});
test('[DOCUMENTS]: deleting completed documents as an owner should hide it from only the owner', async ({
page,
}) => {
const { sender, recipients } = await seedDeleteDocumentsTestRequirements();
await apiSignin({
page,
email: sender.email,
});
// Open document action menu.
await page
.locator('tr', { hasText: 'Document 1 - Completed' })
.getByTestId('document-table-action-btn')
.click();
// Delete document.
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
// Check document counts.
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 0);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 2);
// Sign into the recipient account.
await apiSignout({ page });
await apiSignin({
page,
email: recipients[0].email,
});
// Check document counts.
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).toBeVisible();
await checkDocumentTabCount(page, 'Inbox', 1);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 0);
await checkDocumentTabCount(page, 'All', 2);
});
test('[DOCUMENTS]: deleting documents as a recipient should only hide it for them', async ({
page,
}) => {
const { sender, recipients } = await seedDeleteDocumentsTestRequirements();
const recipientA = recipients[0];
const recipientB = recipients[1];
await apiSignin({
page,
email: recipientA.email,
});
// Open document action menu.
await page
.locator('tr', { hasText: 'Document 1 - Completed' })
.getByTestId('document-table-action-btn')
.click();
// Delete document.
await page.getByRole('menuitem', { name: 'Hide' }).click();
await page.getByRole('button', { name: 'Hide' }).click();
// Open document action menu.
await page
.locator('tr', { hasText: 'Document 1 - Pending' })
.getByTestId('document-table-action-btn')
.click();
// Delete document.
await page.getByRole('menuitem', { name: 'Hide' }).click();
await page.getByRole('button', { name: 'Hide' }).click();
// Check document counts.
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Completed', 0);
await checkDocumentTabCount(page, 'Draft', 0);
await checkDocumentTabCount(page, 'All', 0);
// Sign into the sender account.
await apiSignout({ page });
await apiSignin({
page,
email: sender.email,
});
// Check document counts for sender.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 3);
// Sign into the other recipient account.
await apiSignout({ page });
await apiSignin({
page,
email: recipientB.email,
});
// Check document counts for other recipient.
await checkDocumentTabCount(page, 'Inbox', 1);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 0);
await checkDocumentTabCount(page, 'All', 2);
}); });

View File

@ -1,17 +0,0 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
await page.getByRole('tab', { name: tabName }).click();
if (tabName !== 'All') {
await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
}
if (count === 0) {
await expect(page.getByTestId('empty-document-state')).toBeVisible();
return;
}
await expect(page.getByRole('main')).toContainText(`Showing ${count}`);
};

View File

@ -1,3 +1,4 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
@ -6,10 +7,24 @@ import { seedTeamEmail, unseedTeam, unseedTeamEmail } from '@documenso/prisma/se
import { seedUser } from '@documenso/prisma/seed/users'; import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication'; import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
test.describe.configure({ mode: 'parallel' }); test.describe.configure({ mode: 'parallel' });
const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
await page.getByRole('tab', { name: tabName }).click();
if (tabName !== 'All') {
await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
}
if (count === 0) {
await expect(page.getByRole('main')).toContainText(`Nothing to do`);
return;
}
await expect(page.getByRole('main')).toContainText(`Showing ${count}`);
};
test('[TEAMS]: check team documents count', async ({ page }) => { test('[TEAMS]: check team documents count', async ({ page }) => {
const { team, teamMember2 } = await seedTeamDocuments(); const { team, teamMember2 } = await seedTeamDocuments();
@ -230,6 +245,24 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
await unseedTeam(team.url); await unseedTeam(team.url);
}); });
test('[TEAMS]: delete pending team document', async ({ page }) => {
const { team, teamMember2: currentUser } = await seedTeamDocuments();
await apiSignin({
page,
email: currentUser.email,
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
await page.getByRole('row').getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await checkDocumentTabCount(page, 'Pending', 1);
});
test('[TEAMS]: resend pending team document', async ({ page }) => { test('[TEAMS]: resend pending team document', async ({ page }) => {
const { team, teamMember2: currentUser } = await seedTeamDocuments(); const { team, teamMember2: currentUser } = await seedTeamDocuments();
@ -247,125 +280,3 @@ test('[TEAMS]: resend pending team document', async ({ page }) => {
await expect(page.getByRole('status')).toContainText('Document re-sent'); await expect(page.getByRole('status')).toContainText('Document re-sent');
}); });
test('[TEAMS]: delete draft team document', async ({ page }) => {
const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments();
await apiSignin({
page,
email: teamMember3.email,
redirectPath: `/t/${team.url}/documents?status=DRAFT`,
});
await page.getByRole('row').getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await checkDocumentTabCount(page, 'Draft', 1);
// Should be hidden for all team members.
await apiSignout({ page });
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
for (const user of [team.owner, teamEmailMember]) {
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 2);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 4);
await apiSignout({ page });
}
await unseedTeam(team.url);
});
test('[TEAMS]: delete pending team document', async ({ page }) => {
const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments();
await apiSignin({
page,
email: teamMember3.email,
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
await page.getByRole('row').getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await checkDocumentTabCount(page, 'Pending', 1);
// Should be hidden for all team members.
await apiSignout({ page });
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
for (const user of [team.owner, teamEmailMember]) {
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 2);
await checkDocumentTabCount(page, 'All', 4);
await apiSignout({ page });
}
await unseedTeam(team.url);
});
test('[TEAMS]: delete completed team document', async ({ page }) => {
const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments();
await apiSignin({
page,
email: teamMember3.email,
redirectPath: `/t/${team.url}/documents?status=COMPLETED`,
});
await page.getByRole('row').getByRole('button').nth(2).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await checkDocumentTabCount(page, 'Completed', 0);
// Should be hidden for all team members.
await apiSignout({ page });
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
for (const user of [team.owner, teamEmailMember]) {
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 2);
await checkDocumentTabCount(page, 'Completed', 0);
await checkDocumentTabCount(page, 'Draft', 2);
await checkDocumentTabCount(page, 'All', 4);
await apiSignout({ page });
}
await unseedTeam(team.url);
});

View File

@ -23,10 +23,6 @@ export const TemplateDocumentCancel = ({
<br />"{documentName}" <br />"{documentName}"
</Text> </Text>
<Text className="my-1 text-center text-base text-slate-400">
All signatures have been voided.
</Text>
<Text className="my-1 text-center text-base text-slate-400"> <Text className="my-1 text-center text-base text-slate-400">
You don't need to sign it anymore. You don't need to sign it anymore.
</Text> </Text>

View File

@ -11,7 +11,6 @@ export interface TemplateDocumentInviteProps {
signDocumentLink: string; signDocumentLink: string;
assetBaseUrl: string; assetBaseUrl: string;
role: RecipientRole; role: RecipientRole;
selfSigner: boolean;
} }
export const TemplateDocumentInvite = ({ export const TemplateDocumentInvite = ({
@ -20,7 +19,6 @@ export const TemplateDocumentInvite = ({
signDocumentLink, signDocumentLink,
assetBaseUrl, assetBaseUrl,
role, role,
selfSigner,
}: TemplateDocumentInviteProps) => { }: TemplateDocumentInviteProps) => {
const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION[role]; const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
@ -30,19 +28,8 @@ export const TemplateDocumentInvite = ({
<Section> <Section>
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold"> <Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
{selfSigner ? ( {inviterName} has invited you to {actionVerb.toLowerCase()}
<> <br />"{documentName}"
{`Please ${actionVerb.toLowerCase()} your document`}
<br />
{`"${documentName}"`}
</>
) : (
<>
{`${inviterName} has invited you to ${actionVerb.toLowerCase()}`}
<br />
{`"${documentName}"`}
</>
)}
</Text> </Text>
<Text className="my-1 text-center text-base text-slate-400"> <Text className="my-1 text-center text-base text-slate-400">

View File

@ -22,7 +22,6 @@ import { TemplateFooter } from '../template-components/template-footer';
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & { export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {
customBody?: string; customBody?: string;
role: RecipientRole; role: RecipientRole;
selfSigner?: boolean;
}; };
export const DocumentInviteEmailTemplate = ({ export const DocumentInviteEmailTemplate = ({
@ -33,13 +32,10 @@ export const DocumentInviteEmailTemplate = ({
assetBaseUrl = 'http://localhost:3002', assetBaseUrl = 'http://localhost:3002',
customBody, customBody,
role, role,
selfSigner = false,
}: DocumentInviteEmailTemplateProps) => { }: DocumentInviteEmailTemplateProps) => {
const action = RECIPIENT_ROLES_DESCRIPTION[role].actionVerb.toLowerCase(); const action = RECIPIENT_ROLES_DESCRIPTION[role].actionVerb.toLowerCase();
const previewText = selfSigner const previewText = `${inviterName} has invited you to ${action} ${documentName}`;
? `Please ${action} your document ${documentName}`
: `${inviterName} has invited you to ${action} ${documentName}`;
const getAssetUrl = (path: string) => { const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString(); return new URL(path, assetBaseUrl).toString();
@ -75,7 +71,6 @@ export const DocumentInviteEmailTemplate = ({
signDocumentLink={signDocumentLink} signDocumentLink={signDocumentLink}
assetBaseUrl={assetBaseUrl} assetBaseUrl={assetBaseUrl}
role={role} role={role}
selfSigner={selfSigner}
/> />
</Section> </Section>
</Container> </Container>

View File

@ -32,10 +32,3 @@ export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
[RecipientRole.VIEWER]: 'VIEW_REQUEST', [RecipientRole.VIEWER]: 'VIEW_REQUEST',
[RecipientRole.APPROVER]: 'APPROVE_REQUEST', [RecipientRole.APPROVER]: 'APPROVE_REQUEST',
} as const; } as const;
export const RECIPIENT_ROLE_SIGNING_REASONS = {
[RecipientRole.SIGNER]: 'I am a signer of this document',
[RecipientRole.APPROVER]: 'I am an approver of this document',
[RecipientRole.CC]: 'I am required to recieve a copy of this document',
[RecipientRole.VIEWER]: 'I am a viewer of this document',
} satisfies Record<keyof typeof RecipientRole, string>;

View File

@ -39,7 +39,6 @@
"next-auth": "4.24.5", "next-auth": "4.24.5",
"oslo": "^0.17.0", "oslo": "^0.17.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"playwright": "1.41.0",
"react": "18.2.0", "react": "18.2.0",
"remeda": "^1.27.1", "remeda": "^1.27.1",
"stripe": "^12.7.0", "stripe": "^12.7.0",
@ -47,7 +46,6 @@
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@types/luxon": "^3.3.1", "@types/luxon": "^3.3.1"
"@playwright/browser-chromium": "1.41.0"
} }
} }

View File

@ -10,14 +10,6 @@ export const getEntireDocument = async ({ id }: GetEntireDocumentOptions) => {
id, id,
}, },
include: { include: {
documentMeta: true,
User: {
select: {
id: true,
name: true,
email: true,
},
},
Recipient: { Recipient: {
include: { include: {
Field: { Field: {

View File

@ -49,8 +49,8 @@ export const completeDocumentWithToken = async ({
const document = await getDocument({ token, documentId }); const document = await getDocument({ token, documentId });
if (document.status !== DocumentStatus.PENDING) { if (document.status === DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} must be pending`); throw new Error(`Document ${document.id} has already been completed`);
} }
if (document.Recipient.length === 0) { if (document.Recipient.length === 0) {

View File

@ -6,7 +6,6 @@ import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render'; import { render } from '@documenso/email/render';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel'; import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { Document, DocumentMeta, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
@ -28,132 +27,46 @@ export const deleteDocument = async ({
teamId, teamId,
requestMetadata, requestMetadata,
}: DeleteDocumentOptions) => { }: DeleteDocumentOptions) => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (!user) {
throw new Error('User not found');
}
const document = await prisma.document.findUnique({ const document = await prisma.document.findUnique({
where: { where: {
id, id,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
}, },
include: { include: {
Recipient: true, Recipient: true,
documentMeta: true, documentMeta: true,
team: { User: true,
select: {
members: true,
},
},
}, },
}); });
if (!document || (teamId !== undefined && teamId !== document.teamId)) { if (!document) {
throw new Error('Document not found'); throw new Error('Document not found');
} }
const isUserOwner = document.userId === userId; const { status, User: user } = document;
const isUserTeamMember = document.team?.members.some((member) => member.userId === userId);
const userRecipient = document.Recipient.find((recipient) => recipient.email === user.email);
if (!isUserOwner && !isUserTeamMember && !userRecipient) { // if the document is a draft, hard-delete
throw new Error('Not allowed'); if (status === DocumentStatus.DRAFT) {
}
// Handle hard or soft deleting the actual document if user has permission.
if (isUserOwner || isUserTeamMember) {
await handleDocumentOwnerDelete({
document,
user,
requestMetadata,
});
}
// Continue to hide the document from the user if they are a recipient.
if (userRecipient?.documentDeletedAt === null) {
await prisma.recipient.update({
where: {
documentId_email: {
documentId: document.id,
email: user.email,
},
},
data: {
documentDeletedAt: new Date().toISOString(),
},
});
}
// Return partial document for API v1 response.
return {
id: document.id,
userId: document.userId,
teamId: document.teamId,
title: document.title,
status: document.status,
documentDataId: document.documentDataId,
createdAt: document.createdAt,
updatedAt: document.updatedAt,
completedAt: document.completedAt,
};
};
type HandleDocumentOwnerDeleteOptions = {
document: Document & {
Recipient: Recipient[];
documentMeta: DocumentMeta | null;
};
user: User;
requestMetadata?: RequestMetadata;
};
const handleDocumentOwnerDelete = async ({
document,
user,
requestMetadata,
}: HandleDocumentOwnerDeleteOptions) => {
if (document.deletedAt) {
return;
}
// Soft delete completed documents.
if (document.status === DocumentStatus.COMPLETED) {
return await prisma.$transaction(async (tx) => { return await prisma.$transaction(async (tx) => {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
documentId: document.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user,
requestMetadata,
data: {
type: 'SOFT',
},
}),
});
return await tx.document.update({
where: {
id: document.id,
},
data: {
deletedAt: new Date().toISOString(),
},
});
});
}
// Hard delete draft and pending documents.
const deletedDocument = await prisma.$transaction(async (tx) => {
// Currently redundant since deleting a document will delete the audit logs. // Currently redundant since deleting a document will delete the audit logs.
// However may be useful if we disassociate audit logs and documents if required. // However may be useful if we disassociate audit lgos and documents if required.
await tx.documentAuditLog.create({ await tx.documentAuditLog.create({
data: createDocumentAuditLogData({ data: createDocumentAuditLogData({
documentId: document.id, documentId: id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user, user,
requestMetadata, requestMetadata,
@ -163,17 +76,12 @@ const handleDocumentOwnerDelete = async ({
}), }),
}); });
return await tx.document.delete({ return await tx.document.delete({ where: { id, status: DocumentStatus.DRAFT } });
where: {
id: document.id,
status: {
not: DocumentStatus.COMPLETED,
},
},
});
}); });
}
// Send cancellation emails to recipients. // if the document is pending, send cancellation emails to all recipients
if (status === DocumentStatus.PENDING && document.Recipient.length > 0) {
await Promise.all( await Promise.all(
document.Recipient.map(async (recipient) => { document.Recipient.map(async (recipient) => {
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
@ -200,6 +108,29 @@ const handleDocumentOwnerDelete = async ({
}); });
}), }),
); );
}
return deletedDocument; // If the document is not a draft, only soft-delete.
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(),
},
});
});
}; };

View File

@ -94,63 +94,22 @@ export const findDocuments = async ({
}; };
} }
let deletedFilter: Prisma.DocumentWhereInput = {
AND: {
OR: [
{
userId: user.id,
deletedAt: null,
},
{
Recipient: {
some: {
email: user.email,
documentDeletedAt: null,
},
},
},
],
},
};
if (team) {
deletedFilter = {
AND: {
OR: team.teamEmail
? [
{
teamId: team.id,
deletedAt: null,
},
{
User: {
email: team.teamEmail.email,
},
deletedAt: null,
},
{
Recipient: {
some: {
email: team.teamEmail.email,
documentDeletedAt: null,
},
},
},
]
: [
{
teamId: team.id,
deletedAt: null,
},
],
},
};
}
const whereClause: Prisma.DocumentWhereInput = { const whereClause: Prisma.DocumentWhereInput = {
...termFilters, ...termFilters,
...filters, ...filters,
...deletedFilter, AND: {
OR: [
{
status: ExtendedDocumentStatus.COMPLETED,
},
{
status: {
not: ExtendedDocumentStatus.COMPLETED,
},
deletedAt: null,
},
],
},
}; };
if (period) { if (period) {

View File

@ -1,43 +0,0 @@
import { prisma } from '@documenso/prisma';
import { DOCUMENT_AUDIT_LOG_TYPE, DOCUMENT_EMAIL_TYPE } from '../../types/document-audit-logs';
import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
export type GetDocumentCertificateAuditLogsOptions = {
id: number;
};
export const getDocumentCertificateAuditLogs = async ({
id,
}: GetDocumentCertificateAuditLogsOptions) => {
const rawAuditLogs = await prisma.documentAuditLog.findMany({
where: {
documentId: id,
type: {
in: [
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
],
},
},
});
const auditLogs = rawAuditLogs.map((log) => parseDocumentAuditLogData(log));
const groupedAuditLogs = {
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs.filter(
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
),
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs.filter(
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
),
[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs.filter(
(log) =>
log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT &&
log.data.emailType !== DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED,
),
} as const;
return groupedAuditLogs;
};

View File

@ -72,7 +72,6 @@ type GetCountsOption = {
const getCounts = async ({ user, createdAt }: GetCountsOption) => { const getCounts = async ({ user, createdAt }: GetCountsOption) => {
return Promise.all([ return Promise.all([
// Owner counts.
prisma.document.groupBy({ prisma.document.groupBy({
by: ['status'], by: ['status'],
_count: { _count: {
@ -85,7 +84,6 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
deletedAt: null, deletedAt: null,
}, },
}), }),
// Not signed counts.
prisma.document.groupBy({ prisma.document.groupBy({
by: ['status'], by: ['status'],
_count: { _count: {
@ -97,13 +95,12 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
some: { some: {
email: user.email, email: user.email,
signingStatus: SigningStatus.NOT_SIGNED, signingStatus: SigningStatus.NOT_SIGNED,
documentDeletedAt: null,
}, },
}, },
createdAt, createdAt,
deletedAt: null,
}, },
}), }),
// Has signed counts.
prisma.document.groupBy({ prisma.document.groupBy({
by: ['status'], by: ['status'],
_count: { _count: {
@ -123,9 +120,9 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
some: { some: {
email: user.email, email: user.email,
signingStatus: SigningStatus.SIGNED, signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
}, },
}, },
deletedAt: null,
}, },
{ {
status: ExtendedDocumentStatus.COMPLETED, status: ExtendedDocumentStatus.COMPLETED,
@ -133,7 +130,6 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
some: { some: {
email: user.email, email: user.email,
signingStatus: SigningStatus.SIGNED, signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
}, },
}, },
}, },
@ -202,7 +198,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
some: { some: {
email: teamEmail, email: teamEmail,
signingStatus: SigningStatus.NOT_SIGNED, signingStatus: SigningStatus.NOT_SIGNED,
documentDeletedAt: null,
}, },
}, },
deletedAt: null, deletedAt: null,
@ -224,7 +219,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
some: { some: {
email: teamEmail, email: teamEmail,
signingStatus: SigningStatus.SIGNED, signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
}, },
}, },
deletedAt: null, deletedAt: null,
@ -235,7 +229,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
some: { some: {
email: teamEmail, email: teamEmail,
signingStatus: SigningStatus.SIGNED, signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
}, },
}, },
deletedAt: null, deletedAt: null,

View File

@ -88,11 +88,6 @@ export const resendDocument = async ({
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role]; const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient; const { email, name } = recipient;
const selfSigner = email === user.email;
const selfSignerCustomEmail = `You have initiated the document ${`"${document.title}"`} that requires you to ${RECIPIENT_ROLES_DESCRIPTION[
recipient.role
].actionVerb.toLowerCase()} it.`;
const customEmailTemplate = { const customEmailTemplate = {
'signer.name': name, 'signer.name': name,
@ -109,20 +104,12 @@ export const resendDocument = async ({
inviterEmail: user.email, inviterEmail: user.email,
assetBaseUrl, assetBaseUrl,
signDocumentLink, signDocumentLink,
customBody: renderCustomEmailTemplate( customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
selfSigner ? selfSignerCustomEmail : customEmail?.message || '',
customEmailTemplate,
),
role: recipient.role, role: recipient.role,
selfSigner,
}); });
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
const emailSubject = selfSigner
? `Reminder: Please ${actionVerb.toLowerCase()} your document`
: `Reminder: Please ${actionVerb.toLowerCase()} this document`;
await prisma.$transaction( await prisma.$transaction(
async (tx) => { async (tx) => {
await mailer.sendMail({ await mailer.sendMail({
@ -136,7 +123,7 @@ export const resendDocument = async ({
}, },
subject: customEmail?.subject subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: emailSubject, : `Please ${actionVerb.toLowerCase()} this document`,
html: render(template), html: render(template),
text: render(template, { plainText: true }), text: render(template, { plainText: true }),
}); });

View File

@ -15,7 +15,6 @@ import { signPdf } from '@documenso/signing';
import type { RequestMetadata } from '../../universal/extract-request-metadata'; import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file'; import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file'; import { putFile } from '../../universal/upload/put-file';
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations'; import { flattenAnnotations } from '../pdf/flatten-annotations';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf'; import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances'; import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances';
@ -92,10 +91,6 @@ export const sealDocument = async ({
// !: Need to write the fields onto the document as a hard copy // !: Need to write the fields onto the document as a hard copy
const pdfData = await getFile(documentData); const pdfData = await getFile(documentData);
const certificate = await getCertificatePdf({ documentId }).then(async (doc) =>
PDFDocument.load(doc),
);
const doc = await PDFDocument.load(pdfData); const doc = await PDFDocument.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature // Normalize and flatten layers that could cause issues with the signature
@ -103,12 +98,6 @@ export const sealDocument = async ({
doc.getForm().flatten(); doc.getForm().flatten();
flattenAnnotations(doc); flattenAnnotations(doc);
const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices());
certificatePages.forEach((page) => {
doc.addPage(page);
});
for (const field of fields) { for (const field of fields) {
await insertFieldInPDF(doc, field); await insertFieldInPDF(doc, field);
} }

View File

@ -1,6 +1,7 @@
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import type { Document, Recipient, User } from '@documenso/prisma/client';
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
export type SearchDocumentsWithKeywordOptions = { export type SearchDocumentsWithKeywordOptions = {
query: string; query: string;
@ -78,19 +79,12 @@ export const searchDocumentsWithKeyword = async ({
take: limit, take: limit,
}); });
const isOwner = (document: Document, user: User) => document.userId === user.id; const maskedDocuments = documents.map((document) =>
const getSigningLink = (recipients: Recipient[], user: User) => maskRecipientTokensForDocument({
`/sign/${recipients.find((r) => r.email === user.email)?.token}`; document,
user,
const maskedDocuments = documents.map((document) => { }),
const { Recipient, ...documentWithoutRecipient } = document; );
return {
...documentWithoutRecipient,
path: isOwner(document, user) ? `/documents/${document.id}` : getSigningLink(Recipient, user),
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
};
});
return maskedDocuments; return maskedDocuments;
}; };

View File

@ -80,7 +80,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
text: render(template, { plainText: true }), text: render(template, { plainText: true }),
attachments: [ attachments: [
{ {
filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf', filename: document.title,
content: Buffer.from(completedDocument), content: Buffer.from(completedDocument),
}, },
], ],
@ -130,7 +130,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
text: render(template, { plainText: true }), text: render(template, { plainText: true }),
attachments: [ attachments: [
{ {
filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf', filename: document.title,
content: Buffer.from(completedDocument), content: Buffer.from(completedDocument),
}, },
], ],

View File

@ -127,11 +127,6 @@ export const sendDocument = async ({
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role]; const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient; const { email, name } = recipient;
const selfSigner = email === user.email;
const selfSignerCustomEmail = `You have initiated the document ${`"${document.title}"`} that requires you to ${RECIPIENT_ROLES_DESCRIPTION[
recipient.role
].actionVerb.toLowerCase()} it.`;
const customEmailTemplate = { const customEmailTemplate = {
'signer.name': name, 'signer.name': name,
@ -148,20 +143,12 @@ export const sendDocument = async ({
inviterEmail: user.email, inviterEmail: user.email,
assetBaseUrl, assetBaseUrl,
signDocumentLink, signDocumentLink,
customBody: renderCustomEmailTemplate( customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
selfSigner ? selfSignerCustomEmail : customEmail?.message || '',
customEmailTemplate,
),
role: recipient.role, role: recipient.role,
selfSigner,
}); });
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
const emailSubject = selfSigner
? `Please ${actionVerb.toLowerCase()} your document`
: `Please ${actionVerb.toLowerCase()} this document`;
await prisma.$transaction( await prisma.$transaction(
async (tx) => { async (tx) => {
await mailer.sendMail({ await mailer.sendMail({
@ -175,7 +162,7 @@ export const sendDocument = async ({
}, },
subject: customEmail?.subject subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: emailSubject, : `Please ${actionVerb.toLowerCase()} this document`,
html: render(template), html: render(template),
text: render(template, { plainText: true }), text: render(template, { plainText: true }),
}); });

View File

@ -36,8 +36,8 @@ export const removeSignedFieldWithToken = async ({
throw new Error(`Document not found for field ${field.id}`); throw new Error(`Document not found for field ${field.id}`);
} }
if (document.status !== DocumentStatus.PENDING) { if (document.status === DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} must be pending`); throw new Error(`Document ${document.id} has already been completed`);
} }
if (recipient?.signingStatus === SigningStatus.SIGNED) { if (recipient?.signingStatus === SigningStatus.SIGNED) {

View File

@ -58,12 +58,12 @@ export const signFieldWithToken = async ({
throw new Error(`Recipient not found for field ${field.id}`); throw new Error(`Recipient not found for field ${field.id}`);
} }
if (document.deletedAt) { if (document.status === DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has been deleted`); throw new Error(`Document ${document.id} has already been completed`);
} }
if (document.status !== DocumentStatus.PENDING) { if (document.deletedAt) {
throw new Error(`Document ${document.id} must be pending for signing`); throw new Error(`Document ${document.id} has been deleted`);
} }
if (recipient?.signingStatus === SigningStatus.SIGNED) { if (recipient?.signingStatus === SigningStatus.SIGNED) {

View File

@ -1,45 +0,0 @@
import { DateTime } from 'luxon';
import type { Browser } from 'playwright';
import { chromium } from 'playwright';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { encryptSecondaryData } from '../crypto/encrypt';
export type GetCertificatePdfOptions = {
documentId: number;
};
export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions) => {
const encryptedId = encryptSecondaryData({
data: documentId.toString(),
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
});
let browser: Browser;
if (process.env.NEXT_PRIVATE_BROWSERLESS_URL) {
browser = await chromium.connect(process.env.NEXT_PRIVATE_BROWSERLESS_URL);
} else {
browser = await chromium.launch();
}
if (!browser) {
throw new Error(
'Failed to establish a browser, please ensure you have either a Browserless.io url or chromium browser installed',
);
}
const page = await browser.newPage();
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`, {
waitUntil: 'networkidle',
});
const result = await page.pdf({
format: 'A4',
});
void browser.close();
return result;
};

View File

@ -1,13 +0,0 @@
-- AlterTable
ALTER TABLE "Recipient" ADD COLUMN "documentDeletedAt" TIMESTAMP(3);
-- Hard delete all PENDING documents that have been soft deleted
DELETE FROM "Document" WHERE "deletedAt" IS NOT NULL AND "status" = 'PENDING';
-- Update all recipients who are the owner of the document and where the document has deletedAt set to not null
UPDATE "Recipient"
SET "documentDeletedAt" = "Document"."deletedAt"
FROM "Document", "User"
WHERE "Recipient"."documentId" = "Document"."id"
AND "Recipient"."email" = "User"."email"
AND "Document"."deletedAt" IS NOT NULL;

View File

@ -1,23 +0,0 @@
-- DropForeignKey
ALTER TABLE "PasswordResetToken" DROP CONSTRAINT "PasswordResetToken_userId_fkey";
-- DropForeignKey
ALTER TABLE "Signature" DROP CONSTRAINT "Signature_fieldId_fkey";
-- DropForeignKey
ALTER TABLE "Team" DROP CONSTRAINT "Team_ownerUserId_fkey";
-- DropForeignKey
ALTER TABLE "TeamMember" DROP CONSTRAINT "TeamMember_userId_fkey";
-- AddForeignKey
ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Signature" ADD CONSTRAINT "Signature_fieldId_fkey" FOREIGN KEY ("fieldId") REFERENCES "Field"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Team" ADD CONSTRAINT "Team_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -98,7 +98,7 @@ model PasswordResetToken {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
expiry DateTime expiry DateTime
userId Int userId Int
User User @relation(fields: [userId], references: [id], onDelete: Cascade) User User @relation(fields: [userId], references: [id])
} }
model Passkey { model Passkey {
@ -353,7 +353,6 @@ model Recipient {
email String @db.VarChar(255) email String @db.VarChar(255)
name String @default("") @db.VarChar(255) name String @default("") @db.VarChar(255)
token String token String
documentDeletedAt DateTime?
expired DateTime? expired DateTime?
signedAt DateTime? signedAt DateTime?
authOptions Json? authOptions Json?
@ -415,7 +414,7 @@ model Signature {
typedSignature String? typedSignature String?
Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade) Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
Field Field @relation(fields: [fieldId], references: [id], onDelete: Cascade) Field Field @relation(fields: [fieldId], references: [id], onDelete: Restrict)
@@index([recipientId]) @@index([recipientId])
} }
@ -457,7 +456,7 @@ model Team {
emailVerification TeamEmailVerification? emailVerification TeamEmailVerification?
transferVerification TeamTransferVerification? transferVerification TeamTransferVerification?
owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade) owner User @relation(fields: [ownerUserId], references: [id])
subscription Subscription? subscription Subscription?
document Document[] document Document[]
@ -483,7 +482,7 @@ model TeamMember {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
role TeamMemberRole role TeamMemberRole
userId Int userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id])
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@unique([userId, teamId]) @@unique([userId, teamId])
@ -564,5 +563,5 @@ model SiteSettings {
data Json data Json
lastModifiedByUserId Int? lastModifiedByUserId Int?
lastModifiedAt DateTime @default(now()) lastModifiedAt DateTime @default(now())
lastModifiedByUser User? @relation(fields: [lastModifiedByUserId], references: [id], onDelete: SetNull) lastModifiedByUser User? @relation(fields: [lastModifiedByUserId], references: [id])
} }

View File

@ -342,15 +342,14 @@ export const seedPendingDocumentWithFullFields = async ({
}, },
}); });
const latestDocument = await prisma.document.update({ const latestDocument = updateDocumentOptions
? await prisma.document.update({
where: { where: {
id: document.id, id: document.id,
}, },
data: { data: updateDocumentOptions,
...updateDocumentOptions, })
status: DocumentStatus.PENDING, : document;
},
});
return { return {
document: latestDocument, document: latestDocument,

View File

@ -7,9 +7,6 @@ module.exports = {
content: ['src/**/*.{ts,tsx}'], content: ['src/**/*.{ts,tsx}'],
theme: { theme: {
extend: { extend: {
screens: {
print: { raw: 'print' },
},
fontFamily: { fontFamily: {
sans: ['var(--font-sans)', ...fontFamily.sans], sans: ['var(--font-sans)', ...fontFamily.sans],
signature: ['var(--font-signature)'], signature: ['var(--font-signature)'],

View File

@ -56,7 +56,7 @@ export const authRouter = router({
return user; return user;
} catch (err) { } catch (err) {
console.error(err); console.log(err);
const error = AppError.parseError(err); const error = AppError.parseError(err);

View File

@ -23,7 +23,7 @@ export const ZSignUpMutationSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
email: z.string().email(), email: z.string().email(),
password: ZPasswordSchema, password: ZPasswordSchema,
signature: z.string().nullish(), signature: z.string().min(1, { message: 'A signature is required.' }),
url: z url: z
.string() .string()
.trim() .trim()

View File

@ -1,10 +1,7 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { DateTime } from 'luxon';
import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta'; import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
import { createDocument } from '@documenso/lib/server-only/document/create-document'; import { createDocument } from '@documenso/lib/server-only/document/create-document';
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document'; import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
@ -25,7 +22,6 @@ import { authenticatedProcedure, procedure, router } from '../trpc';
import { import {
ZCreateDocumentMutationSchema, ZCreateDocumentMutationSchema,
ZDeleteDraftDocumentMutationSchema as ZDeleteDocumentMutationSchema, ZDeleteDraftDocumentMutationSchema as ZDeleteDocumentMutationSchema,
ZDownloadAuditLogsMutationSchema,
ZFindDocumentAuditLogsQuerySchema, ZFindDocumentAuditLogsQuerySchema,
ZGetDocumentByIdQuerySchema, ZGetDocumentByIdQuerySchema,
ZGetDocumentByTokenQuerySchema, ZGetDocumentByTokenQuerySchema,
@ -221,10 +217,6 @@ export const documentRouter = router({
} }
}), }),
getDocumentMetaById: authenticatedProcedure
.input(ZSetSettingsForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {}),
setTitleForDocument: authenticatedProcedure setTitleForDocument: authenticatedProcedure
.input(ZSetTitleForDocumentMutationSchema) .input(ZSetTitleForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
@ -362,7 +354,6 @@ export const documentRouter = router({
query, query,
userId: ctx.user.id, userId: ctx.user.id,
}); });
return documents; return documents;
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -373,66 +364,4 @@ export const documentRouter = router({
}); });
} }
}), }),
downloadAuditLogs: authenticatedProcedure
.input(ZDownloadAuditLogsMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { documentId, teamId } = input;
const document = await getDocumentById({
id: documentId,
userId: ctx.user.id,
teamId,
});
const encrypted = encryptSecondaryData({
data: document.id.toString(),
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
});
return {
url: `${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encrypted}`,
};
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'We were unable to download the audit logs for this document. Please try again later.',
});
}
}),
downloadCertificate: authenticatedProcedure
.input(ZDownloadAuditLogsMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { documentId, teamId } = input;
const document = await getDocumentById({
id: documentId,
userId: ctx.user.id,
teamId,
});
const encrypted = encryptSecondaryData({
data: document.id.toString(),
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
});
return {
url: `${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encrypted}`,
};
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'We were unable to download the audit logs for this document. Please try again later.',
});
}
}),
}); });

View File

@ -163,8 +163,3 @@ export type TDeleteDraftDocumentMutationSchema = z.infer<typeof ZDeleteDraftDocu
export const ZSearchDocumentsMutationSchema = z.object({ export const ZSearchDocumentsMutationSchema = z.object({
query: z.string(), query: z.string(),
}); });
export const ZDownloadAuditLogsMutationSchema = z.object({
documentId: z.number(),
teamId: z.number().optional(),
});

View File

@ -61,9 +61,6 @@ declare namespace NodeJS {
NEXT_PUBLIC_DISABLE_SIGNUP?: string; NEXT_PUBLIC_DISABLE_SIGNUP?: string;
//
NEXT_PRIVATE_BROWSERLESS_URL?: string;
/** /**
* Vercel environment variables * Vercel environment variables
*/ */

View File

@ -55,8 +55,6 @@ type AvatarWithTextProps = {
primaryText: React.ReactNode; primaryText: React.ReactNode;
secondaryText?: React.ReactNode; secondaryText?: React.ReactNode;
rightSideComponent?: React.ReactNode; rightSideComponent?: React.ReactNode;
// Optional class to hide/show the text beside avatar
textSectionClassName?: string;
}; };
const AvatarWithText = ({ const AvatarWithText = ({
@ -66,7 +64,6 @@ const AvatarWithText = ({
primaryText, primaryText,
secondaryText, secondaryText,
rightSideComponent, rightSideComponent,
textSectionClassName,
}: AvatarWithTextProps) => ( }: AvatarWithTextProps) => (
<div className={cn('flex w-full max-w-xs items-center gap-2', className)}> <div className={cn('flex w-full max-w-xs items-center gap-2', className)}>
<Avatar <Avatar
@ -75,7 +72,7 @@ const AvatarWithText = ({
<AvatarFallback className="text-xs text-gray-400">{avatarFallback}</AvatarFallback> <AvatarFallback className="text-xs text-gray-400">{avatarFallback}</AvatarFallback>
</Avatar> </Avatar>
<div className={cn('flex flex-col text-left text-sm font-normal', textSectionClassName)}> <div className="flex flex-col text-left text-sm font-normal">
<span className="text-foreground truncate">{primaryText}</span> <span className="text-foreground truncate">{primaryText}</span>
<span className="text-muted-foreground truncate text-xs">{secondaryText}</span> <span className="text-muted-foreground truncate text-xs">{secondaryText}</span>
</div> </div>

View File

@ -32,11 +32,7 @@ type CommandDialogProps = DialogProps & {
const CommandDialog = ({ children, commandProps, ...props }: CommandDialogProps) => { const CommandDialog = ({ children, commandProps, ...props }: CommandDialogProps) => {
return ( return (
<Dialog {...props}> <Dialog {...props}>
<DialogContent <DialogContent className="overflow-hidden p-0 shadow-2xl">
className="w-11/12 items-center overflow-hidden rounded-lg p-0 shadow-2xl lg:mt-0"
position="center"
overlayClassName="bg-background/60"
>
<Command <Command
{...commandProps} {...commandProps}
className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-0 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-4" className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-0 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-4"

View File

@ -54,16 +54,10 @@ const DialogContent = React.forwardRef<
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
position?: 'start' | 'end' | 'center'; position?: 'start' | 'end' | 'center';
hideClose?: boolean; hideClose?: boolean;
/* Below prop is to add additional classes to the overlay */
overlayClassName?: string;
} }
>( >(({ className, children, position = 'start', hideClose = false, ...props }, ref) => (
(
{ className, children, overlayClassName, position = 'start', hideClose = false, ...props },
ref,
) => (
<DialogPortal position={position}> <DialogPortal position={position}>
<DialogOverlay className={cn(overlayClassName)} /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
@ -81,8 +75,7 @@ const DialogContent = React.forwardRef<
)} )}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
), ));
);
DialogContent.displayName = DialogPrimitive.Content.displayName; DialogContent.displayName = DialogPrimitive.Content.displayName;

View File

@ -28,8 +28,6 @@ export const ZAddSettingsFormSchema = z.object({
ZDocumentActionAuthTypesSchema.optional(), ZDocumentActionAuthTypesSchema.optional(),
), ),
meta: z.object({ meta: z.object({
subject: z.string().optional(),
message: z.string().optional(),
timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE), timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT), dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),
redirectUrl: z redirectUrl: z

View File

@ -63,6 +63,7 @@ export const AddSignersFormPartial = ({
const { remaining } = useLimits(); const { remaining } = useLimits();
const { data: session } = useSession(); const { data: session } = useSession();
const user = session?.user; const user = session?.user;
const [selfSignerFormId, setSelfSignerFormId] = useState<string | undefined>(undefined);
const initialId = useId(); const initialId = useId();
@ -139,13 +140,17 @@ export const AddSignersFormPartial = ({
}; };
const onAddSelfSigner = () => { const onAddSelfSigner = () => {
const newSelfSignerId = nanoid(12);
appendSigner({ appendSigner({
formId: nanoid(12), formId: newSelfSignerId,
name: user?.name ?? '', name: user?.name ?? '',
email: user?.email ?? '', email: user?.email ?? '',
role: RecipientRole.SIGNER, role: RecipientRole.SIGNER,
actionAuth: undefined, actionAuth: undefined,
}); });
setSelfSignerFormId(newSelfSignerId);
}; };
const onAddSigner = () => { const onAddSigner = () => {
@ -171,6 +176,10 @@ export const AddSignersFormPartial = ({
return; return;
} }
if (signer.formId === selfSignerFormId) {
setSelfSignerFormId(undefined);
}
removeSigner(index); removeSigner(index);
}; };
@ -226,6 +235,7 @@ export const AddSignersFormPartial = ({
disabled={ disabled={
isSubmitting || isSubmitting ||
hasBeenSentToRecipientId(signer.nativeId) || hasBeenSentToRecipientId(signer.nativeId) ||
signer.formId === selfSignerFormId ||
signers[index].email === user?.email signers[index].email === user?.email
} }
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
@ -247,7 +257,9 @@ export const AddSignersFormPartial = ({
'col-span-4': showAdvancedSettings, 'col-span-4': showAdvancedSettings,
})} })}
> >
{!showAdvancedSettings && index === 0 && <FormLabel>Name</FormLabel>} {!showAdvancedSettings && index === 0 && (
<FormLabel required>Name</FormLabel>
)}
<FormControl> <FormControl>
<Input <Input
@ -256,6 +268,7 @@ export const AddSignersFormPartial = ({
disabled={ disabled={
isSubmitting || isSubmitting ||
hasBeenSentToRecipientId(signer.nativeId) || hasBeenSentToRecipientId(signer.nativeId) ||
signer.formId === selfSignerFormId ||
signers[index].email === user?.email signers[index].email === user?.email
} }
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
@ -277,7 +290,12 @@ export const AddSignersFormPartial = ({
<Select <Select
{...field} {...field}
onValueChange={field.onChange} onValueChange={field.onChange}
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)} disabled={
isSubmitting ||
hasBeenSentToRecipientId(signer.nativeId) ||
signer.formId === selfSignerFormId ||
signers[index].email === user?.email
}
> >
<SelectTrigger className="bg-background text-muted-foreground"> <SelectTrigger className="bg-background text-muted-foreground">
<SelectValue placeholder="Inherit authentication method" /> <SelectValue placeholder="Inherit authentication method" />
@ -349,7 +367,12 @@ export const AddSignersFormPartial = ({
<Select <Select
{...field} {...field}
onValueChange={field.onChange} onValueChange={field.onChange}
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)} disabled={
isSubmitting ||
hasBeenSentToRecipientId(signer.nativeId) ||
signer.formId === selfSignerFormId ||
signers[index].email === user?.email
}
> >
<SelectTrigger className="bg-background w-[60px]"> <SelectTrigger className="bg-background w-[60px]">
{/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */} {/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */}
@ -359,83 +382,29 @@ export const AddSignersFormPartial = ({
<SelectContent align="end"> <SelectContent align="end">
<SelectItem value={RecipientRole.SIGNER}> <SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center"> <div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span> <span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
Needs to sign Signer
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is required to sign the document for it to be
completed.
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">
{ROLE_ICONS[RecipientRole.APPROVER]}
</span>
Needs to approve
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is required to approve the document for it to
be completed.
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Needs to view
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is required to view the document for it to be
completed.
</p>
</TooltipContent>
</Tooltip>
</div> </div>
</SelectItem> </SelectItem>
<SelectItem value={RecipientRole.CC}> <SelectItem value={RecipientRole.CC}>
<div className="flex items-center"> <div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span> <span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
Receives copy Receives copy
</div> </div>
<Tooltip> </SelectItem>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" /> <SelectItem value={RecipientRole.APPROVER}>
</TooltipTrigger> <div className="flex items-center">
<TooltipContent className="text-foreground z-9999 max-w-md p-4"> <span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
<p> Approver
The recipient is not required to take any action and </div>
receives a copy of the document after it is completed. </SelectItem>
</p>
</TooltipContent> <SelectItem value={RecipientRole.VIEWER}>
</Tooltip> <div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Viewer
</div> </div>
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
@ -489,17 +458,18 @@ export const AddSignersFormPartial = ({
className="dark:bg-muted dark:hover:bg-muted/80 bg-black/5 hover:bg-black/10" className="dark:bg-muted dark:hover:bg-muted/80 bg-black/5 hover:bg-black/10"
disabled={ disabled={
isSubmitting || isSubmitting ||
form.getValues('signers').some((signer) => signer.email === user?.email) signers.length >= remaining.recipients ||
!!selfSignerFormId ||
signers.some((signer) => signer.email === user?.email)
} }
onClick={() => onAddSelfSigner()} onClick={() => onAddSelfSigner()}
> >
<Plus className="-ml-1 mr-2 h-5 w-5" /> <Plus className="-ml-1 mr-2 h-5 w-5" />
Add myself Add myself
</Button> </Button>
</div>
{!alwaysShowAdvancedSettings && isDocumentEnterprise && ( {!alwaysShowAdvancedSettings && isDocumentEnterprise && (
<div className="mt-4 flex flex-row items-center"> <div className="flex flex-row items-center">
<Checkbox <Checkbox
id="showAdvancedRecipientSettings" id="showAdvancedRecipientSettings"
className="h-5 w-5" className="h-5 w-5"
@ -516,6 +486,7 @@ export const AddSignersFormPartial = ({
</label> </label>
</div> </div>
)} )}
</div>
</Form> </Form>
</AnimateGenericFadeInOut> </AnimateGenericFadeInOut>
</DocumentFlowFormContainerContent> </DocumentFlowFormContainerContent>

View File

@ -1,7 +1,5 @@
'use client'; 'use client';
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@ -32,8 +30,6 @@ export type AddSubjectFormProps = {
document: DocumentWithData; document: DocumentWithData;
onSubmit: (_data: TAddSubjectFormSchema) => void; onSubmit: (_data: TAddSubjectFormSchema) => void;
isDocumentPdfLoaded: boolean; isDocumentPdfLoaded: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setSubjectFormFields: (subject?: string, message?: string) => void;
}; };
export const AddSubjectFormPartial = ({ export const AddSubjectFormPartial = ({
@ -43,12 +39,10 @@ export const AddSubjectFormPartial = ({
document, document,
onSubmit, onSubmit,
isDocumentPdfLoaded, isDocumentPdfLoaded,
setSubjectFormFields,
}: AddSubjectFormProps) => { }: AddSubjectFormProps) => {
const { const {
register, register,
handleSubmit, handleSubmit,
getValues,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<TAddSubjectFormSchema>({ } = useForm<TAddSubjectFormSchema>({
defaultValues: { defaultValues: {
@ -63,13 +57,6 @@ export const AddSubjectFormPartial = ({
const onFormSubmit = handleSubmit(onSubmit); const onFormSubmit = handleSubmit(onSubmit);
const { currentStep, totalSteps, previousStep } = useStep(); const { currentStep, totalSteps, previousStep } = useStep();
useEffect(() => {
return () => {
const { meta } = getValues();
setSubjectFormFields(meta.subject, meta.message);
};
}, [getValues, setSubjectFormFields]);
return ( return (
<> <>
<DocumentFlowFormContainerHeader <DocumentFlowFormContainerHeader

View File

@ -2,16 +2,13 @@ import * as React from 'react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
const Table = React.forwardRef< const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
HTMLTableElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLTableElement> & { <div className="w-full overflow-auto">
overflowHidden?: boolean;
}
>(({ className, overflowHidden, ...props }, ref) => (
<div className={cn('w-full', overflowHidden ? 'overflow-hidden' : 'overflow-auto')}>
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} /> <table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
</div> </div>
)); ),
);
Table.displayName = 'Table'; Table.displayName = 'Table';
@ -79,17 +76,11 @@ TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef< const TableCell = React.forwardRef<
HTMLTableCellElement, HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement> & { React.TdHTMLAttributes<HTMLTableCellElement>
truncate?: boolean; >(({ className, ...props }, ref) => (
}
>(({ className, truncate = true, ...props }, ref) => (
<td <td
ref={ref} ref={ref}
className={cn( className={cn('truncate p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
'p-4 align-middle [&:has([role=checkbox])]:pr-0',
truncate && 'truncate',
className,
)}
{...props} {...props}
/> />
)); ));

View File

@ -4,8 +4,7 @@ import React, { useId, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { InfoIcon, Plus, Trash } from 'lucide-react'; import { Plus, Trash } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { Controller, useFieldArray, useForm } from 'react-hook-form'; import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
@ -25,7 +24,6 @@ import type { DocumentFlowStep } from '../document-flow/types';
import { ROLE_ICONS } from '../recipient-role-icons'; import { ROLE_ICONS } from '../recipient-role-icons';
import { Select, SelectContent, SelectItem, SelectTrigger } from '../select'; import { Select, SelectContent, SelectItem, SelectTrigger } from '../select';
import { useStep } from '../stepper'; import { useStep } from '../stepper';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types'; import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types'; import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
@ -43,8 +41,6 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
onSubmit, onSubmit,
}: AddTemplatePlaceholderRecipientsFormProps) => { }: AddTemplatePlaceholderRecipientsFormProps) => {
const initialId = useId(); const initialId = useId();
const { data: session } = useSession();
const user = session?.user;
const [placeholderRecipientCount, setPlaceholderRecipientCount] = useState(() => const [placeholderRecipientCount, setPlaceholderRecipientCount] = useState(() =>
recipients.length > 1 ? recipients.length + 1 : 2, recipients.length > 1 ? recipients.length + 1 : 2,
); );
@ -54,7 +50,6 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
const { const {
control, control,
handleSubmit, handleSubmit,
getValues,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<TAddTemplatePlacholderRecipientsFormSchema>({ } = useForm<TAddTemplatePlacholderRecipientsFormSchema>({
resolver: zodResolver(ZAddTemplatePlacholderRecipientsFormSchema), resolver: zodResolver(ZAddTemplatePlacholderRecipientsFormSchema),
@ -90,15 +85,6 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
name: 'signers', name: 'signers',
}); });
const onAddPlaceholderSelfRecipient = () => {
appendSigner({
formId: nanoid(12),
name: user?.name ?? '',
email: user?.email ?? '',
role: RecipientRole.SIGNER,
});
};
const onAddPlaceholderRecipient = () => { const onAddPlaceholderRecipient = () => {
appendSigner({ appendSigner({
formId: nanoid(12), formId: nanoid(12),
@ -160,81 +146,29 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
<SelectContent className="" align="end"> <SelectContent className="" align="end">
<SelectItem value={RecipientRole.SIGNER}> <SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center"> <div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span> <span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
Needs to sign Signer
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is required to sign the document for it to be
completed.
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
Needs to approve
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is required to approve the document for it to be
completed.
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Needs to view
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is required to view the document for it to be
completed.
</p>
</TooltipContent>
</Tooltip>
</div> </div>
</SelectItem> </SelectItem>
<SelectItem value={RecipientRole.CC}> <SelectItem value={RecipientRole.CC}>
<div className="flex items-center"> <div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span> <span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
Receives copy Receives copy
</div> </div>
<Tooltip> </SelectItem>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" /> <SelectItem value={RecipientRole.APPROVER}>
</TooltipTrigger> <div className="flex items-center">
<TooltipContent className="text-foreground z-9999 max-w-md p-4"> <span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
<p> Approver
The recipient is not required to take any action and receives a </div>
copy of the document after it is completed. </SelectItem>
</p>
</TooltipContent> <SelectItem value={RecipientRole.VIEWER}>
</Tooltip> <div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Viewer
</div> </div>
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
@ -269,27 +203,11 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
error={'signers__root' in errors && errors['signers__root']} error={'signers__root' in errors && errors['signers__root']}
/> />
<div className="mt-4 flex flex-row items-center space-x-4"> <div className="mt-4">
<Button <Button type="button" disabled={isSubmitting} onClick={() => onAddPlaceholderRecipient()}>
type="button"
className="flex-1"
disabled={isSubmitting}
onClick={() => onAddPlaceholderRecipient()}
>
<Plus className="-ml-1 mr-2 h-5 w-5" /> <Plus className="-ml-1 mr-2 h-5 w-5" />
Add Placeholder Recipient Add Placeholder Recipient
</Button> </Button>
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 bg-black/5 hover:bg-black/10"
disabled={
isSubmitting || getValues('signers').some((signer) => signer.email === user?.email)
}
onClick={() => onAddPlaceholderSelfRecipient()}
>
<Plus className="-ml-1 mr-2 h-5 w-5" />
Add Myself
</Button>
</div> </div>
</DocumentFlowFormContainerContent> </DocumentFlowFormContainerContent>

View File

@ -97,21 +97,6 @@
} }
} }
/*
* Custom CSS for printing reports
* - Sets page margins to 0.5 inches
* - Hides the header and footer
* - Hides the print button
* - Sets page size to A4
* - Sets the font size to 12pt
*/
.print-provider {
@page {
margin: 1in;
size: A4;
}
}
.gradient-border-mask::before { .gradient-border-mask::before {
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);

View File

@ -2,13 +2,8 @@
"$schema": "https://turbo.build/schema.json", "$schema": "https://turbo.build/schema.json",
"pipeline": { "pipeline": {
"build": { "build": {
"dependsOn": [ "dependsOn": ["^build"],
"^build" "outputs": [".next/**", "!.next/cache/**"]
],
"outputs": [
".next/**",
"!.next/cache/**"
]
}, },
"lint": { "lint": {
"cache": false "cache": false
@ -24,9 +19,7 @@
"persistent": true "persistent": true
}, },
"start": { "start": {
"dependsOn": [ "dependsOn": ["^build"],
"^build"
],
"cache": false, "cache": false,
"persistent": true "persistent": true
}, },
@ -34,15 +27,11 @@
"cache": false "cache": false
}, },
"test:e2e": { "test:e2e": {
"dependsOn": [ "dependsOn": ["^build"],
"^build"
],
"cache": false "cache": false
} }
}, },
"globalDependencies": [ "globalDependencies": ["**/.env.*local"],
"**/.env.*local"
],
"globalEnv": [ "globalEnv": [
"APP_VERSION", "APP_VERSION",
"NEXT_PRIVATE_ENCRYPTION_KEY", "NEXT_PRIVATE_ENCRYPTION_KEY",
@ -104,7 +93,6 @@
"NEXT_PRIVATE_STRIPE_API_KEY", "NEXT_PRIVATE_STRIPE_API_KEY",
"NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET", "NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET",
"NEXT_PRIVATE_GITHUB_TOKEN", "NEXT_PRIVATE_GITHUB_TOKEN",
"NEXT_PRIVATE_BROWSERLESS_URL",
"CI", "CI",
"VERCEL", "VERCEL",
"VERCEL_ENV", "VERCEL_ENV",