feat: embed signing experience (#1322)

This commit is contained in:
Lucas Smith
2024-09-04 23:13:00 +10:00
committed by GitHub
parent 3657050b02
commit a1a8a174bf
39 changed files with 2090 additions and 75 deletions

View File

@ -45,6 +45,7 @@
"posthog-js": "^1.75.3",
"posthog-node": "^3.1.1",
"react": "^18",
"react-call": "^1.3.0",
"react-dom": "^18",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.43.9",

View File

@ -94,7 +94,7 @@ export const DirectTemplatePageView = ({
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
}
const token = await createDocumentFromDirectTemplate({
const { token } = await createDocumentFromDirectTemplate({
directTemplateToken,
directTemplateExternalId,
directRecipientName: fullName,

View File

@ -63,6 +63,7 @@ export const SigningPageView = ({
{document.User.name}
</p>
</div>
<p className="text-muted-foreground">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => (

View File

@ -0,0 +1,32 @@
import { Trans } from '@lingui/macro';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Logo } from '~/components/branding/logo';
import { SignInForm } from '~/components/forms/signin';
export type EmbedAuthenticateViewProps = {
email?: string;
returnTo: string;
};
export const EmbedAuthenticateView = ({ email, returnTo }: EmbedAuthenticateViewProps) => {
return (
<div className="flex min-h-[100dvh] w-full items-center justify-center">
<div className="flex w-full max-w-md flex-col">
<Logo className="h-8" />
<Alert className="mt-8" variant="warning">
<AlertDescription>
<Trans>
To view this document you need to be signed into your account, please sign in to
continue.
</Trans>
</AlertDescription>
</Alert>
<SignInForm className="mt-4" initialEmail={email} returnTo={returnTo} />
</div>
</div>
);
};

View File

@ -0,0 +1,5 @@
import { z } from 'zod';
export const ZBaseEmbedDataSchema = z.object({
css: z.string().optional().transform(value => value || undefined),
});

View File

@ -0,0 +1,7 @@
export const EmbedClientLoading = () => {
return (
<div className="bg-background fixed left-0 top-0 z-[9999] flex h-full w-full items-center justify-center">
Loading...
</div>
);
};

View File

@ -0,0 +1,33 @@
import { Trans } from '@lingui/macro';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import type { Signature } from '@documenso/prisma/client';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
export type EmbedDocumentCompletedPageProps = {
name?: string;
signature?: Signature;
};
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
return (
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<h3 className="text-foreground text-2xl font-semibold">
<Trans>Document Completed!</Trans>
</h3>
<div className="mt-8 w-full max-w-md">
<SigningCard3D
className='w-full mx-auto'
name={name || 'Documenso'}
signature={signature}
signingCelebrationImage={signingCelebration}
/>
</div>
<p className="mt-8 max-w-[50ch] text-center text-muted-foreground text-sm">
<Trans>The document is now completed, please follow any instructions provided within the parent application.</Trans>
</p>
</div>
);
};

View File

@ -0,0 +1,456 @@
'use client';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
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 { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { DocumentMeta, Recipient, TemplateMeta } from '@documenso/prisma/client';
import { FieldType, type DocumentData, type Field } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { DirectTemplateLocalField } from '~/app/(recipient)/d/[token]/sign-direct-template';
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
import { Logo } from '~/components/branding/logo';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { EmbedClientLoading } from '../../client-loading';
import { EmbedDocumentCompleted } from '../../completed';
import { EmbedDocumentFields } from '../../document-fields';
import { ZDirectTemplateEmbedDataSchema } from './schema';
export type EmbedDirectTemplateClientPageProps = {
token: string;
updatedAt: Date;
documentData: DocumentData;
recipient: Recipient;
fields: Field[];
metadata?: DocumentMeta | TemplateMeta | null;
};
export const EmbedDirectTemplateClientPage = ({
token,
updatedAt,
documentData,
recipient,
fields,
metadata,
}: EmbedDirectTemplateClientPageProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const searchParams = useSearchParams();
const { fullName, email, signature, setFullName, setEmail, setSignature } =
useRequiredSigningContext();
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
const [hasCompletedDocument, setHasCompletedDocument] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [isEmailLocked, setIsEmailLocked] = useState(false);
const [isNameLocked, setIsNameLocked] = useState(false);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields);
const [pendingFields, _completedFields] = [
localFields.filter((field) => !field.inserted),
localFields.filter((field) => field.inserted),
];
const { mutateAsync: createDocumentFromDirectTemplate, isLoading: isSubmitting } =
trpc.template.createDocumentFromDirectTemplate.useMutation();
const onSignField = (payload: TSignFieldWithTokenMutationSchema) => {
setLocalFields((fields) =>
fields.map((field) => {
if (field.id !== payload.fieldId) {
return field;
}
const newField: DirectTemplateLocalField = structuredClone({
...field,
customText: payload.value,
inserted: true,
signedValue: payload,
});
if (field.type === FieldType.SIGNATURE) {
newField.Signature = {
id: 1,
created: new Date(),
recipientId: 1,
fieldId: 1,
signatureImageAsBase64: payload.value,
typedSignature: null,
};
}
if (field.type === FieldType.DATE) {
newField.customText = DateTime.now()
.setZone(metadata?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
.toFormat(metadata?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT);
}
return newField;
}),
);
if (window.parent) {
window.parent.postMessage(
{
action: 'field-signed',
data: null,
},
'*',
);
}
setShowPendingFieldTooltip(false);
};
const onUnsignField = (payload: TRemovedSignedFieldWithTokenMutationSchema) => {
setLocalFields((fields) =>
fields.map((field) => {
if (field.id !== payload.fieldId) {
return field;
}
return structuredClone({
...field,
customText: '',
inserted: false,
signedValue: undefined,
Signature: undefined,
});
}),
);
if (window.parent) {
window.parent.postMessage(
{
action: 'field-unsigned',
data: null,
},
'*',
);
}
setShowPendingFieldTooltip(false);
};
const onNextFieldClick = () => {
validateFieldsInserted(localFields);
setShowPendingFieldTooltip(true);
setIsExpanded(false);
};
const onCompleteClick = async () => {
try {
const valid = validateFieldsInserted(localFields);
if (!valid) {
setShowPendingFieldTooltip(true);
return;
}
let directTemplateExternalId = searchParams?.get('externalId') || undefined;
if (directTemplateExternalId) {
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
}
localFields.forEach((field) => {
if (!field.signedValue) {
throw new Error('Invalid configuration');
}
});
const {
documentId,
token: documentToken,
recipientId,
} = await createDocumentFromDirectTemplate({
directTemplateToken: token,
directTemplateExternalId,
directRecipientName: fullName,
directRecipientEmail: email,
templateUpdatedAt: updatedAt,
signedFieldValues: localFields.map((field) => {
if (!field.signedValue) {
throw new Error('Invalid configuration');
}
return field.signedValue;
}),
});
if (window.parent) {
window.parent.postMessage(
{
action: 'document-completed',
data: {
token: documentToken,
documentId,
recipientId,
},
},
'*',
);
}
setHasCompletedDocument(true);
} catch (err) {
if (window.parent) {
window.parent.postMessage(
{
action: 'document-error',
data: String(err),
},
'*',
);
}
toast({
title: _(msg`Something went wrong`),
description: _(
msg`We were unable to submit this document at this time. Please try again later.`,
),
variant: 'destructive',
});
}
};
useEffect(() => {
const hash = window.location.hash.slice(1);
try {
const data = ZDirectTemplateEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
if (data.email) {
setEmail(data.email);
setIsEmailLocked(!!data.lockEmail);
}
if (data.name) {
setFullName(data.name);
setIsNameLocked(!!data.lockName);
}
} catch (err) {
console.error(err);
}
setHasFinishedInit(true);
// !: While the two setters are stable we still want to ensure we're avoiding
// !: re-renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (hasFinishedInit && hasDocumentLoaded && window.parent) {
window.parent.postMessage(
{
action: 'document-ready',
data: null,
},
'*',
);
}
}, [hasFinishedInit, hasDocumentLoaded]);
if (hasCompletedDocument) {
return (
<EmbedDocumentCompleted
name={fullName}
signature={{
id: 1,
fieldId: 1,
recipientId: 1,
created: new Date(),
typedSignature: null,
signatureImageAsBase64: signature,
}}
/>
);
}
return (
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
<div className="relative flex flex-col md:flex-row w-full gap-x-6 gap-y-12">
{/* Viewer */}
<div className="flex-1">
<LazyPDFViewer
documentData={documentData}
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>
</div>
{/* Widget */}
<div
className="group/document-widget fixed md:sticky md:top-4 left-0 w-full bottom-8 px-6 md:px-0 z-50 md:z-auto md:w-[350px] flex-shrink-0 h-fit"
data-expanded={isExpanded || undefined}
>
<div className="w-full border-border bg-widget flex md:min-h-[min(calc(100dvh-2rem),48rem)] h-fit flex-col rounded-xl border px-4 py-4 md:py-6">
{/* Header */}
<div>
<div className="flex items-center justify-between gap-x-2">
<h3 className="text-foreground text-xl md:text-2xl font-semibold">
<Trans>Sign document</Trans>
</h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
{isExpanded ? (
<LucideChevronDown
className="h-5 w-5 text-muted-foreground"
onClick={() => setIsExpanded(false)}
/>
) : (
<LucideChevronUp
className="h-5 w-5 text-muted-foreground"
onClick={() => setIsExpanded(true)}
/>
)}
</Button>
</div>
</div>
<div className="hidden group-data-[expanded]/document-widget:block md:block">
<p className="text-muted-foreground mt-2 text-sm">
<Trans>Sign the document to complete the process.</Trans>
</p>
<hr className="border-border mb-8 mt-4" />
</div>
{/* Form */}
<div className="-mx-2 px-2 hidden group-data-[expanded]/document-widget:block md:block">
<div className="flex flex-1 flex-col gap-y-4">
<div>
<Label htmlFor="full-name">
<Trans>Full Name</Trans>
</Label>
<Input
type="text"
id="full-name"
className="bg-background mt-2"
disabled={isNameLocked}
value={fullName}
onChange={(e) => !isNameLocked && setFullName(e.target.value.trim())}
/>
</div>
<div>
<Label htmlFor="email">
<Trans>Email</Trans>
</Label>
<Input
type="email"
id="email"
className="bg-background mt-2"
disabled={isEmailLocked}
value={email}
onChange={(e) => !isEmailLocked && setEmail(e.target.value.trim())}
/>
</div>
<div>
<Label htmlFor="Signature">
<Trans>Signature</Trans>
</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
key={signature}
className="h-44 w-full"
disabled={isThrottled || isSubmitting}
defaultValue={signature ?? undefined}
onChange={(value) => {
setSignature(value);
}}
/>
</CardContent>
</Card>
</div>
</div>
</div>
<div className="flex-1 hidden group-data-[expanded]/document-widget:block md:block" />
<div className="w-full grid-cols-2 items-center mt-4 hidden group-data-[expanded]/document-widget:grid md:grid">
{pendingFields.length > 0 ? (
<Button className="col-start-2" onClick={() => onNextFieldClick()}>
<Trans>Next</Trans>
</Button>
) : (
<Button
className="col-start-2"
disabled={isThrottled}
loading={isSubmitting}
onClick={() => throttledOnCompleteClick()}
>
<Trans>Complete</Trans>
</Button>
)}
</div>
</div>
</div>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{showPendingFieldTooltip && pendingFields.length > 0 && (
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
<Trans>Click to insert field</Trans>
</FieldToolTip>
)}
</ElementVisible>
{/* Fields */}
<EmbedDocumentFields
recipient={recipient}
fields={localFields}
metadata={metadata}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
</div>
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
<span>Powered by</span>
<Logo className="ml-2 inline-block h-[14px]" />
</div>
</div>
);
};

View File

@ -0,0 +1,3 @@
export default function EmbedDirectTemplateNotFound() {
return <div>Not Found</div>
}

View File

@ -0,0 +1,97 @@
import { notFound } from 'next/navigation';
import { match } from 'ts-pattern';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { DocumentAuthProvider } from '~/app/(signing)/sign/[token]/document-auth-provider';
import { SigningProvider } from '~/app/(signing)/sign/[token]/provider';
import { EmbedAuthenticateView } from '../../authenticate';
import { EmbedPaywall } from '../../paywall';
import { EmbedDirectTemplateClientPage } from './client';
export type EmbedDirectTemplatePageProps = {
params: {
url?: string[];
};
};
export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTemplatePageProps) {
if (params.url?.length !== 1) {
return notFound();
}
const [token] = params.url;
const template = await getTemplateByDirectLinkToken({
token,
}).catch(() => null);
// `template.directLink` is always available but we're doing this to
// satisfy the type checker.
if (!template || !template.directLink) {
return notFound();
}
// TODO: Make this more robust, we need to ensure the owner is either
// TODO: the member of a team that has an active subscription, is an early
// TODO: adopter or is an enterprise user.
if (IS_BILLING_ENABLED() && !template.teamId) {
return <EmbedPaywall />;
}
const { user } = await getServerComponentSession();
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: template.authOptions,
});
const isAccessAuthValid = match(derivedRecipientAccessAuth)
.with(DocumentAccessAuth.ACCOUNT, () => user !== null)
.with(null, () => true)
.exhaustive();
if (!isAccessAuthValid) {
return <EmbedAuthenticateView email={user?.email} returnTo={`/embed/direct/${token}`} />;
}
const { directTemplateRecipientId } = template.directLink;
const recipient = template.Recipient.find(
(recipient) => recipient.id === directTemplateRecipientId,
);
if (!recipient) {
return notFound();
}
const fields = template.Field.filter((field) => field.recipientId === directTemplateRecipientId);
return (
<SigningProvider
email={user?.email}
fullName={user?.name}
signature={user?.signature}
>
<DocumentAuthProvider
documentAuthOptions={template.authOptions}
recipient={recipient}
user={user}
>
<EmbedDirectTemplateClientPage
token={token}
updatedAt={template.updatedAt}
documentData={template.templateDocumentData}
recipient={recipient}
fields={fields}
metadata={template.templateMeta}
/>
</DocumentAuthProvider>
</SigningProvider>
);
}

View File

@ -0,0 +1,20 @@
import { z } from 'zod';
import { ZBaseEmbedDataSchema } from '../../base-schema';
export const ZDirectTemplateEmbedDataSchema = ZBaseEmbedDataSchema.extend({
email: z
.union([z.literal(''), z.string().email()])
.optional()
.transform((value) => value || undefined),
lockEmail: z.boolean().optional().default(false),
name: z
.string()
.optional()
.transform((value) => value || undefined),
lockName: z.boolean().optional().default(false),
});
export type TDirectTemplateEmbedDataSchema = z.infer<typeof ZDirectTemplateEmbedDataSchema>;
export type TDirectTemplateEmbedDataInputSchema = z.input<typeof ZDirectTemplateEmbedDataSchema>;

View File

@ -0,0 +1,185 @@
'use client';
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 {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import type { DocumentMeta, Recipient, TemplateMeta } from '@documenso/prisma/client';
import { FieldType, type Field } from '@documenso/prisma/client';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { CheckboxField } from '~/app/(signing)/sign/[token]/checkbox-field';
import { DateField } from '~/app/(signing)/sign/[token]/date-field';
import { DropdownField } from '~/app/(signing)/sign/[token]/dropdown-field';
import { EmailField } from '~/app/(signing)/sign/[token]/email-field';
import { InitialsField } from '~/app/(signing)/sign/[token]/initials-field';
import { NameField } from '~/app/(signing)/sign/[token]/name-field';
import { NumberField } from '~/app/(signing)/sign/[token]/number-field';
import { RadioField } from '~/app/(signing)/sign/[token]/radio-field';
import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field';
import { TextField } from '~/app/(signing)/sign/[token]/text-field';
export type EmbedDocumentFieldsProps = {
recipient: Recipient;
fields: Field[];
metadata?: DocumentMeta | TemplateMeta | null;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const EmbedDocumentFields = ({
recipient,
fields,
metadata,
onSignField,
onUnsignField,
}: EmbedDocumentFieldsProps) => {
return (
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map((field) =>
match(field.type)
.with(FieldType.SIGNATURE, () => (
<SignatureField
key={field.id}
field={field}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
))
.with(FieldType.INITIALS, () => (
<InitialsField
key={field.id}
field={field}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
))
.with(FieldType.NAME, () => (
<NameField
key={field.id}
field={field}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
))
.with(FieldType.DATE, () => (
<DateField
key={field.id}
field={field}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
dateFormat={metadata?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
timezone={metadata?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
/>
))
.with(FieldType.EMAIL, () => (
<EmailField
key={field.id}
field={field}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
))
.with(FieldType.TEXT, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null,
};
return (
<TextField
key={field.id}
field={fieldWithMeta}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
);
})
.with(FieldType.NUMBER, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null,
};
return (
<NumberField
key={field.id}
field={fieldWithMeta}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
);
})
.with(FieldType.RADIO, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null,
};
return (
<RadioField
key={field.id}
field={fieldWithMeta}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
);
})
.with(FieldType.CHECKBOX, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null,
};
return (
<CheckboxField
key={field.id}
field={fieldWithMeta}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
);
})
.with(FieldType.DROPDOWN, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null,
};
return (
<DropdownField
key={field.id}
field={fieldWithMeta}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
);
})
.otherwise(() => null),
)}
</ElementVisible>
);
};

