Compare commits

..

19 Commits

Author SHA1 Message Date
David Nguyen
3c0918963d fix: polish 2024-03-27 18:35:20 +08:00
David Nguyen
47916e3127 feat: add document auth 2FA 2024-03-27 16:26:59 +08:00
David Nguyen
62dd737cf0 feat: add document auth passkey 2024-03-27 16:13:22 +08:00
David Nguyen
0d510cf280 Merge branch 'main' into feat/document-auth 2024-03-27 15:19:24 +08:00
David Nguyen
f88faa43d9 chore: refactor env config 2024-03-27 15:17:10 +08:00
David Nguyen
074adccae2 chore: set global env for playwright 2024-03-26 21:39:45 +08:00
David Nguyen
844261c35c Merge branch 'main' into feat/document-auth 2024-03-26 21:36:58 +08:00
David Nguyen
c0fb5caf9c fix: update reauth constraints and tests 2024-03-26 18:33:20 +08:00
David Nguyen
b6c4cc9dc8 feat: restrict reauth to EE 2024-03-26 16:46:47 +08:00
David Nguyen
94da57704d Merge branch 'main' into feat/document-auth 2024-03-25 23:06:46 +08:00
David Nguyen
1375946bc5 chore: refactor 2024-03-21 22:02:09 +08:00
David Nguyen
dc6aba58e6 chore: typo 2024-03-17 14:47:06 +08:00
David Nguyen
364b9e03e1 fix: build 2024-03-17 14:11:49 +08:00
David Nguyen
5f3152b7db Merge branch 'main' into feat/document-auth 2024-03-17 13:58:37 +08:00
David Nguyen
dc8d8433dd chore: update documentation 2024-03-17 13:54:14 +08:00
David Nguyen
6781ff137e fix: test 2024-03-16 19:00:19 +08:00
David Nguyen
fa9099bc86 chore: add more tests 2024-03-16 18:41:25 +08:00
David Nguyen
228ac90036 chore: add initial tests 2024-03-16 15:54:20 +08:00
David Nguyen
8d1b0adbb2 feat: add document auth 2024-03-15 19:12:01 +08:00
99 changed files with 1602 additions and 2615 deletions

View File

@@ -40,6 +40,16 @@ NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS=
# OPTIONAL: The path to the Google Cloud Credentials file to use for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS=
# [[SIGNING]]
# OPTIONAL: Defines the signing transport to use. Available options: local (default)
NEXT_PRIVATE_SIGNING_TRANSPORT="local"
# OPTIONAL: Defines the passphrase for the signing certificate.
NEXT_PRIVATE_SIGNING_PASSPHRASE=
# OPTIONAL: Defines the file contents for the signing certificate as a base64 encoded string.
NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS=
# OPTIONAL: Defines the file path for the signing certificate. defaults to ./example/cert.p12
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=
# [[STORAGE]]
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"

View File

@@ -2,7 +2,6 @@
const fs = require('fs');
const path = require('path');
const { withContentlayer } = require('next-contentlayer');
const { withAxiom } = require('next-axiom');
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
@@ -96,4 +95,4 @@ const config = {
},
};
module.exports = withAxiom(withContentlayer(config));
module.exports = withContentlayer(config);

View File

@@ -19,7 +19,6 @@
"@documenso/trpc": "*",
"@documenso/ui": "*",
"@hookform/resolvers": "^3.1.0",
"@openstatus/react": "^0.0.3",
"contentlayer": "^0.3.4",
"framer-motion": "^10.12.8",
"lucide-react": "^0.279.0",
@@ -27,7 +26,6 @@
"micro": "^10.0.1",
"next": "14.0.3",
"next-auth": "4.24.5",
"next-axiom": "^1.1.1",
"next-contentlayer": "^0.3.4",
"next-plausible": "^3.10.1",
"perfect-freehand": "^1.2.0",

View File

@@ -2,7 +2,6 @@ import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google';
import { AxiomWebVitals } from 'next-axiom';
import { PublicEnvScript } from 'next-runtime-env';
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
@@ -68,8 +67,6 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<PublicEnvScript />
</head>
<AxiomWebVitals />
<Suspense>
<PostHogPageview />
</Suspense>

View File

@@ -13,8 +13,6 @@ import LogoImage from '@documenso/assets/logo.png';
import { cn } from '@documenso/ui/lib/utils';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
import { StatusWidgetContainer } from './status-widget-container';
export type FooterProps = HTMLAttributes<HTMLDivElement>;
const SOCIAL_LINKS = [
@@ -64,10 +62,6 @@ export const Footer = ({ className, ...props }: FooterProps) => {
</Link>
))}
</div>
<div className="mt-6">
<StatusWidgetContainer />
</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">

View File

@@ -1,4 +1,4 @@
import type { HTMLAttributes } from 'react';
import { HTMLAttributes } from 'react';
import Image from 'next/image';
@@ -51,7 +51,7 @@ export const ShareConnectPaidWidgetBento = ({
<Card className="col-span-2 lg:col-span-1" spotlight>
<CardContent className="grid grid-cols-1 gap-8 p-6">
<p className="text-foreground/80 leading-relaxed">
<strong className="block">Connections</strong>
<strong className="block">Connections (Soon).</strong>
Create connections and automations with Zapier and more to integrate with your
favorite tools.
</p>

View File

@@ -1,21 +0,0 @@
// https://github.com/documenso/documenso/pull/1044/files#r1538258462
import { Suspense } from 'react';
import { StatusWidget } from './status-widget';
export function StatusWidgetContainer() {
return (
<Suspense fallback={<StatusWidgetFallback />}>
<StatusWidget />
</Suspense>
);
}
function StatusWidgetFallback() {
return (
<div className="border-border inline-flex max-w-fit items-center justify-between space-x-2 rounded-md border border-gray-200 px-2 py-2 pr-3 text-sm">
<span className="bg-muted h-2 w-36 animate-pulse rounded-md" />
<span className="bg-muted relative inline-flex h-2 w-2 rounded-full" />
</div>
);
}

View File

@@ -1,75 +0,0 @@
import { use, useMemo } from 'react';
import type { Status } from '@openstatus/react';
import { getStatus } from '@openstatus/react';
import { cn } from '@documenso/ui/lib/utils';
const getStatusLevel = (level: Status) => {
return {
operational: {
label: 'Operational',
color: 'bg-green-500',
color2: 'bg-green-400',
},
degraded_performance: {
label: 'Degraded Performance',
color: 'bg-yellow-500',
color2: 'bg-yellow-400',
},
partial_outage: {
label: 'Partial Outage',
color: 'bg-yellow-500',
color2: 'bg-yellow-400',
},
major_outage: {
label: 'Major Outage',
color: 'bg-red-500',
color2: 'bg-red-400',
},
unknown: {
label: 'Unknown',
color: 'bg-gray-500',
color2: 'bg-gray-400',
},
incident: {
label: 'Incident',
color: 'bg-yellow-500',
color2: 'bg-yellow-400',
},
under_maintenance: {
label: 'Under Maintenance',
color: 'bg-gray-500',
color2: 'bg-gray-400',
},
}[level];
};
export function StatusWidget() {
const getStatusMemoized = useMemo(async () => getStatus('documenso-status'), []);
const { status } = use(getStatusMemoized);
const level = getStatusLevel(status);
return (
<a
className="border-border inline-flex max-w-fit items-center justify-between gap-2 space-x-2 rounded-md border border-gray-200 px-3 py-1 text-sm"
href="https://status.documenso.com"
target="_blank"
rel="noreferrer"
>
<div>
<p className="text-sm">{level.label}</p>
</div>
<span className="relative ml-auto flex h-1.5 w-1.5">
<span
className={cn(
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-75',
level.color2,
)}
/>
<span className={cn('relative inline-flex h-1.5 w-1.5 rounded-full', level.color)} />
</span>
</a>
);
}

View File

@@ -2,7 +2,6 @@
const fs = require('fs');
const path = require('path');
const { version } = require('./package.json');
const { withAxiom } = require('next-axiom');
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
@@ -92,4 +91,4 @@ const config = {
},
};
module.exports = withAxiom(config);
module.exports = config;

View File

@@ -33,10 +33,8 @@
"micro": "^10.0.1",
"next": "14.0.3",
"next-auth": "4.24.5",
"next-axiom": "^1.1.1",
"next-plausible": "^3.10.1",
"next-themes": "^0.2.1",
"papaparse": "^5.4.1",
"perfect-freehand": "^1.2.0",
"posthog-js": "^1.75.3",
"posthog-node": "^3.1.1",
@@ -60,7 +58,6 @@
"@types/formidable": "^2.0.6",
"@types/luxon": "^3.3.1",
"@types/node": "20.1.0",
"@types/papaparse": "^5.3.14",
"@types/react": "18.2.18",
"@types/react-dom": "18.2.7",
"@types/ua-parser-js": "^0.7.39",

View File

@@ -14,7 +14,6 @@ import { LocaleDate } from '~/components/formatter/locale-date';
import { AdminActions } from './admin-actions';
import { RecipientItem } from './recipient-item';
import { SuperDeleteDocumentDialog } from './super-delete-document-dialog';
type AdminDocumentDetailsPageProps = {
params: {
@@ -82,10 +81,6 @@ export default async function AdminDocumentDetailsPage({ params }: AdminDocument
))}
</Accordion>
</div>
<hr className="my-4" />
{document && <SuperDeleteDocumentDialog document={document} />}
</div>
);
}

View File

@@ -1,130 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { Document } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type SuperDeleteDocumentDialogProps = {
document: Document;
};
export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialogProps) => {
const { toast } = useToast();
const router = useRouter();
const [reason, setReason] = useState('');
const { mutateAsync: deleteDocument, isLoading: isDeletingDocument } =
trpc.admin.deleteDocument.useMutation();
const handleDeleteDocument = async () => {
try {
if (!reason) {
return;
}
await deleteDocument({ id: document.id, reason });
toast({
title: 'Document deleted',
description: 'The Document has been deleted successfully.',
duration: 5000,
});
router.push('/admin/documents');
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
err.message ??
'We encountered an unknown error while attempting to delete your document. Please try again later.',
});
}
}
};
return (
<div>
<div>
<Alert
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row "
variant="neutral"
>
<div>
<AlertTitle>Delete Document</AlertTitle>
<AlertDescription className="mr-2">
Delete the document. This action is irreversible so proceed with caution.
</AlertDescription>
</div>
<div className="flex-shrink-0">
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Delete Document</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="space-y-4">
<DialogTitle>Delete Document</DialogTitle>
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
This action is not reversible. Please be certain.
</AlertDescription>
</Alert>
</DialogHeader>
<div>
<DialogDescription>To confirm, please enter the reason</DialogDescription>
<Input
className="mt-2"
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</div>
<DialogFooter>
<Button
onClick={handleDeleteDocument}
loading={isDeletingDocument}
variant="destructive"
disabled={!reason}
>
{isDeletingDocument ? 'Deleting document...' : 'Delete Document'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</Alert>
</div>
</div>
);
};

View File

