feat: add direct templates links (#1165)

## Description

Direct templates links is a feature that provides template owners the
ability to allow users to create documents based of their templates.

## General outline

This works by allowing the template owner to configure a "direct
recipient" in the template.

When a user opens the direct link to the template, it will create a flow
where they sign the fields configured by the template owner for the
direct recipient. After these fields are signed the following will
occur:

- A document will be created where the owner is the template owner
- The direct recipient fields will be signed
- The document will be sent to any other recipients configured in the
template
- If there are none the document will be immediately completed

## Notes

There's a custom prisma migration to migrate all documents to have
'DOCUMENT' as the source, then sets the column to required.

---------

Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
This commit is contained in:
David Nguyen
2024-06-02 15:49:09 +10:00
committed by GitHub
parent c346a3fd6a
commit d11a68fc4c
71 changed files with 3636 additions and 283 deletions

View File

@ -67,7 +67,7 @@ export default async function CompletedSigningPage({
const isDocumentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
document,
documentAuthOptions: document.authOptions,
recipient,
userId: user?.id,
});
@ -131,7 +131,7 @@ export default async function CompletedSigningPage({
</div>
))
.with({ deletedAt: null }, () => (
<div className="flex items-center mt-4 text-center text-blue-600">
<div className="mt-4 flex items-center text-center text-blue-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">Waiting for others to sign</span>
</div>

View File

@ -17,6 +17,10 @@ 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';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SigningFieldContainer } from './signing-field-container';
@ -26,6 +30,8 @@ export type DateFieldProps = {
recipient: Recipient;
dateFormat?: string | null;
timezone?: string | null;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const DateField = ({
@ -33,6 +39,8 @@ export const DateField = ({
recipient,
dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT,
timezone = DEFAULT_DOCUMENT_TIME_ZONE,
onSignField,
onUnsignField,
}: DateFieldProps) => {
const router = useRouter();
@ -58,12 +66,19 @@ export const DateField = ({
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
await signFieldWithToken({
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
authOptions,
});
};
if (onSignField) {
await onSignField(payload);
return;
}
await signFieldWithToken(payload);
startTransition(() => router.refresh());
} catch (err) {
@ -85,10 +100,17 @@ export const DateField = ({
const onRemove = async () => {
try {
await removeSignedFieldWithToken({
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
});
};
if (onUnsignField) {
await onUnsignField(payload);
return;
}
await removeSignedFieldWithToken(payload);
startTransition(() => router.refresh());
} catch (err) {

View File

@ -34,9 +34,9 @@ type PasskeyData = {
export type DocumentAuthContextValue = {
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;
document: Document;
documentAuthOptions: Document['authOptions'];
documentAuthOption: TDocumentAuthOptions;
setDocument: (_value: Document) => void;
setDocumentAuthOptions: (_value: Document['authOptions']) => void;
recipient: Recipient;
recipientAuthOption: TRecipientAuthOptions;
setRecipient: (_value: Recipient) => void;
@ -69,19 +69,19 @@ export const useRequiredDocumentAuthContext = () => {
};
export interface DocumentAuthProviderProps {
document: Document;
documentAuthOptions: Document['authOptions'];
recipient: Recipient;
user?: User | null;
children: React.ReactNode;
}
export const DocumentAuthProvider = ({
document: initialDocument,
documentAuthOptions: initialDocumentAuthOptions,
recipient: initialRecipient,
user,
children,
}: DocumentAuthProviderProps) => {
const [document, setDocument] = useState(initialDocument);
const [documentAuthOptions, setDocumentAuthOptions] = useState(initialDocumentAuthOptions);
const [recipient, setRecipient] = useState(initialRecipient);
const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false);
@ -95,10 +95,10 @@ export const DocumentAuthProvider = ({
} = useMemo(
() =>
extractDocumentAuthMethods({
documentAuth: document.authOptions,
documentAuth: documentAuthOptions,
recipientAuth: recipient.authOptions,
}),
[document, recipient],
[documentAuthOptions, recipient],
);
const passkeyQuery = trpc.auth.findPasskeys.useQuery(
@ -189,8 +189,8 @@ export const DocumentAuthProvider = ({
<DocumentAuthContext.Provider
value={{
user,
document,
setDocument,
documentAuthOptions,
setDocumentAuthOptions,
executeActionAuthProcedure,
recipient,
setRecipient,

View File

@ -12,6 +12,10 @@ 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';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredSigningContext } from './provider';
@ -20,9 +24,11 @@ import { SigningFieldContainer } from './signing-field-container';
export type EmailFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const EmailField = ({ field, recipient }: EmailFieldProps) => {
export const EmailField = ({ field, recipient, onSignField, onUnsignField }: EmailFieldProps) => {
const router = useRouter();
const { toast } = useToast();
@ -43,13 +49,22 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
await signFieldWithToken({
const value = providedEmail ?? '';
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
value: providedEmail ?? '',
value,
isBase64: false,
authOptions,
});
};
if (onSignField) {
await onSignField(payload);
return;
}
await signFieldWithToken(payload);
startTransition(() => router.refresh());
} catch (err) {
@ -71,10 +86,17 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
const onRemove = async () => {
try {
await removeSignedFieldWithToken({
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
});
};
if (onUnsignField) {
await onUnsignField(payload);
return;
}
await removeSignedFieldWithToken(payload);
startTransition(() => router.refresh());
} catch (err) {

View File

@ -145,7 +145,7 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
document={document}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
role={recipient.role}
@ -208,7 +208,7 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
document={document}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
role={recipient.role}

View File

@ -12,6 +12,10 @@ 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';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
@ -25,9 +29,11 @@ import { SigningFieldContainer } from './signing-field-container';
export type NameFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const NameField = ({ field, recipient }: NameFieldProps) => {
export const NameField = ({ field, recipient, onSignField, onUnsignField }: NameFieldProps) => {
const router = useRouter();
const { toast } = useToast();
@ -83,13 +89,20 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
return;
}
await signFieldWithToken({
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
value,
isBase64: false,
authOptions,
});
};
if (onSignField) {
await onSignField(payload);
return;
}
await signFieldWithToken(payload);
startTransition(() => router.refresh());
} catch (err) {
@ -111,10 +124,17 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
const onRemove = async () => {
try {
await removeSignedFieldWithToken({
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
});
};
if (onUnsignField) {
await onUnsignField(payload);
return;
}
await removeSignedFieldWithToken(payload);
startTransition(() => router.refresh());
} catch (err) {

View File

@ -65,7 +65,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
const isDocumentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
document,
documentAuthOptions: document.authOptions,
recipient,
userId: user?.id,
});
@ -126,7 +126,11 @@ 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}>
<DocumentAuthProvider
documentAuthOptions={document.authOptions}
recipient={recipient}
user={user}
>
<SigningPageView
recipient={recipient}
document={document}

View File

@ -1,6 +1,6 @@
import { useState } from 'react';
import type { Document, Field } from '@documenso/prisma/client';
import type { Field } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -16,7 +16,7 @@ import { truncateTitle } from '~/helpers/truncate-title';
export type SignDialogProps = {
isSubmitting: boolean;
document: Document;
documentTitle: string;
fields: Field[];
fieldsValidated: () => void | Promise<void>;
onSignatureComplete: () => void | Promise<void>;
@ -25,14 +25,14 @@ export type SignDialogProps = {
export const SignDialog = ({
isSubmitting,
document,
documentTitle,
fields,
fieldsValidated,
onSignatureComplete,
role,
}: SignDialogProps) => {
const [showDialog, setShowDialog] = useState(false);
const truncatedTitle = truncateTitle(document.title);
const truncatedTitle = truncateTitle(documentTitle);
const isComplete = fields.every((field) => field.inserted);
const handleOpenChange = (open: boolean) => {
@ -40,18 +40,6 @@ export const SignDialog = ({
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);
};

View File

@ -12,6 +12,10 @@ 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';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Label } from '@documenso/ui/primitives/label';
@ -29,9 +33,16 @@ type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text';
export type SignatureFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
export const SignatureField = ({
field,
recipient,
onSignField,
onUnsignField,
}: SignatureFieldProps) => {
const router = useRouter();
const { toast } = useToast();
@ -105,13 +116,20 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
return;
}
await signFieldWithToken({
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
value,
isBase64: true,
authOptions,
});
};
if (onSignField) {
await onSignField(payload);
return;
}
await signFieldWithToken(payload);
startTransition(() => router.refresh());
} catch (err) {
@ -133,10 +151,17 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
const onRemove = async () => {
try {
await removeSignedFieldWithToken({
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
});
};
if (onUnsignField) {
await onUnsignField(payload);
return;
}
await removeSignedFieldWithToken(payload);
startTransition(() => router.refresh());
} catch (err) {

View File

@ -12,6 +12,10 @@ 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';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
@ -24,9 +28,11 @@ import { SigningFieldContainer } from './signing-field-container';
export type TextFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const TextField = ({ field, recipient }: TextFieldProps) => {
export const TextField = ({ field, recipient, onSignField, onUnsignField }: TextFieldProps) => {
const router = useRouter();
const { toast } = useToast();
@ -81,13 +87,20 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
return;
}
await signFieldWithToken({
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
value: localText,
isBase64: true,
authOptions,
});
};
if (onSignField) {
await onSignField(payload);
return;
}
await signFieldWithToken(payload);
setLocalCustomText('');
@ -111,10 +124,17 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
const onRemove = async () => {
try {
await removeSignedFieldWithToken({
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
});
};
if (onUnsignField) {
await onUnsignField(payload);
return;
}
await removeSignedFieldWithToken(payload);
startTransition(() => router.refresh());
} catch (err) {