View File

@ -0,0 +1,5 @@
export const EmbedPaywall = () => {
return <div>
<h1>Paywall</h1>
</div>
}

View File

@ -0,0 +1,327 @@
'use client';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { DocumentMeta, Recipient, TemplateMeta } from '@documenso/prisma/client';
import { type DocumentData, type Field } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useEffect, useState } from 'react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
import { Logo } from '~/components/branding/logo';
import { EmbedClientLoading } from '../../client-loading';
import { EmbedDocumentCompleted } from '../../completed';
import { EmbedDocumentFields } from '../../document-fields';
import { ZSignDocumentEmbedDataSchema } from './schema';
export type EmbedSignDocumentClientPageProps = {
token: string;
documentId: number;
documentData: DocumentData;
recipient: Recipient;
fields: Field[];
metadata?: DocumentMeta | TemplateMeta | null;
isCompleted?: boolean;
};
export const EmbedSignDocumentClientPage = ({
token,
documentId,
documentData,
recipient,
fields,
metadata,
isCompleted,
}: EmbedSignDocumentClientPageProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { fullName, email, signature, setFullName, setSignature } = useRequiredSigningContext();
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
const [isExpanded, setIsExpanded] = useState(false);
const [isNameLocked, setIsNameLocked] = useState(false);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
const [pendingFields, _completedFields] = [
fields.filter((field) => !field.inserted),
fields.filter((field) => field.inserted),
];
const { mutateAsync: completeDocumentWithToken, isLoading: isSubmitting } =
trpc.recipient.completeDocumentWithToken.useMutation();
const onNextFieldClick = () => {
validateFieldsInserted(fields);
setShowPendingFieldTooltip(true);
setIsExpanded(false);
};
const onCompleteClick = async () => {
try {
const valid = validateFieldsInserted(fields);
if (!valid) {
setShowPendingFieldTooltip(true);
return;
}
await completeDocumentWithToken({
documentId,
token,
});
if (window.parent) {
window.parent.postMessage(
{
action: 'document-completed',
data: {
token,
documentId,
recipientId: recipient.id,
},
},
'*',
);
}
setHasCompletedDocument(true);
} catch (err) {
if (window.parent) {
window.parent.postMessage(
{
action: 'document-error',
data: null,
},
'*',
);
}
toast({
title: _(msg`Something went wrong`),
description: _(
msg`We were unable to submit this document at this time. Please try again later.`,
),
variant: 'destructive',
});
}
};
useEffect(() => {
const hash = window.location.hash.slice(1);
try {
const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
if (!isCompleted && data.name) {
setFullName(data.name);
}
// Since a recipient can be provided a name we can lock it without requiring
// a to be provided by the parent application, unlike direct templates.
setIsNameLocked(!!data.lockName);
} catch (err) {
console.error(err);
}
setHasFinishedInit(true);
// !: While the two setters are stable we still want to ensure we're avoiding
// !: re-renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (hasFinishedInit && hasDocumentLoaded && window.parent) {
window.parent.postMessage(
{
action: 'document-ready',
data: null,
},
'*',
);
}
}, [hasFinishedInit, hasDocumentLoaded]);
if (hasCompletedDocument) {
return (
<EmbedDocumentCompleted
name={fullName}
signature={{
id: 1,
fieldId: 1,
recipientId: 1,
created: new Date(),
typedSignature: null,
signatureImageAsBase64: signature,
}}
/>
);
}
return (
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
<div className="relative flex flex-col md:flex-row w-full gap-x-6 gap-y-12">
{/* Viewer */}
<div className="flex-1">
<LazyPDFViewer
documentData={documentData}
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>
</div>
{/* Widget */}
<div
className="group/document-widget fixed md:sticky md:top-4 left-0 w-full bottom-8 px-6 md:px-0 z-50 md:z-auto md:w-[350px] flex-shrink-0 h-fit"
data-expanded={isExpanded || undefined}
>
<div className="w-full border-border bg-widget flex flex-col rounded-xl border px-4 py-4 md:py-6">
{/* Header */}
<div>
<div className="flex items-center justify-between gap-x-2">
<h3 className="text-foreground text-xl md:text-2xl font-semibold">
<Trans>Sign document</Trans>
</h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
{isExpanded ? (
<LucideChevronDown
className="h-5 w-5 text-muted-foreground"
onClick={() => setIsExpanded(false)}
/>
) : (
<LucideChevronUp
className="h-5 w-5 text-muted-foreground"
onClick={() => setIsExpanded(true)}
/>
)}
</Button>
</div>
</div>
<div className="hidden group-data-[expanded]/document-widget:block md:block">
<p className="text-muted-foreground mt-2 text-sm">
<Trans>Sign the document to complete the process.</Trans>
</p>
<hr className="border-border mb-8 mt-4" />
</div>
{/* Form */}
<div className="-mx-2 px-2 hidden group-data-[expanded]/document-widget:block md:block">
<div className="flex flex-1 flex-col gap-y-4">
<div>
<Label htmlFor="full-name">
<Trans>Full Name</Trans>
</Label>
<Input
type="text"
id="full-name"
className="bg-background mt-2"
disabled={isNameLocked}
value={fullName}
onChange={(e) => !isNameLocked && setFullName(e.target.value.trim())}
/>
</div>
<div>
<Label htmlFor="email">
<Trans>Email</Trans>
</Label>
<Input
type="email"
id="email"
className="bg-background mt-2"
value={email}
disabled
/>
</div>
<div>
<Label htmlFor="Signature">
<Trans>Signature</Trans>
</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
key={signature}
className="h-44 w-full"
disabled={isThrottled || isSubmitting}
defaultValue={signature ?? undefined}
onChange={(value) => {
setSignature(value);
}}
/>
</CardContent>
</Card>
</div>
</div>
</div>
<div className="flex-1 hidden group-data-[expanded]/document-widget:block md:block" />
<div className="w-full grid-cols-2 items-center mt-4 hidden group-data-[expanded]/document-widget:grid md:grid">
{pendingFields.length > 0 ? (
<Button className="col-start-2" onClick={() => onNextFieldClick()}>
<Trans>Next</Trans>
</Button>
) : (
<Button
className="col-start-2"
disabled={isThrottled}
loading={isSubmitting}
onClick={() => throttledOnCompleteClick()}
>
<Trans>Complete</Trans>
</Button>
)}
</div>
</div>
</div>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{showPendingFieldTooltip && pendingFields.length > 0 && (
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
<Trans>Click to insert field</Trans>
</FieldToolTip>
)}
</ElementVisible>
{/* Fields */}
<EmbedDocumentFields recipient={recipient} fields={fields} metadata={metadata} />
</div>
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
<span>Powered by</span>
<Logo className="ml-2 inline-block h-[14px]" />
</div>
</div>
);
};