@@ -58,7 +58,6 @@ export const UsersDataTable = ({
perPage,
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchString]);
const onPaginationChange = (page: number, perPage: number) => {

View File

@@ -7,11 +7,9 @@ import {
Dialog,
DialogContent,
DialogFooter,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { SigningDisclosure } from '~/components/general/signing-disclosure';
import { truncateTitle } from '~/helpers/truncate-title';
export type SignDialogProps = {
@@ -68,39 +66,23 @@ export const SignDialog = ({
{isComplete ? 'Complete' : 'Next field'}
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>
<div className="text-center">
<div className="text-foreground text-xl font-semibold">
{role === RecipientRole.VIEWER && 'Complete Viewing'}
{role === RecipientRole.SIGNER && 'Complete Signing'}
{role === RecipientRole.APPROVER && 'Complete Approval'}
{role === RecipientRole.VIEWER && 'Mark Document as Viewed'}
{role === RecipientRole.SIGNER && 'Sign Document'}
{role === RecipientRole.APPROVER && 'Approve Document'}
</div>
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
{role === RecipientRole.VIEWER &&
`You are about to finish viewing "${truncatedTitle}". Are you sure?`}
{role === RecipientRole.SIGNER &&
`You are about to finish signing "${truncatedTitle}". Are you sure?`}
{role === RecipientRole.APPROVER &&
`You are about to finish approving "${truncatedTitle}". Are you sure?`}
</div>
</DialogTitle>
<div className="text-muted-foreground max-w-[50ch]">
{role === RecipientRole.VIEWER && (
<span>
You are about to complete viewing "{truncatedTitle}".
<br /> Are you sure?
</span>
)}
{role === RecipientRole.SIGNER && (
<span>
You are about to complete signing "{truncatedTitle}".
<br /> Are you sure?
</span>
)}
{role === RecipientRole.APPROVER && (
<span>
You are about to complete approving "{truncatedTitle}".
<br /> Are you sure?
</span>
)}
</div>
<SigningDisclosure className="mt-4" />
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button

View File

@@ -18,8 +18,6 @@ import { Label } from '@documenso/ui/primitives/label';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SigningDisclosure } from '~/components/general/signing-disclosure';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRequiredSigningContext } from './provider';
import { SigningFieldContainer } from './signing-field-container';
@@ -202,8 +200,6 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
/>
</div>
<SigningDisclosure />
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button

View File

@@ -1,108 +0,0 @@
import Link from 'next/link';
import { Button } from '@documenso/ui/primitives/button';
export default function SignatureDisclosure() {
return (
<div>
<article className="prose">
<h1>Electronic Signature Disclosure</h1>
<h2>Welcome</h2>
<p>
Thank you for using Documenso to perform your electronic document signing. The purpose of
this disclosure is to inform you about the process, legality, and your rights regarding
the use of electronic signatures on our platform. By opting to use an electronic
signature, you are agreeing to the terms and conditions outlined below.
</p>
<h2>Acceptance and Consent</h2>
<p>
When you use our platform to affix your electronic signature to documents, you are
consenting to do so under the Electronic Signatures in Global and National Commerce Act
(E-Sign Act) and other applicable laws. This action indicates your agreement to use
electronic means to sign documents and receive notifications.
</p>
<h2>Legality of Electronic Signatures</h2>
<p>
An electronic signature provided by you on our platform, achieved through clicking through
to a document and entering your name, or any other electronic signing method we provide,
is legally binding. It carries the same weight and enforceability as a manual signature
written with ink on paper.
</p>
<h2>System Requirements</h2>
<p>To use our electronic signature service, you must have access to:</p>
<ul>
<li>A stable internet connection</li>
<li>An email account</li>
<li>A device capable of accessing, opening, and reading documents</li>
<li>A means to print or download documents for your records</li>
</ul>
<h2>Electronic Delivery of Documents</h2>
<p>
All documents related to the electronic signing process will be provided to you
electronically through our platform or via email. It is your responsibility to ensure that
your email address is current and that you can receive and open our emails.
</p>
<h2>Consent to Electronic Transactions</h2>
<p>
By using the electronic signature feature, you are consenting to conduct transactions and
receive disclosures electronically. You acknowledge that your electronic signature on
documents is binding and that you accept the terms outlined in the documents you are
signing.
</p>
<h2>Withdrawing Consent</h2>
<p>
You have the right to withdraw your consent to use electronic signatures at any time
before completing the signing process. To withdraw your consent, please contact the sender
of the document. In failing to contact the sender you may reach out to{' '}
<a href="mailto:support@documenso.com">support@documenso.com</a> for assistance. Be aware
that withdrawing consent may delay or halt the completion of the related transaction or
service.
</p>
<h2>Updating Your Information</h2>
<p>
It is crucial to keep your contact information, especially your email address, up to date
with us. Please notify us immediately of any changes to ensure that you continue to
receive all necessary communications.
</p>
<h2>Retention of Documents</h2>
<p>
After signing a document electronically, you will be provided the opportunity to view,
download, and print the document for your records. It is highly recommended that you
retain a copy of all electronically signed documents for your personal records. We will
also retain a copy of the signed document for our records however we may not be able to
provide you with a copy of the signed document after a certain period of time.
</p>
<h2>Acknowledgment</h2>
<p>
By proceeding to use the electronic signature service provided by Documenso, you affirm
that you have read and understood this disclosure. You agree to all terms and conditions
related to the use of electronic signatures and electronic transactions as outlined
herein.
</p>
<h2>Contact Information</h2>
<p>
For any questions regarding this disclosure, electronic signatures, or any related
process, please contact us at:{' '}
<a href="mailto:support@documenso.com">support@documenso.com</a>
</p>
</article>
<div className="mt-8">
<Button asChild>
<Link href="/documents">Back to Documents</Link>
</Button>
</div>
</div>
);
}

View File

@@ -2,7 +2,6 @@ import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google';
import { AxiomWebVitals } from 'next-axiom';
import { PublicEnvScript } from 'next-runtime-env';
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
@@ -72,8 +71,6 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<PublicEnvScript />
</head>
<AxiomWebVitals />
<Suspense>
<PostHogPageview />
</Suspense>

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client';
import { Recipient } from '@documenso/prisma/client';
import { StackAvatar } from './stack-avatar';

View File

@@ -1,22 +1,19 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react';
import Papa, { type ParseResult } from 'papaparse';
import { Mail, PlusCircle, Trash } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import { TeamMemberRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
Dialog,
DialogContent,
@@ -42,7 +39,6 @@ import {
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type InviteTeamMembersDialogProps = {
@@ -55,45 +51,18 @@ const ZInviteTeamMembersFormSchema = z
.object({
invitations: ZCreateTeamMemberInvitesMutationSchema.shape.invitations,
})
// Display exactly which rows are duplicates.
.superRefine((items, ctx) => {
const uniqueEmails = new Map<string, number>();
.refine(
(schema) => {
const emails = schema.invitations.map((invitation) => invitation.email.toLowerCase());
for (const [index, invitation] of items.invitations.entries()) {
const email = invitation.email.toLowerCase();
const firstFoundIndex = uniqueEmails.get(email);
if (firstFoundIndex === undefined) {
uniqueEmails.set(email, index);
continue;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['invitations', index, 'email'],
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['invitations', firstFoundIndex, 'email'],
});
}
});
return new Set(emails).size === emails.length;
},
// Dirty hack to handle errors when .root is populated for an array type
{ message: 'Members must have unique emails', path: ['members__root'] },
);
type TInviteTeamMembersFormSchema = z.infer<typeof ZInviteTeamMembersFormSchema>;
type TabTypes = 'INDIVIDUAL' | 'BULK';
const ZImportTeamMemberSchema = z.array(
z.object({
email: z.string().email(),
role: z.nativeEnum(TeamMemberRole),
}),
);
export const InviteTeamMembersDialog = ({
currentUserTeamRole,
teamId,
@@ -101,8 +70,6 @@ export const InviteTeamMembersDialog = ({
...props
}: InviteTeamMembersDialogProps) => {
const [open, setOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [invitationType, setInvitationType] = useState<TabTypes>('INDIVIDUAL');
const { toast } = useToast();
@@ -163,75 +130,9 @@ export const InviteTeamMembersDialog = ({
useEffect(() => {
if (!open) {
form.reset();
setInvitationType('INDIVIDUAL');
}
}, [open, form]);
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.length) {
return;
}
const csvFile = e.target.files[0];
Papa.parse(csvFile, {
skipEmptyLines: true,
comments: 'Work email,Job title',
complete: (results: ParseResult<string[]>) => {
const members = results.data.map((row) => {
const [email, role] = row;
return {
email: email.trim(),
role: role.trim().toUpperCase(),
};
});
// Remove the first row if it contains the headers.
if (members.length > 1 && members[0].role.toUpperCase() === 'ROLE') {
members.shift();
}
try {
const importedInvitations = ZImportTeamMemberSchema.parse(members);
form.setValue('invitations', importedInvitations);
form.clearErrors('invitations');
setInvitationType('INDIVIDUAL');
} catch (err) {
console.error(err.message);
toast({
variant: 'destructive',
title: 'Something went wrong',
description: 'Please check the CSV file and make sure it is according to our format',
});
}
},
});
};
const downloadTemplate = () => {
const data = [
{ email: 'admin@documenso.com', role: 'Admin' },
{ email: 'manager@documenso.com', role: 'Manager' },
{ email: 'member@documenso.com', role: 'Member' },
];
const csvContent =
'Email address,Role\n' + data.map((row) => `${row.email},${row.role}`).join('\n');
const blob = new Blob([csvContent], {
type: 'text/csv',
});
downloadFile({
filename: 'documenso-team-member-invites-template.csv',
data: blob,
});
};
return (
<Dialog
{...props}
@@ -251,144 +152,92 @@ export const InviteTeamMembersDialog = ({
</DialogDescription>
</DialogHeader>
<Tabs
defaultValue="INDIVIDUAL"
value={invitationType}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
onValueChange={(value) => setInvitationType(value as TabTypes)}
>
<TabsList className="w-full">
<TabsTrigger value="INDIVIDUAL" className="hover:text-foreground w-full">
<MailIcon size={20} className="mr-2" />
Invite Members
</TabsTrigger>
<TabsTrigger value="BULK" className="hover:text-foreground w-full">
<UsersIcon size={20} className="mr-2" /> Bulk Import
</TabsTrigger>
</TabsList>
<TabsContent value="INDIVIDUAL">
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
{teamMemberInvites.map((teamMemberInvite, index) => (
<div className="flex w-full flex-row space-x-4" key={teamMemberInvite.id}>
<FormField
control={form.control}
name={`invitations.${index}.email`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && <FormLabel required>Email address</FormLabel>}
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`invitations.${index}.role`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && <FormLabel required>Role</FormLabel>}
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground max-w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{TEAM_MEMBER_ROLE_HIERARCHY[currentUserTeamRole].map((role) => (
<SelectItem key={role} value={role}>
{TEAM_MEMBER_ROLE_MAP[role] ?? role}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<button
type="button"
className={cn(
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
index === 0 ? 'mt-8' : 'mt-0',
)}
disabled={teamMemberInvites.length === 1}
onClick={() => removeTeamMemberInvite(index)}
>
<Trash className="h-5 w-5" />
</button>
</div>
))}
</div>
<Button
type="button"
size="sm"
variant="outline"
className="w-fit"
onClick={() => onAddTeamMemberInvite()}
>
<PlusCircle className="mr-2 h-4 w-4" />
Add more
</Button>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
{!form.formState.isSubmitting && <Mail className="mr-2 h-4 w-4" />}
Invite
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</TabsContent>
<TabsContent value="BULK">
<div className="mt-4 space-y-4">
<Card gradient className="h-32">
<CardContent
className="text-muted-foreground/80 hover:text-muted-foreground/90 flex h-full cursor-pointer flex-col items-center justify-center rounded-lg p-0 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-5 w-5" />
<p className="mt-1 text-sm">Click here to upload</p>
<input
onChange={onFileInputChange}
type="file"
ref={fileInputRef}
accept=".csv"
hidden
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
{teamMemberInvites.map((teamMemberInvite, index) => (
<div className="flex w-full flex-row space-x-4" key={teamMemberInvite.id}>
<FormField
control={form.control}
name={`invitations.${index}.email`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && <FormLabel required>Email address</FormLabel>}
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<FormField
control={form.control}
name={`invitations.${index}.role`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && <FormLabel required>Role</FormLabel>}
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground max-w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{TEAM_MEMBER_ROLE_HIERARCHY[currentUserTeamRole].map((role) => (
<SelectItem key={role} value={role}>
{TEAM_MEMBER_ROLE_MAP[role] ?? role}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<button
type="button"
className={cn(
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
index === 0 ? 'mt-8' : 'mt-0',
)}
disabled={teamMemberInvites.length === 1}
onClick={() => removeTeamMemberInvite(index)}
>
<Trash className="h-5 w-5" />
</button>
</div>
))}
<Button
type="button"
size="sm"
variant="outline"
className="w-fit"
onClick={() => onAddTeamMemberInvite()}
>
<PlusCircle className="mr-2 h-4 w-4" />
Add more
</Button>
<DialogFooter>
<Button type="button" variant="secondary" onClick={downloadTemplate}>
<Download className="mr-2 h-4 w-4" />
Template
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
{!form.formState.isSubmitting && <Mail className="mr-2 h-4 w-4" />}
Invite
</Button>
</DialogFooter>
</div>
</TabsContent>
</Tabs>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);

View File

@@ -1,4 +1,4 @@
import type { SVGAttributes } from 'react';
import { SVGAttributes } from 'react';
export type LogoProps = SVGAttributes<SVGSVGElement>;

View File

@@ -1,9 +1,9 @@
import type { HTMLAttributes } from 'react';
import { HTMLAttributes } from 'react';
import { Globe, Lock } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import type { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client';
import { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
type TemplateTypeIcon = {

View File

@@ -47,9 +47,12 @@ export const ViewRecoveryCodesDialog = () => {
data: recoveryCodes,
mutate,
isLoading,
isError,
error,
} = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
// error?.data?.code
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
defaultValues: {
token: '',

View File

@@ -55,8 +55,11 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
});
const isSubmitting = form.formState.isSubmitting;
const hasTwoFactorAuthentication = user.twoFactorEnabled;
const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation();
const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } =
trpc.profile.deleteAccount.useMutation();
const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => {
try {

View File

@@ -1,29 +0,0 @@
import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { cn } from '@documenso/ui/lib/utils';
export type SigningDisclosureProps = HTMLAttributes<HTMLParagraphElement>;
export const SigningDisclosure = ({ className, ...props }: SigningDisclosureProps) => {
return (
<p className={cn('text-muted-foreground text-xs', className)} {...props}>
By proceeding with your electronic signature, you acknowledge and consent that it will be used
to sign the given document and holds the same legal validity as a handwritten signature. By
completing the electronic signing process, you affirm your understanding and acceptance of
these conditions.
<span className="mt-2 block">
Read the full{' '}
<Link
className="text-documenso-700 underline"
href="/articles/signature-disclosure"
target="_blank"
>
signature disclosure
</Link>
.
</span>
</p>
);
};

View File

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

View File

@@ -24,7 +24,7 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk
className,
)}
>
<div className="border-border bg-background text-muted-foreground inline-block max-w-full truncate rounded-md border px-2.5 py-1.5 text-sm lowercase">
<div className="border-border bg-background text-muted-foreground inline-block max-w-full truncate rounded-md border px-2.5 py-1.5 text-sm">
{baseUrl.host}/u/{user.url}
</div>

View File

@@ -3,7 +3,7 @@
import * as React from 'react';
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) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;

View File

@@ -11,17 +11,6 @@ services:
ports:
- 54320:5432
queue:
image: postgres:15
container_name: queue
user: postgres
command: -c jit=off
environment:
POSTGRES_PASSWORD: password
POSTGRES_DB: queue
ports:
- 54321:5432
inbucket:
image: inbucket/inbucket
container_name: mailserver

259
package-lock.json generated
View File

@@ -42,7 +42,6 @@
"@documenso/trpc": "*",
"@documenso/ui": "*",
"@hookform/resolvers": "^3.1.0",
"@openstatus/react": "^0.0.3",
"contentlayer": "^0.3.4",
"framer-motion": "^10.12.8",
"lucide-react": "^0.279.0",
@@ -50,7 +49,6 @@
"micro": "^10.0.1",
"next": "14.0.3",
"next-auth": "4.24.5",
"next-axiom": "^1.1.1",
"next-contentlayer": "^0.3.4",
"next-plausible": "^3.10.1",
"perfect-freehand": "^1.2.0",
@@ -113,10 +111,8 @@
"micro": "^10.0.1",
"next": "14.0.3",
"next-auth": "4.24.5",
"next-axiom": "^1.1.1",
"next-plausible": "^3.10.1",
"next-themes": "^0.2.1",
"papaparse": "^5.4.1",
"perfect-freehand": "^1.2.0",
"posthog-js": "^1.75.3",
"posthog-node": "^3.1.1",
@@ -140,7 +136,6 @@
"@types/formidable": "^2.0.6",
"@types/luxon": "^3.3.1",
"@types/node": "20.1.0",
"@types/papaparse": "^5.3.14",
"@types/react": "18.2.18",
"@types/react-dom": "18.2.7",
"@types/ua-parser-js": "^0.7.39",
@@ -4143,14 +4138,6 @@
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
"integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw=="
},
"node_modules/@openstatus/react": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/@openstatus/react/-/react-0.0.3.tgz",
"integrity": "sha512-uDiegz7e3H67pG8lTT+op+6w5keTT7XpcENrREaqlWl5j53TYyO8nheOG1PeNw2/Qgd5KaGeRJJFn1crhTUSYw==",
"peerDependencies": {
"react": "^18.0.0"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.4.1.tgz",
@@ -8092,15 +8079,6 @@
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz",
"integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="
},
"node_modules/@types/papaparse": {
"version": "5.3.14",
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.14.tgz",
"integrity": "sha512-LxJ4iEFcpqc6METwp9f6BV6VVc43m6MfH0VqFosHvrUgfXiFe6ww7R3itkOQ+TCK6Y+Iv/+RnnvtRZnkc5Kc9g==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/parse5": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-6.0.3.tgz",
@@ -8443,18 +8421,6 @@
"node": ">= 6.0.0"
}
},
"node_modules/aggregate-error": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
"integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
"dependencies": {
"clean-stack": "^2.0.0",
"indent-string": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/ajv": {
"version": "8.12.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
@@ -9429,14 +9395,6 @@
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
},
"node_modules/clean-stack": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
"integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
"engines": {
"node": ">=6"
}
},
"node_modules/cli-cursor": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz",
@@ -10212,17 +10170,6 @@
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"devOptional": true
},
"node_modules/cron-parser": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
"dependencies": {
"luxon": "^3.2.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/cross-fetch": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
@@ -10589,17 +10536,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delay": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz",
"integrity": "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -13714,6 +13650,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -16731,22 +16668,6 @@
}
}
},
"node_modules/next-axiom": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/next-axiom/-/next-axiom-1.1.1.tgz",
"integrity": "sha512-0r/TJ+/zetD+uDc7B+2E7WpC86hEtQ1U+DuWYrP/JNmUz+ZdPFbrZgzOSqaZ6TwYbXP56VVlPfYwq1YsKHTHYQ==",
"dependencies": {
"remeda": "^1.29.0",
"whatwg-fetch": "^3.6.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"next": ">=13.4",
"react": ">=18.0.0"
}
},
"node_modules/next-contentlayer": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/next-contentlayer/-/next-contentlayer-0.3.4.tgz",
@@ -17291,20 +17212,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-map": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
"integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==",
"dependencies": {
"aggregate-error": "^3.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
@@ -17329,11 +17236,6 @@
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
},
"node_modules/papaparse": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz",
"integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw=="
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -17614,124 +17516,6 @@
"is-reference": "^3.0.0"
}
},
"node_modules/pg": {
"version": "8.11.5",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.11.5.tgz",
"integrity": "sha512-jqgNHSKL5cbDjFlHyYsCXmQDrfIX/3RsNwYqpd4N0Kt8niLuNoRNH+aazv6cOd43gPh9Y4DjQCtb+X0MH0Hvnw==",
"dependencies": {
"pg-connection-string": "^2.6.4",
"pg-pool": "^3.6.2",
"pg-protocol": "^1.6.1",
"pg-types": "^2.1.0",
"pgpass": "1.x"
},
"engines": {
"node": ">= 8.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.1.1"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-boss": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/pg-boss/-/pg-boss-9.0.3.tgz",
"integrity": "sha512-cUWUiv3sr563yNy0nCZ25Tv5U0m59Y9MhX/flm0vTR012yeVCrqpfboaZP4xFOQPdWipMJpuu4g94HR0SncTgw==",
"dependencies": {
"cron-parser": "^4.0.0",
"delay": "^5.0.0",
"lodash.debounce": "^4.0.8",
"p-map": "^4.0.0",
"pg": "^8.5.1",
"serialize-error": "^8.1.0",
"uuid": "^9.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/pg-boss/node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/pg-cloudflare": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz",
"integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.6.4",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz",
"integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA=="
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz",
"integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz",
"integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg=="
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/pgpass/node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/picocolors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
@@ -17990,41 +17774,6 @@
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/posthog-js": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.93.2.tgz",
@@ -23187,11 +22936,6 @@
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"node_modules/whatwg-fetch": {
"version": "3.6.20",
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
@@ -25134,7 +24878,6 @@
"next-auth": "4.24.5",
"oslo": "^0.17.0",
"pdf-lib": "^1.17.1",
"pg-boss": "^9.0.3",
"react": "18.2.0",
"remeda": "^1.27.1",
"stripe": "^12.7.0",

View File

@@ -1,54 +0,0 @@
import { expect, test } from '@playwright/test';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test('[COMMAND_MENU]: should see sent documents', async ({ page }) => {
const user = await seedUser();
const recipient = await seedUser();
const document = await seedPendingDocument(user, [recipient]);
await apiSignin({
page,
email: user.email,
});
await page.keyboard.press('Meta+K');
await page.getByPlaceholder('Type a command or search...').first().fill(document.title);
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
});
test('[COMMAND_MENU]: should see received documents', async ({ page }) => {
const user = await seedUser();
const recipient = await seedUser();
const document = await seedPendingDocument(user, [recipient]);
await apiSignin({
page,
email: recipient.email,
});
await page.keyboard.press('Meta+K');
await page.getByPlaceholder('Type a command or search...').first().fill(document.title);
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
});
test('[COMMAND_MENU]: should be able to search by recipient', async ({ page }) => {
const user = await seedUser();
const recipient = await seedUser();
const document = await seedPendingDocument(user, [recipient]);
await apiSignin({
page,
email: recipient.email,
});
await page.keyboard.press('Meta+K');
await page.getByPlaceholder('Type a command or search...').first().fill(recipient.email);
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
});

View File

@@ -71,6 +71,7 @@ test('[DOCUMENT_AUTH]: should allow or deny access when required', async ({ page
await apiSignin({
page,
email: recipientWithAccount.email,
redirectPath: '/',
});
// Check that the one logged in is granted access.

View File

@@ -14,7 +14,7 @@ import { seedTestEmail, seedUser, unseedUser } from '@documenso/prisma/seed/user
import { apiSignin, apiSignout } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
test.describe.configure({ mode: 'parallel' });
test('[DOCUMENT_AUTH]: should allow signing when no auth setup', async ({ page }) => {
const user = await seedUser();

View File

@@ -1,172 +0,0 @@
import { expect, test } from '@playwright/test';
import {
seedCompletedDocument,
seedDraftDocument,
seedPendingDocument,
} from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
test.describe.configure({ mode: 'serial' });
const seedDeleteDocumentsTestRequirements = async () => {
const [sender, recipientA, recipientB] = await Promise.all([seedUser(), seedUser(), seedUser()]);
const [draftDocument, pendingDocument, completedDocument] = await Promise.all([
seedDraftDocument(sender, [recipientA, recipientB], {
createDocumentOptions: { title: 'Document 1 - Draft' },
}),
seedPendingDocument(sender, [recipientA, recipientB], {
createDocumentOptions: { title: 'Document 1 - Pending' },
}),
seedCompletedDocument(sender, [recipientA, recipientB], {
createDocumentOptions: { title: 'Document 1 - Completed' },
}),
]);
return {
sender,
recipients: [recipientA, recipientB],
draftDocument,
pendingDocument,
completedDocument,
};
};
test('[DOCUMENTS]: seeded documents should be visible', async ({ page }) => {
const { sender, recipients } = await seedDeleteDocumentsTestRequirements();
await apiSignin({
page,
email: sender.email,
});
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).toBeVisible();
await apiSignout({ page });
for (const recipient of recipients) {
await apiSignin({
page,
email: recipient.email,
});
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).not.toBeVisible();
await apiSignout({ page });
}
});
test('[DOCUMENTS]: deleting a completed document should not remove it from recipients', async ({
page,
}) => {
const { sender, recipients } = await seedDeleteDocumentsTestRequirements();
await apiSignin({
page,
email: sender.email,
});
// open actions menu
await page
.locator('tr', { hasText: 'Document 1 - Completed' })
.getByRole('cell', { name: 'Download' })
.getByRole('button')
.nth(1)
.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 - Completed/ })).not.toBeVisible();
await apiSignout({ page });
for (const recipient of recipients) {
await apiSignin({
page,
email: recipient.email,
});
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
await page.getByRole('link', { name: 'Document 1 - Completed' }).click();
await expect(page.getByText('Everyone has signed').nth(0)).toBeVisible();
await apiSignout({ page });
}
});
test('[DOCUMENTS]: deleting a pending document should remove it from recipients', async ({
page,
}) => {
const { sender, pendingDocument } = await seedDeleteDocumentsTestRequirements();
await apiSignin({
page,
email: sender.email,
});
// open actions menu
await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).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();
// signout
await apiSignout({ page });
for (const recipient of pendingDocument.Recipient) {
await apiSignin({
page,
email: recipient.email,
});
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 });
}
});
test('[DOCUMENTS]: deleting a draft document should remove it without additional prompting', async ({
page,
}) => {
const { sender } = await seedDeleteDocumentsTestRequirements();
await apiSignin({
page,
email: sender.email,
});
// open actions menu
await page
.locator('tr', { hasText: 'Document 1 - Draft' })
.getByRole('cell', { name: 'Edit' })
.getByRole('button')
.click();
// delete document
await page.getByRole('menuitem', { name: 'Delete' }).click();
await expect(page.getByPlaceholder("Type 'delete' to confirm")).not.toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
});

View File

@@ -13,11 +13,38 @@ type LoginOptions = {
redirectPath?: string;
};
export const manualLogin = async ({
page,
email = 'example@documenso.com',
password = 'password',
redirectPath,
}: LoginOptions) => {
await page.goto(`${WEBAPP_BASE_URL}/signin`);
await page.getByLabel('Email').click();
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password', { exact: true }).fill(password);
await page.getByLabel('Password', { exact: true }).press('Enter');
if (redirectPath) {
await page.waitForURL(`${WEBAPP_BASE_URL}/documents`);
await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`);
}
};
export const manualSignout = async ({ page }: LoginOptions) => {
await page.waitForTimeout(1000);
await page.getByTestId('menu-switcher').click();
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
await page.waitForURL(`${WEBAPP_BASE_URL}/signin`);
};
export const apiSignin = async ({
page,
email = 'example@documenso.com',
password = 'password',
redirectPath = '/documents',
redirectPath = '/',
}: LoginOptions) => {
const { request } = page.context();
@@ -32,7 +59,9 @@ export const apiSignin = async ({
},
});
await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`);
if (redirectPath) {
await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`);
}
};
export const apiSignout = async ({ page }: { page: Page }) => {

View File

@@ -0,0 +1,159 @@
import { expect, test } from '@playwright/test';
import { TEST_USERS } from '@documenso/prisma/seed/pr-711-deletion-of-documents';
import { manualLogin, manualSignout } from './fixtures/authentication';
test.describe.configure({ mode: 'serial' });
test('[PR-711]: seeded documents should be visible', async ({ page }) => {
const [sender, ...recipients] = TEST_USERS;
await page.goto('/signin');
await page.getByLabel('Email').fill(sender.email);
await page.getByLabel('Password', { exact: true }).fill(sender.password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).toBeVisible();
await manualSignout({ page });
for (const recipient of recipients) {
await page.waitForURL('/signin');
await manualLogin({ page, email: recipient.email, password: recipient.password });
await page.waitForURL('/documents');
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).not.toBeVisible();
await manualSignout({ page });
}
});
test('[PR-711]: deleting a completed document should not remove it from recipients', async ({
page,
}) => {
const [sender, ...recipients] = TEST_USERS;
await page.goto('/signin');
// sign in
await page.getByLabel('Email').fill(sender.email);
await page.getByLabel('Password', { exact: true }).fill(sender.password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
// open actions menu
await page
.locator('tr', { hasText: 'Document 1 - Completed' })
.getByRole('cell', { name: 'Download' })
.getByRole('button')
.nth(1)
.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 - Completed/ })).not.toBeVisible();
await manualSignout({ page });
for (const recipient of recipients) {
await page.waitForURL('/signin');
await page.goto('/signin');
// sign in
await page.getByLabel('Email').fill(recipient.email);
await page.getByLabel('Password', { exact: true }).fill(recipient.password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
await page.goto(`/sign/completed-token-${recipients.indexOf(recipient)}`);
await expect(page.getByText('Everyone has signed').nth(0)).toBeVisible();
await page.goto('/documents');
await manualSignout({ page });
}
});
test('[PR-711]: deleting a pending document should remove it from recipients', async ({ page }) => {
const [sender, ...recipients] = TEST_USERS;
for (const recipient of recipients) {
await page.goto(`/sign/pending-token-${recipients.indexOf(recipient)}`);
await expect(page.getByText('Waiting for others to sign').nth(0)).toBeVisible();
}
await page.goto('/signin');
await manualLogin({ page, email: sender.email, password: sender.password });
await page.waitForURL('/documents');
// open actions menu
await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).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();
// signout
await manualSignout({ page });
for (const recipient of recipients) {
await page.waitForURL('/signin');
await manualLogin({ page, email: recipient.email, password: recipient.password });
await page.waitForURL('/documents');
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible();
await page.goto(`/sign/pending-token-${recipients.indexOf(recipient)}`);
await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible();
await page.goto('/documents');
await page.waitForURL('/documents');
await manualSignout({ page });
}
});
test('[PR-711]: deleting a draft document should remove it without additional prompting', async ({
page,
}) => {
const [sender] = TEST_USERS;
await manualLogin({ page, email: sender.email, password: sender.password });
await page.waitForURL('/documents');
// open actions menu
await page
.locator('tr', { hasText: 'Document 1 - Draft' })
.getByRole('cell', { name: 'Edit' })
.getByRole('button')
.click();
// delete document
await page.getByRole('menuitem', { name: 'Delete' }).click();
await expect(page.getByPlaceholder("Type 'delete' to confirm")).not.toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
});

View File

@@ -0,0 +1,54 @@
import { expect, test } from '@playwright/test';
import { TEST_USERS } from '@documenso/prisma/seed/pr-713-add-document-search-to-command-menu';
test('[PR-713]: should see sent documents', async ({ page }) => {
const [user] = TEST_USERS;
await page.goto('/signin');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
await page.keyboard.press('Meta+K');
await page.getByPlaceholder('Type a command or search...').first().fill('sent');
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
});
test('[PR-713]: should see received documents', async ({ page }) => {
const [user] = TEST_USERS;
await page.goto('/signin');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
await page.keyboard.press('Meta+K');
await page.getByPlaceholder('Type a command or search...').first().fill('received');
await expect(page.getByRole('option', { name: '[713] Document - Received' })).toBeVisible();
});
test('[PR-713]: should be able to search by recipient', async ({ page }) => {
const [user, recipient] = TEST_USERS;
await page.goto('/signin');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
await page.keyboard.press('Meta+K');
await page.getByPlaceholder('Type a command or search...').first().fill(recipient.email);
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
});

View File

@@ -1,29 +1,18 @@
import { expect, test } from '@playwright/test';
import path from 'node:path';
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { apiSignin } from './fixtures/authentication';
// Can't use the function in server-only/document due to it indirectly using
// require imports.
const getDocumentByToken = async (token: string) => {
return await prisma.document.findFirstOrThrow({
where: {
Recipient: {
some: {
token,
},
},
},
});
};
test(`[PR-718]: should be able to create a document`, async ({ page }) => {
await page.goto('/signin');
const documentTitle = `example-${Date.now()}.pdf`;
test('[DOCUMENT_FLOW]: should be able to upload a PDF document', async ({ page }) => {
const user = await seedUser();
await apiSignin({
@@ -31,7 +20,7 @@ test('[DOCUMENT_FLOW]: should be able to upload a PDF document', async ({ page }
email: user.email,
});
// Upload document.
// Upload document
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.locator('input[type=file]').evaluate((e) => {
@@ -41,23 +30,10 @@ test('[DOCUMENT_FLOW]: should be able to upload a PDF document', async ({ page }
}),
]);
await fileChooser.setFiles(path.join(__dirname, '../../../../assets/example.pdf'));
await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf'));
// Wait to be redirected to the edit page.
// Wait to be redirected to the edit page
await page.waitForURL(/\/documents\/\d+/);
});
test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
const documentTitle = `example-${Date.now()}.pdf`;
// Set general settings
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
@@ -103,23 +79,34 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
// Assert document was created
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipients', async ({
page,
}) => {
test('should be able to create a document with multiple recipients', async ({ page }) => {
await page.goto('/signin');
const documentTitle = `example-${Date.now()}.pdf`;
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
const documentTitle = `example-${Date.now()}.pdf`;
// Upload document
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.locator('input[type=file]').evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
]);
await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf'));
// Wait to be redirected to the edit page
await page.waitForURL(/\/documents\/\d+/);
// Set title
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
@@ -188,21 +175,34 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
// Assert document was created
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', async ({ page }) => {
test('should be able to create, send and sign a document', async ({ page }) => {
await page.goto('/signin');
const documentTitle = `example-${Date.now()}.pdf`;
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
const documentTitle = `example-${Date.now()}.pdf`;
// Upload document
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.locator('input[type=file]').evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
]);
await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf'));
// Wait to be redirected to the edit page
await page.waitForURL(/\/documents\/\d+/);
// Set title
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
@@ -246,36 +246,49 @@ test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', asyn
await page.waitForURL(`/sign/${token}`);
// Check if document has been viewed
const { status } = await getDocumentByToken(token);
const { status } = await getDocumentByToken({ token });
expect(status).toBe(DocumentStatus.PENDING);
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('dialog').getByText('Complete Signing').first()).toBeVisible();
await expect(page.getByRole('dialog').getByText('Sign Document')).toBeVisible();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`/sign/${token}/complete`);
await expect(page.getByText('You have signed')).toBeVisible();
// Check if document has been signed
const { status: completedStatus } = await getDocumentByToken(token);
const { status: completedStatus } = await getDocumentByToken({ token });
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a document and redirect to redirect url', async ({
test('should be able to create, send with redirect url, sign a document and redirect to redirect url', async ({
page,
}) => {
await page.goto('/signin');
const documentTitle = `example-${Date.now()}.pdf`;
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
const documentTitle = `example-${Date.now()}.pdf`;
// Upload document
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.locator('input[type=file]').evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
]);
await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf'));
// Wait to be redirected to the edit page
await page.waitForURL(/\/documents\/\d+/);
// Set title & advanced redirect
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
@@ -318,18 +331,16 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
await page.waitForURL(`/sign/${token}`);
// Check if document has been viewed
const { status } = await getDocumentByToken(token);
const { status } = await getDocumentByToken({ token });
expect(status).toBe(DocumentStatus.PENDING);
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('dialog').getByText('Complete Signing').first()).toBeVisible();
await expect(page.getByRole('dialog').getByText('Sign Document')).toBeVisible();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL('https://documenso.com');
// Check if document has been signed
const { status: completedStatus } = await getDocumentByToken(token);
const { status: completedStatus } = await getDocumentByToken({ token });
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
await unseedUser(user.id);
});

View File

@@ -11,11 +11,6 @@ test.describe.configure({ mode: 'parallel' });
test('[TEAMS]: create team', async ({ page }) => {
const user = await seedUser();
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true',
'Test skipped because billing is enabled.',
);
await apiSignin({
page,
email: user.email,
@@ -31,6 +26,9 @@ test('[TEAMS]: create team', async ({ page }) => {
await page.getByTestId('dialog-create-team-button').waitFor({ state: 'hidden' });
const isCheckoutRequired = page.url().includes('pending');
test.skip(isCheckoutRequired, 'Test skipped because billing is enabled.');
// Goto new team settings page.
await page.getByRole('row').filter({ hasText: teamId }).getByRole('link').nth(1).click();

View File

@@ -108,7 +108,7 @@ test('[TEMPLATES]: delete template', async ({ page }) => {
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('Template deleted').first()).toBeVisible();
await page.reload();
await page.waitForTimeout(1000);
}
await unseedTeam(team.url);

View File

@@ -2,7 +2,6 @@ import { type Page, expect, test } from '@playwright/test';
import {
extractUserVerificationToken,
seedTestEmail,
seedUser,
unseedUser,
unseedUserByEmail,
@@ -10,9 +9,9 @@ import {
test.use({ storageState: { cookies: [], origins: [] } });
test('[USER] can sign up with email and password', async ({ page }: { page: Page }) => {
test('user can sign up with email and password', async ({ page }: { page: Page }) => {
const username = 'Test User';
const email = seedTestEmail();
const email = `test-user-${Date.now()}@auth-flow.documenso.com`;
const password = 'Password123#';
await page.goto('/signup');
@@ -31,7 +30,7 @@ test('[USER] can sign up with email and password', async ({ page }: { page: Page
}
await page.getByRole('button', { name: 'Next', exact: true }).click();
await page.getByLabel('Public profile username').fill(Date.now().toString());
await page.getByLabel('Public profile username').fill('username-123');
await page.getByRole('button', { name: 'Complete', exact: true }).click();
@@ -51,7 +50,7 @@ test('[USER] can sign up with email and password', async ({ page }: { page: Page
await unseedUserByEmail(email);
});
test('[USER] can sign in using email and password', async ({ page }: { page: Page }) => {
test('user can login with user and password', async ({ page }: { page: Page }) => {
const user = await seedUser();
await page.goto('/signin');

View File

@@ -4,16 +4,19 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { manualLogin } from './fixtures/authentication';
test('[USER] delete account', async ({ page }) => {
test('delete user', async ({ page }) => {
const user = await seedUser();
await apiSignin({ page, email: user.email, redirectPath: '/settings' });
await manualLogin({
page,
email: user.email,
redirectPath: '/settings',
});
await page.getByRole('button', { name: 'Delete Account' }).click();
await page.getByLabel('Confirm Email').fill(user.email);
await expect(page.getByRole('button', { name: 'Confirm Deletion' })).not.toBeDisabled();
await page.getByRole('button', { name: 'Confirm Deletion' }).click();

View File

@@ -3,12 +3,16 @@ import { expect, test } from '@playwright/test';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { manualLogin } from './fixtures/authentication';
test('[USER] update full name', async ({ page }) => {
test('update user name', async ({ page }) => {
const user = await seedUser();
await apiSignin({ page, email: user.email, redirectPath: '/settings/profile' });
await manualLogin({
page,
email: user.email,
redirectPath: '/settings/profile',
});
await page.getByLabel('Full Name').fill('John Doe');

View File

@@ -17,11 +17,12 @@ export default defineConfig({
testDir: './e2e',
/* Run tests in files in parallel */
fullyParallel: true,
workers: '50%',
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 1,
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */

View File

@@ -1,4 +1,3 @@
import type { SendMailOptions } from 'nodemailer';
import { createTransport } from 'nodemailer';
import { ResendTransport } from '@documenso/nodemailer-resend';
@@ -55,4 +54,3 @@ const getTransport = () => {
};
export const mailer = getTransport();
export type MailOptions = SendMailOptions;

View File

@@ -1,45 +0,0 @@
import { Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
export interface TemplateDocumentDeleteProps {
reason: string;
documentName: string;
assetBaseUrl: string;
}
export const TemplateDocumentDelete = ({
reason,
documentName,
assetBaseUrl,
}: TemplateDocumentDeleteProps) => {
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="text-primary mb-0 mt-6 text-left text-lg font-semibold">
Your document has been deleted by an admin!
</Text>
<Text className="mx-auto mb-6 mt-1 text-left text-base text-slate-400">
"{documentName}" has been deleted by an admin.
</Text>
<Text className="mx-auto mb-6 mt-1 text-left text-base text-slate-400">
This document can not be recovered, if you would like to dispute the reason for future
documents please contact support.
</Text>
<Text className="mx-auto mt-1 text-left text-base text-slate-400">
The reason provided for deletion is the following:
</Text>
<Text className="mx-auto mb-6 mt-1 text-left text-base italic text-slate-400">
{reason}
</Text>
</Section>
</>
);
};
export default TemplateDocumentDelete;

View File

@@ -1,66 +0,0 @@
import config from '@documenso/tailwind-config';
import { Body, Container, Head, Hr, Html, Img, Preview, Section, Tailwind } from '../components';
import {
TemplateDocumentDelete,
type TemplateDocumentDeleteProps,
} from '../template-components/template-document-super-delete';
import { TemplateFooter } from '../template-components/template-footer';
export type DocumentDeleteEmailTemplateProps = Partial<TemplateDocumentDeleteProps>;
export const DocumentSuperDeleteEmailTemplate = ({
documentName = 'Open Source Pledge.pdf',
assetBaseUrl = 'http://localhost:3002',
reason = 'Unknown',
}: DocumentDeleteEmailTemplateProps) => {
const previewText = `An admin has deleted your document "${documentName}".`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<Body className="mx-auto my-auto bg-white font-sans">
<Section>
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
<Section>
<Img
src={getAssetUrl('/static/logo.png')}
alt="Documenso Logo"
className="mb-4 h-6"
/>
<TemplateDocumentDelete
reason={reason}
documentName={documentName}
assetBaseUrl={assetBaseUrl}
/>
</Section>
</Container>
<Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter />
</Container>
</Section>
</Body>
</Tailwind>
</Html>
);
};
export default DocumentSuperDeleteEmailTemplate;

View File

@@ -27,19 +27,18 @@
"@next-auth/prisma-adapter": "1.0.7",
"@noble/ciphers": "0.4.0",
"@noble/hashes": "1.3.2",
"@node-rs/bcrypt": "^1.10.0",
"@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1",
"@upstash/redis": "^1.20.6",
"@vvo/tzdb": "^6.117.0",
"@node-rs/bcrypt": "^1.10.0",
"luxon": "^3.4.0",
"nanoid": "^4.0.2",
"next": "14.0.3",
"next-auth": "4.24.5",
"oslo": "^0.17.0",
"pdf-lib": "^1.17.1",
"pg-boss": "^9.0.3",
"react": "18.2.0",
"remeda": "^1.27.1",
"stripe": "^12.7.0",

View File

@@ -1,4 +1,4 @@
import type { User } from '@documenso/prisma/client';
import { User } from '@documenso/prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
@@ -9,5 +9,9 @@ type IsTwoFactorAuthenticationEnabledOptions = {
export const isTwoFactorAuthenticationEnabled = ({
user,
}: IsTwoFactorAuthenticationEnabledOptions) => {
return user.twoFactorEnabled && typeof DOCUMENSO_ENCRYPTION_KEY === 'string';
return (
user.twoFactorEnabled &&
user.identityProvider === 'DOCUMENSO' &&
typeof DOCUMENSO_ENCRYPTION_KEY === 'string'
);
};

View File

@@ -2,11 +2,12 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { diffDocumentMetaChanges } from '@documenso/lib/utils/document-audit-logs';
import {
createDocumentAuditLogData,
diffDocumentMetaChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { queueJob } from '../queue/job';
export type CreateDocumentMetaOptions = {
documentId: number;
subject?: string;
@@ -64,45 +65,46 @@ export const upsertDocumentMeta = async ({
},
});
const upsertedDocumentMeta = await prisma.documentMeta.upsert({
where: {
documentId,
},
create: {
subject,
message,
password,
dateFormat,
timezone,
documentId,
redirectUrl,
},
update: {
subject,
message,
password,
dateFormat,
timezone,
redirectUrl,
},
});
const changes = diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta);
if (changes.length > 0) {
await queueJob({
job: 'create-document-audit-log',
args: {
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED,
return await prisma.$transaction(async (tx) => {
const upsertedDocumentMeta = await tx.documentMeta.upsert({
where: {
documentId,
user,
requestMetadata,
data: {
changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta),
},
},
create: {
subject,
message,
password,
dateFormat,
timezone,
documentId,
redirectUrl,
},
update: {
subject,
message,
password,
dateFormat,
timezone,
redirectUrl,
},
});
}
return upsertedDocumentMeta;
const changes = diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta);
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED,
documentId,
user,
requestMetadata,
data: {
changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta),
},
}),
});
}
return upsertedDocumentMeta;
});
};

View File

@@ -2,14 +2,15 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import type { TRecipientActionAuth } from '../../types/document-auth';
import { queueJob } from '../queue/job';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sealDocument } from './seal-document';
import { sendPendingEmail } from './send-pending-email';
export type CompleteDocumentWithTokenOptions = {
token: string;
@@ -92,34 +93,35 @@ export const completeDocumentWithToken = async ({
// throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
// }
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
signingStatus: SigningStatus.SIGNED,
signedAt: new Date(),
},
});
await queueJob({
job: 'create-document-audit-log',
args: {
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
documentId: document.id,
user: {
name: recipient.name,
email: recipient.email,
await prisma.$transaction(async (tx) => {
await tx.recipient.update({
where: {
id: recipient.id,
},
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
// actionAuth: derivedRecipientActionAuth || undefined,
signingStatus: SigningStatus.SIGNED,
signedAt: new Date(),
},
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
documentId: document.id,
user: {
name: recipient.name,
email: recipient.email,
},
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
// actionAuth: derivedRecipientActionAuth || undefined,
},
}),
});
});
const pendingRecipients = await prisma.recipient.count({
@@ -132,13 +134,7 @@ export const completeDocumentWithToken = async ({
});
if (pendingRecipients > 0) {
await queueJob({
job: 'send-pending-email',
args: {
documentId: document.id,
recipientId: recipient.id,
},
});
await sendPendingEmail({ documentId, recipientId: recipient.id });
}
const documents = await prisma.document.updateMany({

View File

@@ -3,10 +3,10 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { queueJob } from '../queue/job';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentOptions = {
@@ -44,34 +44,35 @@ export const createDocument = async ({
throw new AppError(AppErrorCode.NOT_FOUND, 'Team not found');
}
const document = await prisma.document.create({
data: {
title,
documentDataId,
userId,
teamId,
},
});
await queueJob({
job: 'create-document-audit-log',
args: {
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id,
user,
requestMetadata,
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
title,
documentDataId,
userId,
teamId,
},
},
});
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: document,
userId,
teamId,
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id,
user,
requestMetadata,
data: {
title,
},
}),
});
return document;
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: document,
userId,
teamId,
});
return document;
});
};

View File

@@ -2,6 +2,7 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
@@ -11,7 +12,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { queueJob } from '../queue/job';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type DeleteDocumentOptions = {
id: number;
@@ -60,22 +61,23 @@ export const deleteDocument = async ({
// if the document is a draft, hard-delete
if (status === DocumentStatus.DRAFT) {
// Currently redundant since deleting a document will delete the audit logs.
// However may be useful if we disassociate audit lgos and documents if required.
await queueJob({
job: 'create-document-audit-log',
args: {
documentId: id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user,
requestMetadata,
data: {
type: 'HARD',
},
},
});
return await prisma.$transaction(async (tx) => {
// Currently redundant since deleting a document will delete the audit logs.
// However may be useful if we disassociate audit lgos and documents if required.
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
documentId: id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user,
requestMetadata,
data: {
type: 'HARD',
},
}),
});
return await prisma.document.delete({ where: { id, status: DocumentStatus.DRAFT } });
return await tx.document.delete({ where: { id, status: DocumentStatus.DRAFT } });
});
}
// if the document is pending, send cancellation emails to all recipients
@@ -91,46 +93,44 @@ export const deleteDocument = async ({
assetBaseUrl,
});
await queueJob({
job: 'send-mail',
args: {
to: {
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document Cancelled',
html: render(template),
text: render(template, { plainText: true }),
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document Cancelled',
html: render(template),
text: render(template, { plainText: true }),
});
}),
);
}
// If the document is not a draft, only soft-delete.
await queueJob({
job: 'create-document-audit-log',
args: {
documentId: id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user,
requestMetadata,
data: {
type: 'SOFT',
},
},
});
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 prisma.document.update({
where: {
id,
},
data: {
deletedAt: new Date().toISOString(),
},
return await tx.document.update({
where: {
id,
},
data: {
deletedAt: new Date().toISOString(),
},
});
});
};

View File

@@ -1,5 +1,6 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
@@ -9,13 +10,13 @@ import {
} from '@documenso/lib/constants/recipient-roles';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { queueJob } from '../queue/job';
import { getDocumentWhereInput } from './get-document-by-id';
export type ResendDocumentOptions = {
@@ -109,42 +110,43 @@ export const resendDocument = async ({
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
await queueJob({
job: 'send-mail',
args: {
to: {
address: email,
name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: `Please ${actionVerb.toLowerCase()} this document`,
html: render(template),
text: render(template, { plainText: true }),
},
});
await prisma.$transaction(
async (tx) => {
await mailer.sendMail({
to: {
address: email,
name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: `Please ${actionVerb.toLowerCase()} this document`,
html: render(template),
text: render(template, { plainText: true }),
});
await queueJob({
job: 'create-document-audit-log',
args: {
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: true,
},
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: true,
},
}),
});
},
});
{ timeout: 30_000 },
);
}),
);
};

View File

@@ -2,23 +2,22 @@
import { nanoid } from 'nanoid';
import path from 'node:path';
import { PDFDocument } from 'pdf-lib';
import { PDFDocument, PDFSignature, rectangle } from 'pdf-lib';
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file';
import { flattenAnnotations } from '../pdf/flatten-annotations';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances';
import { queueJob } from '../queue/job';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sendCompletedEmail } from './send-completed-email';
export type SealDocumentOptions = {
documentId: number;
@@ -92,10 +91,31 @@ export const sealDocument = async ({
const doc = await PDFDocument.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature
normalizeSignatureAppearances(doc);
doc.getForm().flatten();
flattenAnnotations(doc);
const form = doc.getForm();
// Remove old signatures
for (const field of form.getFields()) {
if (field instanceof PDFSignature) {
field.acroField.getWidgets().forEach((widget) => {
widget.ensureAP();
try {
widget.getNormalAppearance();
} catch (e) {
const { context } = widget.dict;
const xobj = context.formXObject([rectangle(0, 0, 0, 0)]);
const streamRef = context.register(xobj);
widget.setNormalAppearance(streamRef);
}
});
}
}
// Flatten the form to stop annotation layers from appearing above documenso fields
form.flatten();
for (const field of fields) {
await insertFieldInPDF(doc, field);
@@ -125,33 +145,31 @@ export const sealDocument = async ({
});
}
await prisma.documentData.update({
where: {
id: documentData.id,
},
data: {
data: newData,
},
});
await queueJob({
job: 'create-document-audit-log',
args: {
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
documentId: document.id,
requestMetadata,
user: null,
data: {
transactionId: nanoid(),
await prisma.$transaction(async (tx) => {
await tx.documentData.update({
where: {
id: documentData.id,
},
},
data: {
data: newData,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
documentId: document.id,
requestMetadata,
user: null,
data: {
transactionId: nanoid(),
},
}),
});
});
if (sendEmail && !isResealing) {
await queueJob({
job: 'send-completed-email',
args: { documentId, requestMetadata },
});
await sendCompletedEmail({ documentId, requestMetadata });
}
await triggerWebhook({

View File

@@ -9,7 +9,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file';
import { queueJob } from '../queue/job';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export interface SendDocumentOptions {
documentId: number;
@@ -86,9 +86,8 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
],
});
await queueJob({
job: 'create-document-audit-log',
args: {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user: null,
@@ -101,7 +100,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
recipientRole: 'OWNER',
isResending: false,
},
},
}),
});
}
@@ -137,9 +136,8 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
],
});
await queueJob({
job: 'create-document-audit-log',
args: {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user: null,
@@ -152,7 +150,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
recipientRole: recipient.role,
isResending: false,
},
},
}),
});
}),
);

View File

@@ -1,52 +0,0 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentSuperDeleteEmailTemplate } from '@documenso/email/templates/document-super-delete';
import { prisma } from '@documenso/prisma';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
export interface SendDeleteEmailOptions {
documentId: number;
reason: string;
}
export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOptions) => {
const document = await prisma.document.findFirst({
where: {
id: documentId,
},
include: {
User: true,
},
});
if (!document) {
throw new Error('Document not found');
}
const { email, name } = document.User;
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentSuperDeleteEmailTemplate, {
documentName: document.title,
reason,
assetBaseUrl,
});
await mailer.sendMail({
to: {
address: email,
name: name || '',
},
from: {
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Document Deleted!',
html: render(template),
text: render(template, { plainText: true }),
});
};

View File

@@ -6,6 +6,7 @@ import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
@@ -16,7 +17,6 @@ import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '../../constants/recipient-roles';
import { queueJob } from '../queue/job';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type SendDocumentOptions = {
@@ -113,75 +113,79 @@ export const sendDocument = async ({
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
// TODO: Move this to a seperate queue of it's own
await mailer.sendMail({
to: {
address: email,
name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: `Please ${actionVerb.toLowerCase()} this document`,
html: render(template),
text: render(template, { plainText: true }),
});
await prisma.$transaction(
async (tx) => {
await mailer.sendMail({
to: {
address: email,
name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: `Please ${actionVerb.toLowerCase()} this document`,
html: render(template),
text: render(template, { plainText: true }),
});
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
sendStatus: SendStatus.SENT,
},
});
await tx.recipient.update({
where: {
id: recipient.id,
},
data: {
sendStatus: SendStatus.SENT,
},
});
await queueJob({
job: 'create-document-audit-log',
args: {
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: false,
},
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: false,
},
}),
});
},
});
{ timeout: 30_000 },
);
}),
);
if (document.status === DocumentStatus.DRAFT) {
await queueJob({
job: 'create-document-audit-log',
args: {
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
documentId: document.id,
requestMetadata,
user,
data: {},
const updatedDocument = await prisma.$transaction(async (tx) => {
if (document.status === DocumentStatus.DRAFT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
documentId: document.id,
requestMetadata,
user,
data: {},
}),
});
}
return await tx.document.update({
where: {
id: documentId,
},
data: {
status: DocumentStatus.PENDING,
},
include: {
Recipient: true,
},
});
}
const updatedDocument = await prisma.document.update({
where: {
id: documentId,
},
data: {
status: DocumentStatus.PENDING,
},
include: {
Recipient: true,
},
});
await triggerWebhook({

View File

@@ -1,86 +0,0 @@
'use server';
import { createElement } from 'react';
import { render } from '@documenso/email/render';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { queueJob } from '../queue/job';
export type SuperDeleteDocumentOptions = {
id: number;
requestMetadata?: RequestMetadata;
};
export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDocumentOptions) => {
const document = await prisma.document.findUnique({
where: {
id,
},
include: {
Recipient: true,
documentMeta: true,
User: true,
},
});
if (!document) {
throw new Error('Document not found');
}
const { status, User: user } = document;
// if the document is pending, send cancellation emails to all recipients
if (status === DocumentStatus.PENDING && document.Recipient.length > 0) {
await Promise.all(
document.Recipient.map(async (recipient) => {
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentCancelTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
});
await queueJob({
job: 'send-mail',
args: {
to: {
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document Cancelled',
html: render(template),
text: render(template, { plainText: true }),
},
});
}),
);
}
await queueJob({
job: 'create-document-audit-log',
args: {
documentId: id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user,
requestMetadata,
data: {
type: 'HARD',
},
},
});
// always hard delete if deleted from admin
return await prisma.document.delete({ where: { id } });
};

View File

@@ -2,10 +2,9 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { queueJob } from '../queue/job';
export type UpdateTitleOptions = {
userId: number;
teamId?: number;
@@ -52,32 +51,33 @@ export const updateTitle = async ({
return document;
}
// Instead of doing everything in a transaction we can use our knowledge
// of the current document title to ensure we aren't performing a conflicting
// update.
const updatedDocument = await prisma.document.update({
where: {
id: documentId,
title: document.title,
},
data: {
title,
},
});
await queueJob({
job: 'create-document-audit-log',
args: {
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED,
documentId,
user,
requestMetadata,
data: {
from: document.title,
to: updatedDocument.title,
return await prisma.$transaction(async (tx) => {
// Instead of doing everything in a transaction we can use our knowledge
// of the current document title to ensure we aren't performing a conflicting
// update.
const updatedDocument = await tx.document.update({
where: {
id: documentId,
title: document.title,
},
},
});
data: {
title,
},
});
return updatedDocument;
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED,
documentId,
user,
requestMetadata,
data: {
from: document.title,
to: updatedDocument.title,
},
}),
});
return updatedDocument;
});
};

View File

@@ -1,11 +1,11 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { ReadStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import type { TDocumentAccessAuthTypes } from '../../types/document-auth';
import { queueJob } from '../queue/job';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getDocumentAndRecipientByToken } from './get-document-by-token';
@@ -33,33 +33,34 @@ export const viewedDocument = async ({
const { documentId } = recipient;
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
readStatus: ReadStatus.OPENED,
},
});
await queueJob({
job: 'create-document-audit-log',
args: {
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
documentId,
user: {
name: recipient.name,
email: recipient.email,
await prisma.$transaction(async (tx) => {
await tx.recipient.update({
where: {
id: recipient.id,
},
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientId: recipient.id,
recipientName: recipient.name,
recipientRole: recipient.role,
accessAuth: recipientAccessAuth || undefined,
readStatus: ReadStatus.OPENED,
},
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
documentId,
user: {
name: recipient.name,
email: recipient.email,
},
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientId: recipient.id,
recipientName: recipient.name,
recipientRole: recipient.role,
accessAuth: recipientAccessAuth || undefined,
},
}),
});
});
const document = await getDocumentAndRecipientByToken({ token, requireAccessAuth: false });

View File

@@ -2,7 +2,7 @@ import { prisma } from '@documenso/prisma';
import type { FieldType, Team } from '@documenso/prisma/client';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { queueJob } from '../queue/job';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type CreateFieldOptions = {
documentId: number;
@@ -103,9 +103,8 @@ export const createField = async ({
},
});
await queueJob({
job: 'create-document-audit-log',
args: {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: 'FIELD_CREATED',
documentId,
user: {
@@ -120,7 +119,7 @@ export const createField = async ({
fieldType: field.type,
},
requestMetadata,
},
}),
});
return field;

View File

@@ -2,7 +2,7 @@ import { prisma } from '@documenso/prisma';
import type { Team } from '@documenso/prisma/client';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { queueJob } from '../queue/job';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type DeleteFieldOptions = {
fieldId: number;
@@ -67,9 +67,8 @@ export const deleteField = async ({
});
}
await queueJob({
job: 'create-document-audit-log',
args: {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: 'FIELD_DELETED',
documentId,
user: {
@@ -84,7 +83,7 @@ export const deleteField = async ({
fieldType: field.type,
},
requestMetadata,
},
}),
});
return field;

View File

@@ -2,11 +2,10 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { queueJob } from '../queue/job';
export type RemovedSignedFieldWithTokenOptions = {
token: string;
fieldId: number;
@@ -66,22 +65,21 @@ export const removeSignedFieldWithToken = async ({
fieldId: field.id,
},
});
});
await queueJob({
job: 'create-document-audit-log',
args: {
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
documentId: document.id,
user: {
name: recipient?.name,
email: recipient?.email,
},
requestMetadata,
data: {
field: field.type,
fieldId: field.secondaryId,
},
},
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
documentId: document.id,
user: {
name: recipient?.name,
email: recipient?.email,
},
requestMetadata,
data: {
field: field.type,
fieldId: field.secondaryId,
},
}),
});
});
};

View File

@@ -8,8 +8,6 @@ import { prisma } from '@documenso/prisma';
import type { Field, FieldType } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { queueJob } from '../queue/job';
export interface SetFieldsForDocumentOptions {
userId: number;
documentId: number;
@@ -157,9 +155,8 @@ export const setFieldsForDocument = async ({
// Handle field updated audit log.
if (field._persisted && changes.length > 0) {
await queueJob({
job: 'create-document-audit-log',
args: {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
documentId: documentId,
user,
@@ -168,15 +165,14 @@ export const setFieldsForDocument = async ({
changes,
...baseAuditLog,
},
},
}),
});
}
// Handle field created audit log.
if (!field._persisted) {
await queueJob({
job: 'create-document-audit-log',
args: {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
documentId: documentId,
user,
@@ -184,7 +180,7 @@ export const setFieldsForDocument = async ({
data: {
...baseAuditLog,
},
},
}),
});
}

View File

@@ -12,9 +12,9 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuth } from '../../types/document-auth';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
import { queueJob } from '../queue/job';
export type SignFieldWithTokenOptions = {
token: string;
@@ -168,9 +168,8 @@ export const signFieldWithToken = async ({
});
}
await queueJob({
job: 'create-document-audit-log',
args: {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
documentId: document.id,
user: {
@@ -200,7 +199,7 @@ export const signFieldWithToken = async ({
}
: undefined,
},
},
}),
});
return updatedField;

View File

@@ -3,8 +3,7 @@ import type { FieldType, Team } from '@documenso/prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { diffFieldChanges } from '../../utils/document-audit-logs';
import { queueJob } from '../queue/job';
import { createDocumentAuditLogData, diffFieldChanges } from '../../utils/document-audit-logs';
export type UpdateFieldOptions = {
fieldId: number;
@@ -78,9 +77,8 @@ export const updateField = async ({
},
});
await queueJob({
job: 'create-document-audit-log',
args: {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
documentId,
user: {
@@ -96,7 +94,7 @@ export const updateField = async ({
changes: diffFieldChanges(oldField, updatedField),
},
requestMetadata,
},
}),
});
return updatedField;

View File

@@ -1,63 +0,0 @@
import { PDFAnnotation, PDFRef } from 'pdf-lib';
import {
PDFDict,
type PDFDocument,
PDFName,
drawObject,
popGraphicsState,
pushGraphicsState,
rotateInPlace,
translate,
} from 'pdf-lib';
export const flattenAnnotations = (document: PDFDocument) => {
const pages = document.getPages();
for (const page of pages) {
const annotations = page.node.Annots()?.asArray() ?? [];
annotations.forEach((annotation) => {
if (!(annotation instanceof PDFRef)) {
return;
}
const actualAnnotation = page.node.context.lookup(annotation);
if (!(actualAnnotation instanceof PDFDict)) {
return;
}
const pdfAnnot = PDFAnnotation.fromDict(actualAnnotation);
const appearance = pdfAnnot.ensureAP();
// Skip annotations without a normal appearance
if (!appearance.has(PDFName.of('N'))) {
return;
}
const normalAppearance = pdfAnnot.getNormalAppearance();
const rectangle = pdfAnnot.getRectangle();
if (!(normalAppearance instanceof PDFRef)) {
// Not sure how to get the reference to the normal appearance yet
// so we should skip this annotation for now
return;
}
const xobj = page.node.newXObject('FlatAnnot', normalAppearance);
const operators = [
pushGraphicsState(),
translate(rectangle.x, rectangle.y),
...rotateInPlace({ ...rectangle, rotation: 0 }),
drawObject(xobj),
popGraphicsState(),
].filter((op) => !!op);
page.pushOperators(...operators);
page.node.removeAnnot(annotation);
});
}
};

View File

@@ -1,26 +0,0 @@
import type { PDFDocument } from 'pdf-lib';
import { PDFSignature, rectangle } from 'pdf-lib';
export const normalizeSignatureAppearances = (document: PDFDocument) => {
const form = document.getForm();
for (const field of form.getFields()) {
if (field instanceof PDFSignature) {
field.acroField.getWidgets().forEach((widget) => {
widget.ensureAP();
try {
widget.getNormalAppearance();
} catch {
const { context } = widget.dict;
const xobj = context.formXObject([rectangle(0, 0, 0, 0)]);
const streamRef = context.register(xobj);
widget.setNormalAppearance(streamRef);
}
});
}
}
};

View File

@@ -1,52 +0,0 @@
import type { WorkHandler } from 'pg-boss';
import PgBoss from 'pg-boss';
import { jobHandlers } from './job';
type QueueState = {
isReady: boolean;
queue: PgBoss | null;
};
let initPromise: Promise<PgBoss> | null = null;
const state: QueueState = {
isReady: false,
queue: null,
};
export async function initQueue() {
if (state.isReady) {
return state.queue as PgBoss;
}
if (initPromise) {
return initPromise;
}
initPromise = (async () => {
const queue = new PgBoss({
connectionString: 'postgres://postgres:password@127.0.0.1:54321/queue',
schema: 'documenso_queue',
});
try {
await queue.start();
} catch (error) {
console.error('Failed to start queue', error);
}
await Promise.all(
Object.entries(jobHandlers).map(async ([job, jobHandler]) => {
await queue.work(job, jobHandler as WorkHandler<unknown>);
}),
);
state.isReady = true;
state.queue = queue;
return queue;
})();
return initPromise;
}

View File

@@ -1,85 +0,0 @@
import type { WorkHandler } from 'pg-boss';
import type { MailOptions } from '@documenso/email/mailer';
import { mailer } from '@documenso/email/mailer';
import { prisma } from '@documenso/prisma';
import { initQueue } from '.';
import type { CreateDocumentAuditLogDataOptions } from '../../utils/document-audit-logs';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import {
type SendDocumentOptions as SendCompletedDocumentOptions,
sendCompletedEmail,
} from '../document/send-completed-email';
import { type SendPendingEmailOptions, sendPendingEmail } from '../document/send-pending-email';
type JobOptions = {
'send-mail': MailOptions;
'send-completed-email': SendCompletedDocumentOptions;
'send-pending-email': SendPendingEmailOptions;
'create-document-audit-log': CreateDocumentAuditLogDataOptions;
};
export const jobHandlers: {
[K in keyof JobOptions]: WorkHandler<JobOptions[K]>;
} = {
'send-completed-email': async ({ id, name, data: { documentId, requestMetadata } }) => {
console.log('Running Queue: ', name, ' ', id);
await sendCompletedEmail({
documentId,
requestMetadata,
});
},
'send-pending-email': async ({ id, name, data: { documentId, recipientId } }) => {
console.log('Running Queue: ', name, ' ', id);
await sendPendingEmail({
documentId,
recipientId,
});
},
'send-mail': async ({ id, name, data: { attachments, to, from, subject, html, text } }) => {
console.log('Running Queue: ', name, ' ', id);
await mailer.sendMail({
to,
from,
subject,
html,
text,
attachments,
});
},
// Audit Logs Queue
'create-document-audit-log': async ({
name,
data: { documentId, type, requestMetadata, user, data },
id,
}) => {
console.log('Running Queue: ', name, ' ', id);
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type,
documentId,
requestMetadata,
user,
data,
}),
});
},
};
export const queueJob = async ({
job,
args,
}: {
job: keyof JobOptions;
args?: JobOptions[keyof JobOptions];
}) => {
const queue = await initQueue();
await queue.send(job, args ?? {});
};

View File

@@ -3,7 +3,7 @@ import type { Team } from '@documenso/prisma/client';
import { SendStatus } from '@documenso/prisma/client';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { queueJob } from '../queue/job';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type DeleteRecipientOptions = {
documentId: number;
@@ -73,30 +73,33 @@ export const deleteRecipient = async ({
});
}
const deletedRecipient = await prisma.recipient.delete({
where: {
id: recipient.id,
},
});
const deletedRecipient = await prisma.$transaction(async (tx) => {
const deleted = await tx.recipient.delete({
where: {
id: recipient.id,
},
});
await queueJob({
job: 'create-document-audit-log',
args: {
type: 'RECIPIENT_DELETED',
documentId,
user: {
id: team?.id ?? user.id,
email: team?.name ?? user.email,
name: team ? '' : user.name,
},
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
},
requestMetadata,
},
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: 'RECIPIENT_DELETED',
documentId,
user: {
id: team?.id ?? user.id,
email: team?.name ?? user.email,
name: team ? '' : user.name,
},
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
},
requestMetadata,
}),
});
return deleted;
});
return deletedRecipient;

View File

@@ -17,7 +17,6 @@ import { RecipientRole } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { queueJob } from '../queue/job';
export interface SetRecipientsForDocumentOptions {
userId: number;
@@ -204,9 +203,8 @@ export const setRecipientsForDocument = async ({
// Handle recipient updated audit log.
if (recipient._persisted && changes.length > 0) {
await queueJob({
job: 'create-document-audit-log',
args: {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
documentId: documentId,
user,
@@ -215,15 +213,14 @@ export const setRecipientsForDocument = async ({
changes,
...baseAuditLog,
},
},
}),
});
}
// Handle recipient created audit log.
if (!recipient._persisted) {
await queueJob({
job: 'create-document-audit-log',
args: {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED,
documentId: documentId,
user,
@@ -232,7 +229,7 @@ export const setRecipientsForDocument = async ({
...baseAuditLog,
actionAuth: recipient.actionAuth || undefined,
},
},
}),
});
}

View File

@@ -3,8 +3,7 @@ import type { RecipientRole, Team } from '@documenso/prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { diffRecipientChanges } from '../../utils/document-audit-logs';
import { queueJob } from '../queue/job';
import { createDocumentAuditLogData, diffRecipientChanges } from '../../utils/document-audit-logs';
export type UpdateRecipientOptions = {
documentId: number;
@@ -76,43 +75,44 @@ export const updateRecipient = async ({
throw new Error('Recipient not found');
}
const updatedRecipient = await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
email: email?.toLowerCase() ?? recipient.email,
name: name ?? recipient.name,
role: role ?? recipient.role,
},
});
const changes = diffRecipientChanges(recipient, updatedRecipient);
if (changes.length > 0) {
await queueJob({
job: 'create-document-audit-log',
args: {
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
documentId: documentId,
user: {
id: team?.id ?? user.id,
name: team?.name ?? user.name,
email: team ? '' : user.email,
},
requestMetadata,
data: {
changes,
recipientId,
recipientEmail: updatedRecipient.email,
recipientName: updatedRecipient.name,
recipientRole: updatedRecipient.role,
},
const updatedRecipient = await prisma.$transaction(async (tx) => {
const persisted = await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
email: email?.toLowerCase() ?? recipient.email,
name: name ?? recipient.name,
role: role ?? recipient.role,
},
});
return updatedRecipient;
}
const changes = diffRecipientChanges(recipient, persisted);
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
documentId: documentId,
user: {
id: team?.id ?? user.id,
name: team?.name ?? user.name,
email: team ? '' : user.email,
},
requestMetadata,
data: {
changes,
recipientId,
recipientEmail: persisted.email,
recipientName: persisted.name,
recipientRole: persisted.role,
},
}),
});
return persisted;
}
});
return updatedRecipient;
};

View File

@@ -2,6 +2,7 @@ import { createElement } from 'react';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { ConfirmTeamEmailTemplate } from '@documenso/email/templates/confirm-team-email';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
@@ -12,8 +13,6 @@ import { createTokenVerification } from '@documenso/lib/utils/token-verification
import { prisma } from '@documenso/prisma';
import { Prisma } from '@documenso/prisma/client';
import { queueJob } from '../queue/job';
export type CreateTeamEmailVerificationOptions = {
userId: number;
teamId: number;
@@ -123,17 +122,14 @@ export const sendTeamEmailVerificationEmail = async (
token,
});
await queueJob({
job: 'send-mail',
args: {
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `A request to use your email has been initiated by ${teamName} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `A request to use your email has been initiated by ${teamName} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
});
};

View File

@@ -2,6 +2,7 @@ import { createElement } from 'react';
import { nanoid } from 'nanoid';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import type { TeamInviteEmailProps } from '@documenso/email/templates/team-invite';
import { TeamInviteEmailTemplate } from '@documenso/email/templates/team-invite';
@@ -14,8 +15,6 @@ import { prisma } from '@documenso/prisma';
import { TeamMemberInviteStatus } from '@documenso/prisma/client';
import type { TCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
import { queueJob } from '../queue/job';
export type CreateTeamMemberInvitesOptions = {
userId: number;
userName: string;
@@ -149,17 +148,14 @@ export const sendTeamMemberInviteEmail = async ({
...emailTemplateOptions,
});
await queueJob({
job: 'send-mail',
args: {
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `You have been invited to join ${emailTemplateOptions.teamName} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `You have been invited to join ${emailTemplateOptions.teamName} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
});
};

View File

@@ -1,5 +1,6 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { TeamEmailRemovedTemplate } from '@documenso/email/templates/team-email-removed';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
@@ -7,8 +8,6 @@ import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { prisma } from '@documenso/prisma';
import { queueJob } from '../queue/job';
export type DeleteTeamEmailOptions = {
userId: number;
userEmail: string;
@@ -74,21 +73,18 @@ export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamE
teamUrl: team.url,
});
await queueJob({
job: 'create-document-audit-log',
args: {
to: {
address: team.owner.email,
name: team.owner.name ?? '',
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `Team email has been revoked for ${team.name}`,
html: render(template),
text: render(template, { plainText: true }),
await mailer.sendMail({
to: {
address: team.owner.email,
name: team.owner.name ?? '',
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `Team email has been revoked for ${team.name}`,
html: render(template),
text: render(template, { plainText: true }),
});
} catch (e) {
// Todo: Teams - Alert us.

View File

@@ -1,5 +1,6 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { TeamTransferRequestTemplate } from '@documenso/email/templates/team-transfer-request';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
@@ -7,8 +8,6 @@ import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { createTokenVerification } from '@documenso/lib/utils/token-verification';
import { prisma } from '@documenso/prisma';
import { queueJob } from '../queue/job';
export type RequestTeamOwnershipTransferOptions = {
/**
* The ID of the user initiating the transfer.
@@ -94,18 +93,15 @@ export const requestTeamOwnershipTransfer = async ({
token,
});
await queueJob({
job: 'create-document-audit-log',
args: {
to: newOwnerUser.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `You have been requested to take ownership of team ${team.name} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
await mailer.sendMail({
to: newOwnerUser.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `You have been requested to take ownership of team ${team.name} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
});
},
{ timeout: 30_000 },

View File

@@ -25,7 +25,7 @@ import {
import { ZRecipientAuthOptionsSchema } from '../types/document-auth';
import type { RequestMetadata } from '../universal/extract-request-metadata';
export type CreateDocumentAuditLogDataOptions<T = TDocumentAuditLog['type']> = {
type CreateDocumentAuditLogDataOptions<T = TDocumentAuditLog['type']> = {
documentId: number;
type: T;
data: Extract<TDocumentAuditLog, { type: T }>['data'];

View File

@@ -6,13 +6,7 @@
*/
-- AlterTable
ALTER TABLE "VerificationToken" ADD COLUMN "secondaryId" TEXT;
-- Set all null secondaryId fields to a uuid
UPDATE "VerificationToken" SET "secondaryId" = gen_random_uuid()::text WHERE "secondaryId" IS NULL;
-- Restrict the VerificationToken to required
ALTER TABLE "VerificationToken" ALTER COLUMN "secondaryId" SET NOT NULL;
ALTER TABLE "VerificationToken" ADD COLUMN "secondaryId" TEXT NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_secondaryId_key" ON "VerificationToken"("secondaryId");

View File

@@ -213,14 +213,7 @@ export const seedPendingDocument = async (
});
}
return prisma.document.findFirstOrThrow({
where: {
id: document.id,
},
include: {
Recipient: true,
},
});
return document;
};
export const seedPendingDocumentNoFields = async ({

View File

@@ -0,0 +1,223 @@
import type { User } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { prisma } from '..';
import {
DocumentDataType,
DocumentStatus,
FieldType,
Prisma,
ReadStatus,
SendStatus,
SigningStatus,
} from '../client';
const PULL_REQUEST_NUMBER = 711;
const EMAIL_DOMAIN = `pr-${PULL_REQUEST_NUMBER}.documenso.com`;
export const TEST_USERS = [
{
name: 'Sender 1',
email: `sender1@${EMAIL_DOMAIN}`,
password: 'Password123',
},
{
name: 'Sender 2',
email: `sender2@${EMAIL_DOMAIN}`,
password: 'Password123',
},
{
name: 'Sender 3',
email: `sender3@${EMAIL_DOMAIN}`,
password: 'Password123',
},
] as const;
const examplePdf = fs
.readFileSync(path.join(__dirname, '../../../assets/example.pdf'))
.toString('base64');
export const seedDatabase = async () => {
const users = await Promise.all(
TEST_USERS.map(async (u) =>
prisma.user.create({
data: {
name: u.name,
email: u.email,
password: hashSync(u.password),
emailVerified: new Date(),
url: u.email,
},
}),
),
);
const [user1, user2, user3] = users;
await createDraftDocument(user1, [user2, user3]);
await createPendingDocument(user1, [user2, user3]);
await createCompletedDocument(user1, [user2, user3]);
};
const createDraftDocument = async (sender: User, recipients: User[]) => {
const documentData = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
});
const document = await prisma.document.create({
data: {
title: `[${PULL_REQUEST_NUMBER}] Document 1 - Draft`,
status: DocumentStatus.DRAFT,
documentDataId: documentData.id,
userId: sender.id,
},
});
for (const recipient of recipients) {
const index = recipients.indexOf(recipient);
await prisma.recipient.create({
data: {
email: String(recipient.email),
name: String(recipient.name),
token: `draft-token-${index}`,
readStatus: ReadStatus.NOT_OPENED,
sendStatus: SendStatus.NOT_SENT,
signingStatus: SigningStatus.NOT_SIGNED,
signedAt: new Date(),
Document: {
connect: {
id: document.id,
},
},
Field: {
create: {
page: 1,
type: FieldType.NAME,
inserted: true,
customText: String(recipient.name),
positionX: new Prisma.Decimal(1),
positionY: new Prisma.Decimal(1),
width: new Prisma.Decimal(1),
height: new Prisma.Decimal(1),
documentId: document.id,
},
},
},
});
}
};
const createPendingDocument = async (sender: User, recipients: User[]) => {
const documentData = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
});
const document = await prisma.document.create({
data: {
title: `[${PULL_REQUEST_NUMBER}] Document 1 - Pending`,
status: DocumentStatus.PENDING,
documentDataId: documentData.id,
userId: sender.id,
},
});
for (const recipient of recipients) {
const index = recipients.indexOf(recipient);
await prisma.recipient.create({
data: {
email: String(recipient.email),
name: String(recipient.name),
token: `pending-token-${index}`,
readStatus: ReadStatus.OPENED,
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.SIGNED,
signedAt: new Date(),
Document: {
connect: {
id: document.id,
},
},
Field: {
create: {
page: 1,
type: FieldType.NAME,
inserted: true,
customText: String(recipient.name),
positionX: new Prisma.Decimal(1),
positionY: new Prisma.Decimal(1),
width: new Prisma.Decimal(1),
height: new Prisma.Decimal(1),
documentId: document.id,
},
},
},
});
}
};
const createCompletedDocument = async (sender: User, recipients: User[]) => {
const documentData = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
});
const document = await prisma.document.create({
data: {
title: `[${PULL_REQUEST_NUMBER}] Document 1 - Completed`,
status: DocumentStatus.COMPLETED,
documentDataId: documentData.id,
completedAt: new Date(),
userId: sender.id,
},
});
for (const recipient of recipients) {
const index = recipients.indexOf(recipient);
await prisma.recipient.create({
data: {
email: String(recipient.email),
name: String(recipient.name),
token: `completed-token-${index}`,
readStatus: ReadStatus.OPENED,
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.SIGNED,
signedAt: new Date(),
Document: {
connect: {
id: document.id,
},
},
Field: {
create: {
page: 1,
type: FieldType.NAME,
inserted: true,
customText: String(recipient.name),
positionX: new Prisma.Decimal(1),
positionY: new Prisma.Decimal(1),
width: new Prisma.Decimal(1),
height: new Prisma.Decimal(1),
documentId: document.id,
},
},
},
});
}
};

View File

@@ -0,0 +1,168 @@
import type { User } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { prisma } from '..';
import {
DocumentDataType,
DocumentStatus,
FieldType,
Prisma,
ReadStatus,
SendStatus,
SigningStatus,
} from '../client';
//
// https://github.com/documenso/documenso/pull/713
//
const PULL_REQUEST_NUMBER = 713;
const EMAIL_DOMAIN = `pr-${PULL_REQUEST_NUMBER}.documenso.com`;
export const TEST_USERS = [
{
name: 'User 1',
email: `user1@${EMAIL_DOMAIN}`,
password: 'Password123',
},
{
name: 'User 2',
email: `user2@${EMAIL_DOMAIN}`,
password: 'Password123',
},
] as const;
const examplePdf = fs
.readFileSync(path.join(__dirname, '../../../assets/example.pdf'))
.toString('base64');
export const seedDatabase = async () => {
const users = await Promise.all(
TEST_USERS.map(async (u) =>
prisma.user.create({
data: {
name: u.name,
email: u.email,
password: hashSync(u.password),
emailVerified: new Date(),
url: u.email,
},
}),
),
);
const [user1, user2] = users;
await createSentDocument(user1, [user2]);
await createReceivedDocument(user2, [user1]);
};
const createSentDocument = async (sender: User, recipients: User[]) => {
const documentData = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
});
const document = await prisma.document.create({
data: {
title: `[${PULL_REQUEST_NUMBER}] Document - Sent`,
status: DocumentStatus.PENDING,
documentDataId: documentData.id,
userId: sender.id,
},
});
for (const recipient of recipients) {
const index = recipients.indexOf(recipient);
await prisma.recipient.create({
data: {
email: String(recipient.email),
name: String(recipient.name),
token: `sent-token-${index}`,
readStatus: ReadStatus.NOT_OPENED,
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.NOT_SIGNED,
signedAt: new Date(),
Document: {
connect: {
id: document.id,
},
},
Field: {
create: {
page: 1,
type: FieldType.NAME,
inserted: true,
customText: String(recipient.name),
positionX: new Prisma.Decimal(1),
positionY: new Prisma.Decimal(1),
width: new Prisma.Decimal(1),
height: new Prisma.Decimal(1),
documentId: document.id,
},
},
},
});
}
};
const createReceivedDocument = async (sender: User, recipients: User[]) => {
const documentData = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
});
const document = await prisma.document.create({
data: {
title: `[${PULL_REQUEST_NUMBER}] Document - Received`,
status: DocumentStatus.PENDING,
documentDataId: documentData.id,
userId: sender.id,
},
});
for (const recipient of recipients) {
const index = recipients.indexOf(recipient);
await prisma.recipient.create({
data: {
email: String(recipient.email),
name: String(recipient.name),
token: `received-token-${index}`,
readStatus: ReadStatus.NOT_OPENED,
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.NOT_SIGNED,
signedAt: new Date(),
Document: {
connect: {
id: document.id,
},
},
Field: {
create: {
page: 1,
type: FieldType.NAME,
inserted: true,
customText: String(recipient.name),
positionX: new Prisma.Decimal(1),
positionY: new Prisma.Decimal(1),
width: new Prisma.Decimal(1),
height: new Prisma.Decimal(1),
documentId: document.id,
},
},
},
});
}
};

View File

@@ -1,11 +1,8 @@
import { customAlphabet } from 'nanoid';
import { prisma } from '..';
import { TeamMemberInviteStatus, TeamMemberRole } from '../client';
import { seedUser } from './users';
const EMAIL_DOMAIN = `test.documenso.com`;
const nanoid = customAlphabet('1234567890abcdef', 10);
type SeedTeamOptions = {
createTeamMembers?: number;
@@ -16,7 +13,7 @@ export const seedTeam = async ({
createTeamMembers = 0,
createTeamEmail,
}: SeedTeamOptions = {}) => {
const teamUrl = `team-${nanoid()}`;
const teamUrl = `team-${Date.now()}`;
const teamEmail = createTeamEmail === true ? `${teamUrl}@${EMAIL_DOMAIN}` : createTeamEmail;
const teamOwner = await seedUser({

View File

@@ -1,5 +1,3 @@
import { customAlphabet } from 'nanoid';
import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { prisma } from '..';
@@ -13,22 +11,12 @@ type SeedUserOptions = {
verified?: boolean;
};
const nanoid = customAlphabet('1234567890abcdef', 10);
export const seedUser = async ({
name,
email,
name = `user-${Date.now()}`,
email = `user-${Date.now()}@test.documenso.com`,
password = 'password',
verified = true,
}: SeedUserOptions = {}) => {
if (!name) {
name = nanoid();
}
if (!email) {
email = `${nanoid()}@test.documenso.com`;
}
return await prisma.user.create({
data: {
name,

View File

@@ -1,6 +1,5 @@
import {
PDFArray,
PDFDict,
PDFDocument,
PDFHexString,
PDFName,
@@ -17,7 +16,7 @@ export type AddSigningPlaceholderOptions = {
export const addSigningPlaceholder = async ({ pdf }: AddSigningPlaceholderOptions) => {
const doc = await PDFDocument.load(pdf);
const [firstPage] = doc.getPages();
const pages = doc.getPages();
const byteRange = PDFArray.withContext(doc.context);
@@ -26,71 +25,64 @@ export const addSigningPlaceholder = async ({ pdf }: AddSigningPlaceholderOption
byteRange.push(PDFName.of(BYTE_RANGE_PLACEHOLDER));
byteRange.push(PDFName.of(BYTE_RANGE_PLACEHOLDER));
const signature = doc.context.register(
const signature = doc.context.obj({
Type: 'Sig',
Filter: 'Adobe.PPKLite',
SubFilter: 'adbe.pkcs7.detached',
ByteRange: byteRange,
Contents: PDFHexString.fromText(' '.repeat(8192)),
Reason: PDFString.of('Signed by Documenso'),
M: PDFString.fromDate(new Date()),
});
const signatureRef = doc.context.register(signature);
const widget = doc.context.obj({
Type: 'Annot',
Subtype: 'Widget',
FT: 'Sig',
Rect: [0, 0, 0, 0],
V: signatureRef,
T: PDFString.of('Signature1'),
F: 4,
P: pages[0].ref,
});
const xobj = widget.context.formXObject([rectangle(0, 0, 0, 0)]);
const streamRef = widget.context.register(xobj);
widget.set(PDFName.of('AP'), widget.context.obj({ N: streamRef }));
const widgetRef = doc.context.register(widget);
let widgets = pages[0].node.get(PDFName.of('Annots'));
if (widgets instanceof PDFArray) {
widgets.push(widgetRef);
} else {
const newWidgets = PDFArray.withContext(doc.context);
newWidgets.push(widgetRef);
pages[0].node.set(PDFName.of('Annots'), newWidgets);
widgets = pages[0].node.get(PDFName.of('Annots'));
}
if (!widgets) {
throw new Error('No widgets');
}
pages[0].node.set(PDFName.of('Annots'), widgets);
doc.catalog.set(
PDFName.of('AcroForm'),
doc.context.obj({
Type: 'Sig',
Filter: 'Adobe.PPKLite',
SubFilter: 'adbe.pkcs7.detached',
ByteRange: byteRange,
Contents: PDFHexString.fromText(' '.repeat(8192)),
Reason: PDFString.of('Signed by Documenso'),
M: PDFString.fromDate(new Date()),
SigFlags: 3,
Fields: [widgetRef],
}),
);
const widget = doc.context.register(
doc.context.obj({
Type: 'Annot',
Subtype: 'Widget',
FT: 'Sig',
Rect: [0, 0, 0, 0],
V: signature,
T: PDFString.of('Signature1'),
F: 4,
P: firstPage.ref,
AP: doc.context.obj({
N: doc.context.register(doc.context.formXObject([rectangle(0, 0, 0, 0)])),
}),
}),
);
let widgets: PDFArray;
try {
widgets = firstPage.node.lookup(PDFName.of('Annots'), PDFArray);
} catch {
widgets = PDFArray.withContext(doc.context);
firstPage.node.set(PDFName.of('Annots'), widgets);
}
widgets.push(widget);
let arcoForm: PDFDict;
try {
arcoForm = doc.catalog.lookup(PDFName.of('AcroForm'), PDFDict);
} catch {
arcoForm = doc.context.obj({
Fields: PDFArray.withContext(doc.context),
});
doc.catalog.set(PDFName.of('AcroForm'), arcoForm);
}
let fields: PDFArray;
try {
fields = arcoForm.lookup(PDFName.of('Fields'), PDFArray);
} catch {
fields = PDFArray.withContext(doc.context);
arcoForm.set(PDFName.of('Fields'), fields);
}
fields.push(widget);
arcoForm.set(PDFName.of('SigFlags'), PDFNumber.of(3));
return Buffer.from(await doc.save({ useObjectStreams: false }));
};

View File

@@ -4,16 +4,12 @@ import { findDocuments } from '@documenso/lib/server-only/admin/get-all-document
import { updateRecipient } from '@documenso/lib/server-only/admin/update-recipient';
import { updateUser } from '@documenso/lib/server-only/admin/update-user';
import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
import { sendDeleteEmail } from '@documenso/lib/server-only/document/send-delete-email';
import { superDeleteDocument } from '@documenso/lib/server-only/document/super-delete-document';
import { upsertSiteSetting } from '@documenso/lib/server-only/site-settings/upsert-site-setting';
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { adminProcedure, router } from '../trpc';
import {
ZAdminDeleteDocumentMutationSchema,
ZAdminDeleteUserMutationSchema,
ZAdminFindDocumentsQuerySchema,
ZAdminResealDocumentMutationSchema,
@@ -29,8 +25,6 @@ export const adminRouter = router({
try {
return await findDocuments({ term, page, perPage });
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to retrieve the documents. Please try again.',
@@ -46,8 +40,6 @@ export const adminRouter = router({
try {
return await updateUser({ id, name, email, roles });
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to retrieve the specified account. Please try again.',
@@ -63,8 +55,6 @@ export const adminRouter = router({
try {
return await updateRecipient({ id, name, email });
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to update the recipient provided.',
@@ -85,8 +75,6 @@ export const adminRouter = router({
userId: ctx.user.id,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to update the site setting provided.',
@@ -103,7 +91,6 @@ export const adminRouter = router({
return await sealDocument({ documentId: id, isResealing: true });
} catch (err) {
console.log('resealDocument error', err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to reseal the document provided.',
@@ -131,25 +118,4 @@ export const adminRouter = router({
});
}
}),
deleteDocument: adminProcedure
.input(ZAdminDeleteDocumentMutationSchema)
.mutation(async ({ ctx, input }) => {
const { id, reason } = input;
try {
await sendDeleteEmail({ documentId: id, reason });
return await superDeleteDocument({
id,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.log(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to delete the specified document. Please try again.',
});
}
}),
});

View File

@@ -48,10 +48,3 @@ export const ZAdminDeleteUserMutationSchema = z.object({
});
export type TAdminDeleteUserMutationSchema = z.infer<typeof ZAdminDeleteUserMutationSchema>;
export const ZAdminDeleteDocumentMutationSchema = z.object({
id: z.number().min(1),
reason: z.string(),
});
export type TAdminDeleteDocomentMutationSchema = z.infer<typeof ZAdminDeleteDocumentMutationSchema>;

View File

@@ -16,9 +16,7 @@ export const apiTokenRouter = router({
getTokens: authenticatedProcedure.query(async ({ ctx }) => {
try {
return await getUserTokens({ userId: ctx.user.id });
} catch (err) {
console.error(err);
} catch (e) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to find your API tokens. Please try again.',
@@ -36,9 +34,7 @@ export const apiTokenRouter = router({
id,
userId: ctx.user.id,
});
} catch (err) {
console.error(err);
} catch (e) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to find this API token. Please try again.',
@@ -58,9 +54,7 @@ export const apiTokenRouter = router({
tokenName,
expiresIn: expirationDate,
});
} catch (err) {
console.error(err);
} catch (e) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to create an API token. Please try again.',
@@ -79,9 +73,7 @@ export const apiTokenRouter = router({
teamId,
userId: ctx.user.id,
});
} catch (err) {
console.error(err);
} catch (e) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to delete this API Token. Please try again.',

View File

@@ -153,10 +153,7 @@ export const authRouter = router({
}),
createPasskeySigninOptions: procedure.mutation(async ({ ctx }) => {
const cookies = parse(ctx.req.headers.cookie ?? '');
const sessionIdToken =
cookies['__Host-next-auth.csrf-token'] || cookies['next-auth.csrf-token'];
const sessionIdToken = parse(ctx.req.headers.cookie ?? '')['next-auth.csrf-token'];
if (!sessionIdToken) {
throw new Error('Missing CSRF token');

View File

@@ -115,8 +115,6 @@ export const documentRouter = router({
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
if (err instanceof TRPCError) {
throw err;
}
@@ -224,19 +222,13 @@ export const documentRouter = router({
const userId = ctx.user.id;
try {
return await updateTitle({
title,
userId,
teamId,
documentId,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
throw err;
}
return await updateTitle({
title,
userId,
teamId,
documentId,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
}),
setPasswordForDocument: authenticatedProcedure
@@ -355,9 +347,7 @@ export const documentRouter = router({
userId: ctx.user.id,
});
return documents;
} catch (err) {
console.error(err);
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We are unable to search for documents. Please try again later.',

View File

@@ -52,26 +52,20 @@ export const fieldRouter = router({
.mutation(async ({ input, ctx }) => {
const { templateId, fields } = input;
try {
await setFieldsForTemplate({
userId: ctx.user.id,
templateId,
fields: fields.map((field) => ({
id: field.nativeId,
signerEmail: field.signerEmail,
type: field.type,
pageNumber: field.pageNumber,
pageX: field.pageX,
pageY: field.pageY,
pageWidth: field.pageWidth,
pageHeight: field.pageHeight,
})),
});
} catch (err) {
console.error(err);
throw err;
}
await setFieldsForTemplate({
userId: ctx.user.id,
templateId,
fields: fields.map((field) => ({
id: field.nativeId,
signerEmail: field.signerEmail,
type: field.type,
pageNumber: field.pageNumber,
pageX: field.pageX,
pageY: field.pageY,
pageWidth: field.pageWidth,
pageHeight: field.pageHeight,
})),
});
}),
signFieldWithToken: procedure

View File

@@ -37,8 +37,6 @@ export const profileRouter = router({
...input,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to find user security audit logs. Please try again.',
@@ -52,8 +50,6 @@ export const profileRouter = router({
return await getUserById({ id });
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to retrieve the specified account. Please try again.',
@@ -112,8 +108,6 @@ export const profileRouter = router({
return { success: true, url: user.url };
} catch (err) {
console.error(err);
const error = AppError.parseError(err);
if (error.code !== AppErrorCode.UNKNOWN_ERROR) {
@@ -141,8 +135,6 @@ export const profileRouter = router({
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
let message =
'We were unable to update your profile. Please review the information you provided and try again.';
@@ -179,8 +171,6 @@ export const profileRouter = router({
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
let message = 'We were unable to reset your password. Please try again.';
if (err instanceof Error) {
@@ -202,8 +192,6 @@ export const profileRouter = router({
return await sendConfirmationToken({ email });
} catch (err) {
console.error(err);
let message = 'We were unable to send a confirmation email. Please try again.';
if (err instanceof Error) {
@@ -223,8 +211,6 @@ export const profileRouter = router({
id: ctx.user.id,
});
} catch (err) {
console.error(err);
let message = 'We were unable to delete your account. Please try again.';
if (err instanceof Error) {

View File

@@ -2,12 +2,12 @@ import { createElement } from 'react';
import { PDFDocument } from 'pdf-lib';
import { mailer } from '@documenso/email/mailer';
import { renderAsync } from '@documenso/email/render';
import { DocumentSelfSignedEmailTemplate } from '@documenso/email/templates/document-self-signed';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/constants/email';
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
import { queueJob } from '@documenso/lib/server-only/queue/job';
import { alphaid } from '@documenso/lib/universal/id';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putFile } from '@documenso/lib/universal/upload/put-file';
@@ -29,160 +29,151 @@ export const singleplayerRouter = router({
createSinglePlayerDocument: procedure
.input(ZCreateSinglePlayerDocumentMutationSchema)
.mutation(async ({ input }) => {
try {
const { signer, fields, documentData, documentName } = input;
const { signer, fields, documentData, documentName } = input;
const document = await getFile({
data: documentData.data,
type: documentData.type,
const document = await getFile({
data: documentData.data,
type: documentData.type,
});
const doc = await PDFDocument.load(document);
const createdAt = new Date();
const isBase64 = signer.signature.startsWith('data:image/png;base64,');
const signatureImageAsBase64 = isBase64 ? signer.signature : null;
const typedSignature = !isBase64 ? signer.signature : null;
// Update the document with the fields inserted.
for (const field of fields) {
const isSignatureField = field.type === FieldType.SIGNATURE;
await insertFieldInPDF(doc, {
...mapField(field, signer),
Signature: isSignatureField
? {
created: createdAt,
signatureImageAsBase64,
typedSignature,
// Dummy data.
id: -1,
recipientId: -1,
fieldId: -1,
}
: null,
// Dummy data.
id: -1,
secondaryId: '-1',
documentId: -1,
templateId: null,
recipientId: -1,
});
}
const doc = await PDFDocument.load(document);
const unsignedPdfBytes = await doc.save();
const createdAt = new Date();
const signedPdfBuffer = await signPdf({ pdf: Buffer.from(unsignedPdfBytes) });
const isBase64 = signer.signature.startsWith('data:image/png;base64,');
const signatureImageAsBase64 = isBase64 ? signer.signature : null;
const typedSignature = !isBase64 ? signer.signature : null;
const { token } = await prisma.$transaction(
async (tx) => {
const token = alphaid();
// Update the document with the fields inserted.
for (const field of fields) {
const isSignatureField = field.type === FieldType.SIGNATURE;
await insertFieldInPDF(doc, {
...mapField(field, signer),
Signature: isSignatureField
? {
created: createdAt,
signatureImageAsBase64,
typedSignature,
// Dummy data.
id: -1,
recipientId: -1,
fieldId: -1,
}
: null,
// Dummy data.
id: -1,
secondaryId: '-1',
documentId: -1,
templateId: null,
recipientId: -1,
// Fetch service user who will be the owner of the document.
const serviceUser = await tx.user.findFirstOrThrow({
where: {
email: SERVICE_USER_EMAIL,
},
});
}
const unsignedPdfBytes = await doc.save();
const { id: documentDataId } = await putFile({
name: `${documentName}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(signedPdfBuffer),
});
const signedPdfBuffer = await signPdf({ pdf: Buffer.from(unsignedPdfBytes) });
// Create document.
const document = await tx.document.create({
data: {
title: documentName,
status: DocumentStatus.COMPLETED,
documentDataId,
userId: serviceUser.id,
createdAt,
},
});
const { token } = await prisma.$transaction(
async (tx) => {
const token = alphaid();
// Create recipient.
const recipient = await tx.recipient.create({
data: {
documentId: document.id,
name: signer.name,
email: signer.email,
token,
signedAt: createdAt,
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
sendStatus: SendStatus.SENT,
},
});
// Fetch service user who will be the owner of the document.
const serviceUser = await tx.user.findFirstOrThrow({
where: {
email: SERVICE_USER_EMAIL,
},
});
// Create fields and signatures.
await Promise.all(
fields.map(async (field) => {
const insertedField = await tx.field.create({
data: {
documentId: document.id,
recipientId: recipient.id,
...mapField(field, signer),
},
});
const { id: documentDataId } = await putFile({
name: `${documentName}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(signedPdfBuffer),
});
// Create document.
const document = await tx.document.create({
data: {
title: documentName,
status: DocumentStatus.COMPLETED,
documentDataId,
userId: serviceUser.id,
createdAt,
},
});
// Create recipient.
const recipient = await tx.recipient.create({
data: {
documentId: document.id,
name: signer.name,
email: signer.email,
token,
signedAt: createdAt,
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
sendStatus: SendStatus.SENT,
},
});
// Create fields and signatures.
await Promise.all(
fields.map(async (field) => {
const insertedField = await tx.field.create({
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
await tx.signature.create({
data: {
documentId: document.id,
fieldId: insertedField.id,
signatureImageAsBase64,
typedSignature,
recipientId: recipient.id,
...mapField(field, signer),
},
});
}
}),
);
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
await tx.signature.create({
data: {
fieldId: insertedField.id,
signatureImageAsBase64,
typedSignature,
recipientId: recipient.id,
},
});
}
}),
);
return { document, token };
},
{
maxWait: 5000,
timeout: 30000,
},
);
return { document, token };
},
{
maxWait: 5000,
timeout: 30000,
},
);
const template = createElement(DocumentSelfSignedEmailTemplate, {
documentName: documentName,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000',
});
const template = createElement(DocumentSelfSignedEmailTemplate, {
documentName: documentName,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000',
});
const [html, text] = await Promise.all([
renderAsync(template),
renderAsync(template, { plainText: true }),
]);
const [html, text] = await Promise.all([
renderAsync(template),
renderAsync(template, { plainText: true }),
]);
// Send email to signer.
await mailer.sendMail({
to: {
address: signer.email,
name: signer.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document signed',
html,
text,
attachments: [{ content: signedPdfBuffer, filename: documentName }],
});
// Send email to signer.
await queueJob({
job: 'send-mail',
args: {
to: {
address: signer.email,
name: signer.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document signed',
html,
text,
attachments: [{ content: signedPdfBuffer, filename: documentName }],
},
});
return token;
} catch (err) {
console.error(err);
throw err;
}
return token;
}),
});

View File

@@ -56,8 +56,6 @@ export const templateRouter = router({
recipients: input.recipients,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to create this document. Please try again later.',

View File

@@ -21,8 +21,6 @@ export const webhookRouter = router({
try {
return await getWebhooksByUserId(ctx.user.id);
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to fetch your webhooks. Please try again later.',
@@ -38,8 +36,6 @@ export const webhookRouter = router({
try {
return await getWebhooksByTeamId(teamId, ctx.user.id);
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to fetch your webhooks. Please try again later.',
@@ -59,8 +55,6 @@ export const webhookRouter = router({
teamId,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to fetch your webhook. Please try again later.',
@@ -83,8 +77,6 @@ export const webhookRouter = router({
userId: ctx.user.id,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to create this webhook. Please try again later.',
@@ -104,8 +96,6 @@ export const webhookRouter = router({
userId: ctx.user.id,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to create this webhook. Please try again later.',
@@ -126,8 +116,6 @@ export const webhookRouter = router({
teamId,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to create this webhook. Please try again later.',

View File

@@ -223,10 +223,6 @@ export const AddSettingsFormPartial = ({
<strong>Require passkey</strong> - The recipient must have an account
and passkey configured via their settings
</li>
<li>
<strong>Require 2FA</strong> - The recipient must have an account and
2FA enabled via their settings
</li>
<li>
<strong>None</strong> - No authentication required
</li>

View File

@@ -291,10 +291,6 @@ export const AddSignersFormPartial = ({
<strong>Require passkey</strong> - The recipient must have
an account and passkey configured via their settings
</li>
<li>
<strong>Require 2FA</strong> - The recipient must have an
account and 2FA enabled via their settings
</li>
<li>
<strong>None</strong> - No authentication required
</li>

View File

@@ -91,6 +91,11 @@
@apply border-border;
}
html,
body {
scrollbar-gutter: stable;
}
body {
@apply bg-background text-foreground;
font-feature-settings: 'rlig' 1, 'calt' 1;
@@ -125,11 +130,11 @@
background: rgb(100 116 139 / 0.5);
}
/* Custom Swagger Dark Theme */
/* Custom Swagger Dark Theme */
.swagger-dark-theme .swagger-ui {
filter: invert(88%) hue-rotate(180deg);
}
.swagger-dark-theme .swagger-ui .microlight {
filter: invert(100%) hue-rotate(180deg);
}
}

View File

@@ -93,7 +93,6 @@
"NEXT_PRIVATE_STRIPE_API_KEY",
"NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET",
"NEXT_PRIVATE_GITHUB_TOKEN",
"NEXT_RUNTIME",
"CI",
"VERCEL",
"VERCEL_ENV",