Compare commits

..

2 Commits

Author SHA1 Message Date
David Nguyen 824f4a070a feat: demo 2024-03-27 20:31:08 +08:00
David Nguyen 2c8d11c37d fix: demo 2024-03-26 22:42:38 +08:00
103 changed files with 1088 additions and 4534 deletions
-4
View File
@@ -55,9 +55,6 @@ NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
NEXT_PRIVATE_UPLOAD_ENDPOINT="http://127.0.0.1:9002"
# OPTIONAL: Defines the force path style to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
# This will change it from using virtual hosts <bucket>.domain.com/<path> to fully qualified paths domain.com/<bucket>/<path>
NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE="false"
# OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1.
NEXT_PRIVATE_UPLOAD_REGION="unknown"
# REQUIRED: Defines the bucket to use for the S3 storage transport.
@@ -107,7 +104,6 @@ NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5
NEXT_PRIVATE_STRIPE_API_KEY=
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID=
# [[FEATURES]]
# OPTIONAL: Leave blank to disable PostHog and feature flags.
@@ -161,7 +161,6 @@ export const SinglePlayerClient = () => {
signingStatus: 'NOT_SIGNED',
sendStatus: 'NOT_SENT',
role: 'SIGNER',
authOptions: null,
};
const onFileDrop = async (file: File) => {
@@ -247,7 +246,6 @@ export const SinglePlayerClient = () => {
recipients={uploadedFile ? [placeholderRecipient] : []}
fields={fields}
onSubmit={onFieldsSubmit}
isDocumentPdfLoaded={true}
/>
</fieldset>
+2 -1
View File
@@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { withAxiom } = require('next-axiom');
const fs = require('fs');
const path = require('path');
const { version } = require('./package.json');
@@ -91,4 +92,4 @@ const config = {
},
};
module.exports = config;
module.exports = withAxiom(config);
+1
View File
@@ -33,6 +33,7 @@
"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",
"perfect-freehand": "^1.2.0",
@@ -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>
);
}
@@ -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>
);
};
@@ -8,18 +8,19 @@ import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import { DocumentStatus } from '@documenso/prisma/client';
import type { DocumentWithDetails } from '@documenso/prisma/types/document';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
import { AddSettingsFormPartial } from '@documenso/ui/primitives/document-flow/add-settings';
import type { TAddSettingsFormSchema } from '@documenso/ui/primitives/document-flow/add-settings.types';
import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers';
import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
import { AddTitleFormPartial } from '@documenso/ui/primitives/document-flow/add-title';
import type { TAddTitleFormSchema } from '@documenso/ui/primitives/document-flow/add-title.types';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
@@ -32,17 +33,15 @@ export type EditDocumentFormProps = {
className?: string;
initialDocument: DocumentWithDetails;
documentRootPath: string;
isDocumentEnterprise: boolean;
};
type EditDocumentStep = 'settings' | 'signers' | 'fields' | 'subject';
const EditDocumentSteps: EditDocumentStep[] = ['settings', 'signers', 'fields', 'subject'];
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
const EditDocumentSteps: EditDocumentStep[] = ['title', 'signers', 'fields', 'subject'];
export const EditDocumentForm = ({
className,
initialDocument,
documentRootPath,
isDocumentEnterprise,
}: EditDocumentFormProps) => {
const { toast } = useToast();
@@ -50,8 +49,6 @@ export const EditDocumentForm = ({
const searchParams = useSearchParams();
const team = useOptionalCurrentTeam();
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
const utils = trpc.useUtils();
const { data: document, refetch: refetchDocument } =
@@ -68,7 +65,7 @@ export const EditDocumentForm = ({
const { Recipient: recipients, Field: fields } = document;
const { mutateAsync: setSettingsForDocument } = trpc.document.setSettingsForDocument.useMutation({
const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData(
@@ -124,9 +121,9 @@ export const EditDocumentForm = ({
trpc.document.setPasswordForDocument.useMutation();
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
settings: {
title: 'General',
description: 'Configure general settings for the document.',
title: {
title: 'Add Title',
description: 'Add the title to the document.',
stepIndex: 1,
},
signers: {
@@ -150,7 +147,8 @@ export const EditDocumentForm = ({
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const searchParamStep = searchParams?.get('step') as EditDocumentStep | undefined;
let initialStep: EditDocumentStep = 'settings';
let initialStep: EditDocumentStep =
document.status === DocumentStatus.DRAFT ? 'title' : 'signers';
if (
searchParamStep &&
@@ -163,23 +161,12 @@ export const EditDocumentForm = ({
return initialStep;
});
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => {
try {
const { timezone, dateFormat, redirectUrl } = data.meta;
await setSettingsForDocument({
await addTitle({
documentId: document.id,
teamId: team?.id,
data: {
title: data.title,
globalAccessAuth: data.globalAccessAuth ?? null,
globalActionAuth: data.globalActionAuth ?? null,
},
meta: {
timezone,
dateFormat,
redirectUrl,
},
title: data.title,
});
// Router refresh is here to clear the router cache for when navigating to /documents.
@@ -191,7 +178,7 @@ export const EditDocumentForm = ({
toast({
title: 'Error',
description: 'An error occurred while updating the document settings.',
description: 'An error occurred while updating title.',
variant: 'destructive',
});
}
@@ -202,11 +189,7 @@ export const EditDocumentForm = ({
await addSigners({
documentId: document.id,
teamId: team?.id,
signers: data.signers.map((signer) => ({
...signer,
// Explicitly set to null to indicate we want to remove auth if required.
actionAuth: signer.actionAuth || null,
})),
signers: data.signers,
});
// Router refresh is here to clear the router cache for when navigating to /documents.
@@ -247,7 +230,7 @@ export const EditDocumentForm = ({
};
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
const { subject, message } = data.meta;
const { subject, message, timezone, dateFormat, redirectUrl } = data.meta;
try {
await sendDocument({
@@ -256,6 +239,9 @@ export const EditDocumentForm = ({
meta: {
subject,
message,
dateFormat,
timezone,
redirectUrl,
},
});
@@ -308,7 +294,6 @@ export const EditDocumentForm = ({
document={document}
password={document.documentMeta?.password}
onPasswordSubmit={onPasswordSubmit}
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>
</Card>
@@ -322,35 +307,30 @@ export const EditDocumentForm = ({
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(EditDocumentSteps[step - 1])}
>
<AddSettingsFormPartial
<AddTitleFormPartial
key={recipients.length}
documentFlow={documentFlow.settings}
documentFlow={documentFlow.title}
document={document}
recipients={recipients}
fields={fields}
isDocumentEnterprise={isDocumentEnterprise}
isDocumentPdfLoaded={isDocumentPdfLoaded}
onSubmit={onAddSettingsFormSubmit}
onSubmit={onAddTitleFormSubmit}
/>
<AddSignersFormPartial
key={recipients.length}
documentFlow={documentFlow.signers}
document={document}
recipients={recipients}
fields={fields}
isDocumentEnterprise={isDocumentEnterprise}
onSubmit={onAddSignersFormSubmit}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
<AddFieldsFormPartial
key={fields.length}
documentFlow={documentFlow.fields}
recipients={recipients}
fields={fields}
onSubmit={onAddFieldsFormSubmit}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
<AddSubjectFormPartial
key={recipients.length}
documentFlow={documentFlow.subject}
@@ -358,7 +338,6 @@ export const EditDocumentForm = ({
recipients={recipients}
fields={fields}
onSubmit={onAddSubjectFormSubmit}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
</Stepper>
</DocumentFlowFormContainer>
@@ -3,7 +3,6 @@ import { redirect } from 'next/navigation';
import { ChevronLeft, Users2 } from 'lucide-react';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
@@ -36,11 +35,6 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
const { user } = await getRequiredServerComponentSession();
const isDocumentEnterprise = await isUserEnterprise({
userId: user.id,
teamId: team?.id,
});
const document = await getDocumentWithDetailsById({
id: documentId,
userId: user.id,
@@ -103,7 +97,6 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
className="mt-8"
initialDocument={document}
documentRootPath={documentRootPath}
isDocumentEnterprise={isDocumentEnterprise}
/>
</div>
);
@@ -6,9 +6,7 @@ import { getServerSession } from 'next-auth';
import { match } from 'ts-pattern';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
@@ -19,7 +17,6 @@ import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { truncateTitle } from '~/helpers/truncate-title';
import { SigningAuthPageView } from '../signing-auth-page';
import { DocumentPreviewButton } from './document-preview-button';
export type CompletedSigningPageProps = {
@@ -35,11 +32,8 @@ export default async function CompletedSigningPage({
return notFound();
}
const { user } = await getServerComponentSession();
const document = await getDocumentAndSenderByToken({
token,
requireAccessAuth: false,
}).catch(() => null);
if (!document || !document.documentData) {
@@ -59,17 +53,6 @@ export default async function CompletedSigningPage({
return notFound();
}
const isDocumentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
document,
recipient,
userId: user?.id,
});
if (!isDocumentAccessValid) {
return <SigningAuthPageView email={recipient.email} />;
}
const signatures = await getRecipientSignatures({ recipientId: recipient.id });
const recipientName =
@@ -12,8 +12,6 @@ import {
} from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
@@ -56,23 +54,16 @@ export const DateField = ({
const tooltipText = `"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`;
const onSign = async (authOptions?: TRecipientActionAuth) => {
const onSign = async () => {
try {
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
authOptions,
});
startTransition(() => router.refresh());
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
console.error(err);
toast({
@@ -1,241 +0,0 @@
/**
* Note: This file has some commented out stuff for password auth which is no longer possible.
*
* Leaving it here until after we add passkeys and 2FA since it can be reused.
*/
import { useState } from 'react';
import { DateTime } from 'luxon';
import { signOut } from 'next-auth/react';
import { match } from 'ts-pattern';
import {
DocumentAuth,
type TRecipientActionAuth,
type TRecipientActionAuthTypes,
} from '@documenso/lib/types/document-auth';
import type { FieldType } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
export type DocumentActionAuthDialogProps = {
title?: string;
documentAuthType: TRecipientActionAuthTypes;
description?: string;
actionTarget: FieldType | 'DOCUMENT';
isSubmitting?: boolean;
open: boolean;
onOpenChange: (value: boolean) => void;
/**
* The callback to run when the reauth form is filled out.
*/
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
};
// const ZReauthFormSchema = z.object({
// password: ZCurrentPasswordSchema,
// });
// type TReauthFormSchema = z.infer<typeof ZReauthFormSchema>;
export const DocumentActionAuthDialog = ({
title,
description,
documentAuthType,
// onReauthFormSubmit,
isSubmitting,
open,
onOpenChange,
}: DocumentActionAuthDialogProps) => {
const { recipient } = useRequiredDocumentAuthContext();
// const form = useForm({
// resolver: zodResolver(ZReauthFormSchema),
// defaultValues: {
// password: '',
// },
// });
const [isSigningOut, setIsSigningOut] = useState(false);
const isLoading = isSigningOut || isSubmitting; // || form.formState.isSubmitting;
const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation();
// const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
// const onFormSubmit = async (_values: TReauthFormSchema) => {
// const documentAuthValue: TRecipientActionAuth = match(documentAuthType)
// // Todo: Add passkey.
// // .with(DocumentAuthType.PASSKEY, (type) => ({
// // type,
// // value,
// // }))
// .otherwise((type) => ({
// type,
// }));
// try {
// await onReauthFormSubmit(documentAuthValue);
// onOpenChange(false);
// } catch (e) {
// const error = AppError.parseError(e);
// setFormErrorCode(error.code);
// // Suppress unauthorized errors since it's handled in this component.
// if (error.code === AppErrorCode.UNAUTHORIZED) {
// return;
// }
// throw error;
// }
// };
const handleChangeAccount = async (email: string) => {
try {
setIsSigningOut(true);
const encryptedEmail = await encryptSecondaryData({
data: email,
expiresAt: DateTime.now().plus({ days: 1 }).toMillis(),
});
await signOut({
callbackUrl: `/signin?email=${encodeURIComponent(encryptedEmail)}`,
});
} catch {
setIsSigningOut(false);
// Todo: Alert.
}
};
const handleOnOpenChange = (value: boolean) => {
if (isLoading) {
return;
}
onOpenChange(value);
};
// useEffect(() => {
// form.reset();
// setFormErrorCode(null);
// }, [open, form]);
return (
<Dialog open={open} onOpenChange={handleOnOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title || 'Sign field'}</DialogTitle>
<DialogDescription>
{description || `Reauthentication is required to sign the field`}
</DialogDescription>
</DialogHeader>
{match(documentAuthType)
.with(DocumentAuth.ACCOUNT, () => (
<fieldset disabled={isSigningOut} className="space-y-4">
<Alert>
<AlertDescription>
To sign this field, you need to be logged in as <strong>{recipient.email}</strong>
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="submit"
onClick={async () => handleChangeAccount(recipient.email)}
loading={isSigningOut}
>
Login
</Button>
</DialogFooter>
</fieldset>
))
.with(DocumentAuth.EXPLICIT_NONE, () => null)
.exhaustive()}
{/* <Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full flex-col space-y-4" disabled={isLoading}>
<FormItem>
<FormLabel required>Email</FormLabel>
<FormControl>
<Input className="bg-background" value={recipient.email} disabled />
</FormControl>
</FormItem>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel required>Password</FormLabel>
<FormControl>
<PasswordInput className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{formErrorCode && (
<Alert variant="destructive">
{match(formErrorCode)
.with(AppErrorCode.UNAUTHORIZED, () => (
<>
<AlertTitle>Unauthorized</AlertTitle>
<AlertDescription>
We were unable to verify your details. Please ensure the details are
correct
</AlertDescription>
</>
))
.otherwise(() => (
<>
<AlertTitle>Something went wrong</AlertTitle>
<AlertDescription>
We were unable to sign this field at this time. Please try again or
contact support.
</AlertDescription>
</>
))}
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" loading={isLoading}>
Sign field
</Button>
</DialogFooter>
</fieldset>
</form>
</Form> */}
</DialogContent>
</Dialog>
);
};
@@ -1,168 +0,0 @@
'use client';
import { createContext, useContext, useMemo, useState } from 'react';
import { match } from 'ts-pattern';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import type {
TDocumentAuthOptions,
TRecipientAccessAuthTypes,
TRecipientActionAuthTypes,
TRecipientAuthOptions,
} from '@documenso/lib/types/document-auth';
import { DocumentAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { type Document, FieldType, type Recipient, type User } from '@documenso/prisma/client';
import type { DocumentActionAuthDialogProps } from './document-action-auth-dialog';
import { DocumentActionAuthDialog } from './document-action-auth-dialog';
export type DocumentAuthContextValue = {
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;
document: Document;
documentAuthOption: TDocumentAuthOptions;
setDocument: (_value: Document) => void;
recipient: Recipient;
recipientAuthOption: TRecipientAuthOptions;
setRecipient: (_value: Recipient) => void;
derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null;
derivedRecipientActionAuth: TRecipientActionAuthTypes | null;
isAuthRedirectRequired: boolean;
user?: User | null;
};
const DocumentAuthContext = createContext<DocumentAuthContextValue | null>(null);
export const useDocumentAuthContext = () => {
return useContext(DocumentAuthContext);
};
export const useRequiredDocumentAuthContext = () => {
const context = useDocumentAuthContext();
if (!context) {
throw new Error('Document auth context is required');
}
return context;
};
export interface DocumentAuthProviderProps {
document: Document;
recipient: Recipient;
user?: User | null;
children: React.ReactNode;
}
export const DocumentAuthProvider = ({
document: initialDocument,
recipient: initialRecipient,
user,
children,
}: DocumentAuthProviderProps) => {
const [document, setDocument] = useState(initialDocument);
const [recipient, setRecipient] = useState(initialRecipient);
const {
documentAuthOption,
recipientAuthOption,
derivedRecipientAccessAuth,
derivedRecipientActionAuth,
} = useMemo(
() =>
extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
}),
[document, recipient],
);
const [documentAuthDialogPayload, setDocumentAuthDialogPayload] =
useState<ExecuteActionAuthProcedureOptions | null>(null);
/**
* The pre calculated auth payload if the current user is authenticated correctly
* for the `derivedRecipientActionAuth`.
*
* Will be `null` if the user still requires authentication, or if they don't need
* authentication.
*/
const preCalculatedActionAuthOptions = match(derivedRecipientActionAuth)
.with(DocumentAuth.ACCOUNT, () => {
if (recipient.email !== user?.email) {
return null;
}
return {
type: DocumentAuth.ACCOUNT,
};
})
.with(DocumentAuth.EXPLICIT_NONE, () => ({
type: DocumentAuth.EXPLICIT_NONE,
}))
.with(null, () => null)
.exhaustive();
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
// Directly run callback if no auth required.
if (!derivedRecipientActionAuth || options.actionTarget !== FieldType.SIGNATURE) {
await options.onReauthFormSubmit();
return;
}
// Run callback with precalculated auth options if available.
if (preCalculatedActionAuthOptions) {
setDocumentAuthDialogPayload(null);
await options.onReauthFormSubmit(preCalculatedActionAuthOptions);
return;
}
// Request the required auth from the user.
setDocumentAuthDialogPayload({
...options,
});
};
const isAuthRedirectRequired = Boolean(
DOCUMENT_AUTH_TYPES[derivedRecipientActionAuth || '']?.isAuthRedirectRequired &&
!preCalculatedActionAuthOptions,
);
return (
<DocumentAuthContext.Provider
value={{
user,
document,
setDocument,
executeActionAuthProcedure,
recipient,
setRecipient,
documentAuthOption,
recipientAuthOption,
derivedRecipientAccessAuth,
derivedRecipientActionAuth,
isAuthRedirectRequired,
}}
>
{children}
{documentAuthDialogPayload && derivedRecipientActionAuth && (
<DocumentActionAuthDialog
open={true}
onOpenChange={() => setDocumentAuthDialogPayload(null)}
onReauthFormSubmit={documentAuthDialogPayload.onReauthFormSubmit}
actionTarget={documentAuthDialogPayload.actionTarget}
documentAuthType={derivedRecipientActionAuth}
/>
)}
</DocumentAuthContext.Provider>
);
};
type ExecuteActionAuthProcedureOptions = Omit<
DocumentActionAuthDialogProps,
'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole'
>;
DocumentAuthProvider.displayName = 'DocumentAuthProvider';
@@ -7,8 +7,6 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
@@ -41,24 +39,17 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const onSign = async (authOptions?: TRecipientActionAuth) => {
const onSign = async () => {
try {
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value: providedEmail ?? '',
isBase64: false,
authOptions,
});
startTransition(() => router.refresh());
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
console.error(err);
toast({
@@ -8,7 +8,6 @@ import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { type Document, type Field, type Recipient, RecipientRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
@@ -65,20 +64,9 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
return;
}
await completeDocument();
// Reauth is currently not required for completing the document.
// await executeActionAuthProcedure({
// onReauthFormSubmit: completeDocument,
// actionTarget: 'DOCUMENT',
// });
};
const completeDocument = async (authOptions?: TRecipientActionAuth) => {
await completeDocumentWithToken({
token: recipient.token,
documentId: document.id,
authOptions,
});
analytics.capture('App: Recipient has completed signing', {
@@ -7,9 +7,7 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { type Recipient } from '@documenso/prisma/client';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
@@ -18,7 +16,6 @@ import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRequiredSigningContext } from './provider';
import { SigningFieldContainer } from './signing-field-container';
@@ -35,8 +32,6 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
const { fullName: providedFullName, setFullName: setProvidedFullName } =
useRequiredSigningContext();
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
@@ -52,33 +47,9 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
const [showFullNameModal, setShowFullNameModal] = useState(false);
const [localFullName, setLocalFullName] = useState('');
const onPreSign = () => {
if (!providedFullName) {
setShowFullNameModal(true);
return false;
}
return true;
};
/**
* When the user clicks the sign button in the dialog where they enter their full name.
*/
const onDialogSignClick = () => {
setShowFullNameModal(false);
setProvidedFullName(localFullName);
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions, localFullName),
actionTarget: field.type,
});
};
const onSign = async (authOptions?: TRecipientActionAuth, name?: string) => {
const onSign = async (source: 'local' | 'provider' = 'provider') => {
try {
const value = name || providedFullName;
if (!value) {
if (!providedFullName && !localFullName) {
setShowFullNameModal(true);
return;
}
@@ -86,19 +57,18 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value,
value: source === 'local' && localFullName ? localFullName : providedFullName ?? '',
isBase64: false,
authOptions,
});
if (source === 'local' && !providedFullName) {
setProvidedFullName(localFullName);
}
setLocalFullName('');
startTransition(() => router.refresh());
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
console.error(err);
toast({
@@ -129,13 +99,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
};
return (
<SigningFieldContainer
field={field}
onPreSign={onPreSign}
onSign={onSign}
onRemove={onRemove}
type="Name"
>
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Name">
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
@@ -184,7 +148,10 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
type="button"
className="flex-1"
disabled={!localFullName}
onClick={() => onDialogSignClick()}
onClick={() => {
setShowFullNameModal(false);
void onSign('local');
}}
>
Sign
</Button>
@@ -1,24 +1,35 @@
import { headers } from 'next/headers';
import { notFound, redirect } from 'next/navigation';
import { match } from 'ts-pattern';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { DocumentAuthProvider } from './document-auth-provider';
import { truncateTitle } from '~/helpers/truncate-title';
import { DateField } from './date-field';
import { EmailField } from './email-field';
import { SigningForm } from './form';
import { NameField } from './name-field';
import { NoLongerAvailable } from './no-longer-available';
import { SigningProvider } from './provider';
import { SigningAuthPageView } from './signing-auth-page';
import { SigningPageView } from './signing-page-view';
import { SignatureField } from './signature-field';
import { TextField } from './text-field';
export type SigningPageProps = {
params: {
@@ -31,8 +42,6 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
return notFound();
}
const { user } = await getServerComponentSession();
const requestHeaders = Object.fromEntries(headers().entries());
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
@@ -40,40 +49,21 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
const [document, fields, recipient] = await Promise.all([
getDocumentAndSenderByToken({
token,
userId: user?.id,
requireAccessAuth: false,
}).catch(() => null),
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
viewedDocument({ token, requestMetadata }).catch(() => null),
]);
if (!document || !document.documentData || !recipient) {
return notFound();
}
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
const truncatedTitle = truncateTitle(document.title);
const isDocumentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
document,
recipient,
userId: user?.id,
});
const { documentData, documentMeta } = document;
if (!isDocumentAccessValid) {
return <SigningAuthPageView email={recipient.email} />;
}
await viewedDocument({
token,
requestMetadata,
recipientAccessAuth: derivedRecipientAccessAuth,
}).catch(() => null);
const { documentMeta } = document;
const { user } = await getServerComponentSession();
if (
document.status === DocumentStatus.COMPLETED ||
@@ -119,9 +109,73 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
fullName={user?.email === recipient.email ? user.name : recipient.name}
signature={user?.email === recipient.email ? user.signature : undefined}
>
<DocumentAuthProvider document={document} recipient={recipient} user={user}>
<SigningPageView recipient={recipient} document={document} fields={fields} />
</DocumentAuthProvider>
<div className="mx-auto w-full max-w-screen-xl">
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{truncatedTitle}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<p className="text-muted-foreground">
{document.User.name} ({document.User.email}) has invited you to{' '}
{recipient.role === RecipientRole.VIEWER && 'view'}
{recipient.role === RecipientRole.SIGNER && 'sign'}
{recipient.role === RecipientRole.APPROVER && 'approve'} this document.
</p>
</div>
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
<Card
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
gradient
>
<CardContent className="p-2">
<LazyPDFViewer
key={documentData.id}
documentData={documentData}
document={document}
password={documentMeta?.password}
/>
</CardContent>
</Card>
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
<SigningForm
document={document}
recipient={recipient}
fields={fields}
redirectUrl={documentMeta?.redirectUrl}
/>
</div>
</div>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map((field) =>
match(field.type)
.with(FieldType.SIGNATURE, () => (
<SignatureField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.NAME, () => (
<NameField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.DATE, () => (
<DateField
key={field.id}
field={field}
recipient={recipient}
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
/>
))
.with(FieldType.EMAIL, () => (
<EmailField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.TEXT, () => (
<TextField key={field.id} field={field} recipient={recipient} />
))
.otherwise(() => null),
)}
</ElementVisible>
</div>
</SigningProvider>
);
}
@@ -33,28 +33,8 @@ export const SignDialog = ({
const truncatedTitle = truncateTitle(document.title);
const isComplete = fields.every((field) => field.inserted);
const handleOpenChange = (open: boolean) => {
if (isSubmitting || !isComplete) {
return;
}
// Reauth is currently not required for signing the document.
// if (isAuthRedirectRequired) {
// await executeActionAuthProcedure({
// actionTarget: 'DOCUMENT',
// onReauthFormSubmit: () => {
// // Do nothing since the user should be redirected.
// },
// });
// return;
// }
setShowDialog(open);
};
return (
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
<Dialog open={showDialog && isComplete} onOpenChange={setShowDialog}>
<DialogTrigger asChild>
<Button
className="w-full"
@@ -1,15 +1,13 @@
'use client';
import { useMemo, useState, useTransition } from 'react';
import { useEffect, useMemo, useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { type Recipient } from '@documenso/prisma/client';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
@@ -18,7 +16,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 { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRequiredSigningContext } from './provider';
import { SigningFieldContainer } from './signing-field-container';
@@ -33,12 +30,9 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
const router = useRouter();
const { toast } = useToast();
const { signature: providedSignature, setSignature: setProvidedSignature } =
useRequiredSigningContext();
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
@@ -55,6 +49,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
const [showSignatureModal, setShowSignatureModal] = useState(false);
const [localSignature, setLocalSignature] = useState<string | null>(null);
const [isLocalSignatureSet, setIsLocalSignatureSet] = useState(false);
const state = useMemo<SignatureFieldState>(() => {
if (!field.inserted) {
@@ -68,38 +63,23 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
return 'signed-text';
}, [field.inserted, signature?.signatureImageAsBase64]);
const onPreSign = () => {
if (!providedSignature) {
setShowSignatureModal(true);
return false;
useEffect(() => {
if (!showSignatureModal && !isLocalSignatureSet) {
setLocalSignature(null);
}
}, [showSignatureModal, isLocalSignatureSet]);
return true;
};
/**
* When the user clicks the sign button in the dialog where they enter their signature.
*/
const onDialogSignClick = () => {
setShowSignatureModal(false);
setProvidedSignature(localSignature);
if (!localSignature) {
return;
}
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions, localSignature),
actionTarget: field.type,
});
};
const onSign = async (authOptions?: TRecipientActionAuth, signature?: string) => {
const onSign = async (source: 'local' | 'provider' = 'provider') => {
try {
const value = signature || providedSignature;
if (!providedSignature && !localSignature) {
setIsLocalSignatureSet(false);
setShowSignatureModal(true);
return;
}
const value = source === 'local' && localSignature ? localSignature : providedSignature ?? '';
if (!value) {
setShowSignatureModal(true);
return;
}
@@ -108,17 +88,16 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
fieldId: field.id,
value,
isBase64: true,
authOptions,
});
if (source === 'local' && !providedSignature) {
setProvidedSignature(localSignature);
}
setLocalSignature(null);
startTransition(() => router.refresh());
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
console.error(err);
toast({
@@ -149,13 +128,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
};
return (
<SigningFieldContainer
field={field}
onPreSign={onPreSign}
onSign={onSign}
onRemove={onRemove}
type="Signature"
>
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Signature">
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
@@ -218,7 +191,11 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
type="button"
className="flex-1"
disabled={!localSignature}
onClick={() => onDialogSignClick()}
onClick={() => {
setShowSignatureModal(false);
setIsLocalSignatureSet(true);
void onSign('local');
}}
>
Sign
</Button>
@@ -1,67 +0,0 @@
'use client';
import { useState } from 'react';
import { DateTime } from 'luxon';
import { signOut } from 'next-auth/react';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type SigningAuthPageViewProps = {
email: string;
};
export const SigningAuthPageView = ({ email }: SigningAuthPageViewProps) => {
const { toast } = useToast();
const [isSigningOut, setIsSigningOut] = useState(false);
const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation();
const handleChangeAccount = async (email: string) => {
try {
setIsSigningOut(true);
const encryptedEmail = await encryptSecondaryData({
data: email,
expiresAt: DateTime.now().plus({ days: 1 }).toMillis(),
});
await signOut({
callbackUrl: `/signin?email=${encodeURIComponent(encryptedEmail)}`,
});
} catch {
toast({
title: 'Something went wrong',
description: 'We were unable to log you out at this time.',
duration: 10000,
variant: 'destructive',
});
}
setIsSigningOut(false);
};
return (
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
<div>
<h1 className="text-3xl font-semibold">Authentication required</h1>
<p className="text-muted-foreground mt-2 text-sm">
You need to be logged in as <strong>{email}</strong> to view this page.
</p>
<Button
className="mt-4 w-full"
type="submit"
onClick={async () => handleChangeAccount(email)}
loading={isSigningOut}
>
Login
</Button>
</div>
</div>
);
};
@@ -2,38 +2,15 @@
import React from 'react';
import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { FieldType } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
export type SignatureFieldProps = {
field: FieldWithSignature;
loading?: boolean;
children: React.ReactNode;
/**
* A function that is called before the field requires to be signed, or reauthed.
*
* Example, you may want to show a dialog prior to signing where they can enter a value.
*
* Once that action is complete, you will need to call `executeActionAuthProcedure` to proceed
* regardless if it requires reauth or not.
*
* If the function returns true, we will proceed with the signing process. Otherwise if
* false is returned we will not proceed.
*/
onPreSign?: () => Promise<boolean> | boolean;
/**
* The function required to be executed to insert the field.
*
* The auth values will be passed in if available.
*/
onSign?: (documentAuthValue?: TRecipientActionAuth) => Promise<void> | void;
onSign?: () => Promise<void> | void;
onRemove?: () => Promise<void> | void;
type?: 'Date' | 'Email' | 'Name' | 'Signature';
tooltipText?: string | null;
@@ -42,56 +19,18 @@ export type SignatureFieldProps = {
export const SigningFieldContainer = ({
field,
loading,
onPreSign,
onSign,
onRemove,
children,
type,
tooltipText,
}: SignatureFieldProps) => {
const { executeActionAuthProcedure, isAuthRedirectRequired } = useRequiredDocumentAuthContext();
const handleInsertField = async () => {
if (field.inserted || !onSign) {
const onSignFieldClick = async () => {
if (field.inserted) {
return;
}
// Bypass reauth for non signature fields.
if (field.type !== FieldType.SIGNATURE) {
const presignResult = await onPreSign?.();
if (presignResult === false) {
return;
}
await onSign();
return;
}
if (isAuthRedirectRequired) {
await executeActionAuthProcedure({
onReauthFormSubmit: () => {
// Do nothing since the user should be redirected.
},
actionTarget: field.type,
});
return;
}
// Handle any presign requirements, and halt if required.
if (onPreSign) {
const preSignResult = await onPreSign();
if (preSignResult === false) {
return;
}
}
await executeActionAuthProcedure({
onReauthFormSubmit: onSign,
actionTarget: field.type,
});
await onSign?.();
};
const onRemoveSignedFieldClick = async () => {
@@ -108,7 +47,7 @@ export const SigningFieldContainer = ({
<button
type="submit"
className="absolute inset-0 z-10 h-full w-full"
onClick={async () => handleInsertField()}
onClick={onSignFieldClick}
/>
)}
@@ -1,102 +0,0 @@
import { match } from 'ts-pattern';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { Field, Recipient } from '@documenso/prisma/client';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { truncateTitle } from '~/helpers/truncate-title';
import { DateField } from './date-field';
import { EmailField } from './email-field';
import { SigningForm } from './form';
import { NameField } from './name-field';
import { SignatureField } from './signature-field';
import { TextField } from './text-field';
export type SigningPageViewProps = {
document: DocumentAndSender;
recipient: Recipient;
fields: Field[];
};
export const SigningPageView = ({ document, recipient, fields }: SigningPageViewProps) => {
const truncatedTitle = truncateTitle(document.title);
const { documentData, documentMeta } = document;
return (
<div className="mx-auto w-full max-w-screen-xl">
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{truncatedTitle}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<p className="text-muted-foreground">
{document.User.name} ({document.User.email}) has invited you to{' '}
{recipient.role === RecipientRole.VIEWER && 'view'}
{recipient.role === RecipientRole.SIGNER && 'sign'}
{recipient.role === RecipientRole.APPROVER && 'approve'} this document.
</p>
</div>
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
<Card
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
gradient
>
<CardContent className="p-2">
<LazyPDFViewer
key={documentData.id}
documentData={documentData}
document={document}
password={documentMeta?.password}
/>
</CardContent>
</Card>
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
<SigningForm
document={document}
recipient={recipient}
fields={fields}
redirectUrl={documentMeta?.redirectUrl}
/>
</div>
</div>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map((field) =>
match(field.type)
.with(FieldType.SIGNATURE, () => (
<SignatureField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.NAME, () => (
<NameField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.DATE, () => (
<DateField
key={field.id}
field={field}
recipient={recipient}
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
/>
))
.with(FieldType.EMAIL, () => (
<EmailField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.TEXT, () => (
<TextField key={field.id} field={field} recipient={recipient} />
))
.otherwise(() => null),
)}
</ElementVisible>
</div>
);
};
@@ -7,8 +7,6 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
@@ -18,7 +16,6 @@ import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { SigningFieldContainer } from './signing-field-container';
export type TextFieldProps = {
@@ -31,8 +28,6 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
const { toast } = useToast();
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
@@ -47,36 +42,22 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
const [showCustomTextModal, setShowCustomTextModal] = useState(false);
const [localText, setLocalCustomText] = useState('');
const [isLocalSignatureSet, setIsLocalSignatureSet] = useState(false);
useEffect(() => {
if (!showCustomTextModal) {
if (!showCustomTextModal && !isLocalSignatureSet) {
setLocalCustomText('');
}
}, [showCustomTextModal]);
}, [showCustomTextModal, isLocalSignatureSet]);
/**
* When the user clicks the sign button in the dialog where they enter the text field.
*/
const onDialogSignClick = () => {
setShowCustomTextModal(false);
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
};
const onPreSign = () => {
if (!localText) {
setShowCustomTextModal(true);
return false;
}
return true;
};
const onSign = async (authOptions?: TRecipientActionAuth) => {
const onSign = async () => {
try {
if (!localText) {
setIsLocalSignatureSet(false);
setShowCustomTextModal(true);
return;
}
if (!localText) {
return;
}
@@ -86,19 +67,12 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
fieldId: field.id,
value: localText,
isBase64: true,
authOptions,
});
setLocalCustomText('');
startTransition(() => router.refresh());
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
console.error(err);
toast({
@@ -129,13 +103,7 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
};
return (
<SigningFieldContainer
field={field}
onPreSign={onPreSign}
onSign={onSign}
onRemove={onRemove}
type="Signature"
>
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Signature">
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
@@ -182,7 +150,11 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
type="button"
className="flex-1"
disabled={!localText}
onClick={() => onDialogSignClick()}
onClick={() => {
setShowCustomTextModal(false);
setIsLocalSignatureSet(true);
void onSign();
}}
>
Save Text
</Button>
+3
View File
@@ -2,6 +2,7 @@ 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';
@@ -71,6 +72,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<PublicEnvScript />
</head>
<AxiomWebVitals />
<Suspense>
<PostHogPageview />
</Suspense>
@@ -7,7 +7,6 @@ import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
import { DOCUMENT_AUDIT_LOG_EMAIL_FORMAT } from '@documenso/lib/constants/document-audit-logs';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { formatDocumentAuditLogActionString } from '@documenso/lib/utils/document-audit-logs';
import { trpc } from '@documenso/trpc/react';
@@ -80,11 +79,7 @@ export const DocumentHistorySheet = ({
* @param text The text to format
* @returns The formatted text
*/
const formatGenericText = (text?: string | null) => {
if (!text) {
return '';
}
const formatGenericText = (text: string) => {
return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' ');
};
@@ -224,24 +219,6 @@ export const DocumentHistorySheet = ({
/>
),
)
.with(
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED },
({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Old',
value: DOCUMENT_AUTH_TYPES[data.from || '']?.value || 'None',
},
{
key: 'New',
value: DOCUMENT_AUTH_TYPES[data.to || '']?.value || 'None',
},
]}
/>
),
)
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, ({ data }) => {
if (data.changes.length === 0) {
return null;
@@ -304,7 +281,6 @@ export const DocumentHistorySheet = ({
]}
/>
))
.exhaustive()}
{isUserDetailsVisible && (
@@ -0,0 +1,34 @@
import { AnimatePresence, motion } from 'framer-motion';
import { cn } from '@documenso/ui/lib/utils';
export type FormErrorMessageProps = {
className?: string;
error: { message?: string } | undefined;
};
export const FormErrorMessage = ({ error, className }: FormErrorMessageProps) => {
return (
<AnimatePresence>
{error && (
<motion.p
initial={{
opacity: 0,
y: -10,
}}
animate={{
opacity: 1,
y: 0,
}}
exit={{
opacity: 0,
y: 10,
}}
className={cn('text-xs text-red-500', className)}
>
{error.message}
</motion.p>
)}
</AnimatePresence>
);
};
@@ -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>
-1
View File
@@ -115,7 +115,6 @@ Here's a markdown table documenting all the provided environment variables:
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport to use for file uploads (database or s3). |
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
-1
View File
@@ -34,7 +34,6 @@ services:
- NEXT_PRIVATE_DIRECT_DATABASE_URL=${NEXT_PRIVATE_DIRECT_DATABASE_URL:-${NEXT_PRIVATE_DATABASE_URL}}
- NEXT_PUBLIC_UPLOAD_TRANSPORT=${NEXT_PUBLIC_UPLOAD_TRANSPORT:-database}
- NEXT_PRIVATE_UPLOAD_ENDPOINT=${NEXT_PRIVATE_UPLOAD_ENDPOINT}
- NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE=${NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE}
- NEXT_PRIVATE_UPLOAD_REGION=${NEXT_PRIVATE_UPLOAD_REGION}
- NEXT_PRIVATE_UPLOAD_BUCKET=${NEXT_PRIVATE_UPLOAD_BUCKET}
- NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID=${NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID}
+22
View File
@@ -111,6 +111,7 @@
"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",
"perfect-freehand": "^1.2.0",
@@ -16668,6 +16669,22 @@
}
}
},
"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",
@@ -22936,6 +22953,11 @@
"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",
@@ -1,97 +0,0 @@
import { expect, test } from '@playwright/test';
import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[DOCUMENT_AUTH]: should grant access when not required', async ({ page }) => {
const user = await seedUser();
const recipientWithAccount = await seedUser();
const document = await seedPendingDocument(user, [
recipientWithAccount,
'recipientwithoutaccount@documenso.com',
]);
const recipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
},
});
const tokens = recipients.map((recipient) => recipient.token);
for (const token of tokens) {
await page.goto(`/sign/${token}`);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
}
await unseedUser(user.id);
});
test('[DOCUMENT_AUTH]: should allow or deny access when required', async ({ page }) => {
const user = await seedUser();
const recipientWithAccount = await seedUser();
const document = await seedPendingDocument(
user,
[recipientWithAccount, 'recipientwithoutaccount@documenso.com'],
{
createDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: 'ACCOUNT',
globalActionAuth: null,
}),
},
},
);
const recipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
},
});
// Check that both are denied access.
for (const recipient of recipients) {
const { email, token } = recipient;
await page.goto(`/sign/${token}`);
await expect(page.getByRole('heading', { name: 'Authentication required' })).toBeVisible();
await expect(page.getByRole('paragraph')).toContainText(email);
}
await apiSignin({
page,
email: recipientWithAccount.email,
redirectPath: '/',
});
// Check that the one logged in is granted access.
for (const recipient of recipients) {
const { email, token } = recipient;
await page.goto(`/sign/${token}`);
// Recipient should be granted access.
if (recipient.email === recipientWithAccount.email) {
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
}
// Recipient should still be denied.
if (recipient.email !== recipientWithAccount.email) {
await expect(page.getByRole('heading', { name: 'Authentication required' })).toBeVisible();
await expect(page.getByRole('paragraph')).toContainText(email);
}
}
await unseedUser(user.id);
await unseedUser(recipientWithAccount.id);
});
@@ -1,418 +0,0 @@
import { expect, test } from '@playwright/test';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import {
createDocumentAuthOptions,
createRecipientAuthOptions,
} from '@documenso/lib/utils/document-auth';
import { FieldType } from '@documenso/prisma/client';
import {
seedPendingDocumentNoFields,
seedPendingDocumentWithFullFields,
} from '@documenso/prisma/seed/documents';
import { seedTestEmail, seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[DOCUMENT_AUTH]: should allow signing when no auth setup', async ({ page }) => {
const user = await seedUser();
const recipientWithAccount = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [recipientWithAccount, seedTestEmail()],
});
// Check that both are granted access.
for (const recipient of recipients) {
const { token, Field } = recipient;
const signUrl = `/sign/${token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
// Add signature.
const canvas = page.locator('canvas');
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
await page.mouse.up();
}
for (const field of Field) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.getByLabel('Custom Text').fill('TEXT');
await page.getByRole('button', { name: 'Save Text' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
}
await unseedUser(user.id);
await unseedUser(recipientWithAccount.id);
});
test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ page }) => {
const user = await seedUser();
const recipientWithAccount = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [recipientWithAccount],
updateDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: null,
globalActionAuth: 'ACCOUNT',
}),
},
});
const recipient = recipients[0];
const { token, Field } = recipient;
const signUrl = `/sign/${token}`;
await apiSignin({
page,
email: recipientWithAccount.email,
redirectPath: signUrl,
});
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
// Add signature.
const canvas = page.locator('canvas');
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
await page.mouse.up();
}
for (const field of Field) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.getByLabel('Custom Text').fill('TEXT');
await page.getByRole('button', { name: 'Save Text' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
await unseedUser(user.id);
await unseedUser(recipientWithAccount.id);
});
// Currently document auth for signing/approving/viewing is not required.
test.skip('[DOCUMENT_AUTH]: should deny signing document when required for global auth', async ({
page,
}) => {
const user = await seedUser();
const recipientWithAccount = await seedUser();
const { recipients } = await seedPendingDocumentNoFields({
owner: user,
recipients: [recipientWithAccount],
updateDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: null,
globalActionAuth: 'ACCOUNT',
}),
},
});
const recipient = recipients[0];
const { token } = recipient;
await page.goto(`/sign/${token}`);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('paragraph')).toContainText(
'Reauthentication is required to sign the document',
);
await unseedUser(user.id);
await unseedUser(recipientWithAccount.id);
});
test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth', async ({
page,
}) => {
const user = await seedUser();
const recipientWithAccount = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [recipientWithAccount, seedTestEmail()],
updateDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: null,
globalActionAuth: 'ACCOUNT',
}),
},
});
// Check that both are denied access.
for (const recipient of recipients) {
const { token, Field } = recipient;
await page.goto(`/sign/${token}`);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
for (const field of Field) {
if (field.type !== FieldType.SIGNATURE) {
continue;
}
await page.locator(`#field-${field.id}`).getByRole('button').click();
await expect(page.getByRole('paragraph')).toContainText(
'Reauthentication is required to sign the field',
);
await page.getByRole('button', { name: 'Cancel' }).click();
}
}
await unseedUser(user.id);
await unseedUser(recipientWithAccount.id);
});
test('[DOCUMENT_AUTH]: should allow field signing when required for recipient auth', async ({
page,
}) => {
const user = await seedUser();
const recipientWithInheritAuth = await seedUser();
const recipientWithExplicitNoneAuth = await seedUser();
const recipientWithExplicitAccountAuth = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [
recipientWithInheritAuth,
recipientWithExplicitNoneAuth,
recipientWithExplicitAccountAuth,
],
recipientsCreateOptions: [
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: null,
}),
},
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: 'EXPLICIT_NONE',
}),
},
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: 'ACCOUNT',
}),
},
],
fields: [FieldType.DATE],
});
for (const recipient of recipients) {
const { token, Field } = recipient;
const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
// This document has no global action auth, so only account should require auth.
const isAuthRequired = actionAuth === 'ACCOUNT';
const signUrl = `/sign/${token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
if (isAuthRequired) {
for (const field of Field) {
if (field.type !== FieldType.SIGNATURE) {
continue;
}
await page.locator(`#field-${field.id}`).getByRole('button').click();
await expect(page.getByRole('paragraph')).toContainText(
'Reauthentication is required to sign the field',
);
await page.getByRole('button', { name: 'Cancel' }).click();
}
// Sign in and it should work.
await apiSignin({
page,
email: recipient.email,
redirectPath: signUrl,
});
}
// Add signature.
const canvas = page.locator('canvas');
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
await page.mouse.up();
}
for (const field of Field) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.getByLabel('Custom Text').fill('TEXT');
await page.getByRole('button', { name: 'Save Text' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true', {
timeout: 5000,
});
}
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
if (isAuthRequired) {
await apiSignout({ page });
}
}
});
test('[DOCUMENT_AUTH]: should allow field signing when required for recipient and global auth', async ({
page,
}) => {
const user = await seedUser();
const recipientWithInheritAuth = await seedUser();
const recipientWithExplicitNoneAuth = await seedUser();
const recipientWithExplicitAccountAuth = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [
recipientWithInheritAuth,
recipientWithExplicitNoneAuth,
recipientWithExplicitAccountAuth,
],
recipientsCreateOptions: [
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: null,
}),
},
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: 'EXPLICIT_NONE',
}),
},
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: 'ACCOUNT',
}),
},
],
fields: [FieldType.DATE],
updateDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: null,
globalActionAuth: 'ACCOUNT',
}),
},
});
for (const recipient of recipients) {
const { token, Field } = recipient;
const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
// This document HAS global action auth, so account and inherit should require auth.
const isAuthRequired = actionAuth === 'ACCOUNT' || actionAuth === null;
const signUrl = `/sign/${token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
if (isAuthRequired) {
for (const field of Field) {
if (field.type !== FieldType.SIGNATURE) {
continue;
}
await page.locator(`#field-${field.id}`).getByRole('button').click();
await expect(page.getByRole('paragraph')).toContainText(
'Reauthentication is required to sign the field',
);
await page.getByRole('button', { name: 'Cancel' }).click();
}
// Sign in and it should work.
await apiSignin({
page,
email: recipient.email,
redirectPath: signUrl,
});
}
// Add signature.
const canvas = page.locator('canvas');
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
await page.mouse.up();
}
for (const field of Field) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.getByLabel('Custom Text').fill('TEXT');
await page.getByRole('button', { name: 'Save Text' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true', {
timeout: 5000,
});
}
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
if (isAuthRequired) {
await apiSignout({ page });
}
}
});
@@ -1,200 +0,0 @@
import { expect, test } from '@playwright/test';
import {
seedBlankDocument,
seedDraftDocument,
seedPendingDocument,
} from '@documenso/prisma/seed/documents';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test.describe('[EE_ONLY]', () => {
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
test.beforeEach(() => {
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId,
'Billing required for this test',
);
});
test('[DOCUMENT_FLOW] add action auth settings', async ({ page }) => {
const user = await seedUser();
await seedUserSubscription({
userId: user.id,
priceId: enterprisePriceId,
});
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Return to the settings step to check that the results are saved correctly.
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
// Todo: Verify that the values are correct once we fix the issue where going back
// does not show the updated values.
// await expect(page.getByLabel('Title')).toContainText('New Title');
// await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW] enterprise team member can add action auth settings', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const teamMemberUser = team.members[1].user;
// Make the team enterprise by giving the owner the enterprise subscription.
await seedUserSubscription({
userId: team.ownerUserId,
priceId: enterprisePriceId,
});
const document = await seedBlankDocument(owner, {
createDocumentOptions: {
teamId: team.id,
},
});
await apiSignin({
page,
email: teamMemberUser.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Advanced settings should be visible.
await expect(page.getByLabel('Show advanced settings')).toBeVisible();
await unseedTeam(team.url);
});
test('[DOCUMENT_FLOW] enterprise team member should not have access to enterprise on personal account', async ({
page,
}) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const teamMemberUser = team.members[1].user;
// Make the team enterprise by giving the owner the enterprise subscription.
await seedUserSubscription({
userId: team.ownerUserId,
priceId: enterprisePriceId,
});
const document = await seedBlankDocument(teamMemberUser);
await apiSignin({
page,
email: teamMemberUser.email,
redirectPath: `/documents/${document.id}/edit`,
});
// Global action auth should not be visible.
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
// Next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Advanced settings should not be visible.
await expect(page.getByLabel('Show advanced settings')).not.toBeVisible();
await unseedTeam(team.url);
});
});
test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
// Set title.
await page.getByLabel('Title').fill('New Title');
// Set access auth.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Action auth should NOT be visible.
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Return to the settings step to check that the results are saved correctly.
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
// Todo: Verify that the values are correct once we fix the issue where going back
// does not show the updated values.
// await expect(page.getByLabel('Title')).toContainText('New Title');
// await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW]: title should be disabled depending on document status', async ({ page }) => {
const user = await seedUser();
const pendingDocument = await seedPendingDocument(user, []);
const draftDocument = await seedDraftDocument(user, []);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${pendingDocument.id}/edit`,
});
// Should be disabled for pending documents.
await expect(page.getByLabel('Title')).toBeDisabled();
// Should be enabled for draft documents.
await page.goto(`/documents/${draftDocument.id}/edit`);
await expect(page.getByLabel('Title')).toBeEnabled();
await unseedUser(user.id);
});
@@ -1,118 +0,0 @@
import { expect, test } from '@playwright/test';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test.describe('[EE_ONLY]', () => {
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
test.beforeEach(() => {
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId,
'Billing required for this test',
);
});
test('[DOCUMENT_FLOW] add EE settings', async ({ page }) => {
const user = await seedUser();
await seedUserSubscription({
userId: user.id,
priceId: enterprisePriceId,
});
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page
.getByRole('textbox', { name: 'Email', exact: true })
.fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2');
// Display advanced settings.
await page.getByLabel('Show advanced settings').click();
// Navigate to the next step and back.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Todo: Fix stepper component back issue before finishing test.
await unseedUser(user.id);
});
});
// Note: Not complete yet due to issue with back button.
test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2');
// Advanced settings should not be visible for non EE users.
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
// Navigate to the next step and back.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Todo: Fix stepper component back issue before finishing test.
// // Expect that the advanced settings is unchecked, since no advanced settings were applied.
// await expect(page.getByLabel('Show advanced settings')).toBeChecked({ checked: false });
// // Add advanced settings for a single recipient.
// await page.getByLabel('Show advanced settings').click();
// await page.getByRole('combobox').first().click();
// await page.getByLabel('Require account').click();
// // Navigate to the next step and back.
// await page.getByRole('button', { name: 'Continue' }).click();
// await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// await page.getByRole('button', { name: 'Go Back' }).click();
// await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Expect that the advanced settings is visible, and the checkbox is hidden. Since advanced
// settings were applied.
// Todo: Fix stepper component back issue before finishing test.
await unseedUser(user.id);
});
@@ -1,8 +1,8 @@
import { type Page } from '@playwright/test';
import type { Page } from '@playwright/test';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
type LoginOptions = {
type ManualLoginOptions = {
page: Page;
email?: string;
password?: string;
@@ -18,7 +18,7 @@ export const manualLogin = async ({
email = 'example@documenso.com',
password = 'password',
redirectPath,
}: LoginOptions) => {
}: ManualLoginOptions) => {
await page.goto(`${WEBAPP_BASE_URL}/signin`);
await page.getByLabel('Email').click();
@@ -33,63 +33,9 @@ export const manualLogin = async ({
}
};
export const manualSignout = async ({ page }: LoginOptions) => {
export const manualSignout = async ({ page }: ManualLoginOptions) => {
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 = '/',
}: LoginOptions) => {
const { request } = page.context();
const csrfToken = await getCsrfToken(page);
await request.post(`${WEBAPP_BASE_URL}/api/auth/callback/credentials`, {
form: {
email,
password,
json: true,
csrfToken,
},
});
if (redirectPath) {
await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`);
}
};
export const apiSignout = async ({ page }: { page: Page }) => {
const { request } = page.context();
const csrfToken = await getCsrfToken(page);
await request.post(`${WEBAPP_BASE_URL}/api/auth/signout`, {
form: {
csrfToken,
json: true,
},
});
await page.goto(`${WEBAPP_BASE_URL}/signin`);
};
const getCsrfToken = async (page: Page) => {
const { request } = page.context();
const response = await request.fetch(`${WEBAPP_BASE_URL}/api/auth/csrf`, {
method: 'get',
});
const { csrfToken } = await response.json();
if (!csrfToken) {
throw new Error('Invalid session');
}
return csrfToken;
};
@@ -4,21 +4,17 @@ 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 { DocumentStatus } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from './fixtures/authentication';
import { TEST_USER } from '@documenso/prisma/seed/pr-718-add-stepper-component';
test(`[PR-718]: should be able to create a document`, async ({ page }) => {
await page.goto('/signin');
const documentTitle = `example-${Date.now()}.pdf`;
const user = await seedUser();
await apiSignin({
page,
email: user.email,
});
// Sign in
await page.getByLabel('Email').fill(TEST_USER.email);
await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password);
await page.getByRole('button', { name: 'Sign In' }).click();
// Upload document
const [fileChooser] = await Promise.all([
@@ -35,8 +31,8 @@ test(`[PR-718]: should be able to create a document`, async ({ page }) => {
// Wait to be redirected to the edit page
await page.waitForURL(/\/documents\/\d+/);
// Set general settings
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
// Set title
await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible();
await page.getByLabel('Title').fill(documentTitle);
@@ -86,12 +82,10 @@ test('should be able to create a document with multiple recipients', async ({ pa
const documentTitle = `example-${Date.now()}.pdf`;
const user = await seedUser();
await apiSignin({
page,
email: user.email,
});
// Sign in
await page.getByLabel('Email').fill(TEST_USER.email);
await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password);
await page.getByRole('button', { name: 'Sign In' }).click();
// Upload document
const [fileChooser] = await Promise.all([
@@ -109,7 +103,7 @@ test('should be able to create a document with multiple recipients', async ({ pa
await page.waitForURL(/\/documents\/\d+/);
// Set title
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible();
await page.getByLabel('Title').fill(documentTitle);
@@ -118,12 +112,13 @@ test('should be able to create a document with multiple recipients', async ({ pa
// Add signers
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('user1@example.com');
await page.getByPlaceholder('Name').fill('User 1');
await page.getByLabel('Email*').fill('user1@example.com');
await page.getByLabel('Name').fill('User 1');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('user2@example.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('User 2');
await page.getByLabel('Email*').nth(1).fill('user2@example.com');
await page.getByLabel('Name').nth(1).fill('User 2');
await page.getByRole('button', { name: 'Continue' }).click();
@@ -182,12 +177,10 @@ test('should be able to create, send and sign a document', async ({ page }) => {
const documentTitle = `example-${Date.now()}.pdf`;
const user = await seedUser();
await apiSignin({
page,
email: user.email,
});
// Sign in
await page.getByLabel('Email').fill(TEST_USER.email);
await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password);
await page.getByRole('button', { name: 'Sign In' }).click();
// Upload document
const [fileChooser] = await Promise.all([
@@ -205,7 +198,7 @@ test('should be able to create, send and sign a document', async ({ page }) => {
await page.waitForURL(/\/documents\/\d+/);
// Set title
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible();
await page.getByLabel('Title').fill(documentTitle);
@@ -214,8 +207,8 @@ test('should be able to create, send and sign a document', async ({ page }) => {
// Add signers
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByPlaceholder('Email').fill('user1@example.com');
await page.getByPlaceholder('Name').fill('User 1');
await page.getByLabel('Email*').fill('user1@example.com');
await page.getByLabel('Name').fill('User 1');
await page.getByRole('button', { name: 'Continue' }).click();
@@ -232,9 +225,8 @@ test('should be able to create, send and sign a document', async ({ page }) => {
// Assert document was created
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
await page.getByRole('link', { name: documentTitle }).click();
await page.waitForURL(/\/documents\/\d+/);
const url = page.url().split('/');
const url = await page.url().split('/');
const documentId = url[url.length - 1];
const { token } = await getRecipientByEmail({
@@ -268,12 +260,10 @@ test('should be able to create, send with redirect url, sign a document and redi
const documentTitle = `example-${Date.now()}.pdf`;
const user = await seedUser();
await apiSignin({
page,
email: user.email,
});
// Sign in
await page.getByLabel('Email').fill(TEST_USER.email);
await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password);
await page.getByRole('button', { name: 'Sign In' }).click();
// Upload document
const [fileChooser] = await Promise.all([
@@ -290,19 +280,18 @@ test('should be able to create, send with redirect url, sign a document and redi
// 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();
// Set title
await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible();
await page.getByLabel('Title').fill(documentTitle);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByLabel('Redirect URL').fill('https://documenso.com');
await page.getByRole('button', { name: 'Continue' }).click();
// Add signers
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByPlaceholder('Email').fill('user1@example.com');
await page.getByPlaceholder('Name').fill('User 1');
await page.getByLabel('Email*').fill('user1@example.com');
await page.getByLabel('Name').fill('User 1');
await page.getByRole('button', { name: 'Continue' }).click();
@@ -310,6 +299,11 @@ test('should be able to create, send with redirect url, sign a document and redi
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Continue' }).click();
// Add subject and send
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByLabel('Redirect URL').fill('https://documenso.com');
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents');
@@ -317,9 +311,8 @@ test('should be able to create, send with redirect url, sign a document and redi
// Assert document was created
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
await page.getByRole('link', { name: documentTitle }).click();
await page.waitForURL(/\/documents\/\d+/);
const url = page.url().split('/');
const url = await page.url().split('/');
const documentId = url[url.length - 1];
const { token } = await getRecipientByEmail({
@@ -4,14 +4,14 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { manualLogin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[TEAMS]: create team', async ({ page }) => {
const user = await seedUser();
await apiSignin({
await manualLogin({
page,
email: user.email,
redirectPath: '/settings/teams',
@@ -38,7 +38,7 @@ test('[TEAMS]: create team', async ({ page }) => {
test('[TEAMS]: delete team', async ({ page }) => {
const team = await seedTeam();
await apiSignin({
await manualLogin({
page,
email: team.owner.email,
redirectPath: `/t/${team.url}/settings`,
@@ -56,7 +56,7 @@ test('[TEAMS]: delete team', async ({ page }) => {
test('[TEAMS]: update team', async ({ page }) => {
const team = await seedTeam();
await apiSignin({
await manualLogin({
page,
email: team.owner.email,
});
@@ -6,7 +6,7 @@ import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documen
import { seedTeamEmail, unseedTeam, unseedTeamEmail } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { manualLogin, manualSignout } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
@@ -30,7 +30,7 @@ test('[TEAMS]: check team documents count', async ({ page }) => {
// Run the test twice, once with the team owner and once with a team member to ensure the counts are the same.
for (const user of [team.owner, teamMember2]) {
await apiSignin({
await manualLogin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
@@ -55,7 +55,7 @@ test('[TEAMS]: check team documents count', async ({ page }) => {
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 3);
await apiSignout({ page });
await manualSignout({ page });
}
await unseedTeam(team.url);
@@ -126,7 +126,7 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
for (const user of [team.owner, teamEmailMember]) {
await apiSignin({
await manualLogin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
@@ -151,7 +151,7 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 3);
await apiSignout({ page });
await manualSignout({ page });
}
await unseedTeamEmail({ teamId: team.id });
@@ -216,7 +216,7 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
},
]);
await apiSignin({
await manualLogin({
page,
email: teamMember2.email,
redirectPath: `/t/${team.url}/documents`,
@@ -248,7 +248,7 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
test('[TEAMS]: delete pending team document', async ({ page }) => {
const { team, teamMember2: currentUser } = await seedTeamDocuments();
await apiSignin({
await manualLogin({
page,
email: currentUser.email,
redirectPath: `/t/${team.url}/documents?status=PENDING`,
@@ -266,7 +266,7 @@ test('[TEAMS]: delete pending team document', async ({ page }) => {
test('[TEAMS]: resend pending team document', async ({ page }) => {
const { team, teamMember2: currentUser } = await seedTeamDocuments();
await apiSignin({
await manualLogin({
page,
email: currentUser.email,
redirectPath: `/t/${team.url}/documents?status=PENDING`,
@@ -4,14 +4,14 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { seedTeam, seedTeamEmailVerification, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { manualLogin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[TEAMS]: send team email request', async ({ page }) => {
const team = await seedTeam();
await apiSignin({
await manualLogin({
page,
email: team.owner.email,
password: 'password',
@@ -57,7 +57,7 @@ test('[TEAMS]: delete team email', async ({ page }) => {
createTeamEmail: true,
});
await apiSignin({
await manualLogin({
page,
email: team.owner.email,
redirectPath: `/t/${team.url}/settings`,
@@ -86,7 +86,7 @@ test('[TEAMS]: team email owner removes access', async ({ page }) => {
email: team.teamEmail.email,
});
await apiSignin({
await manualLogin({
page,
email: teamEmailOwner.email,
redirectPath: `/settings/teams`,
@@ -4,7 +4,7 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { seedTeam, seedTeamInvite, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { manualLogin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
@@ -13,7 +13,7 @@ test('[TEAMS]: update team member role', async ({ page }) => {
createTeamMembers: 1,
});
await apiSignin({
await manualLogin({
page,
email: team.owner.email,
password: 'password',
@@ -75,7 +75,7 @@ test('[TEAMS]: member can leave team', async ({ page }) => {
const teamMember = team.members[1];
await apiSignin({
await manualLogin({
page,
email: teamMember.user.email,
password: 'password',
@@ -97,7 +97,7 @@ test('[TEAMS]: owner cannot leave team', async ({ page }) => {
createTeamMembers: 1,
});
await apiSignin({
await manualLogin({
page,
email: team.owner.email,
password: 'password',
@@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { seedTeam, seedTeamTransfer, unseedTeam } from '@documenso/prisma/seed/teams';
import { apiSignin } from '../fixtures/authentication';
import { manualLogin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
@@ -14,7 +14,7 @@ test('[TEAMS]: initiate and cancel team transfer', async ({ page }) => {
const teamMember = team.members[1];
await apiSignin({
await manualLogin({
page,
email: team.owner.email,
password: 'password',
@@ -4,7 +4,7 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedTemplate } from '@documenso/prisma/seed/templates';
import { apiSignin } from '../fixtures/authentication';
import { manualLogin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
@@ -36,7 +36,7 @@ test('[TEMPLATES]: view templates', async ({ page }) => {
teamId: team.id,
});
await apiSignin({
await manualLogin({
page,
email: owner.email,
redirectPath: '/templates',
@@ -81,7 +81,7 @@ test('[TEMPLATES]: delete template', async ({ page }) => {
teamId: team.id,
});
await apiSignin({
await manualLogin({
page,
email: owner.email,
redirectPath: '/templates',
@@ -135,7 +135,7 @@ test('[TEMPLATES]: duplicate template', async ({ page }) => {
teamId: team.id,
});
await apiSignin({
await manualLogin({
page,
email: owner.email,
redirectPath: '/templates',
@@ -181,7 +181,7 @@ test('[TEMPLATES]: use template', async ({ page }) => {
teamId: team.id,
});
await apiSignin({
await manualLogin({
page,
email: owner.email,
redirectPath: '/templates',
+5 -9
View File
@@ -1,14 +1,10 @@
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
ENV_FILES.forEach((file) => {
dotenv.config({
path: path.join(__dirname, `../../${file}`),
});
});
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
@@ -1,56 +0,0 @@
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { subscriptionsContainActiveEnterprisePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import type { Subscription } from '@documenso/prisma/client';
export type IsUserEnterpriseOptions = {
userId: number;
teamId?: number;
};
/**
* Whether the user is enterprise, or has permission to use enterprise features on
* behalf of their team.
*
* It is assumed that the provided user is part of the provided team.
*/
export const isUserEnterprise = async ({
userId,
teamId,
}: IsUserEnterpriseOptions): Promise<boolean> => {
let subscriptions: Subscription[] = [];
if (!IS_BILLING_ENABLED()) {
return false;
}
if (teamId) {
subscriptions = await prisma.team
.findFirstOrThrow({
where: {
id: teamId,
},
select: {
owner: {
include: {
Subscription: true,
},
},
},
})
.then((team) => team.owner.Subscription);
} else {
subscriptions = await prisma.user
.findFirstOrThrow({
where: {
id: userId,
},
select: {
Subscription: true,
},
})
.then((user) => user.Subscription);
}
return subscriptionsContainActiveEnterprisePlan(subscriptions);
};
@@ -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;
@@ -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;
-31
View File
@@ -1,31 +0,0 @@
import type { TDocumentAuth } from '../types/document-auth';
import { DocumentAuth } from '../types/document-auth';
type DocumentAuthTypeData = {
key: TDocumentAuth;
value: string;
/**
* Whether this authentication event will require the user to halt and
* redirect.
*
* Defaults to false.
*/
isAuthRedirectRequired?: boolean;
};
export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = {
[DocumentAuth.ACCOUNT]: {
key: DocumentAuth.ACCOUNT,
value: 'Require account',
isAuthRedirectRequired: true,
},
// [DocumentAuthType.PASSKEY]: {
// key: DocumentAuthType.PASSKEY,
// value: 'Require passkey',
// },
[DocumentAuth.EXPLICIT_NONE]: {
key: DocumentAuth.EXPLICIT_NONE,
value: 'None (Overrides global settings)',
},
} satisfies Record<TDocumentAuth, DocumentAuthTypeData>;
+4 -8
View File
@@ -137,16 +137,12 @@ export class AppError extends Error {
}
static parseFromJSONString(jsonString: string): AppError | null {
try {
const parsed = ZAppErrorJsonSchema.safeParse(JSON.parse(jsonString));
const parsed = ZAppErrorJsonSchema.safeParse(JSON.parse(jsonString));
if (!parsed.success) {
return null;
}
return new AppError(parsed.data.code, parsed.data.message, parsed.data.userMessage);
} catch {
if (!parsed.success) {
return null;
}
return new AppError(parsed.data.code, parsed.data.message, parsed.data.userMessage);
}
}
@@ -7,7 +7,6 @@ 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 { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sealDocument } from './seal-document';
import { sendPendingEmail } from './send-pending-email';
@@ -15,8 +14,6 @@ import { sendPendingEmail } from './send-pending-email';
export type CompleteDocumentWithTokenOptions = {
token: string;
documentId: number;
userId?: number;
authOptions?: TRecipientActionAuth;
requestMetadata?: RequestMetadata;
};
@@ -74,54 +71,32 @@ export const completeDocumentWithToken = async ({
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
}
// Document reauth for completing documents is currently not required.
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
signingStatus: SigningStatus.SIGNED,
signedAt: new Date(),
},
});
// const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
// documentAuth: document.authOptions,
// recipientAuth: recipient.authOptions,
// });
// const isValid = await isRecipientAuthorized({
// type: 'ACTION',
// document: document,
// recipient: recipient,
// userId,
// authOptions,
// });
// if (!isValid) {
// throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
// }
await prisma.$transaction(async (tx) => {
await tx.recipient.update({
where: {
id: recipient.id,
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
documentId: document.id,
user: {
name: recipient.name,
email: recipient.email,
},
requestMetadata,
data: {
signingStatus: SigningStatus.SIGNED,
signedAt: new Date(),
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
},
});
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({
@@ -3,8 +3,11 @@ import { prisma } from '@documenso/prisma';
import type { DocumentAuditLog } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client';
import { AppError } from '../../errors/app-error';
import type { TDocumentAuditLog } from '../../types/document-audit-logs';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
import { buildServerLogger } from '../../utils/logger';
export interface FindDocumentAuditLogsOptions {
userId: number;
@@ -97,7 +100,26 @@ export const findDocumentAuditLogs = async ({
let nextCursor: string | undefined = undefined;
const parsedData = data.map((auditLog) => parseDocumentAuditLogData(auditLog));
let parsedData: TDocumentAuditLog[] = [];
try {
parsedData = data.map((auditLog) => parseDocumentAuditLogData(auditLog));
} catch (err) {
const error = AppError.parseError(err);
if (error.code === 'MIGRATION_REQUIRED') {
const logger = buildServerLogger();
logger.error('findDocumentAuditLogs', {
level: 'ALERT',
error,
});
void logger.flush();
}
throw error;
}
if (parsedData.length > perPage) {
const nextItem = parsedData.pop();
@@ -1,39 +1,13 @@
import { prisma } from '@documenso/prisma';
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuthMethods } from '../../types/document-auth';
import { isRecipientAuthorized } from './is-recipient-authorized';
export interface GetDocumentAndSenderByTokenOptions {
token: string;
userId?: number;
accessAuth?: TDocumentAuthMethods;
/**
* Whether we enforce the access requirement.
*
* Defaults to true.
*/
requireAccessAuth?: boolean;
}
export interface GetDocumentAndRecipientByTokenOptions {
token: string;
userId?: number;
accessAuth?: TDocumentAuthMethods;
/**
* Whether we enforce the access requirement.
*
* Defaults to true.
*/
requireAccessAuth?: boolean;
}
export type GetDocumentByTokenOptions = {
token: string;
};
export type GetDocumentAndSenderByTokenOptions = GetDocumentByTokenOptions;
export type GetDocumentAndRecipientByTokenOptions = GetDocumentByTokenOptions;
export const getDocumentByToken = async ({ token }: GetDocumentByTokenOptions) => {
if (!token) {
throw new Error('Missing token');
@@ -52,13 +26,8 @@ export const getDocumentByToken = async ({ token }: GetDocumentByTokenOptions) =
return result;
};
export type DocumentAndSender = Awaited<ReturnType<typeof getDocumentAndSenderByToken>>;
export const getDocumentAndSenderByToken = async ({
token,
userId,
accessAuth,
requireAccessAuth = true,
}: GetDocumentAndSenderByTokenOptions) => {
if (!token) {
throw new Error('Missing token');
@@ -76,40 +45,12 @@ export const getDocumentAndSenderByToken = async ({
User: true,
documentData: true,
documentMeta: true,
Recipient: {
where: {
token,
},
},
},
});
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
const { password: _password, ...User } = result.User;
const recipient = result.Recipient[0];
// Sanity check, should not be possible.
if (!recipient) {
throw new Error('Missing recipient');
}
let documentAccessValid = true;
if (requireAccessAuth) {
documentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
document: result,
recipient,
userId,
authOptions: accessAuth,
});
}
if (!documentAccessValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid access values');
}
return {
...result,
User,
@@ -121,9 +62,6 @@ export const getDocumentAndSenderByToken = async ({
*/
export const getDocumentAndRecipientByToken = async ({
token,
userId,
accessAuth,
requireAccessAuth = true,
}: GetDocumentAndRecipientByTokenOptions): Promise<DocumentWithRecipient> => {
if (!token) {
throw new Error('Missing token');
@@ -147,29 +85,6 @@ export const getDocumentAndRecipientByToken = async ({
},
});
const recipient = result.Recipient[0];
// Sanity check, should not be possible.
if (!recipient) {
throw new Error('Missing recipient');
}
let documentAccessValid = true;
if (requireAccessAuth) {
documentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
document: result,
recipient,
userId,
authOptions: accessAuth,
});
}
if (!documentAccessValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid access values');
}
return {
...result,
Recipient: result.Recipient,
@@ -1,86 +0,0 @@
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import type { Document, Recipient } from '@documenso/prisma/client';
import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth';
import { DocumentAuth } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
type IsRecipientAuthorizedOptions = {
type: 'ACCESS' | 'ACTION';
document: Document;
recipient: Recipient;
/**
* The ID of the user who initiated the request.
*/
userId?: number;
/**
* The auth details to check.
*
* Optional because there are scenarios where no auth options are required such as
* using the user ID.
*/
authOptions?: TDocumentAuthMethods;
};
const getUserByEmail = async (email: string) => {
return await prisma.user.findFirst({
where: {
email,
},
select: {
id: true,
},
});
};
/**
* Whether the recipient is authorized to perform the requested operation on a
* document, given the provided auth options.
*
* @returns True if the recipient can perform the requested operation.
*/
export const isRecipientAuthorized = async ({
type,
document,
recipient,
userId,
authOptions,
}: IsRecipientAuthorizedOptions): Promise<boolean> => {
const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
const authMethod: TDocumentAuth | null =
type === 'ACCESS' ? derivedRecipientAccessAuth : derivedRecipientActionAuth;
// Early true return when auth is not required.
if (!authMethod || authMethod === DocumentAuth.EXPLICIT_NONE) {
return true;
}
// Authentication required does not match provided method.
if (authOptions && authOptions.type !== authMethod) {
return false;
}
return await match(authMethod)
.with(DocumentAuth.ACCOUNT, async () => {
if (userId === undefined) {
return false;
}
const recipientUser = await getUserByEmail(recipient.email);
if (!recipientUser) {
return false;
}
return recipientUser.id === userId;
})
.exhaustive();
};
@@ -2,7 +2,7 @@
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';
@@ -15,9 +15,7 @@ import { signPdf } from '@documenso/signing';
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 { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sendCompletedEmail } from './send-completed-email';
@@ -93,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);
@@ -95,7 +95,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
data: {
emailType: 'DOCUMENT_COMPLETED',
recipientEmail: owner.email,
recipientName: owner.name ?? '',
recipientName: owner.name,
recipientId: owner.id,
recipientRole: 'OWNER',
isResending: false,
@@ -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 }),
});
};
@@ -1,85 +0,0 @@
'use server';
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';
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 { createDocumentAuditLogData } from '../../utils/document-audit-logs';
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 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 }),
});
}),
);
}
// always hard delete if deleted from admin
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: 'HARD',
},
}),
});
return await tx.document.delete({ where: { id } });
});
};
@@ -1,178 +0,0 @@
'use server';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
export type UpdateDocumentSettingsOptions = {
userId: number;
teamId?: number;
documentId: number;
data: {
title?: string;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
};
requestMetadata?: RequestMetadata;
};
export const updateDocumentSettings = async ({
userId,
teamId,
documentId,
data,
requestMetadata,
}: UpdateDocumentSettingsOptions) => {
if (!data.title && !data.globalAccessAuth && !data.globalActionAuth) {
throw new AppError(AppErrorCode.INVALID_BODY, 'Missing data to update');
}
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
});
const { documentAuthOption } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});
const documentGlobalAccessAuth = documentAuthOption?.globalAccessAuth ?? null;
const documentGlobalActionAuth = documentAuthOption?.globalActionAuth ?? null;
// If the new global auth values aren't passed in, fallback to the current document values.
const newGlobalAccessAuth =
data?.globalAccessAuth === undefined ? documentGlobalAccessAuth : data.globalAccessAuth;
const newGlobalActionAuth =
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
// Check if user has permission to set the global action auth.
if (newGlobalActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
});
if (!isDocumentEnterprise) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to set the action auth',
);
}
}
const isTitleSame = data.title === document.title;
const isGlobalAccessSame = documentGlobalAccessAuth === newGlobalAccessAuth;
const isGlobalActionSame = documentGlobalActionAuth === newGlobalActionAuth;
const auditLogs: CreateDocumentAuditLogDataResponse[] = [];
if (!isTitleSame && document.status !== DocumentStatus.DRAFT) {
throw new AppError(
AppErrorCode.INVALID_BODY,
'You cannot update the title if the document has been sent',
);
}
if (!isTitleSame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED,
documentId,
user,
requestMetadata,
data: {
from: document.title,
to: data.title || '',
},
}),
);
}
if (!isGlobalAccessSame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED,
documentId,
user,
requestMetadata,
data: {
from: documentGlobalAccessAuth,
to: newGlobalAccessAuth,
},
}),
);
}
if (!isGlobalActionSame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED,
documentId,
user,
requestMetadata,
data: {
from: documentGlobalActionAuth,
to: newGlobalActionAuth,
},
}),
);
}
// Early return if nothing is required.
if (auditLogs.length === 0) {
return document;
}
return await prisma.$transaction(async (tx) => {
const authOptions = createDocumentAuthOptions({
globalAccessAuth: newGlobalAccessAuth,
globalActionAuth: newGlobalActionAuth,
});
const updatedDocument = await tx.document.update({
where: {
id: documentId,
},
data: {
title: data.title,
authOptions,
},
});
await tx.documentAuditLog.createMany({
data: auditLogs,
});
return updatedDocument;
});
};
@@ -5,21 +5,15 @@ 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 { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getDocumentAndRecipientByToken } from './get-document-by-token';
export type ViewedDocumentOptions = {
token: string;
recipientAccessAuth?: TDocumentAccessAuthTypes | null;
requestMetadata?: RequestMetadata;
};
export const viewedDocument = async ({
token,
recipientAccessAuth,
requestMetadata,
}: ViewedDocumentOptions) => {
export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
token,
@@ -57,13 +51,12 @@ export const viewedDocument = async ({
recipientId: recipient.id,
recipientName: recipient.name,
recipientRole: recipient.role,
accessAuth: recipientAccessAuth || undefined,
},
}),
});
});
const document = await getDocumentAndRecipientByToken({ token, requireAccessAuth: false });
const document = await getDocumentAndRecipientByToken({ token });
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_OPENED,
@@ -8,21 +8,15 @@ import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/clie
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
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';
export type SignFieldWithTokenOptions = {
token: string;
fieldId: number;
value: string;
isBase64?: boolean;
userId?: number;
authOptions?: TRecipientActionAuth;
requestMetadata?: RequestMetadata;
};
@@ -31,8 +25,6 @@ export const signFieldWithToken = async ({
fieldId,
value,
isBase64,
userId,
authOptions,
requestMetadata,
}: SignFieldWithTokenOptions) => {
const field = await prisma.field.findFirstOrThrow({
@@ -79,33 +71,6 @@ export const signFieldWithToken = async ({
throw new Error(`Field ${fieldId} has no recipientId`);
}
let { derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
// Override all non-signature fields to not require any auth.
if (field.type !== FieldType.SIGNATURE) {
derivedRecipientActionAuth = null;
}
let isValid = true;
// Only require auth on signature fields for now.
if (field.type === FieldType.SIGNATURE) {
isValid = await isRecipientAuthorized({
type: 'ACTION',
document: document,
recipient: recipient,
userId,
authOptions,
});
}
if (!isValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
}
const documentMeta = await prisma.documentMeta.findFirst({
where: {
documentId: document.id,
@@ -193,11 +158,9 @@ export const signFieldWithToken = async ({
data: updatedField.customText,
}))
.exhaustive(),
fieldSecurity: derivedRecipientActionAuth
? {
type: derivedRecipientActionAuth,
}
: undefined,
fieldSecurity: {
type: 'NONE',
},
},
}),
});
+36 -44
View File
@@ -1,9 +1,8 @@
import { prisma } from '@documenso/prisma';
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 { createDocumentAuditLogData, diffFieldChanges } from '../../utils/document-audit-logs';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type UpdateFieldOptions = {
fieldId: number;
@@ -34,7 +33,7 @@ export const updateField = async ({
pageHeight,
requestMetadata,
}: UpdateFieldOptions) => {
const oldField = await prisma.field.findFirstOrThrow({
const field = await prisma.field.update({
where: {
id: fieldId,
Document: {
@@ -56,49 +55,23 @@ export const updateField = async ({
}),
},
},
data: {
recipientId,
type,
page: pageNumber,
positionX: pageX,
positionY: pageY,
width: pageWidth,
height: pageHeight,
},
include: {
Recipient: true,
},
});
const field = prisma.$transaction(async (tx) => {
const updatedField = await tx.field.update({
where: {
id: fieldId,
},
data: {
recipientId,
type,
page: pageNumber,
positionX: pageX,
positionY: pageY,
width: pageWidth,
height: pageHeight,
},
include: {
Recipient: true,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
documentId,
user: {
id: team?.id ?? user.id,
email: team?.name ?? user.email,
name: team ? '' : user.name,
},
data: {
fieldId: updatedField.secondaryId,
fieldRecipientEmail: updatedField.Recipient?.email ?? '',
fieldRecipientId: recipientId ?? -1,
fieldType: updatedField.type,
changes: diffFieldChanges(oldField, updatedField),
},
requestMetadata,
}),
});
return updatedField;
});
if (!field) {
throw new Error('Field not found');
}
const user = await prisma.user.findFirstOrThrow({
where: {
@@ -126,5 +99,24 @@ export const updateField = async ({
});
}
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: 'FIELD_UPDATED',
documentId,
user: {
id: team?.id ?? user.id,
email: team?.name ?? user.email,
name: team ? '' : user.name,
},
data: {
fieldId: field.secondaryId,
fieldRecipientEmail: field.Recipient?.email ?? '',
fieldRecipientId: recipientId ?? -1,
fieldType: field.type,
},
requestMetadata,
}),
});
return field;
};
@@ -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);
});
}
};
@@ -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);
}
});
}
}
};
@@ -1,23 +1,15 @@
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import {
type TRecipientActionAuthTypes,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { nanoid } from '@documenso/lib/universal/id';
import {
createDocumentAuditLogData,
diffRecipientChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import type { Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
export interface SetRecipientsForDocumentOptions {
userId: number;
teamId?: number;
@@ -27,7 +19,6 @@ export interface SetRecipientsForDocumentOptions {
email: string;
name: string;
role: RecipientRole;
actionAuth?: TRecipientActionAuthTypes | null;
}[];
requestMetadata?: RequestMetadata;
}
@@ -79,23 +70,6 @@ export const setRecipientsForDocument = async ({
throw new Error('Document already complete');
}
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
});
if (!isDocumentEnterprise) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to set the action auth',
);
}
}
const normalizedRecipients = recipients.map((recipient) => ({
...recipient,
email: recipient.email.toLowerCase(),
@@ -138,15 +112,6 @@ export const setRecipientsForDocument = async ({
const persistedRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all(
linkedRecipients.map(async (recipient) => {
let authOptions = ZRecipientAuthOptionsSchema.parse(recipient._persisted?.authOptions);
if (recipient.actionAuth !== undefined) {
authOptions = createRecipientAuthOptions({
accessAuth: authOptions.accessAuth,
actionAuth: recipient.actionAuth,
});
}
const upsertedRecipient = await tx.recipient.upsert({
where: {
id: recipient._persisted?.id ?? -1,
@@ -160,7 +125,6 @@ export const setRecipientsForDocument = async ({
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
authOptions,
},
create: {
name: recipient.name,
@@ -171,7 +135,6 @@ export const setRecipientsForDocument = async ({
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
authOptions,
},
});
@@ -225,10 +188,7 @@ export const setRecipientsForDocument = async ({
documentId: documentId,
user,
requestMetadata,
data: {
...baseAuditLog,
actionAuth: recipient.actionAuth || undefined,
},
data: baseAuditLog,
}),
});
}
+20 -65
View File
@@ -8,8 +8,6 @@ import { z } from 'zod';
import { FieldType } from '@documenso/prisma/client';
import { ZRecipientActionAuthTypesSchema } from './document-auth';
export const ZDocumentAuditLogTypeSchema = z.enum([
// Document actions.
'EMAIL_SENT',
@@ -28,8 +26,6 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_DELETED', // When the document is soft deleted.
'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient.
'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient.
'DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED', // When the global access authentication is updated.
'DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED', // When the global action authentication is updated.
'DOCUMENT_META_UPDATED', // When the document meta data is updated.
'DOCUMENT_OPENED', // When the document is opened by a recipient.
'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document.
@@ -55,13 +51,7 @@ export const ZDocumentMetaDiffTypeSchema = z.enum([
]);
export const ZFieldDiffTypeSchema = z.enum(['DIMENSION', 'POSITION']);
export const ZRecipientDiffTypeSchema = z.enum([
'NAME',
'ROLE',
'EMAIL',
'ACCESS_AUTH',
'ACTION_AUTH',
]);
export const ZRecipientDiffTypeSchema = z.enum(['NAME', 'ROLE', 'EMAIL']);
export const DOCUMENT_AUDIT_LOG_TYPE = ZDocumentAuditLogTypeSchema.Enum;
export const DOCUMENT_EMAIL_TYPE = ZDocumentAuditLogEmailTypeSchema.Enum;
@@ -117,34 +107,25 @@ export const ZDocumentAuditLogFieldDiffSchema = z.union([
ZFieldDiffPositionSchema,
]);
export const ZGenericFromToSchema = z.object({
from: z.string().nullable(),
to: z.string().nullable(),
});
export const ZRecipientDiffActionAuthSchema = ZGenericFromToSchema.extend({
type: z.literal(RECIPIENT_DIFF_TYPE.ACCESS_AUTH),
});
export const ZRecipientDiffAccessAuthSchema = ZGenericFromToSchema.extend({
type: z.literal(RECIPIENT_DIFF_TYPE.ACTION_AUTH),
});
export const ZRecipientDiffNameSchema = ZGenericFromToSchema.extend({
export const ZRecipientDiffNameSchema = z.object({
type: z.literal(RECIPIENT_DIFF_TYPE.NAME),
from: z.string(),
to: z.string(),
});
export const ZRecipientDiffRoleSchema = ZGenericFromToSchema.extend({
export const ZRecipientDiffRoleSchema = z.object({
type: z.literal(RECIPIENT_DIFF_TYPE.ROLE),
from: z.string(),
to: z.string(),
});
export const ZRecipientDiffEmailSchema = ZGenericFromToSchema.extend({
export const ZRecipientDiffEmailSchema = z.object({
type: z.literal(RECIPIENT_DIFF_TYPE.EMAIL),
from: z.string(),
to: z.string(),
});
export const ZDocumentAuditLogRecipientDiffSchema = z.discriminatedUnion('type', [
ZRecipientDiffActionAuthSchema,
ZRecipientDiffAccessAuthSchema,
export const ZDocumentAuditLogRecipientDiffSchema = z.union([
ZRecipientDiffNameSchema,
ZRecipientDiffRoleSchema,
ZRecipientDiffEmailSchema,
@@ -236,11 +217,11 @@ export const ZDocumentAuditLogEventDocumentFieldInsertedSchema = z.object({
data: z.string(),
}),
]),
fieldSecurity: z
.object({
type: ZRecipientActionAuthTypesSchema,
})
.optional(),
// Todo: Replace with union once we have more field security types.
fieldSecurity: z.object({
type: z.literal('NONE'),
}),
}),
});
@@ -255,22 +236,6 @@ export const ZDocumentAuditLogEventDocumentFieldUninsertedSchema = z.object({
}),
});
/**
* Event: Document global authentication access updated.
*/
export const ZDocumentAuditLogEventDocumentGlobalAuthAccessUpdatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED),
data: ZGenericFromToSchema,
});
/**
* Event: Document global authentication action updated.
*/
export const ZDocumentAuditLogEventDocumentGlobalAuthActionUpdatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED),
data: ZGenericFromToSchema,
});
/**
* Event: Document meta updated.
*/
@@ -286,9 +251,7 @@ export const ZDocumentAuditLogEventDocumentMetaUpdatedSchema = z.object({
*/
export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED),
data: ZBaseRecipientDataSchema.extend({
accessAuth: z.string().optional(),
}),
data: ZBaseRecipientDataSchema,
});
/**
@@ -296,9 +259,7 @@ export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({
*/
export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED),
data: ZBaseRecipientDataSchema.extend({
actionAuth: z.string().optional(),
}),
data: ZBaseRecipientDataSchema,
});
/**
@@ -342,9 +303,7 @@ export const ZDocumentAuditLogEventFieldRemovedSchema = z.object({
export const ZDocumentAuditLogEventFieldUpdatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED),
data: ZBaseFieldEventDataSchema.extend({
// Provide an empty array as a migration workaround due to a mistake where we were
// not passing through any changes via API/v1 due to a type error.
changes: z.preprocess((x) => x || [], z.array(ZDocumentAuditLogFieldDiffSchema)),
changes: z.array(ZDocumentAuditLogFieldDiffSchema),
}),
});
@@ -353,9 +312,7 @@ export const ZDocumentAuditLogEventFieldUpdatedSchema = z.object({
*/
export const ZDocumentAuditLogEventRecipientAddedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED),
data: ZBaseRecipientDataSchema.extend({
actionAuth: ZRecipientActionAuthTypesSchema.optional(),
}),
data: ZBaseRecipientDataSchema,
});
/**
@@ -395,8 +352,6 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventDocumentDeletedSchema,
ZDocumentAuditLogEventDocumentFieldInsertedSchema,
ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
ZDocumentAuditLogEventDocumentGlobalAuthAccessUpdatedSchema,
ZDocumentAuditLogEventDocumentGlobalAuthActionUpdatedSchema,
ZDocumentAuditLogEventDocumentMetaUpdatedSchema,
ZDocumentAuditLogEventDocumentOpenedSchema,
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
-121
View File
@@ -1,121 +0,0 @@
import { z } from 'zod';
/**
* All the available types of document authentication options for both access and action.
*/
export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'EXPLICIT_NONE']);
export const DocumentAuth = ZDocumentAuthTypesSchema.Enum;
const ZDocumentAuthAccountSchema = z.object({
type: z.literal(DocumentAuth.ACCOUNT),
});
const ZDocumentAuthExplicitNoneSchema = z.object({
type: z.literal(DocumentAuth.EXPLICIT_NONE),
});
/**
* All the document auth methods for both accessing and actioning.
*/
export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
ZDocumentAuthExplicitNoneSchema,
]);
/**
* The global document access auth methods.
*
* Must keep these two in sync.
*/
export const ZDocumentAccessAuthSchema = z.discriminatedUnion('type', [ZDocumentAuthAccountSchema]);
export const ZDocumentAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]);
/**
* The global document action auth methods.
*
* Must keep these two in sync.
*/
export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [ZDocumentAuthAccountSchema]); // Todo: Add passkeys here.
export const ZDocumentActionAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]);
/**
* The recipient access auth methods.
*
* Must keep these two in sync.
*/
export const ZRecipientAccessAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
]);
export const ZRecipientAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]);
/**
* The recipient action auth methods.
*
* Must keep these two in sync.
*/
export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema, // Todo: Add passkeys here.
ZDocumentAuthExplicitNoneSchema,
]);
export const ZRecipientActionAuthTypesSchema = z.enum([
DocumentAuth.ACCOUNT,
DocumentAuth.EXPLICIT_NONE,
]);
export const DocumentAccessAuth = ZDocumentAccessAuthTypesSchema.Enum;
export const DocumentActionAuth = ZDocumentActionAuthTypesSchema.Enum;
export const RecipientAccessAuth = ZRecipientAccessAuthTypesSchema.Enum;
export const RecipientActionAuth = ZRecipientActionAuthTypesSchema.Enum;
/**
* Authentication options attached to the document.
*/
export const ZDocumentAuthOptionsSchema = z.preprocess(
(unknownValue) => {
if (unknownValue) {
return unknownValue;
}
return {
globalAccessAuth: null,
globalActionAuth: null,
};
},
z.object({
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable(),
globalActionAuth: ZDocumentActionAuthTypesSchema.nullable(),
}),
);
/**
* Authentication options attached to the recipient.
*/
export const ZRecipientAuthOptionsSchema = z.preprocess(
(unknownValue) => {
if (unknownValue) {
return unknownValue;
}
return {
accessAuth: null,
actionAuth: null,
};
},
z.object({
accessAuth: ZRecipientAccessAuthTypesSchema.nullable(),
actionAuth: ZRecipientActionAuthTypesSchema.nullable(),
}),
);
export type TDocumentAuth = z.infer<typeof ZDocumentAuthTypesSchema>;
export type TDocumentAuthMethods = z.infer<typeof ZDocumentAuthMethodsSchema>;
export type TDocumentAuthOptions = z.infer<typeof ZDocumentAuthOptionsSchema>;
export type TDocumentAccessAuth = z.infer<typeof ZDocumentAccessAuthSchema>;
export type TDocumentAccessAuthTypes = z.infer<typeof ZDocumentAccessAuthTypesSchema>;
export type TDocumentActionAuth = z.infer<typeof ZDocumentActionAuthSchema>;
export type TDocumentActionAuthTypes = z.infer<typeof ZDocumentActionAuthTypesSchema>;
export type TRecipientAccessAuth = z.infer<typeof ZRecipientAccessAuthSchema>;
export type TRecipientAccessAuthTypes = z.infer<typeof ZRecipientAccessAuthTypesSchema>;
export type TRecipientActionAuth = z.infer<typeof ZRecipientActionAuthSchema>;
export type TRecipientActionAuthTypes = z.infer<typeof ZRecipientActionAuthTypesSchema>;
export type TRecipientAuthOptions = z.infer<typeof ZRecipientAuthOptionsSchema>;
@@ -132,7 +132,6 @@ const getS3Client = () => {
return new S3Client({
endpoint: process.env.NEXT_PRIVATE_UPLOAD_ENDPOINT || undefined,
forcePathStyle: process.env.NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE === 'true',
region: process.env.NEXT_PRIVATE_UPLOAD_REGION || 'us-east-1',
credentials: hasCredentials
? {
-15
View File
@@ -1,6 +1,3 @@
import { env } from 'next-runtime-env';
import { IS_BILLING_ENABLED } from '../constants/app';
import type { Subscription } from '.prisma/client';
import { SubscriptionStatus } from '.prisma/client';
@@ -16,15 +13,3 @@ export const subscriptionsContainsActivePlan = (
subscription.status === SubscriptionStatus.ACTIVE && priceIds.includes(subscription.priceId),
);
};
export const subscriptionsContainActiveEnterprisePlan = (
subscriptions?: Subscription[],
): boolean => {
const enterprisePlanId = env('NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID');
if (!enterprisePlanId || !subscriptions || !IS_BILLING_ENABLED()) {
return false;
}
return subscriptionsContainsActivePlan(subscriptions, [enterprisePlanId]);
};
+15 -56
View File
@@ -9,6 +9,7 @@ import type {
} from '@documenso/prisma/client';
import { RECIPIENT_ROLES_DESCRIPTION } from '../constants/recipient-roles';
import { AppError } from '../errors/app-error';
import type {
TDocumentAuditLog,
TDocumentAuditLogDocumentMetaDiffSchema,
@@ -22,7 +23,6 @@ import {
RECIPIENT_DIFF_TYPE,
ZDocumentAuditLogSchema,
} from '../types/document-audit-logs';
import { ZRecipientAuthOptionsSchema } from '../types/document-auth';
import type { RequestMetadata } from '../universal/extract-request-metadata';
type CreateDocumentAuditLogDataOptions<T = TDocumentAuditLog['type']> = {
@@ -33,20 +33,20 @@ type CreateDocumentAuditLogDataOptions<T = TDocumentAuditLog['type']> = {
requestMetadata?: RequestMetadata;
};
export type CreateDocumentAuditLogDataResponse = Pick<
type CreateDocumentAuditLogDataResponse = Pick<
DocumentAuditLog,
'type' | 'ipAddress' | 'userAgent' | 'email' | 'userId' | 'name' | 'documentId'
> & {
data: TDocumentAuditLog['data'];
};
export const createDocumentAuditLogData = <T extends TDocumentAuditLog['type']>({
export const createDocumentAuditLogData = ({
documentId,
type,
data,
user,
requestMetadata,
}: CreateDocumentAuditLogDataOptions<T>): CreateDocumentAuditLogDataResponse => {
}: CreateDocumentAuditLogDataOptions): CreateDocumentAuditLogDataResponse => {
return {
type,
data,
@@ -69,15 +69,14 @@ export const parseDocumentAuditLogData = (auditLog: DocumentAuditLog): TDocument
// Handle any required migrations here.
if (!data.success) {
// Todo: Alert us.
console.error(data.error);
throw new Error('Migration required');
throw new AppError('MIGRATION_REQUIRED');
}
return data.data;
};
type PartialRecipient = Pick<Recipient, 'email' | 'name' | 'role' | 'authOptions'>;
type PartialRecipient = Pick<Recipient, 'email' | 'name' | 'role'>;
export const diffRecipientChanges = (
oldRecipient: PartialRecipient,
@@ -85,32 +84,6 @@ export const diffRecipientChanges = (
): TDocumentAuditLogRecipientDiffSchema[] => {
const diffs: TDocumentAuditLogRecipientDiffSchema[] = [];
const oldAuthOptions = ZRecipientAuthOptionsSchema.parse(oldRecipient.authOptions);
const oldAccessAuth = oldAuthOptions.accessAuth;
const oldActionAuth = oldAuthOptions.actionAuth;
const newAuthOptions = ZRecipientAuthOptionsSchema.parse(newRecipient.authOptions);
const newAccessAuth =
newAuthOptions?.accessAuth === undefined ? oldAccessAuth : newAuthOptions.accessAuth;
const newActionAuth =
newAuthOptions?.actionAuth === undefined ? oldActionAuth : newAuthOptions.actionAuth;
if (oldAccessAuth !== newAccessAuth) {
diffs.push({
type: RECIPIENT_DIFF_TYPE.ACCESS_AUTH,
from: oldAccessAuth ?? '',
to: newAccessAuth ?? '',
});
}
if (oldActionAuth !== newActionAuth) {
diffs.push({
type: RECIPIENT_DIFF_TYPE.ACTION_AUTH,
from: oldActionAuth ?? '',
to: newActionAuth ?? '',
});
}
if (oldRecipient.email !== newRecipient.email) {
diffs.push({
type: RECIPIENT_DIFF_TYPE.EMAIL,
@@ -194,13 +167,7 @@ export const diffDocumentMetaChanges = (
const oldPassword = oldData?.password ?? null;
const oldRedirectUrl = oldData?.redirectUrl ?? '';
const newDateFormat = newData?.dateFormat ?? '';
const newMessage = newData?.message ?? '';
const newSubject = newData?.subject ?? '';
const newTimezone = newData?.timezone ?? '';
const newRedirectUrl = newData?.redirectUrl ?? '';
if (oldDateFormat !== newDateFormat) {
if (oldDateFormat !== newData.dateFormat) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.DATE_FORMAT,
from: oldData?.dateFormat ?? '',
@@ -208,35 +175,35 @@ export const diffDocumentMetaChanges = (
});
}
if (oldMessage !== newMessage) {
if (oldMessage !== newData.message) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.MESSAGE,
from: oldMessage,
to: newMessage,
to: newData.message,
});
}
if (oldSubject !== newSubject) {
if (oldSubject !== newData.subject) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.SUBJECT,
from: oldSubject,
to: newSubject,
to: newData.subject,
});
}
if (oldTimezone !== newTimezone) {
if (oldTimezone !== newData.timezone) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.TIMEZONE,
from: oldTimezone,
to: newTimezone,
to: newData.timezone,
});
}
if (oldRedirectUrl !== newRedirectUrl) {
if (oldRedirectUrl !== newData.redirectUrl) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.REDIRECT_URL,
from: oldRedirectUrl,
to: newRedirectUrl,
to: newData.redirectUrl,
});
}
@@ -312,14 +279,6 @@ export const formatDocumentAuditLogAction = (auditLog: TDocumentAuditLog, userId
anonymous: 'Field unsigned',
identified: 'unsigned a field',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED }, () => ({
anonymous: 'Document access auth updated',
identified: 'updated the document access auth requirements',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED }, () => ({
anonymous: 'Document signing auth updated',
identified: 'updated the document signing auth requirements',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, () => ({
anonymous: 'Document updated',
identified: 'updated the document',
-72
View File
@@ -1,72 +0,0 @@
import type { Document, Recipient } from '@documenso/prisma/client';
import type {
TDocumentAuthOptions,
TRecipientAccessAuthTypes,
TRecipientActionAuthTypes,
TRecipientAuthOptions,
} from '../types/document-auth';
import { DocumentAuth } from '../types/document-auth';
import { ZDocumentAuthOptionsSchema, ZRecipientAuthOptionsSchema } from '../types/document-auth';
type ExtractDocumentAuthMethodsOptions = {
documentAuth: Document['authOptions'];
recipientAuth?: Recipient['authOptions'];
};
/**
* Parses and extracts the document and recipient authentication values.
*
* Will combine the recipient and document auth values to derive the final
* auth values for a recipient if possible.
*/
export const extractDocumentAuthMethods = ({
documentAuth,
recipientAuth,
}: ExtractDocumentAuthMethodsOptions) => {
const documentAuthOption = ZDocumentAuthOptionsSchema.parse(documentAuth);
const recipientAuthOption = ZRecipientAuthOptionsSchema.parse(recipientAuth);
const derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null =
recipientAuthOption.accessAuth || documentAuthOption.globalAccessAuth;
const derivedRecipientActionAuth: TRecipientActionAuthTypes | null =
recipientAuthOption.actionAuth || documentAuthOption.globalActionAuth;
const recipientAccessAuthRequired = derivedRecipientAccessAuth !== null;
const recipientActionAuthRequired =
derivedRecipientActionAuth !== DocumentAuth.EXPLICIT_NONE &&
derivedRecipientActionAuth !== null;
return {
derivedRecipientAccessAuth,
derivedRecipientActionAuth,
recipientAccessAuthRequired,
recipientActionAuthRequired,
documentAuthOption,
recipientAuthOption,
};
};
/**
* Create document auth options in a type safe way.
*/
export const createDocumentAuthOptions = (options: TDocumentAuthOptions): TDocumentAuthOptions => {
return {
globalAccessAuth: options?.globalAccessAuth ?? null,
globalActionAuth: options?.globalActionAuth ?? null,
};
};
/**
* Create recipient auth options in a type safe way.
*/
export const createRecipientAuthOptions = (
options: TRecipientAuthOptions,
): TRecipientAuthOptions => {
return {
accessAuth: options?.accessAuth ?? null,
actionAuth: options?.actionAuth ?? null,
};
};
+16
View File
@@ -0,0 +1,16 @@
import type { LoggerConfig } from 'next-axiom';
import { Logger } from 'next-axiom';
/**
* For usage in server-side code.
*
* When used in a server component, you must flush the logs.
*
* https://github.com/axiomhq/next-axiom?tab=readme-ov-file#server-components
*/
export const buildServerLogger = (config?: LoggerConfig) => {
return new Logger({
source: 'server',
...config,
});
};
@@ -1,5 +0,0 @@
-- AlterTable
ALTER TABLE "Document" ADD COLUMN "authOptions" JSONB;
-- AlterTable
ALTER TABLE "Recipient" ADD COLUMN "authOptions" JSONB;
-2
View File
@@ -255,7 +255,6 @@ model Document {
id Int @id @default(autoincrement())
userId Int
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
authOptions Json?
title String
status DocumentStatus @default(DRAFT)
Recipient Recipient[]
@@ -353,7 +352,6 @@ model Recipient {
token String
expired DateTime?
signedAt DateTime?
authOptions Json?
role RecipientRole @default(SIGNER)
readStatus ReadStatus @default(NOT_OPENED)
signingStatus SigningStatus @default(NOT_SIGNED)
+7 -177
View File
@@ -1,4 +1,4 @@
import type { Document, User } from '@prisma/client';
import type { User } from '@prisma/client';
import { nanoid } from 'nanoid';
import fs from 'node:fs';
import path from 'node:path';
@@ -33,19 +33,19 @@ export const seedDocuments = async (documents: DocumentToSeed[]) => {
documents.map(async (document, i) =>
match(document.type)
.with(DocumentStatus.DRAFT, async () =>
seedDraftDocument(document.sender, document.recipients, {
createDraftDocument(document.sender, document.recipients, {
key: i,
createDocumentOptions: document.documentOptions,
}),
)
.with(DocumentStatus.PENDING, async () =>
seedPendingDocument(document.sender, document.recipients, {
createPendingDocument(document.sender, document.recipients, {
key: i,
createDocumentOptions: document.documentOptions,
}),
)
.with(DocumentStatus.COMPLETED, async () =>
seedCompletedDocument(document.sender, document.recipients, {
createCompletedDocument(document.sender, document.recipients, {
key: i,
createDocumentOptions: document.documentOptions,
}),
@@ -55,37 +55,7 @@ export const seedDocuments = async (documents: DocumentToSeed[]) => {
);
};
export const seedBlankDocument = async (owner: User, options: CreateDocumentOptions = {}) => {
const { key, createDocumentOptions = {} } = options;
const documentData = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
});
return await prisma.document.create({
data: {
title: `[TEST] Document ${key} - Draft`,
status: DocumentStatus.DRAFT,
documentDataId: documentData.id,
userId: owner.id,
...createDocumentOptions,
},
});
};
export const unseedDocument = async (documentId: number) => {
await prisma.document.delete({
where: {
id: documentId,
},
});
};
export const seedDraftDocument = async (
const createDraftDocument = async (
sender: User,
recipients: (User | string)[],
options: CreateDocumentOptions = {},
@@ -144,8 +114,6 @@ export const seedDraftDocument = async (
},
});
}
return document;
};
type CreateDocumentOptions = {
@@ -153,7 +121,7 @@ type CreateDocumentOptions = {
createDocumentOptions?: Partial<Prisma.DocumentUncheckedCreateInput>;
};
export const seedPendingDocument = async (
const createPendingDocument = async (
sender: User,
recipients: (User | string)[],
options: CreateDocumentOptions = {},
@@ -212,145 +180,9 @@ export const seedPendingDocument = async (
},
});
}
return document;
};
export const seedPendingDocumentNoFields = async ({
owner,
recipients,
updateDocumentOptions,
}: {
owner: User;
recipients: (User | string)[];
updateDocumentOptions?: Partial<Prisma.DocumentUncheckedUpdateInput>;
}) => {
const document: Document = await seedBlankDocument(owner);
for (const recipient of recipients) {
const email = typeof recipient === 'string' ? recipient : recipient.email;
const name = typeof recipient === 'string' ? recipient : recipient.name ?? '';
await prisma.recipient.create({
data: {
email,
name,
token: nanoid(),
readStatus: ReadStatus.OPENED,
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.NOT_SIGNED,
signedAt: new Date(),
Document: {
connect: {
id: document.id,
},
},
},
});
}
const createdRecipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
},
include: {
Field: true,
},
});
const latestDocument = updateDocumentOptions
? await prisma.document.update({
where: {
id: document.id,
},
data: updateDocumentOptions,
})
: document;
return {
document: latestDocument,
recipients: createdRecipients,
};
};
export const seedPendingDocumentWithFullFields = async ({
owner,
recipients,
recipientsCreateOptions,
updateDocumentOptions,
fields = [FieldType.DATE, FieldType.EMAIL, FieldType.NAME, FieldType.SIGNATURE, FieldType.TEXT],
}: {
owner: User;
recipients: (User | string)[];
recipientsCreateOptions?: Partial<Prisma.RecipientCreateInput>[];
updateDocumentOptions?: Partial<Prisma.DocumentUncheckedUpdateInput>;
fields?: FieldType[];
}) => {
const document: Document = await seedBlankDocument(owner);
for (const [recipientIndex, recipient] of recipients.entries()) {
const email = typeof recipient === 'string' ? recipient : recipient.email;
const name = typeof recipient === 'string' ? recipient : recipient.name ?? '';
await prisma.recipient.create({
data: {
email,
name,
token: nanoid(),
readStatus: ReadStatus.OPENED,
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.NOT_SIGNED,
signedAt: new Date(),
Document: {
connect: {
id: document.id,
},
},
Field: {
createMany: {
data: fields.map((fieldType, fieldIndex) => ({
page: 1,
type: fieldType,
inserted: false,
customText: name,
positionX: new Prisma.Decimal((recipientIndex + 1) * 5),
positionY: new Prisma.Decimal((fieldIndex + 1) * 5),
width: new Prisma.Decimal(5),
height: new Prisma.Decimal(5),
documentId: document.id,
})),
},
},
...(recipientsCreateOptions?.[recipientIndex] ?? {}),
},
});
}
const createdRecipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
},
include: {
Field: true,
},
});
const latestDocument = updateDocumentOptions
? await prisma.document.update({
where: {
id: document.id,
},
data: updateDocumentOptions,
})
: document;
return {
document: latestDocument,
recipients: createdRecipients,
};
};
export const seedCompletedDocument = async (
const createCompletedDocument = async (
sender: User,
recipients: (User | string)[],
options: CreateDocumentOptions = {},
@@ -409,8 +241,6 @@ export const seedCompletedDocument = async (
},
});
}
return document;
};
/**
@@ -0,0 +1,29 @@
import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { prisma } from '..';
//
// https://github.com/documenso/documenso/pull/713
//
const PULL_REQUEST_NUMBER = 718;
const EMAIL_DOMAIN = `pr-${PULL_REQUEST_NUMBER}.documenso.com`;
export const TEST_USER = {
name: 'User 1',
email: `user1@${EMAIL_DOMAIN}`,
password: 'Password123',
} as const;
export const seedDatabase = async () => {
await prisma.user.create({
data: {
name: TEST_USER.name,
email: TEST_USER.email,
password: hashSync(TEST_USER.password),
emailVerified: new Date(),
url: TEST_USER.email,
},
});
};
-19
View File
@@ -1,19 +0,0 @@
import { prisma } from '..';
export const seedTestEmail = () => `user-${Date.now()}@test.documenso.com`;
type SeedSubscriptionOptions = {
userId: number;
priceId: string;
};
export const seedUserSubscription = async ({ userId, priceId }: SeedSubscriptionOptions) => {
return await prisma.subscription.create({
data: {
userId,
planId: Date.now().toString(),
priceId,
status: 'ACTIVE',
},
});
};
-2
View File
@@ -2,8 +2,6 @@ import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { prisma } from '..';
export const seedTestEmail = () => `user-${Date.now()}@test.documenso.com`;
type SeedUserOptions = {
name?: string;
email?: string;
@@ -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 }));
};
@@ -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,
@@ -122,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.',
});
}
}),
});
@@ -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>;
+1 -4
View File
@@ -132,10 +132,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');
+81 -67
View File
@@ -13,10 +13,10 @@ import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { updateDocumentSettings } from '@documenso/lib/server-only/document/update-document-settings';
import { updateTitle } from '@documenso/lib/server-only/document/update-title';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { buildServerLogger } from '@documenso/lib/utils/logger';
import { authenticatedProcedure, procedure, router } from '../trpc';
import {
@@ -30,10 +30,15 @@ import {
ZSearchDocumentsMutationSchema,
ZSendDocumentMutationSchema,
ZSetPasswordForDocumentMutationSchema,
ZSetSettingsForDocumentMutationSchema,
ZSetTitleForDocumentMutationSchema,
} from './schema';
const logger = buildServerLogger({
args: {
context: 'trpcDocumentRouter',
},
});
export const documentRouter = router({
getDocumentById: authenticatedProcedure
.input(ZGetDocumentByIdQuerySchema)
@@ -44,6 +49,10 @@ export const documentRouter = router({
userId: ctx.user.id,
});
} catch (err) {
logger.error('getDocumentById', {
error: err,
});
console.error(err);
throw new TRPCError({
@@ -53,25 +62,26 @@ export const documentRouter = router({
}
}),
getDocumentByToken: procedure
.input(ZGetDocumentByTokenQuerySchema)
.query(async ({ input, ctx }) => {
try {
const { token } = input;
getDocumentByToken: procedure.input(ZGetDocumentByTokenQuerySchema).query(async ({ input }) => {
try {
const { token } = input;
return await getDocumentAndSenderByToken({
token,
userId: ctx.user?.id,
});
} catch (err) {
console.error(err);
return await getDocumentAndSenderByToken({
token,
});
} catch (err) {
logger.error('getDocumentByToken', {
error: err,
});
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to find this document. Please try again later.',
});
}
}),
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to find this document. Please try again later.',
});
}
}),
getDocumentWithDetailsById: authenticatedProcedure
.input(ZGetDocumentWithDetailsByIdQuerySchema)
@@ -82,6 +92,10 @@ export const documentRouter = router({
userId: ctx.user.id,
});
} catch (err) {
logger.error('getDocumentWithDetailsById', {
error: err,
});
console.error(err);
throw new TRPCError({
@@ -115,6 +129,10 @@ export const documentRouter = router({
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
logger.error('createDocument', {
error: err,
});
if (err instanceof TRPCError) {
throw err;
}
@@ -141,6 +159,10 @@ export const documentRouter = router({
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
logger.error('deleteDocument', {
error: err,
});
console.error(err);
throw new TRPCError({
@@ -166,6 +188,10 @@ export const documentRouter = router({
userId: ctx.user.id,
});
} catch (err) {
logger.error('findDocumentAuditLogs', {
error: err,
});
console.error(err);
throw new TRPCError({
@@ -175,46 +201,6 @@ export const documentRouter = router({
}
}),
// Todo: Add API
setSettingsForDocument: authenticatedProcedure
.input(ZSetSettingsForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { documentId, teamId, data, meta } = input;
const userId = ctx.user.id;
const requestMetadata = extractNextApiRequestMetadata(ctx.req);
if (meta.timezone || meta.dateFormat || meta.redirectUrl) {
await upsertDocumentMeta({
documentId,
dateFormat: meta.dateFormat,
timezone: meta.timezone,
redirectUrl: meta.redirectUrl,
userId: ctx.user.id,
requestMetadata,
});
}
return await updateDocumentSettings({
userId,
teamId,
documentId,
data,
requestMetadata,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'We were unable to update the settings for this document. Please try again later.',
});
}
}),
setTitleForDocument: authenticatedProcedure
.input(ZSetTitleForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
@@ -222,13 +208,21 @@ export const documentRouter = router({
const userId = ctx.user.id;
return await updateTitle({
title,
userId,
teamId,
documentId,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
try {
return await updateTitle({
title,
userId,
teamId,
documentId,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
logger.error('setTitleForDocument', {
error: err,
});
console.error(err);
}
}),
setPasswordForDocument: authenticatedProcedure
@@ -255,6 +249,10 @@ export const documentRouter = router({
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
logger.error('setPasswordForDocument', {
error: err,
});
console.error(err);
throw new TRPCError({
@@ -290,6 +288,10 @@ export const documentRouter = router({
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
logger.error('sendDocument', {
error: err,
});
console.error(err);
throw new TRPCError({
@@ -309,6 +311,10 @@ export const documentRouter = router({
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
logger.error('resendDocument', {
error: err,
});
console.error(err);
throw new TRPCError({
@@ -327,6 +333,10 @@ export const documentRouter = router({
...input,
});
} catch (err) {
logger.error('duplicateDocument', {
error: err,
});
console.log(err);
throw new TRPCError({
@@ -347,7 +357,11 @@ export const documentRouter = router({
userId: ctx.user.id,
});
return documents;
} catch (error) {
} catch (err) {
logger.error('searchDocuments', {
error: err,
});
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We are unable to search for documents. Please try again later.',
+2 -30
View File
@@ -1,10 +1,6 @@
import { z } from 'zod';
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
@@ -50,30 +46,6 @@ export const ZCreateDocumentMutationSchema = z.object({
export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>;
export const ZSetSettingsForDocumentMutationSchema = z.object({
documentId: z.number(),
teamId: z.number().min(1).optional(),
data: z.object({
title: z.string().min(1).optional(),
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(),
globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(),
}),
meta: z.object({
timezone: z.string(),
dateFormat: z.string(),
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
}),
}),
});
export type TSetGeneralSettingsForDocumentMutationSchema = z.infer<
typeof ZSetSettingsForDocumentMutationSchema
>;
export const ZSetTitleForDocumentMutationSchema = z.object({
documentId: z.number(),
teamId: z.number().min(1).optional(),
@@ -125,8 +97,8 @@ export const ZSendDocumentMutationSchema = z.object({
meta: z.object({
subject: z.string(),
message: z.string(),
timezone: z.string().optional(),
dateFormat: z.string().optional(),
timezone: z.string(),
dateFormat: z.string(),
redirectUrl: z
.string()
.optional()
+13 -6
View File
@@ -1,6 +1,5 @@
import { TRPCError } from '@trpc/server';
import { AppError } from '@documenso/lib/errors/app-error';
import { removeSignedFieldWithToken } from '@documenso/lib/server-only/field/remove-signed-field-with-token';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template';
@@ -22,6 +21,10 @@ export const fieldRouter = router({
try {
const { documentId, fields } = input;
if (Date.now() > 0) {
throw new Error('Some demo error');
}
return await setFieldsForDocument({
documentId,
userId: ctx.user.id,
@@ -38,7 +41,10 @@ export const fieldRouter = router({
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
console.error(err, {
level: 'CRITICAL',
context: 'fieldRouter.addFields',
});
throw new TRPCError({
code: 'BAD_REQUEST',
@@ -72,21 +78,22 @@ export const fieldRouter = router({
.input(ZSignFieldWithTokenMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { token, fieldId, value, isBase64, authOptions } = input;
const { token, fieldId, value, isBase64 } = input;
return await signFieldWithToken({
token,
fieldId,
value,
isBase64,
userId: ctx.user?.id,
authOptions,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
throw AppError.parseErrorToTRPCError(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to sign this field. Please try again later.',
});
}
}),
@@ -1,6 +1,5 @@
import { z } from 'zod';
import { ZRecipientActionAuthSchema } from '@documenso/lib/types/document-auth';
import { FieldType } from '@documenso/prisma/client';
export const ZAddFieldsMutationSchema = z.object({
@@ -46,7 +45,6 @@ export const ZSignFieldWithTokenMutationSchema = z.object({
fieldId: z.number(),
value: z.string().trim(),
isBase64: z.boolean().optional(),
authOptions: ZRecipientActionAuthSchema.optional(),
});
export type TSignFieldWithTokenMutationSchema = z.infer<typeof ZSignFieldWithTokenMutationSchema>;
@@ -28,7 +28,6 @@ export const recipientRouter = router({
email: signer.email,
name: signer.name,
role: signer.role,
actionAuth: signer.actionAuth,
})),
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
@@ -72,13 +71,11 @@ export const recipientRouter = router({
.input(ZCompleteDocumentWithTokenMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { token, documentId, authOptions } = input;
const { token, documentId } = input;
return await completeDocumentWithToken({
token,
documentId,
authOptions,
userId: ctx.user?.id,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
@@ -1,9 +1,5 @@
import { z } from 'zod';
import {
ZRecipientActionAuthSchema,
ZRecipientActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { RecipientRole } from '@documenso/prisma/client';
export const ZAddSignersMutationSchema = z
@@ -16,7 +12,6 @@ export const ZAddSignersMutationSchema = z
email: z.string().email().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
}),
),
})
@@ -59,7 +54,6 @@ export type TAddTemplateSignersMutationSchema = z.infer<typeof ZAddTemplateSigne
export const ZCompleteDocumentWithTokenMutationSchema = z.object({
token: z.string(),
documentId: z.number(),
authOptions: ZRecipientActionAuthSchema.optional(),
});
export type TCompleteDocumentWithTokenMutationSchema = z.infer<
+1
View File
@@ -23,6 +23,7 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next }) => {
return await next({
ctx: {
...ctx,
user: ctx.user,
session: ctx.session,
},
-1
View File
@@ -17,7 +17,6 @@ declare namespace NodeJS {
NEXT_PUBLIC_UPLOAD_TRANSPORT?: 'database' | 's3';
NEXT_PRIVATE_UPLOAD_ENDPOINT?: string;
NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE?: string;
NEXT_PRIVATE_UPLOAD_REGION?: string;
NEXT_PRIVATE_UPLOAD_BUCKET?: string;
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID?: string;
@@ -5,17 +5,11 @@ import { motion } from 'framer-motion';
type AnimateGenericFadeInOutProps = {
children: React.ReactNode;
className?: string;
motionKey?: string;
};
export const AnimateGenericFadeInOut = ({
children,
className,
motionKey,
}: AnimateGenericFadeInOutProps) => {
export const AnimateGenericFadeInOut = ({ children, className }: AnimateGenericFadeInOutProps) => {
return (
<motion.section
key={motionKey}
initial={{
opacity: 0,
}}
@@ -60,7 +60,7 @@ export const DocumentDownloadButton = ({
loading={isLoading}
{...props}
>
{!isLoading && <Download className="mr-2 h-5 w-5" />}
<Download className="mr-2 h-5 w-5" />
Download
</Button>
);
+1 -1
View File
@@ -16,7 +16,7 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'border-input bg-background ring-offset-background focus-visible:ring-ring data-[state=checked]:border-primary peer h-4 w-4 shrink-0 rounded-sm border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
'border-input ring-offset-background focus-visible:ring-ring data-[state=checked]:border-primary peer h-4 w-4 shrink-0 rounded-sm border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
@@ -53,7 +53,6 @@ export type AddFieldsFormProps = {
recipients: Recipient[];
fields: Field[];
onSubmit: (_data: TAddFieldsFormSchema) => void;
isDocumentPdfLoaded: boolean;
};
export const AddFieldsFormPartial = ({
@@ -62,7 +61,6 @@ export const AddFieldsFormPartial = ({
recipients,
fields,
onSubmit,
isDocumentPdfLoaded,
}: AddFieldsFormProps) => {
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
const { currentStep, totalSteps, previousStep } = useStep();
@@ -344,20 +342,19 @@ export const AddFieldsFormPartial = ({
</Card>
)}
{isDocumentPdfLoaded &&
localFields.map((field, index) => (
<FieldItem
key={index}
field={field}
disabled={selectedSigner?.email !== field.signerEmail || hasSelectedSignerBeenSent}
minHeight={fieldBounds.current.height}
minWidth={fieldBounds.current.width}
passive={isFieldWithinBounds && !!selectedField}
onResize={(options) => onFieldResize(options, index)}
onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)}
/>
))}
{localFields.map((field, index) => (
<FieldItem
key={index}
field={field}
disabled={selectedSigner?.email !== field.signerEmail || hasSelectedSignerBeenSent}
minHeight={fieldBounds.current.height}
minWidth={fieldBounds.current.width}
passive={isFieldWithinBounds && !!selectedField}
onResize={(options) => onFieldResize(options, index)}
onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)}
/>
))}
{!hideRecipients && (
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
@@ -1,366 +0,0 @@
'use client';
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { DocumentAccessAuth, DocumentActionAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { DocumentStatus, type Field, type Recipient, SendStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@documenso/ui/primitives/accordion';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Combobox } from '../combobox';
import { Input } from '../input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
import { useStep } from '../stepper';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import type { TAddSettingsFormSchema } from './add-settings.types';
import { ZAddSettingsFormSchema } from './add-settings.types';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from './document-flow-root';
import { ShowFieldItem } from './show-field-item';
import type { DocumentFlowStep } from './types';
export type AddSettingsFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
fields: Field[];
isDocumentEnterprise: boolean;
isDocumentPdfLoaded: boolean;
document: DocumentWithData;
onSubmit: (_data: TAddSettingsFormSchema) => void;
};
export const AddSettingsFormPartial = ({
documentFlow,
recipients,
fields,
isDocumentEnterprise,
isDocumentPdfLoaded,
document,
onSubmit,
}: AddSettingsFormProps) => {
const { documentAuthOption } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});
const form = useForm<TAddSettingsFormSchema>({
resolver: zodResolver(ZAddSettingsFormSchema),
defaultValues: {
title: document.title,
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined,
globalActionAuth: documentAuthOption?.globalActionAuth || undefined,
meta: {
timezone: document.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
dateFormat: document.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
redirectUrl: document.documentMeta?.redirectUrl ?? '',
},
},
});
const { stepIndex, currentStep, totalSteps, previousStep } = useStep();
const documentHasBeenSent = recipients.some(
(recipient) => recipient.sendStatus === SendStatus.SENT,
);
// We almost always want to set the timezone to the user's local timezone to avoid confusion
// when the document is signed.
useEffect(() => {
if (!form.formState.touchedFields.meta?.timezone && !documentHasBeenSent) {
form.setValue('meta.timezone', Intl.DateTimeFormat().resolvedOptions().timeZone);
}
}, [documentHasBeenSent, form, form.setValue, form.formState.touchedFields.meta?.timezone]);
return (
<>
<DocumentFlowFormContainerHeader
title={documentFlow.title}
description={documentFlow.description}
/>
<DocumentFlowFormContainerContent>
{isDocumentPdfLoaded &&
fields.map((field, index) => (
<ShowFieldItem key={index} field={field} recipients={recipients} />
))}
<Form {...form}>
<fieldset
className="flex h-full flex-col space-y-6"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel required>Title</FormLabel>
<FormControl>
<Input
className="bg-background"
{...field}
disabled={document.status !== DocumentStatus.DRAFT || field.disabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="globalAccessAuth"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
Document access
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<h2>
<strong>Document access</strong>
</h2>
<p>The authentication required for recipients to view the document.</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<strong>Require account</strong> - The recipient must be signed in to
view the document
</li>
<li>
<strong>None</strong> - The document can be accessed directly by the URL
sent to the recipient
</li>
</ul>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue data-testid="documentAccessSelectValue" placeholder="None" />
</SelectTrigger>
<SelectContent position="popper">
{Object.values(DocumentAccessAuth).map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>None</SelectItem>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
{isDocumentEnterprise && (
<FormField
control={form.control}
name="globalActionAuth"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
Recipient action authentication
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<h2>
<strong>Global recipient action authentication</strong>
</h2>
<p>
The authentication required for recipients to sign the signature field.
</p>
<p>
This can be overriden by setting the authentication requirements
directly on each recipient in the next step.
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<strong>Require account</strong> - The recipient must be signed in
</li>
<li>
<strong>None</strong> - No authentication required
</li>
</ul>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue data-testid="documentActionSelectValue" placeholder="None" />
</SelectTrigger>
<SelectContent position="popper">
{Object.values(DocumentActionAuth).map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>None</SelectItem>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
)}
<Accordion type="multiple" className="mt-6">
<AccordionItem value="advanced-options" className="border-none">
<AccordionTrigger className="text-foreground mb-2 rounded border px-3 py-2 text-left hover:bg-neutral-200/30 hover:no-underline">
Advanced Options
</AccordionTrigger>
<AccordionContent className="text-muted-foreground -mx-1 px-1 pt-2 text-sm leading-relaxed">
<div className="flex flex-col space-y-6 ">
<FormField
control={form.control}
name="meta.dateFormat"
render={({ field }) => (
<FormItem>
<FormLabel>Date Format</FormLabel>
<FormControl>
<Select
{...field}
onValueChange={field.onChange}
disabled={documentHasBeenSent}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.timezone"
render={({ field }) => (
<FormItem>
<FormLabel>Time Zone</FormLabel>
<FormControl>
<Combobox
className="bg-background"
options={TIME_ZONES}
{...field}
onChange={(value) => value && field.onChange(value)}
disabled={documentHasBeenSent}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.redirectUrl"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
Redirect URL{' '}
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
Add a URL to redirect the user to once the document is signed
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</fieldset>
</Form>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep
title={documentFlow.title}
step={currentStep}
maxStep={totalSteps}
/>
<DocumentFlowFormContainerActions
loading={form.formState.isSubmitting}
disabled={form.formState.isSubmitting}
canGoBack={stepIndex !== 0}
onGoBackClick={previousStep}
onGoNextClick={form.handleSubmit(onSubmit)}
/>
</DocumentFlowFormContainerFooter>
</>
);
};
@@ -1,42 +0,0 @@
import { z } from 'zod';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
export const ZMapNegativeOneToUndefinedSchema = z
.string()
.optional()
.transform((val) => {
if (val === '-1') {
return undefined;
}
return val;
});
export const ZAddSettingsFormSchema = z.object({
title: z.string().trim().min(1, { message: "Title can't be empty" }),
globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentAccessAuthTypesSchema.optional(),
),
globalActionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentActionAuthTypesSchema.optional(),
),
meta: z.object({
timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
}),
}),
});
export type TAddSettingsFormSchema = z.infer<typeof ZAddSettingsFormSchema>;
@@ -1,33 +1,25 @@
'use client';
import React, { useId, useMemo, useState } from 'react';
import React, { useId } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { motion } from 'framer-motion';
import { InfoIcon, Plus, Trash } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { AnimatePresence, motion } from 'framer-motion';
import { Plus, Trash } from 'lucide-react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import {
RecipientActionAuth,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import type { Field, Recipient } from '@documenso/prisma/client';
import { RecipientRole, SendStatus } from '@documenso/prisma/client';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { cn } from '@documenso/ui/lib/utils';
import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { Button } from '../button';
import { Checkbox } from '../checkbox';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
import { FormErrorMessage } from '../form/form-error-message';
import { Input } from '../input';
import { Label } from '../label';
import { ROLE_ICONS } from '../recipient-role-icons';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
import { Select, SelectContent, SelectItem, SelectTrigger } from '../select';
import { useStep } from '../stepper';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import { useToast } from '../use-toast';
import type { TAddSignersFormSchema } from './add-signers.types';
import { ZAddSignersFormSchema } from './add-signers.types';
@@ -45,18 +37,16 @@ export type AddSignersFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
fields: Field[];
isDocumentEnterprise: boolean;
document: DocumentWithData;
onSubmit: (_data: TAddSignersFormSchema) => void;
isDocumentPdfLoaded: boolean;
};
export const AddSignersFormPartial = ({
documentFlow,
recipients,
document,
fields,
isDocumentEnterprise,
onSubmit,
isDocumentPdfLoaded,
}: AddSignersFormProps) => {
const { toast } = useToast();
const { remaining } = useLimits();
@@ -65,7 +55,11 @@ export const AddSignersFormPartial = ({
const { currentStep, totalSteps, previousStep } = useStep();
const form = useForm<TAddSignersFormSchema>({
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TAddSignersFormSchema>({
resolver: zodResolver(ZAddSignersFormSchema),
defaultValues: {
signers:
@@ -76,8 +70,6 @@ export const AddSignersFormPartial = ({
name: recipient.name,
email: recipient.email,
role: recipient.role,
actionAuth:
ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
}))
: [
{
@@ -85,33 +77,12 @@ export const AddSignersFormPartial = ({
name: '',
email: '',
role: RecipientRole.SIGNER,
actionAuth: undefined,
},
],
},
});
// Always show advanced settings if any recipient has auth options.
const alwaysShowAdvancedSettings = useMemo(() => {
const recipientHasAuthOptions = recipients.find((recipient) => {
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
return recipientAuthOptions?.accessAuth || recipientAuthOptions?.actionAuth;
});
const formHasActionAuth = form.getValues('signers').find((signer) => signer.actionAuth);
return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined;
}, [recipients, form]);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings);
const {
formState: { errors, isSubmitting },
control,
} = form;
const onFormSubmit = form.handleSubmit(onSubmit);
const onFormSubmit = handleSubmit(onSubmit);
const {
append: appendSigner,
@@ -141,7 +112,6 @@ export const AddSignersFormPartial = ({
name: '',
email: '',
role: RecipientRole.SIGNER,
actionAuth: undefined,
});
};
@@ -174,201 +144,110 @@ export const AddSignersFormPartial = ({
description={documentFlow.description}
/>
<DocumentFlowFormContainerContent>
{isDocumentPdfLoaded &&
fields.map((field, index) => (
<div className="flex w-full flex-col gap-y-4">
{fields.map((field, index) => (
<ShowFieldItem key={index} field={field} recipients={recipients} />
))}
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
<Form {...form}>
<div className="flex w-full flex-col gap-y-2">
{signers.map((signer, index) => (
<motion.fieldset
key={signer.id}
data-native-id={signer.nativeId}
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
className={cn('grid grid-cols-8 gap-4 pb-4', {
'border-b pt-2': showAdvancedSettings,
})}
>
<FormField
control={form.control}
<AnimatePresence>
{signers.map((signer, index) => (
<motion.fieldset
key={signer.id}
data-native-id={signer.nativeId}
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
className="flex flex-wrap items-end gap-x-4"
>
<div className="flex-1">
<Label htmlFor={`signer-${signer.id}-email`}>
Email
<span className="text-destructive ml-1 inline-block font-medium">*</span>
</Label>
<Controller
control={control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('relative', {
'col-span-3': !showAdvancedSettings,
'col-span-4': showAdvancedSettings,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel required>Email</FormLabel>
)}
<FormControl>
<Input
type="email"
placeholder="Email"
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
{...field}
onKeyDown={onKeyDown}
/>
</FormControl>
<FormMessage />
</FormItem>
<Input
id={`signer-${signer.id}-email`}
type="email"
className="bg-background mt-2"
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
onKeyDown={onKeyDown}
{...field}
/>
)}
/>
</div>
<FormField
control={form.control}
<div className="flex-1">
<Label htmlFor={`signer-${signer.id}-name`}>Name</Label>
<Controller
control={control}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn({
'col-span-3': !showAdvancedSettings,
'col-span-4': showAdvancedSettings,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel required>Name</FormLabel>
)}
<FormControl>
<Input
placeholder="Name"
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
{...field}
onKeyDown={onKeyDown}
/>
</FormControl>
<FormMessage />
</FormItem>
<Input
id={`signer-${signer.id}-name`}
type="text"
className="bg-background mt-2"
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
onKeyDown={onKeyDown}
{...field}
/>
)}
/>
</div>
{showAdvancedSettings && isDocumentEnterprise && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem className="col-span-6">
<FormControl>
<Select
{...field}
onValueChange={field.onChange}
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue placeholder="Inherit authentication method" />
<Tooltip>
<TooltipTrigger className="-mr-1 ml-auto">
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md p-4">
<h2>
<strong>Recipient action authentication</strong>
</h2>
<p>The authentication required for recipients to sign fields</p>
<p className="mt-2">This will override any global settings.</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<strong>Inherit authentication method</strong> - Use the
global action signing authentication method configured in
the "General Settings" step
</li>
<li>
<strong>Require account</strong> - The recipient must be
signed in
</li>
<li>
<strong>None</strong> - No authentication required
</li>
</ul>
</TooltipContent>
</Tooltip>
</SelectTrigger>
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value="-1">Inherit authentication method</SelectItem>
{Object.values(RecipientActionAuth).map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
<div className="w-[60px]">
<Controller
control={control}
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem className="col-span-1 mt-auto">
<FormControl>
<Select
{...field}
onValueChange={field.onChange}
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
>
<SelectTrigger className="bg-background w-[60px]">
{/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */}
{ROLE_ICONS[field.value as RecipientRole]}
</SelectTrigger>
render={({ field: { value, onChange } }) => (
<Select
value={value}
onValueChange={(x) => onChange(x)}
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
>
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger>
<SelectContent align="end">
<SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
Signer
</div>
</SelectItem>
<SelectContent className="" align="end">
<SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
Signer
</div>
</SelectItem>
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
Receives copy
</div>
</SelectItem>
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
Receives copy
</div>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
Approver
</div>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
Approver
</div>
</SelectItem>
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Viewer
</div>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Viewer
</div>
</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div>
<button
type="button"
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
className="justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
disabled={
isSubmitting ||
hasBeenSentToRecipientId(signer.nativeId) ||
@@ -378,51 +257,33 @@ export const AddSignersFormPartial = ({
>
<Trash className="h-5 w-5" />
</button>
</motion.fieldset>
))}
</div>
<FormErrorMessage
className="mt-2"
// Dirty hack to handle errors when .root is populated for an array type
error={'signers__root' in errors && errors['signers__root']}
/>
<div
className={cn('mt-2 flex flex-row items-center space-x-4', {
'mt-4': showAdvancedSettings,
})}
>
<Button
type="button"
disabled={isSubmitting || signers.length >= remaining.recipients}
onClick={() => onAddSigner()}
>
<Plus className="-ml-1 mr-2 h-5 w-5" />
Add Signer
</Button>
{!alwaysShowAdvancedSettings && isDocumentEnterprise && (
<div className="flex flex-row items-center">
<Checkbox
id="showAdvancedRecipientSettings"
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={showAdvancedSettings}
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
/>
<label
className="text-muted-foreground ml-2 text-sm"
htmlFor="showAdvancedRecipientSettings"
>
Show advanced settings
</label>
</div>
)}
</div>
</Form>
</AnimateGenericFadeInOut>
<div className="w-full">
<FormErrorMessage className="mt-2" error={errors.signers?.[index]?.email} />
<FormErrorMessage className="mt-2" error={errors.signers?.[index]?.name} />
</div>
</motion.fieldset>
))}
</AnimatePresence>
</div>
<FormErrorMessage
className="mt-2"
// Dirty hack to handle errors when .root is populated for an array type
error={'signers__root' in errors && errors['signers__root']}
/>
<div className="mt-4">
<Button
type="button"
disabled={isSubmitting || signers.length >= remaining.recipients}
onClick={() => onAddSigner()}
>
<Plus className="-ml-1 mr-2 h-5 w-5" />
Add Signer
</Button>
</div>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>
@@ -433,6 +294,7 @@ export const AddSignersFormPartial = ({
/>
<DocumentFlowFormContainerActions
canGoBack={document.status === DocumentStatus.DRAFT}
loading={isSubmitting}
disabled={isSubmitting}
onGoBackClick={previousStep}
@@ -1,8 +1,5 @@
import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { ZMapNegativeOneToUndefinedSchema } from './add-settings.types';
import { RecipientRole } from '.prisma/client';
export const ZAddSignersFormSchema = z
@@ -14,9 +11,6 @@ export const ZAddSignersFormSchema = z
email: z.string().email().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZRecipientActionAuthTypesSchema.optional(),
),
}),
),
})
@@ -1,12 +1,33 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Info } from 'lucide-react';
import { Controller, useForm } from 'react-hook-form';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import type { Field, Recipient } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import { SendStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@documenso/ui/primitives/accordion';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { Combobox } from '../combobox';
import { FormErrorMessage } from '../form/form-error-message';
import { Input } from '../input';
import { Label } from '../label';
@@ -29,7 +50,6 @@ export type AddSubjectFormProps = {
fields: Field[];
document: DocumentWithData;
onSubmit: (_data: TAddSubjectFormSchema) => void;
isDocumentPdfLoaded: boolean;
};
export const AddSubjectFormPartial = ({
@@ -38,17 +58,21 @@ export const AddSubjectFormPartial = ({
fields: fields,
document,
onSubmit,
isDocumentPdfLoaded,
}: AddSubjectFormProps) => {
const {
control,
register,
handleSubmit,
formState: { errors, isSubmitting },
formState: { errors, isSubmitting, touchedFields },
setValue,
} = useForm<TAddSubjectFormSchema>({
defaultValues: {
meta: {
subject: document.documentMeta?.subject ?? '',
message: document.documentMeta?.message ?? '',
timezone: document.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
dateFormat: document.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
redirectUrl: document.documentMeta?.redirectUrl ?? '',
},
},
resolver: zodResolver(ZAddSubjectFormSchema),
@@ -57,6 +81,20 @@ export const AddSubjectFormPartial = ({
const onFormSubmit = handleSubmit(onSubmit);
const { currentStep, totalSteps, previousStep } = useStep();
const hasDateField = fields.find((field) => field.type === 'DATE');
const documentHasBeenSent = recipients.some(
(recipient) => recipient.sendStatus === SendStatus.SENT,
);
// We almost always want to set the timezone to the user's local timezone to avoid confusion
// when the document is signed.
useEffect(() => {
if (!touchedFields.meta?.timezone && !documentHasBeenSent) {
setValue('meta.timezone', Intl.DateTimeFormat().resolvedOptions().timeZone);
}
}, [documentHasBeenSent, setValue, touchedFields.meta?.timezone]);
return (
<>
<DocumentFlowFormContainerHeader
@@ -65,10 +103,9 @@ export const AddSubjectFormPartial = ({
/>
<DocumentFlowFormContainerContent>
<div className="flex flex-col">
{isDocumentPdfLoaded &&
fields.map((field, index) => (
<ShowFieldItem key={index} field={field} recipients={recipients} />
))}
{fields.map((field, index) => (
<ShowFieldItem key={index} field={field} recipients={recipients} />
))}
<div className="flex flex-col gap-y-4">
<div>
@@ -130,6 +167,95 @@ export const AddSubjectFormPartial = ({
</li>
</ul>
</div>
<Accordion type="multiple" className="mt-8 border-none">
<AccordionItem value="advanced-options" className="border-none">
<AccordionTrigger className="mb-2 border-b text-left hover:no-underline">
Advanced Options
</AccordionTrigger>
<AccordionContent className="text-muted-foreground -mx-1 flex max-w-prose flex-col px-1 pt-2 text-sm leading-relaxed">
{hasDateField && (
<>
<div className="flex flex-col">
<Label htmlFor="date-format">
Date Format <span className="text-muted-foreground">(Optional)</span>
</Label>
<Controller
control={control}
name={`meta.dateFormat`}
disabled={documentHasBeenSent}
render={({ field: { value, onChange, disabled } }) => (
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="bg-background mt-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="mt-4 flex flex-col">
<Label htmlFor="time-zone">
Time Zone <span className="text-muted-foreground">(Optional)</span>
</Label>
<Controller
control={control}
name={`meta.timezone`}
render={({ field: { value, onChange } }) => (
<Combobox
className="bg-background"
options={TIME_ZONES}
value={value}
onChange={(value) => value && onChange(value)}
disabled={documentHasBeenSent}
/>
)}
/>
</div>
</>
)}
<div className="mt-2 flex flex-col">
<div className="flex flex-col gap-y-4">
<div>
<Label htmlFor="redirectUrl" className="flex items-center">
Redirect URL
<Tooltip>
<TooltipTrigger>
<Info className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
Add a URL to redirect the user to once the document is signed
</TooltipContent>
</Tooltip>
</Label>
<Input
id="redirectUrl"
type="url"
className="bg-background my-2"
{...register('meta.redirectUrl')}
/>
<FormErrorMessage className="mt-2" error={errors.meta?.redirectUrl} />
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</DocumentFlowFormContainerContent>
@@ -1,9 +1,21 @@
import { z } from 'zod';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
export const ZAddSubjectFormSchema = z.object({
meta: z.object({
subject: z.string(),
message: z.string(),
timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
}),
}),
});
@@ -0,0 +1,103 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import type { Field, Recipient } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { FormErrorMessage } from '../form/form-error-message';
import { Input } from '../input';
import { Label } from '../label';
import { useStep } from '../stepper';
import type { TAddTitleFormSchema } from './add-title.types';
import { ZAddTitleFormSchema } from './add-title.types';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from './document-flow-root';
import { ShowFieldItem } from './show-field-item';
import type { DocumentFlowStep } from './types';
export type AddTitleFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
fields: Field[];
document: DocumentWithData;
onSubmit: (_data: TAddTitleFormSchema) => void;
};
export const AddTitleFormPartial = ({
documentFlow,
recipients,
fields,
document,
onSubmit,
}: AddTitleFormProps) => {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TAddTitleFormSchema>({
resolver: zodResolver(ZAddTitleFormSchema),
defaultValues: {
title: document.title,
},
});
const onFormSubmit = handleSubmit(onSubmit);
const { stepIndex, currentStep, totalSteps, previousStep } = useStep();
return (
<>
<DocumentFlowFormContainerHeader
title={documentFlow.title}
description={documentFlow.description}
/>
<DocumentFlowFormContainerContent>
{fields.map((field, index) => (
<ShowFieldItem key={index} field={field} recipients={recipients} />
))}
<div className="flex flex-col">
<div className="flex flex-col gap-y-4">
<div>
<Label htmlFor="title">
Title<span className="text-destructive ml-1 inline-block font-medium">*</span>
</Label>
<Input
id="title"
className="bg-background my-2"
disabled={isSubmitting}
{...register('title')}
/>
<FormErrorMessage className="mt-2" error={errors.title} />
</div>
</div>
</div>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep
title={documentFlow.title}
step={currentStep}
maxStep={totalSteps}
/>
<DocumentFlowFormContainerActions
loading={isSubmitting}
disabled={isSubmitting}
canGoBack={stepIndex !== 0}
onGoBackClick={previousStep}
onGoNextClick={() => void onFormSubmit()}
/>
</DocumentFlowFormContainerFooter>
</>
);
};
@@ -0,0 +1,7 @@
import { z } from 'zod';
export const ZAddTitleFormSchema = z.object({
title: z.string().trim().min(1, { message: "Title can't be empty" }),
});
export type TAddTitleFormSchema = z.infer<typeof ZAddTitleFormSchema>;
+1 -1
View File
@@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
'bg-background border-input ring-offset-background placeholder:text-muted-foreground/40 focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
'bg-background border-input ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
{
'ring-2 !ring-red-500 transition-all': props['aria-invalid'],

Some files were not shown because too many files have changed in this diff Show More