View File

@ -0,0 +1,3 @@
export default function EmbedDirectTemplateNotFound() {
return <div>Not Found</div>
}

View File

@ -0,0 +1,95 @@
import { notFound } from 'next/navigation';
import { match } from 'ts-pattern';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { DocumentAuthProvider } from '~/app/(signing)/sign/[token]/document-auth-provider';
import { SigningProvider } from '~/app/(signing)/sign/[token]/provider';
import { EmbedAuthenticateView } from '../../authenticate';
import { EmbedPaywall } from '../../paywall';
import { EmbedSignDocumentClientPage } from './client';
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 { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { DocumentStatus } from '@documenso/prisma/client';
export type EmbedSignDocumentPageProps = {
params: {
url?: string[];
};
};
export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumentPageProps) {
if (params.url?.length !== 1) {
return notFound();
}
const [token] = params.url;
const { user } = await getServerComponentSession();
const [document, fields, recipient] = await Promise.all([
getDocumentAndSenderByToken({
token,
userId: user?.id,
requireAccessAuth: false,
}).catch(() => null),
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
]);
// `document.directLink` is always available but we're doing this to
// satisfy the type checker.
if (!document || !recipient) {
return notFound();
}
// TODO: Make this more robust, we need to ensure the owner is either
// TODO: the member of a team that has an active subscription, is an early
// TODO: adopter or is an enterprise user.
if (IS_BILLING_ENABLED() && !document.teamId) {
return <EmbedPaywall />;
}
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});
const isAccessAuthValid = match(derivedRecipientAccessAuth)
.with(DocumentAccessAuth.ACCOUNT, () => user !== null)
.with(null, () => true)
.exhaustive();
if (!isAccessAuthValid) {
return <EmbedAuthenticateView email={user?.email || recipient.email} returnTo={`/embed/direct/${token}`} />;
}
return (
<SigningProvider
email={recipient.email}
fullName={user?.email === recipient.email ? user?.name : recipient.name}
signature={user?.email === recipient.email ? user?.signature : undefined}
>
<DocumentAuthProvider
documentAuthOptions={document.authOptions}
recipient={recipient}
user={user}
>
<EmbedSignDocumentClientPage
token={token}
documentId={document.id}
documentData={document.documentData}
recipient={recipient}
fields={fields}
metadata={document.documentMeta}
isCompleted={document.status === DocumentStatus.COMPLETED}
/>
</DocumentAuthProvider>
</SigningProvider>
);
}

View File

@ -0,0 +1,16 @@
import { z } from 'zod';
import { ZBaseEmbedDataSchema } from '../../base-schema';
export const ZSignDocumentEmbedDataSchema = ZBaseEmbedDataSchema.extend({
email: z
.union([z.literal(''), z.string().email()])
.optional()
.transform((value) => value || undefined),
lockEmail: z.boolean().optional().default(false),
name: z
.string()
.optional()
.transform((value) => value || undefined),
lockName: z.boolean().optional().default(false),
});

View File

@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
@ -74,6 +74,7 @@ export type SignInFormProps = {
isGoogleSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
oidcProviderLabel?: string;
returnTo?: string;
};
export const SignInForm = ({
@ -82,6 +83,7 @@ export const SignInForm = ({
isGoogleSSOEnabled,
isOIDCSSOEnabled,
oidcProviderLabel,
returnTo,
}: SignInFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@ -100,6 +102,22 @@ export const SignInForm = ({
const isPasskeyEnabled = getFlag('app_passkey');
const callbackUrl = useMemo(() => {
// Handle SSR
if (typeof window === 'undefined') {
return LOGIN_REDIRECT_PATH;
}
let url = new URL(returnTo || LOGIN_REDIRECT_PATH, window.location.origin);
// Don't allow different origins
if (url.origin !== window.location.origin) {
url = new URL(LOGIN_REDIRECT_PATH, window.location.origin);
}
return url.toString();
}, [returnTo]);
const { mutateAsync: createPasskeySigninOptions } =
trpc.auth.createPasskeySigninOptions.useMutation();
@ -157,7 +175,7 @@ export const SignInForm = ({
const result = await signIn('webauthn', {
credential: JSON.stringify(credential),
callbackUrl: LOGIN_REDIRECT_PATH,
callbackUrl,
redirect: false,
});
@ -210,7 +228,7 @@ export const SignInForm = ({
const result = await signIn('credentials', {
...credentials,
callbackUrl: LOGIN_REDIRECT_PATH,
callbackUrl,
redirect: false,
});
@ -259,7 +277,9 @@ export const SignInForm = ({
const onSignInWithGoogleClick = async () => {
try {
await signIn('google', { callbackUrl: LOGIN_REDIRECT_PATH });
await signIn('google', {
callbackUrl,
});
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
@ -273,7 +293,9 @@ export const SignInForm = ({
const onSignInWithOIDCClick = async () => {
try {
await signIn('oidc', { callbackUrl: LOGIN_REDIRECT_PATH });
await signIn('oidc', {
callbackUrl,
});
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),

View File

@ -76,6 +76,20 @@ async function middleware(req: NextRequest): Promise<NextResponse> {
return response;
}
if (req.nextUrl.pathname.startsWith('/embed')) {
const res = NextResponse.next();
// Allow third parties to iframe the document.
res.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.headers.set('Access-Control-Allow-Origin', '*');
res.headers.set('Content-Security-Policy', "frame-ancestors *");
res.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
res.headers.set('X-Content-Type-Options', 'nosniff');
res.headers.set('X-Frame-Options', 'ALLOW-ALL');
return res;
}
return NextResponse.next();
}